mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
feat(tabs): keep alerted tabs visible during a rail search
A session with a tab alert (red action or yellow idle, whatever tabAlerts holds, the same set a collapsed group header surfaces) now stays visible while the rail search or the sidebar filter is narrowing the list, even when its name does not match. A prompt waiting on you should never be hidden by a view filter. The pure CodemanTabSearch.filter decides it: a row passed with keep: true is never hidden. It counts toward its group, so the group stays on screen and the header number is the rows left showing, but not toward matchCount, so "No sessions match" still shows above a lone alerted row. _applyTabListFilter() flags session rows from tabAlerts; web tabs carry no alerts and are never kept. No new wiring: updateTabAlertFromHooks() and _onSessionWorking() already call renderSessionTabs(), and both render paths end in the shared filter.
This commit is contained in:
@@ -333,7 +333,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. 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; `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)
|
||||
|
||||
|
||||
@@ -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; 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; 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
-3
@@ -5753,9 +5753,15 @@ class CodemanApp {
|
||||
* - Rail: the NAME only, a web tab's title included (it is a row in the same
|
||||
* list, and hiding every web tab would make a dashboard unfindable).
|
||||
*
|
||||
* A group or case box left with no match hides with its header, its count
|
||||
* shows the matches, and the grouped tree's roving stop and posinset follow
|
||||
* the visible items. A collapsed group's rows are not in the DOM at all,
|
||||
* A session row with a tab alert (red action or yellow idle, whatever
|
||||
* tabAlerts holds, the set a collapsed group header surfaces) stays visible
|
||||
* even when it does not match: a prompt waiting on you is never hidden by a
|
||||
* view filter. Alerts come and go through renderSessionTabs(), and both
|
||||
* render paths end here, so nothing else re-runs this for them.
|
||||
*
|
||||
* A group or case box left with nothing showing hides with its header, its
|
||||
* count shows the rows left showing (a kept row included), and the grouped
|
||||
* tree's roving stop and posinset follow the visible items. A collapsed group's rows are not in the DOM at all,
|
||||
* which is why the rail search also expands the projection (_projectTabGroups).
|
||||
*/
|
||||
_applyTabListFilter() {
|
||||
@@ -5771,6 +5777,8 @@ class CodemanApp {
|
||||
? this._tabRowSearchName(tab)
|
||||
: `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`,
|
||||
section: tab.closest('.tab-layout-group, .tab-cluster'),
|
||||
// Web tabs carry no alerts; only a session row can be kept.
|
||||
keep: !tab.dataset.webviewId && !!tab.dataset.id && !!this.tabAlerts?.get(tab.dataset.id),
|
||||
}));
|
||||
const result = window.CodemanTabSearch?.filter(rows, query);
|
||||
if (!result) return;
|
||||
|
||||
@@ -1032,12 +1032,18 @@ function tabSearchNeedle(query) {
|
||||
* rendered and applies the result as classes, so the list itself (grouping,
|
||||
* order, Alt+N badges) is never rebuilt or reordered by a search.
|
||||
*
|
||||
* @param {Array<{key: unknown, text: string, section?: unknown}>} rows in list
|
||||
* order; `section` is the row's group or case box, null/undefined for none.
|
||||
* A row flagged `keep: true` is never hidden, matching or not (the caller keeps
|
||||
* a tab with an alert on screen: a prompt waiting on you is never hidden by a
|
||||
* view filter). It counts toward its section, so its group stays on screen with
|
||||
* it, but not toward `matchCount`.
|
||||
*
|
||||
* @param {Array<{key: unknown, text: string, section?: unknown, keep?: boolean}>} rows
|
||||
* in list order; `section` is the row's group or case box, null/undefined for none.
|
||||
* @param {unknown} query
|
||||
* @returns {{active: boolean, hidden: Set<unknown>, counts: Map<unknown, number>, matchCount: number}}
|
||||
* `counts` has every section seen, an emptied one as 0, so it can be hidden;
|
||||
* `matchCount` is the number of rows left showing.
|
||||
* `counts` is the rows left showing per section (kept rows included), every
|
||||
* section seen, an emptied one as 0, so it can be hidden; `matchCount` is the
|
||||
* number of rows whose TEXT matched, so it can be 0 above a lone kept row.
|
||||
*/
|
||||
function filterTabSearchRows(rows, query) {
|
||||
const needle = tabSearchNeedle(query);
|
||||
@@ -1048,11 +1054,12 @@ function filterTabSearchRows(rows, query) {
|
||||
const hasSection = row.section !== null && row.section !== undefined;
|
||||
if (hasSection && !counts.has(row.section)) counts.set(row.section, 0);
|
||||
const text = typeof row.text === 'string' ? row.text.toLocaleLowerCase() : '';
|
||||
if (needle && !text.includes(needle)) {
|
||||
const matches = !needle || text.includes(needle);
|
||||
if (!matches && row.keep !== true) {
|
||||
hidden.add(row.key);
|
||||
continue;
|
||||
}
|
||||
matchCount++;
|
||||
if (matches) matchCount++;
|
||||
if (hasSection) counts.set(row.section, counts.get(row.section) + 1);
|
||||
}
|
||||
return { active: needle.length > 0, hidden, counts, matchCount };
|
||||
|
||||
@@ -42,7 +42,10 @@ type SearchResult = {
|
||||
};
|
||||
type TabSearch = {
|
||||
needle: (query: unknown) => string;
|
||||
filter: (rows: Array<{ key: unknown; text: string; section?: unknown }>, query: unknown) => SearchResult;
|
||||
filter: (
|
||||
rows: Array<{ key: unknown; text: string; section?: unknown; keep?: boolean }>,
|
||||
query: unknown
|
||||
) => SearchResult;
|
||||
};
|
||||
|
||||
function loadSearch(): TabSearch {
|
||||
@@ -94,6 +97,29 @@ describe('CodemanTabSearch (pure)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('never hides a kept row, counts it toward its section, and leaves it out of matchCount', () => {
|
||||
const rows = [
|
||||
{ key: 'a', text: 'Alpha', section: 'g1', keep: true },
|
||||
{ key: 'b', text: 'Beta', section: 'g1' },
|
||||
{ key: 'c', text: 'Gamma', section: 'g2' },
|
||||
{ key: 'd', text: 'Delta', section: null, keep: true },
|
||||
];
|
||||
const result = search.filter(rows, 'zzz');
|
||||
expect(result.active).toBe(true);
|
||||
expect([...result.hidden]).toEqual(['b', 'c']);
|
||||
// Zero TEXT matches: "No sessions match" still shows above the kept rows.
|
||||
expect(result.matchCount).toBe(0);
|
||||
// The kept row keeps its group on screen; the group without one empties.
|
||||
expect([...result.counts]).toEqual([
|
||||
['g1', 1],
|
||||
['g2', 0],
|
||||
]);
|
||||
// A kept row that also matches is one match, not two.
|
||||
expect(search.filter(rows, 'alpha').matchCount).toBe(1);
|
||||
// Only a literal true keeps a row.
|
||||
expect(search.filter([{ key: 'x', text: 'X', keep: 'yes' as never }], 'zzz').hidden.has('x')).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no matches without throwing on odd input', () => {
|
||||
const result = search.filter([{ key: 'a', text: '', section: 'g' }], 'x');
|
||||
expect(result.matchCount).toBe(0);
|
||||
@@ -196,6 +222,8 @@ function makeApp(options: { tabLayout?: unknown } = {}) {
|
||||
app._sidebarFilter = '';
|
||||
app._tabRailSearch = '';
|
||||
app.tabAlerts = new Map();
|
||||
app.pendingHooks = new Map();
|
||||
app._debounceTimers = {};
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => false;
|
||||
@@ -351,6 +379,68 @@ describe('grouped rail search', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('alerted rows stay visible during a search', () => {
|
||||
/** What the app does on a hook event: pendingHooks, then updateTabAlertFromHooks (debounced render). */
|
||||
async function setHook(app: Record<string, any>, id: string, hook: string | null) {
|
||||
if (hook) app.pendingHooks.set(id, new Set([hook]));
|
||||
else app.pendingHooks.delete(id);
|
||||
app.updateTabAlertFromHooks(id);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 150));
|
||||
}
|
||||
|
||||
it('keeps a non-matching alerted row painted and its group on screen', async () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
await setHook(app, 'alpha', 'permission_prompt');
|
||||
expect(app.tabAlerts.get('alpha')).toBe('action');
|
||||
|
||||
app.setTabRailSearch('review');
|
||||
expect(visibleRows()).toEqual(['alpha', 'review']);
|
||||
expect(visibleGroups()).toEqual(['eng', 'plan']);
|
||||
// The header counts the rows it is showing, the kept one included.
|
||||
expect(document.querySelector('[data-tab-group-header="eng"] .tab-layout-group-count')?.textContent).toBe('1');
|
||||
expect(emptyNote().hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the yellow idle alert too, and still says nothing matched', async () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
await setHook(app, 'roadmap', 'idle_prompt');
|
||||
expect(app.tabAlerts.get('roadmap')).toBe('idle');
|
||||
|
||||
app.setTabRailSearch('zzz');
|
||||
expect(visibleRows()).toEqual(['roadmap']);
|
||||
expect(visibleGroups()).toEqual(['plan']);
|
||||
// matchCount is text matches only: the note explains why the row is there.
|
||||
expect(emptyNote().hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('hides the row again once the alert clears and the rail re-renders', async () => {
|
||||
const app = makeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
app.setTabRailSearch('review');
|
||||
expect(visibleRows()).toEqual(['review']);
|
||||
|
||||
await setHook(app, 'alpha', 'elicitation_dialog');
|
||||
expect(visibleRows()).toEqual(['alpha', 'review']);
|
||||
|
||||
await setHook(app, 'alpha', null);
|
||||
expect(app.tabAlerts.has('alpha')).toBe(false);
|
||||
expect(visibleRows()).toEqual(['review']);
|
||||
expect(visibleGroups()).toEqual(['plan']);
|
||||
});
|
||||
|
||||
it('applies to the sidebar filter box as well', async () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||||
app.isSessionSidebarActive = () => true;
|
||||
app._fullRenderSessionTabs();
|
||||
await setHook(app, 'notes', 'permission_prompt');
|
||||
app.applySidebarFilter('/srv/api');
|
||||
expect(visibleRows()).toEqual(['roadmap', 'notes']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flat rail search (no groups)', () => {
|
||||
it('filters the flat list, keeps it a tablist, and reports an empty result', () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
|
||||
Reference in New Issue
Block a user