Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .ai/contexts/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 9 additions & 10 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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';
Expand Down
7 changes: 5 additions & 2 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
30 changes: 28 additions & 2 deletions test/dom-sidebar-local-status.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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(); }
});

Expand Down Expand Up @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions test/session-meta-layout-css.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading