fix(tabs): no drag while a rail search is active (#580)

From the owner's review of #580 rather than the bot's report: "Drag
while searching: I'd turn it off. A drop is saved for every device
(session order or the tab layout), and where it lands relative to the
rows the search is hiding is something you only see after clearing it."
The PR head left drag on.

Both rail drags now refuse while the search narrows the list: the
grouped rail's pointer drag in _onTabLayoutPointerDown(), and the flat
manual rail's HTML5 drag in its dragstart listener. The flat rail is
refused in the listener, not by flipping `draggable`, because a
keystroke in the box does not re-render the rows, so a cleared search
drags again with the same rows. The sidebar filter box, the header strip
and the keyboard moves (Ctrl+Shift+{ }, the row menu) are unchanged.

Tests: a grouped-rail press during a search starts no drag and one after
clearing does; a flat-rail dragstart during a search is refused and one
after clearing goes through. CLAUDE.md and the Dashboard wiki row say
so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-10 04:10:05 +02:00
parent 4a94eb9ba7
commit 183efacc93
4 changed files with 68 additions and 2 deletions
+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)
**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`. 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; `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`. 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)
**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
@@ -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. |
| **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; 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; dragging rows is off until you clear it; 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`
to `Alt+9` numbers and every status colour below behave identically in both. The setting is
+11
View File
@@ -8006,6 +8006,9 @@ class CodemanApp {
if (this._tabLayoutDrag) this._cancelTabLayoutPointerDrag(container);
if (e.button !== 0 || e.pointerType === 'touch' || !container.classList.contains('session-tabs--grouped')) return;
if (this._inlineRenameActive || !this._tabLayoutEditable()) return;
// No drag while the rail search narrows the list: a drop is saved for every
// device, relative to rows the search is hiding (owner's call on #580).
if (this._tabRailSearchActive()) return;
// Controls keep their own click; only the row body or the header drags.
if (e.target.closest('.tab-actions, .tab-badge, .tab-layout-group-menu, button, input, [onclick*="stopPropagation"]')) return;
const header = e.target.closest('[data-tab-group-header]');
@@ -8223,6 +8226,14 @@ class CodemanApp {
tab.setAttribute('draggable', 'true');
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;
tab.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
+55
View File
@@ -682,6 +682,61 @@ describe('Escape in the search box', () => {
});
});
describe('no drag while a rail search is active', () => {
it('will not start a grouped-rail drag, and starts one again once the search is cleared', () => {
const app = makeApp();
app._fullRenderSessionTabs();
const container = document.getElementById('sessionTabs')!;
expect(container.classList.contains('session-tabs--grouped')).toBe(true);
const press = () => ({
button: 0,
pointerType: 'mouse',
pointerId: 1,
clientX: 10,
clientY: 10,
target: container.querySelector('[data-id="review"]'),
});
app.setTabRailSearch('review');
app._onTabLayoutPointerDown(press(), container);
expect(app._tabLayoutDrag ?? null).toBeNull();
app.clearTabRailSearch();
app._onTabLayoutPointerDown(press(), container);
try {
expect(app._tabLayoutDrag?.source).toEqual({ type: 'ref', ref: { kind: 'session', id: 'review' } });
} finally {
app._cancelTabLayoutPointerDrag(container);
}
});
it('refuses the flat rail drag while searching, though the rows stay bound for after', () => {
const app = makeApp({ tabLayout: null });
app._fullRenderSessionTabs();
const row = () => document.querySelector<HTMLElement>('#sessionTabs [data-id="roadmap"]')!;
expect(row().getAttribute('draggable')).toBe('true');
const dragstart = () => {
const event = new window.Event('dragstart', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: { effectAllowed: '', setData: () => {} } });
return event;
};
app.setTabRailSearch('road');
const refused = dragstart();
row().dispatchEvent(refused);
expect(refused.defaultPrevented).toBe(true);
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');
row().dispatchEvent(new window.Event('dragend', { bubbles: true }));
});
});
describe('connector lines follow the rows a search moves', () => {
it('redraws on a rail keystroke that hides or reveals rows, and only then', () => {
const app = makeApp({ tabLayout: null });