fix(tabs): a found tab still drops onto the tile grid during a rail search (#580)

183efacc turned drag off while the rail search narrows the list, as the
owner asked, by refusing the flat manual rail's dragstart. That also
refused a drag that never reorders anything: with the tile grid open, a
found tab dragged onto a tile or an empty cell set no draggedTabId, so
_acceptTabDrops() (tile-grid.js) saw nothing and the drop was lost. On
the PR head that drop replaced or swapped the tile. The owner's reason
covers the reorder only: a reorder drop is saved for every device,
relative to rows the search hides, while the tile grid is per-device and
lands beside nothing hidden.

dragstart is unconditional again. The rows refuse the reorder instead:
their dragover returns before preventDefault while _tabRailSearchActive()
(the browser shows no-drop and fires no drop there), and their drop
returns before touching sessionOrder, for anything above the row that
might let a drop through. `draggable` stays on, so a cleared search
reorders with the same rows. The grouped rail's pointer drag keeps its
refusal in _onTabLayoutPointerDown(): it has no drop target besides the
rail.

Test: during a search that leaves both rows showing, dragstart is not
cancelled and sets draggedTabId, dragover on another row is not
accepted, a drop there leaves sessionOrder alone and saves nothing, the
real _acceptTabDrops() binder accepts the drag on a tile and hands it
the id, and after clearing the same rows reorder. It fails on the
previous commit, without the dragover guard, and without the drop guard.
CLAUDE.md, the invariants, the Dashboard wiki row and the release
changeset now say reordering by drag is off and a tile drop still works.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-10 04:45:35 +02:00
parent 6844cccd18
commit f152d551d1
6 changed files with 77 additions and 31 deletions
+1 -1
View File
@@ -25,7 +25,7 @@
**CLI logos on tabs can be switched off (#569).** App Settings → Appearance → Tabs → **CLI Logos on Tabs** hides the agent logo on every tab surface (header strip, rails, sidebar, phone chips, the desktop home list) on this device. On by default. Tile headers, split pane headers and the Run menus keep their logos. **CLI logos on tabs can be switched off (#569).** App Settings → Appearance → Tabs → **CLI Logos on Tabs** hides the agent logo on every tab surface (header strip, rails, sidebar, phone chips, the desktop home list) on this device. On by default. Tile headers, split pane headers and the Run menus keep their logos.
**Search sessions on the vertical rail (#580).** The vertical tab rail has a **Search sessions** box at the top: type part of a name and the rail narrows to the tabs that match (a web tab by its title), across every group, collapsed ones included, without touching your groups, their collapse or the tab order. A tab with an alert stays visible even when its name does not match, so a prompt waiting on you is never filtered away. Escape or × clears it, dragging is off while it is active, and nothing is saved. The sidebar's filter box shares the same filter: a tab with an alert stays visible there too, and in the by-case tab layout a case with no match now hides. **Search sessions on the vertical rail (#580).** The vertical tab rail has a **Search sessions** box at the top: type part of a name and the rail narrows to the tabs that match (a web tab by its title), across every group, collapsed ones included, without touching your groups, their collapse or the tab order. A tab with an alert stays visible even when its name does not match, so a prompt waiting on you is never filtered away. Escape or × clears it, reordering by drag is off while it is active (on a manual rail a found tab can still be dragged onto the tile grid), and nothing is saved. The sidebar's filter box shares the same filter: a tab with an alert stays visible there too, and in the by-case tab layout a case with no match now hides.
**Fixes.** **Fixes.**
- **Clone errors on non-English hosts (#572, from #568).** Cloning a repository as a case now classifies a failed clone correctly whatever the host's language: a missing branch or tag is "does not exist on the remote" (400) and a missing repository is a 404, instead of a generic 422 with git's German (or any other) error text. Git runs with `LC_ALL=C` for clones and repo status, so the repo status card's error text is English on every host as well. - **Clone errors on non-English hosts (#572, from #568).** Cloning a repository as a case now classifies a failed clone correctly whatever the host's language: a missing branch or tag is "does not exist on the remote" (400) and a missing repository is a 404, instead of a generic 422 with git's German (or any other) error text. Git runs with `LC_ALL=C` for clones and repo status, so the repo status card's error text is English on every host as well.
+1 -1
View File
@@ -337,7 +337,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling) **Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched, and neither rail drag starts (owner decision: a drop is saved for every device, relative to rows the search hides; refused in `dragstart` and `_onTabLayoutPointerDown`, never via `draggable`, since a keystroke does not re-render); `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar) **Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk alone follows computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched, and nothing reorders by drag (owner decision: a drop is saved for every device, relative to rows the search hides): the grouped rail's drag does not start (`_onTabLayoutPointerDown`) and the flat rail's rows refuse `dragover`/`drop`, never `dragstart` or `draggable`, so a found tab still drops onto a tile (per-device) and a keystroke, which re-renders nothing, needs no rebind; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger) **Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
+1 -1
View File
@@ -802,7 +802,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
- **Collapsed groups.** A collapsed group's rows are not in the DOM, so a class cannot reveal them: the projection opens every group while the rail search runs (see Owner tab layouts), and `setTabRailSearch()` re-renders only when that changes the structure (`_isTabGroupStructureStale()`); every other keystroke only re-applies classes. - **Collapsed groups.** A collapsed group's rows are not in the DOM, so a class cannot reveal them: the projection opens every group while the rail search runs (see Owner tab layouts), and `setTabRailSearch()` re-renders only when that changes the structure (`_isTabGroupStructureStale()`); every other keystroke only re-applies classes.
- **Connectors.** Lineage lines and the subagent/ultracode connectors are anchored to row positions, and a keystroke re-renders nothing, so `_applyTabListFilter()` calls `updateConnectionLines()` whenever it actually showed or hid something (a row, a section, the state headings via `tabs-filtering`, the empty note). An unchanged re-apply at a render tail calls nothing, which keeps the incremental path's lineage gate meaningful. ⚠️ A hidden row still answers `getBoundingClientRect()` with an all-zero rect, which is truthy, so every floating window that anchors to its parent tab (the subagent and ultracode connectors' shared `tab:<id>` cache, both spawn positions, the ultracode genie) finds it through `_paintedSessionTab()` (app.js: the row only while `getClientRects()` is non-empty). An unpainted parent draws no connector, spawns where a window without a tab would, and tears down without the genie; otherwise all three started at the viewport's top-left corner. Lineage needs no guard, since `computeTree` drops zero-size rects. - **Connectors.** Lineage lines and the subagent/ultracode connectors are anchored to row positions, and a keystroke re-renders nothing, so `_applyTabListFilter()` calls `updateConnectionLines()` whenever it actually showed or hid something (a row, a section, the state headings via `tabs-filtering`, the empty note). An unchanged re-apply at a render tail calls nothing, which keeps the incremental path's lineage gate meaningful. ⚠️ A hidden row still answers `getBoundingClientRect()` with an all-zero rect, which is truthy, so every floating window that anchors to its parent tab (the subagent and ultracode connectors' shared `tab:<id>` cache, both spawn positions, the ultracode genie) finds it through `_paintedSessionTab()` (app.js: the row only while `getClientRects()` is non-empty). An unpainted parent draws no connector, spawns where a window without a tab would, and tears down without the genie; otherwise all three started at the viewport's top-left corner. Lineage needs no guard, since `computeTree` drops zero-size rects.
- **Escape.** The global key handler runs in the CAPTURE phase on `document`, before the box's inline `onkeydown`, so a `stopPropagation()` there comes too late: the global Escape branch claims an Escape whose target is `#tabRailSearch` while the box holds text (beside the group menu, the grouped-rail drag and the Tiles count menu) and routes it to `handleTabRailSearchKeydown()`. Without that, clearing the search also collapsed the Monitor and Subagents panels. An empty box leaves Escape to the global handler. - **Escape.** The global key handler runs in the CAPTURE phase on `document`, before the box's inline `onkeydown`, so a `stopPropagation()` there comes too late: the global Escape branch claims an Escape whose target is `#tabRailSearch` while the box holds text (beside the group menu, the grouped-rail drag and the Tiles count menu) and routes it to `handleTabRailSearchKeydown()`. Without that, clearing the search also collapsed the Monitor and Subagents panels. An empty box leaves Escape to the global handler.
- **No drag while searching** (owner decision): a drop is saved for every device, relative to rows the search hides. Both rail drags refuse in their start handlers (`_onTabLayoutPointerDown`, the flat rail's `dragstart`), never by flipping `draggable`, which only a render would restore. - **No reorder by drag while searching** (owner decision): a reorder drop is saved for every device (session order or the tab layout), relative to rows the search hides. The grouped rail's pointer drag does not start (`_onTabLayoutPointerDown`; it has no other drop target). The flat rail's ROWS refuse it instead: their `dragover` returns before `preventDefault` (the browser shows no-drop) and their `drop` returns before touching `sessionOrder`. Its `dragstart` and `draggable` stay on, because a found tab dragged onto the tile grid (`_acceptTabDrops`, which reads `draggedTabId`) is per-device and lands beside nothing the search hides, and because flipping `draggable` would need a render a keystroke never does.
- **In memory only, cleared off the rail.** The text is never persisted or sent. `applyTabOrientation()` calls `_resetTabRailSearch()` before its render when the list leaves the vertical rail, the same reason leaving sidebar mode clears `_sidebarFilter`. - **In memory only, cleared off the rail.** The text is never persisted or sent. `applyTabOrientation()` calls `_resetTabRailSearch()` before its render when the list leaves the vertical rail, the same reason leaving sidebar mode clears `_sidebarFilter`.
Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.test.ts` (browser suite, not in the gate; it installs the real `setupEventListeners()` so the capture-before-inline Escape order is the shipped one). Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.test.ts` (browser suite, not in the gate; it installs the real `setupEventListeners()` so the capture-before-inline Escape order is the shipped one).
+1 -1
View File
@@ -29,7 +29,7 @@ Session List Layout** can move it into a vertical sidebar on the left instead, a
| -------------------- | --------------------------------------------------------------------------------- | | -------------------- | --------------------------------------------------------------------------------- |
| **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it scrolls sideways on a phone. | | **Header tab strip** | The default. One list in tab order unless you pick another [Tab layout](#tab-layouts); it scrolls sideways on a phone. |
| **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. | | **Left sidebar** | A vertical list with a filter box and a live session count. `Alt+B` collapses it to a narrow rail that keeps the status dots and task badges visible. On a phone it is an off-canvas drawer rather than a docked rail. A detailed variant adds the home screen's per-session line (`created 3d ago · working 12m`) and a status pill. |
| **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; dragging rows is off until you clear it; Escape or × clears it, and it is never saved. Desktop and tablet only. | | **Vertical rail** | The strip turned vertical beside the terminal, resizable, with detailed rows by default. **Vertical Rail Order** sorts it by activity (blocked on you first, then longest running, then most recently quiet), the same order as the home screens; pick *Manual* to get your own order and drag-reordering back. **Tab groups:** pick *Move to new group* from a row's ⋯ menu (or Shift+F10 on it) to make the first one; a group header's menu (right-click, Shift+F10 or its ⋯ glyph) renames it (also F2), reorders or deletes it, rows move between groups from their own menu or by dragging with a mouse or pen, and a collapsed group stays collapsed on that device. **Search sessions** at the top of the rail narrows it to the tabs whose name matches (a web tab by its title), across every group, collapsed ones included, without changing the groups or the order; a tab with an alert stays visible even when its name does not match; reordering rows by dragging is off until you clear it, though a found tab can still be dragged onto the tile grid; Escape or × clears it, and it is never saved. Desktop and tablet only. |
It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1` It is the same list either way, just re-hosted: tab order, drag-to-reorder, the `Alt+1`
to `Alt+9` numbers and every status colour below behave identically in both. The setting is to `Alt+9` numbers and every status colour below behave identically in both. The setting is
+10 -8
View File
@@ -8240,14 +8240,6 @@ class CodemanApp {
tab.setAttribute('draggable', 'true'); tab.setAttribute('draggable', 'true');
tab.addEventListener('dragstart', (e) => { tab.addEventListener('dragstart', (e) => {
// No drag while the rail search narrows the list: the drop rewrites the
// order every device sees, relative to rows the search is hiding
// (owner's call on #580). Refused here rather than by `draggable`,
// because a keystroke in the box does not re-render the rows.
if (this._tabRailSearchActive()) {
e.preventDefault();
return;
}
this.draggedTabId = tab.dataset.id; this.draggedTabId = tab.dataset.id;
tab.classList.add('dragging'); tab.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
@@ -8264,6 +8256,13 @@ class CodemanApp {
}); });
tab.addEventListener('dragover', (e) => { tab.addEventListener('dragover', (e) => {
// No reorder while the rail search narrows the list: the drop rewrites
// the order every device sees, relative to rows the search is hiding
// (owner's call on #580). The rows refuse it here, never in dragstart
// or via `draggable`: a found tab can still be dropped onto the tile
// grid (per-device, nothing hidden to land beside), and a keystroke in
// the box does not re-render the rows.
if (this._tabRailSearchActive()) return;
// Grouped by state: a tab in another group is not a drop target, and // Grouped by state: a tab in another group is not a drop target, and
// leaving the event alone (no preventDefault) is what shows "no drop". // leaving the event alone (no preventDefault) is what shows "no drop".
if (this._isTabDropAcrossGroups(tab)) return; if (this._isTabDropAcrossGroups(tab)) return;
@@ -8295,6 +8294,9 @@ class CodemanApp {
e.preventDefault(); e.preventDefault();
tab.classList.remove('drag-over-left', 'drag-over-right'); tab.classList.remove('drag-over-left', 'drag-over-right');
// dragover refuses it during a rail search; this holds should anything
// above the row accept the drag and let a drop through.
if (this._tabRailSearchActive()) return;
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return; if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
if (this._isTabDropAcrossGroups(tab)) return; if (this._isTabDropAcrossGroups(tab)) return;
+63 -19
View File
@@ -22,6 +22,9 @@
* case box on screen (the owner's call on #580). * case box on screen (the owner's call on #580).
* - Escape in a box that holds text clears the search and closes nothing else: * - Escape in a box that holds text clears the search and closes nothing else:
* the global key handler runs first (capture phase) and must route it. * the global key handler runs first (capture phase) and must route it.
* - No reorder by drag while searching (the owner's call): the grouped rail's
* drag does not start, and the flat rail's rows refuse the drop, while a
* found tab can still be dropped onto a tile.
* - A change in what shows redraws the connector lines, which are anchored to * - A change in what shows redraws the connector lines, which are anchored to
* row positions; an unchanged re-apply does not. * row positions; an unchanged re-apply does not.
* - The sidebar hides a case box its filter emptied, like the rail. * - The sidebar hides a case box its filter emptied, like the rail.
@@ -60,6 +63,21 @@ type TabSearch = {
) => SearchResult; ) => SearchResult;
}; };
/**
* The tile grid's real drop-target binder (`_acceptTabDrops`), lifted out of
* tile-grid.js in a context of its own, so the rest of that module (it wraps
* the tab renders) is never mixed into the app under test.
*/
function loadAcceptTabDrops(): (this: unknown, el: unknown, onDrop: (id: string) => void) => void {
const context = vm.createContext({ window: {}, document: {} });
vm.runInContext(
`function CodemanApp() {}\n${read('tile-grid.js')}\n;globalThis.__accept = CodemanApp.prototype._acceptTabDrops;`,
context,
{ filename: 'tile-grid.js' }
);
return (context as unknown as { __accept: ReturnType<typeof loadAcceptTabDrops> }).__accept;
}
function loadSearch(): TabSearch { function loadSearch(): TabSearch {
const context = vm.createContext({ window: {}, globalThis: {} }); const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' }); vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
@@ -690,7 +708,7 @@ describe('Escape in the search box', () => {
}); });
}); });
describe('no drag while a rail search is active', () => { describe('no reorder by drag while a rail search is active', () => {
it('will not start a grouped-rail drag, and starts one again once the search is cleared', () => { it('will not start a grouped-rail drag, and starts one again once the search is cleared', () => {
const app = makeApp(); const app = makeApp();
app._fullRenderSessionTabs(); app._fullRenderSessionTabs();
@@ -718,30 +736,56 @@ describe('no drag while a rail search is active', () => {
} }
}); });
it('refuses the flat rail drag while searching, though the rows stay bound for after', () => { it('lets a found tab be dragged onto a tile, never dropped onto a rail row, and reorders again once cleared', () => {
const app = makeApp({ tabLayout: null }); const app = makeApp({ tabLayout: null });
app.saveSessionOrder = vi.fn();
app._fullRenderSessionTabs(); app._fullRenderSessionTabs();
const row = () => document.querySelector<HTMLElement>('#sessionTabs [data-id="roadmap"]')!; const row = (id: string) => document.querySelector<HTMLElement>(`#sessionTabs [data-id="${id}"]`)!;
expect(row().getAttribute('draggable')).toBe('true'); expect(row('roadmap').getAttribute('draggable')).toBe('true');
const dragstart = () => { const drag = (type: string, target: HTMLElement) => {
const event = new window.Event('dragstart', { bubbles: true, cancelable: true }); const event = new window.Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: { effectAllowed: '', setData: () => {} } }); Object.defineProperty(event, 'dataTransfer', {
value: { effectAllowed: '', dropEffect: '', setData: () => {} },
});
target.dispatchEvent(event);
return event; return event;
}; };
// A tile, with the tile grid's own drop-target binder (tile-grid.js).
const tile = document.createElement('div');
document.body.appendChild(tile);
const onTileDrop = vi.fn();
loadAcceptTabDrops().call(app, tile, onTileDrop);
app._tileGrid = { open: true };
app.setTabRailSearch('road'); // 'r' leaves Roadmap and API Review both showing, so only the search refuses.
const refused = dragstart(); app.setTabRailSearch('r');
row().dispatchEvent(refused); expect(visibleRows()).toEqual(['roadmap', 'review', 'web']);
expect(refused.defaultPrevented).toBe(true); expect(drag('dragstart', row('roadmap')).defaultPrevented).toBe(false);
expect(app.draggedTabId ?? null).toBeNull();
// A keystroke does not re-render, so the cleared rail drags with the same rows.
app.clearTabRailSearch();
const allowed = dragstart();
row().dispatchEvent(allowed);
expect(allowed.defaultPrevented).toBe(false);
expect(app.draggedTabId).toBe('roadmap'); expect(app.draggedTabId).toBe('roadmap');
row().dispatchEvent(new window.Event('dragend', { bubbles: true }));
// A rail row is no drop target: no preventDefault (the browser shows no-drop),
// no indicator, and a drop that gets through anyway moves nothing.
expect(drag('dragover', row('review')).defaultPrevented).toBe(false);
expect(row('review').className).not.toMatch(/drag-over/);
drag('drop', row('review'));
expect(app.sessionOrder).toEqual(['alpha', 'roadmap', 'review', 'notes']);
expect(app.saveSessionOrder).not.toHaveBeenCalled();
// A tile is (per-device, nothing hidden to land beside).
expect(drag('dragover', tile).defaultPrevented).toBe(true);
drag('drop', tile);
expect(onTileDrop).toHaveBeenCalledWith('roadmap');
drag('dragend', row('roadmap'));
expect(app.draggedTabId ?? null).toBeNull();
app._tileGrid = null;
// A keystroke does not re-render, so the cleared rail reorders with the same rows.
app.clearTabRailSearch();
drag('dragstart', row('roadmap'));
expect(drag('dragover', row('review')).defaultPrevented).toBe(true);
drag('drop', row('review'));
expect(app.sessionOrder).toEqual(['alpha', 'review', 'roadmap', 'notes']);
expect(app.saveSessionOrder).toHaveBeenCalledTimes(1);
}); });
}); });