feat(sidebar): add a rich session sidebar that carries the home screen's row detail

Session List Layout gains a third option. The old "Left sidebar" becomes
"Left sidebar simple" and is unchanged down to the byte; the new "Left sidebar"
puts on each row what the desktop home rail and the phone overview already show:
when the session was first created, how long it has been in the state it is in,
and a status pill naming that state.

A docked column is not a tab strip. It has width to spare and a row per session
either way, and "name + folder" is the whole story a TAB can tell, not the whole
story there is. This is the information that was missing, and it already existed
one surface over.

Both sidebar values are the same layout, and both set data-session-list="sidebar";
the row detail rides on a separate data-sidebar-detail attribute. That split is
the load-bearing decision here: every one of the ~25 isSessionSidebarActive()
call sites and every html[data-session-list="sidebar"] rule in styles.css and
mobile.css keeps matching both variants without being touched. A third
data-session-list value would have meant auditing and editing all of them.

- Stored values: 'header', 'sidebar' (simple), 'sidebar-rich'. Anyone already on
  'sidebar' keeps exactly the layout they picked — the rename is label-only.
- State classification and the "how long has it been like this" anchor come from
  _mobileOverviewState() / _mobileOverviewSince(), not re-derived, so the three
  surfaces cannot disagree about what "working" means. A working pane repaints
  ~1/s, so its duration is measured from the turn's last Enter: a running turn
  reads "working 12m", not "0m".
- Stamps refresh in place on a 20s clock rather than by re-rendering — a rebuild
  would restart every load spinner and alert animation in the list, twice a
  minute. The clock runs only while rich rows are on screen, and is stopped from
  both render paths and from applySessionListLayout().
- The incremental render path updates the pill, the accent class and the since
  anchor; a tick alone cannot see a state change, and a new turn re-stamps
  lastSubmitAt without changing state.
- applySessionListLayout() now re-renders on a DETAIL change too. simple <-> rich
  leaves data-session-list on 'sidebar' both times, and the meta line is emitted
  by the row template rather than toggled by CSS, so the old layout-only test
  would have flipped the setting and repainted nothing.
- Width: 300px for the extra line. The collapsed 44px rail and the handheld
  drawer are both explicitly held back from it — the desktop rule is (0,3,1) and
  would otherwise out-specify mobile.css's (0,2,1) drawer base and pin a 320px
  phone's drawer to 300px.
- Missing/stale mobile-overview.js degrades to a row with no meta line rather
  than throwing and taking the whole tab strip down.

