diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index 0b2628ae..521b7eef 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -734,6 +734,20 @@ liveness/attach state. The only thing this row's absent transcript changes is in the renderer: `sidebar.js` does not render the `.session-jsonl-btn` for a `placeholder` row, since there is nothing to view. +## Surfacing status on the session object (`.session-meta` layout, issue #286) + +`buildSessionItem`'s `.session-meta` row used to append `statusEl` only when +`session.status` was truthy, and the row was laid out with +`justify-content: space-between` — with two children (time, short id) that +pins the short id to the far right, with three (status appended) it lands in +the middle, so the short id visibly jumped depending on whether that session +happened to have a live process. `statusEl` is now always created (empty +`textContent` when there is no status), keeping the DOM order +(`session-time`, `session-short-id`, `session-status`) and child count +constant; the CSS dropped `space-between` for a plain `gap`, with +`margin-left: auto` on `.session-status` alone so it — not the short id — +is the element whose position depends on how much room is left. + ## Known limits - **The remote-stop pid-reuse guard is weak.** `remote-stop.js`'s diff --git a/public/sidebar.js b/public/sidebar.js index cb025de6..4c43d34f 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -1414,7 +1414,15 @@ function buildSessionItem(session) { shortIdEl.className = 'session-short-id'; shortIdEl.title = session.sessionId; shortIdEl.textContent = session.sessionId.split('-')[0]; - metaEl.append(timeEl, shortIdEl); + + // see .ai/contexts/session-state.md ("Surfacing status on the session object") + const statusEl = document.createElement('span'); + statusEl.className = 'session-status'; + if (session.status) { + const age = formatStatusAge(session.statusUpdatedAt); + statusEl.textContent = session.status + (age ? ' · ' + age : ''); + } + metaEl.append(timeEl, shortIdEl, statusEl); if (session.remoteAlias) { const badge = document.createElement('span'); @@ -1426,15 +1434,6 @@ function buildSessionItem(session) { summaryEl.prepend(badge); } - // see .ai/contexts/cli-session-state.md ("Surfacing status on the session object") - if (session.status) { - const age = formatStatusAge(session.statusUpdatedAt); - const statusEl = document.createElement('span'); - statusEl.className = 'session-status'; - statusEl.textContent = session.status + (age ? ' · ' + age : ''); - metaEl.appendChild(statusEl); - } - if (session.type === 'terminal') { const badge = document.createElement('span'); badge.className = 'terminal-badge'; diff --git a/public/style.css b/public/style.css index 71334b16..c45b4fe2 100644 --- a/public/style.css +++ b/public/style.css @@ -1106,14 +1106,17 @@ body { display: flex; flex-direction: column; } margin-top: 3px; } -/* Sidebar session items: time on the left, short session id on the right */ +/* Sidebar session items: time, short id, then status pinned right — see .ai/contexts/session-state.md */ .session-item .session-meta { display: flex; align-items: baseline; - justify-content: space-between; gap: 8px; } +.session-item .session-meta .session-status { + margin-left: auto; +} + .session-short-id { font-family: 'SF Mono', 'Fira Code', Menlo, monospace; color: #5a5a72; diff --git a/test/dom-sidebar-local-status.test.js b/test/dom-sidebar-local-status.test.js index 198a1c6f..83c96b20 100644 --- a/test/dom-sidebar-local-status.test.js +++ b/test/dom-sidebar-local-status.test.js @@ -37,7 +37,7 @@ test('a local session with a status renders the state+age line', () => { } finally { ctx.destroy(); } }); -test('a local session with no status renders no status line', () => { +test('a local session with no status renders an empty status slot (issue #286: slot stays in the DOM to keep layout stable)', () => { const ctx = setupSidebarDom(); try { const session = { @@ -51,7 +51,9 @@ test('a local session with no status renders no status line', () => { const item = ctx.sidebar.buildSessionItem(session); - assert.equal(item.querySelector('.session-status'), null, 'no status field must mean no status line'); + const statusEl = item.querySelector('.session-status'); + assert.ok(statusEl, 'no status field must still render the status slot, empty'); + assert.equal(statusEl.textContent, '', 'no status field must mean no status text'); } finally { ctx.destroy(); } }); @@ -87,6 +89,30 @@ test('a remote session still renders both its remote badge and the status line', } finally { ctx.destroy(); } }); +test('.session-meta renders the same three children in the same order whether session.status is set or not (issue #286)', () => { + const ctx = setupSidebarDom(); + try { + const base = { + summary: 'local session', + modified: '2026-09-09T11:59:00.000Z', + starred: false, + archived: 0, + messageCount: 1, + }; + + const withStatus = ctx.sidebar.buildSessionItem({ ...base, sessionId: 'meta-order-with-status', status: 'idle', statusUpdatedAt: Date.now() }); + const withoutStatus = ctx.sidebar.buildSessionItem({ ...base, sessionId: 'meta-order-without-status' }); + + for (const item of [withStatus, withoutStatus]) { + const meta = item.querySelector('.session-meta'); + assert.ok(meta, '.session-meta must exist'); + const classes = Array.from(meta.children).map((el) => el.className); + assert.deepEqual(classes, ['session-time', 'session-short-id', 'session-status'], + '.session-meta must always render the same three children in the same order'); + } + } finally { ctx.destroy(); } +}); + test('renderProjects wires a real project fixture with a mix of local and remote sessions correctly', () => { const ctx = setupSidebarDom(); try { diff --git a/test/session-meta-layout-css.test.js b/test/session-meta-layout-css.test.js new file mode 100644 index 00000000..ecefed09 --- /dev/null +++ b/test/session-meta-layout-css.test.js @@ -0,0 +1,40 @@ +// Issue #286 — `.session-meta`'s short session id used to shift between the +// far right (two children: time, short id) and the middle (three children: +// status also appended) depending on whether the row currently had a live +// process. See .ai/contexts/session-state.md ("Surfacing status on the +// session object (`.session-meta` layout, issue #286)"). +// +// Same source-grep shape as test/session-icon-slot-css-boundary.test.js — no +// real CSS parser, just brace/comment stripping good enough to isolate +// selector text. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const CSS = fs.readFileSync(path.join(__dirname, '..', 'public', 'style.css'), 'utf8'); +const CSS_NO_COMMENTS = CSS.replace(/\/\*[\s\S]*?\*\//g, ''); + +function ruleFor(selectorPattern) { + const ruleBlocks = CSS_NO_COMMENTS.match(/[^{}]+\{[^{}]*\}/g) || []; + return ruleBlocks.find((block) => { + const lines = block.split('{')[0].split('\n'); + const selector = lines[lines.length - 1].trim(); + return selectorPattern.test(selector); + }); +} + +test('style.css: .session-item .session-meta no longer uses justify-content: space-between', () => { + const rule = ruleFor(/^\.session-item \.session-meta$/); + assert.ok(rule, 'expected a .session-item .session-meta rule to exist'); + assert.doesNotMatch(rule, /justify-content:\s*space-between/, + 'space-between makes a middle child\'s position depend on how many siblings it has — see issue #286'); +}); + +test('style.css: .session-status is pinned to the right independently of the other .session-meta children', () => { + const rule = ruleFor(/\.session-meta \.session-status$/); + assert.ok(rule, 'expected a rule targeting .session-item .session-meta .session-status'); + assert.match(rule, /margin-left:\s*auto/, + 'the status slot, not the short id, should be the element whose position absorbs leftover space'); +});