15 new tests cover the attribute split, the solo-window override, the
detail-change re-render, the row model, both render paths, the clock lifecycle
and the mobile width guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-18 16:26:34 +02:00
parent 5080390e2c
commit 98e37bf895
9 changed files with 682 additions and 24 deletions
+251 -1
View File
@@ -51,6 +51,25 @@ interface LayoutApp {
applySidebarFilter(query?: string): void;
_fullRenderSessionTabs(): void;
updateConnectionLines(): void;
isSessionSidebarRich(): boolean;
_sidebarRichRow(id: string, session: Record<string, unknown>): RichRow | null;
_sidebarRichMetaHTML(row: RichRow | null): string;
_updateSidebarRichRow(tab: Element, id: string, session: Record<string, unknown>): void;
_startSidebarRichClock(): void;
_stopSidebarRichClock(): void;
_tickSidebarRichTimes(): void;
_sidebarRichClock: ReturnType<typeof setInterval> | null;
pendingHooks?: Map<string, Set<string>>;
_mobileOverviewState?: (session: Record<string, unknown>, hooks?: Set<string>) => string;
_mobileOverviewSince?: (state: string, session: Record<string, unknown>) => { key: string; at: number } | null;
_mobileOverviewStampText?: (ts: number, fmt: string) => string;
}
interface RichRow {
state: string;
pill: string;
createdAt: number;
since: { key: string; at: number } | null;
}
/** The parts of index.html this feature touches, minus everything it does not. */
@@ -438,7 +457,7 @@ describe('session list layout wiring', () => {
// SettingsUpdateSchema is .strict() and this key is NOT in the PUT strip-list,
// so without the schema entry the server 400s the ENTIRE settings PUT and every
// unrelated setting silently stops persisting.
expect(SCHEMAS).toContain("sessionListLayout: z.enum(['header', 'sidebar']).optional()");
expect(SCHEMAS).toContain("sessionListLayout: z.enum(['header', 'sidebar', 'sidebar-rich']).optional()");
});
it('plumbs the setting through populate, collect, defaults and the display-key set', () => {
@@ -518,3 +537,234 @@ describe('session list layout wiring', () => {
expect(MOBILE_HANDLERS).toContain("e.target?.closest?.('.session-sidebar')");
});
});
/**
* The rich variant is the SAME sidebar with more on each row, and that is the
* whole reason it does not get its own `data-session-list` value: every one of
* the ~25 `isSessionSidebarActive()` call sites and every
* `html[data-session-list="sidebar"]` rule in styles.css and mobile.css has to
* keep matching it untouched. `data-session-list stays "sidebar"` below is the
* assertion that guards that, and it is the one to read first.
*/
describe('rich session sidebar', () => {
/**
* The row model is built from mobile-overview.js helpers, which the harness
* does not eval (it would drag the whole phone overview in for three
* functions). Stubbing them is also the sharper test: it pins exactly which
* shared helper each field comes from.
*/
function stubOverview(app: LayoutApp, state = 'working') {
app.pendingHooks = new Map();
app._mobileOverviewState = () => state;
app._mobileOverviewSince = (s, session) =>
s === 'working'
? { key: 'working', at: Number(session.lastSubmitAt) || 0 }
: { key: 'idle', at: Number(session.lastActivityAt) || 0 };
app._mobileOverviewStampText = (ts, fmt) => (ts ? `${fmt}:${ts}` : '—');
}
const SESSION = { createdAt: 1000, lastActivityAt: 5000, lastSubmitAt: 4000 };
it('data-session-list stays "sidebar" so every existing sidebar rule and call site still matches', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
expect(app.getSessionListLayout()).toBe('sidebar-rich');
app.applySessionListLayout();
// The load-bearing assertion: the layout attribute is NOT 'sidebar-rich'.
expect(win.document.documentElement.dataset.sessionList).toBe('sidebar');
expect(win.document.documentElement.dataset.sidebarDetail).toBe('rich');
expect(app.isSessionSidebarActive()).toBe(true);
expect(app.isSessionSidebarRich()).toBe(true);
// …and everything the simple sidebar does, it still does.
expect(tabsEl(win).parentElement?.id).toBe('sessionSidebarList');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical');
expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(false);
expect(app._tallTabsEnabled).toBe(true);
});
it('marks the simple sidebar and the header strip as not rich', () => {
for (const layout of ['sidebar', 'header']) {
const { win, app } = boot({ stored: { sessionListLayout: layout } });
app.applySessionListLayout();
expect(win.document.documentElement.dataset.sidebarDetail).toBe('simple');
expect(app.isSessionSidebarRich()).toBe(false);
}
});
it('forces a solo window back to the header strip, detail and all', () => {
// A detached window shows exactly one session: a list of it is noise, and
// #sessionTabs must never be parked inside the display:none <aside>.
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar-rich' }, solo: 'sess-1' });
expect(app.getSessionListLayout()).toBe('header');
app.applySessionListLayout();
expect(win.document.documentElement.dataset.sessionList).toBe('header');
expect(win.document.documentElement.dataset.sidebarDetail).toBe('simple');
expect(app.isSessionSidebarRich()).toBe(false);
});
it('re-renders when only the DETAIL changes, which the old layout-only test could not see', () => {
// simple ⟷ rich leaves data-session-list on 'sidebar' both times. The meta
// line is emitted by the row template, not toggled by CSS, so a missed
// re-render here means flipping the setting repaints nothing until the next
// SSE tick.
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
app.applySessionListLayout();
(app._fullRenderSessionTabs as unknown as { mockClear(): void }).mockClear();
win.localStorage.setItem('codeman-app-settings', JSON.stringify({ sessionListLayout: 'sidebar-rich' }));
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
app.applySessionListLayout();
expect(win.document.documentElement.dataset.sidebarDetail).toBe('rich');
expect(app._fullRenderSessionTabs).toHaveBeenCalled();
});
it('builds the row model from the shared overview helpers, not its own copy', () => {
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
stubOverview(app);
const row = app._sidebarRichRow('s1', SESSION)!;
expect(row.state).toBe('working');
expect(row.pill).toBe('working');
expect(row.createdAt).toBe(1000);
// A working pane repaints ~1/s, so its duration is anchored on the turn's
// last Enter (lastSubmitAt), never on lastActivityAt.
expect(row.since).toEqual({ key: 'working', at: 4000 });
});
it('degrades to no meta line when mobile-overview.js is missing or stale', () => {
// iOS Safari serves old JS after a deploy. A missing helper must cost the
// stamps line, not the whole tab strip.
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
expect(app._sidebarRichRow('s1', SESSION)).toBeNull();
expect(app._sidebarRichMetaHTML(null)).toBe('');
});
it('renders both stamps and the pill, and parks raw epochs for the clock', () => {
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
stubOverview(app);
const html = app._sidebarRichMetaHTML(app._sidebarRichRow('s1', SESSION));
expect(html).toContain('class="tab-meta"');
expect(html).toContain('>created<');
expect(html).toContain('>working<');
expect(html).toContain('tab-pill--working');
// Raw epoch-ms on the element is what lets the clock rewrite the text
// without a re-render — a re-render would restart every load spinner and
// alert animation in the list, twice a minute.
expect(html).toContain('data-tab-ts="1000" data-tab-fmt="ago"');
expect(html).toContain('data-tab-ts="4000" data-tab-fmt="for"');
// Generated relative times must not be handed to the translator.
expect(html).toContain('data-i18n-skip');
});
it('drops the second stamp when the session has never been active', () => {
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
stubOverview(app);
app._mobileOverviewSince = () => null;
const html = app._sidebarRichMetaHTML(app._sidebarRichRow('s1', { createdAt: 1000 }));
expect(html).toContain('data-tab-fmt="ago"');
expect(html).not.toContain('data-tab-fmt="for"');
// The pill is not optional: it is the row's status word.
expect(html).toContain('tab-pill--working');
});
it('rewrites the stamps in place instead of re-rendering the row', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
stubOverview(app);
app.applySessionListLayout();
tabsEl(win).innerHTML = `<div class="session-tab" data-id="s1"><span class="tab-info">${app._sidebarRichMetaHTML(
app._sidebarRichRow('s1', SESSION)
)}</span></div>`;
const metaBefore = tabsEl(win).querySelector('.tab-meta');
app._mobileOverviewStampText = (ts, fmt) => (ts ? `${fmt}:${ts}:later` : '—');
app._tickSidebarRichTimes();
expect(tabsEl(win).querySelector('.tab-meta')).toBe(metaBefore);
expect(metaBefore!.textContent).toContain('ago:1000:later');
});
it('updates the pill and the row accent when the state changes between renders', () => {
// The clock cannot see this: a state flip changes the pill, the accent class
// and which stamp the second slot is even measuring.
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
stubOverview(app);
app.applySessionListLayout();
tabsEl(win).innerHTML = '<div class="session-tab" data-id="s1"><span class="tab-info"></span></div>';
const tab = tabsEl(win).querySelector('.session-tab')!;
app._updateSidebarRichRow(tab, 's1', SESSION);
expect(tab.classList.contains('tab-state-working')).toBe(true);
expect(tab.querySelector('.tab-pill')!.textContent).toBe('working');
stubOverview(app, 'idle');
app._updateSidebarRichRow(tab, 's1', SESSION);
expect(tab.classList.contains('tab-state-working')).toBe(false);
expect(tab.classList.contains('tab-state-idle')).toBe(true);
expect(tab.querySelector('.tab-pill')!.textContent).toBe('idle');
// Idle is measured from the last byte the pane printed, not from a submit.
expect(tab.querySelector('[data-tab-fmt="for"]')!.getAttribute('data-tab-ts')).toBe('5000');
});
it('skips the DOM write when nothing the row displays has changed', () => {
// This runs for every session on every SSE tick.
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
stubOverview(app);
app.applySessionListLayout();
tabsEl(win).innerHTML = '<div class="session-tab" data-id="s1"><span class="tab-info"></span></div>';
const tab = tabsEl(win).querySelector('.session-tab')!;
app._updateSidebarRichRow(tab, 's1', SESSION);
const meta = tab.querySelector('.tab-meta');
app._updateSidebarRichRow(tab, 's1', SESSION);
expect(tab.querySelector('.tab-meta')).toBe(meta);
// …but a new turn re-stamps lastSubmitAt without changing the state, and
// that MUST still repaint: the duration is anchored on it.
app._updateSidebarRichRow(tab, 's1', { ...SESSION, lastSubmitAt: 9000 });
expect(tab.querySelector('.tab-meta')).not.toBe(meta);
expect(tab.querySelector('[data-tab-fmt="for"]')!.getAttribute('data-tab-ts')).toBe('9000');
});
it('runs the clock only while rich rows are on screen', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
app.applySessionListLayout();
expect(app._sidebarRichClock).toBeTruthy();
win.localStorage.setItem('codeman-app-settings', JSON.stringify({ sessionListLayout: 'sidebar' }));
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
app.applySessionListLayout();
// A leaked interval would keep rewriting stamps in a list that no longer
// has any, forever, on every open tab.
expect(app._sidebarRichClock).toBeNull();
});
it('emits the meta line only in the rich row template, and only inside .tab-info', () => {
// .tab-info is already a flex column, so the line needs no row-level
// wrapping — and the collapsed 44px rail hides .tab-info wholesale, which is
// what keeps the stamps out of it for free.
expect(APP).toContain('const richRows = this.isSessionSidebarRich();');
expect(APP).toContain('const richMeta = this._sidebarRichMetaHTML(richRow);');
expect(APP).toContain('${richMeta}\n </span>');
});
it('plumbs the third option through the settings UI and the pre-paint script', () => {
expect(INDEX_HTML).toContain('<option value="sidebar">Left sidebar simple</option>');
expect(INDEX_HTML).toContain('<option value="sidebar-rich">Left sidebar</option>');
// Pre-paint must resolve BOTH sidebar values to the same layout attribute,
// or the first frame paints a header strip and then jumps.
expect(INDEX_HTML).toContain("(L==='sidebar'||L==='sidebar-rich')&&!solo");
expect(INDEX_HTML).toContain("dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple'");
for (const key of ['Left sidebar simple']) expect(I18N).toContain(`'${key}'`);
});
it('keeps the desktop rich width out of the handheld drawer', () => {
// styles.css scopes the 300px column with (0,3,1) — one attribute MORE than
// mobile.css's (0,2,1) drawer base — so without a matching override in
// mobile.css it wins there too and pins a 320px phone's drawer to 300px.
expect(STYLES_CSS).toContain('--sidebar-width-rich');
expect(STYLES_CSS).toContain('html[data-session-list="sidebar"][data-sidebar-detail="rich"] .session-sidebar {');
expect(MOBILE_CSS).toContain('html[data-session-list="sidebar"][data-sidebar-detail="rich"] .session-sidebar {');
});
});