re-expanding it is instant and cannot desync from a turn
that kept running underneath. */}
- {turn.messageIds.map((messageId) => {
- const message = messageById.get(messageId);
- if (!message) return null;
- return (
-
- );
- })}
+ {rowsOf(turn.messageIds, messageById).map(({ message, carriedIds }) => (
+
+ ))}
);
@@ -445,6 +443,41 @@ export const MessageList = React.forwardRef
const ZERO = (): number => 0;
+interface Row {
+ message: ChatMessage;
+ /** The silent steps this row speaks for, oldest first, comma-joined. */
+ carriedIds: string;
+}
+
+/**
+ * The rows a turn actually draws, and which silent steps each one carries.
+ *
+ * A step that produced only tool calls and reasoning has no row (see
+ * MessageBubble). Its id is held here until a message that does speak comes
+ * along, and handed to that message so its pill counts the whole stretch and
+ * opens the trace at the start of it.
+ *
+ * Silent steps still pending at the end of a turn are simply dropped: there is
+ * no reply to hang them on, and inventing a row for them is the thing this
+ * removes. Nothing is lost — the turn strip still counts them and the rail
+ * still holds every event.
+ */
+function rowsOf(messageIds: string[], byId: Map): Row[] {
+ const rows: Row[] = [];
+ let silent: string[] = [];
+ for (const messageId of messageIds) {
+ const message = byId.get(messageId);
+ if (!message) continue;
+ if (!hasVisibleContent(message)) {
+ silent.push(message.id);
+ continue;
+ }
+ rows.push({ message, carriedIds: silent.join(',') });
+ silent = [];
+ }
+ return rows;
+}
+
/**
* Escape an id for use inside an attribute selector.
*
diff --git a/test/browser/checks.ts b/test/browser/checks.ts
index b7b92db..d3fe6a8 100644
--- a/test/browser/checks.ts
+++ b/test/browser/checks.ts
@@ -235,6 +235,7 @@ async function run(): Promise {
await checkTheComposerShrinksWithTheWorkspaceRail();
await checkTheFixedBarsNeverWrap();
await checkALiveAnswerAppearsAsItStreams();
+ await checkSilentStepsLeaveNoRowButKeepTheirTrace();
await checkThePhoneLayoutIsUsable();
await checkThePhoneShellSurfacesAreUsable();
@@ -1054,6 +1055,159 @@ async function checkALiveAnswerAppearsAsItStreams(): Promise {
host.remove();
}
+/**
+ * Issue #46 — a step that only ran commands leaves no row, and the next reply
+ * speaks for it.
+ *
+ * A static render can count rows but cannot click, and the half of this that
+ * matters is the click: the pill on the reply has to open the trace *at the
+ * start* of the stretch it counts, not at its own first call. That is a real
+ * event handler feeding real state into the rail, so it needs a real browser.
+ */
+async function checkSilentStepsLeaveNoRowButKeepTheirTrace(): Promise {
+ const host = document.createElement('div');
+ host.style.cssText = 'width:1280px;height:760px;position:absolute;top:0;left:0;display:flex';
+ document.body.appendChild(host);
+
+ const tool = (id: string, command: string, output: string): unknown => ({
+ kind: 'tool', toolId: id, name: 'bash', toolKind: 'execute',
+ status: 'completed', input: { command }, output, durationMs: 1200,
+ });
+
+ const controller = new ChatController('silent-check', { send: () => {} });
+ controller.handle({
+ type: 'chat_snapshot',
+ sessionId: 'silent-check',
+ snapshot: {
+ sessionId: 'silent-check', runtime: 'claude', state: 'idle',
+ capabilities: {
+ streaming: true, thinking: true, toolCalls: true, diffs: true, permissions: true,
+ interrupt: true, resume: true, fork: false, attachments: true, usage: true,
+ cost: true, plan: true, commands: [],
+ },
+ messages: [
+ {
+ id: 'u1', seq: 1, turnId: 't1', role: 'user', ts: Date.now(),
+ blocks: [{ kind: 'text', text: 'run the tests' }],
+ },
+ // Two steps that said nothing at all — the rows #46 removes.
+ {
+ id: 'a1', seq: 2, turnId: 't1', role: 'assistant', ts: Date.now(),
+ blocks: [tool('x1', 'npm run lint', 'THE-FIRST-SILENT-STEP')],
+ },
+ {
+ id: 'a2', seq: 3, turnId: 't1', role: 'assistant', ts: Date.now(),
+ blocks: [tool('x2', 'npm run typecheck', 'THE-SECOND-SILENT-STEP')],
+ },
+ // …and the reply that finally arrives, which has to speak for them.
+ {
+ id: 'a3', seq: 4, turnId: 't1', role: 'assistant', ts: Date.now(),
+ blocks: [
+ { kind: 'text', text: 'all green' },
+ tool('x3', 'npm test', 'ITS-OWN-STEP'),
+ ],
+ },
+ ],
+ pendingPermissions: [], queued: [], firstSeq: 1, replayFrom: 1, cursor: 4,
+ live: true, bypassPermissions: false,
+ },
+ } as never);
+
+ const root = createRoot(host);
+ root.render(
+ React.createElement(ChatView, {
+ controller,
+ runtime: 'claude',
+ runtimeLabel: 'Claude Code',
+ workingDir: '/home/dev/project',
+ branch: 'main',
+ view: { ...DEFAULT_CHAT_VIEW },
+ onViewChange: () => {},
+ } as never),
+ );
+ await wait(400);
+
+ const rows = (): HTMLElement[] =>
+ Array.from(host.querySelectorAll('[aria-label="Assistant message"]'));
+
+ check(
+ 'a step that only ran commands gets no row of its own',
+ rows().length === 1,
+ `${rows().length} assistant rows: ${rows().map((r) => (r.textContent || '').slice(0, 24)).join(' | ')}`,
+ );
+
+ const reply = rows()[0];
+ const replyText = reply ? reply.textContent || '' : '';
+ check(
+ 'the reply that follows them counts the whole stretch',
+ replyText.includes('all green') && replyText.includes('3 commands'),
+ replyText.slice(0, 160),
+ );
+
+ // The trace never lost any of it — the rail is the record, and it is open.
+ const railText = (): string => host.textContent || '';
+ check(
+ 'every silent step is still on the trace',
+ railText().includes('npm run lint') && railText().includes('npm run typecheck'),
+ 'looked for both suppressed commands on the rail',
+ );
+
+ const pill = Array.from(host.querySelectorAll('button'))
+ .find((node) => (node.textContent || '').includes('show work'));
+ check('the reply carries a way into the trace', Boolean(pill), pill ? 'work pill found' : 'no work pill on the reply');
+ pill?.click();
+ await wait(300);
+
+ // Focusing a row expands it, so the first silent step's output is the proof
+ // that the pill landed at the start of the stretch rather than on its own call.
+ check(
+ 'it opens the trace at the first silent step, not at the reply’s own call',
+ railText().includes('THE-FIRST-SILENT-STEP'),
+ railText().includes('THE-FIRST-SILENT-STEP')
+ ? 'the first silent step is expanded'
+ : railText().includes('ITS-OWN-STEP')
+ ? 'the pill focused the reply’s own call'
+ : 'nothing was expanded',
+ );
+
+ // And live: a silent step arriving mid-stream must not add a row either.
+ const event = (payload: unknown): void =>
+ controller.handle({ type: 'chat_event', sessionId: 'silent-check', event: payload } as never);
+ event({ t: 'msg_start', id: 'u2', seq: 5, turnId: 't2', role: 'user', ts: Date.now() });
+ event({ t: 'block_start', msgId: 'u2', index: 0, block: { kind: 'text', text: 'and again' } });
+ event({ t: 'msg_end', msgId: 'u2' });
+ event({ t: 'msg_start', id: 'a4', seq: 6, turnId: 't2', role: 'assistant', ts: Date.now() });
+ event({
+ t: 'block_start',
+ msgId: 'a4',
+ index: 0,
+ block: tool('x4', 'npm run build', 'A-LIVE-SILENT-STEP'),
+ });
+ await wait(300);
+
+ check(
+ 'a silent step arriving live adds no row while it runs',
+ rows().length === 1,
+ `${rows().length} assistant row(s) while the second turn is only tools`,
+ );
+
+ event({ t: 'msg_end', msgId: 'a4' });
+ event({ t: 'msg_start', id: 'a5', seq: 7, turnId: 't2', role: 'assistant', ts: Date.now() });
+ event({ t: 'block_start', msgId: 'a5', index: 0, block: { kind: 'text', text: 'built' } });
+ await wait(300);
+
+ const live = rows()[1];
+ const liveText = live ? live.textContent || '' : '';
+ check(
+ 'the reply appears with the live step already counted on it',
+ liveText.includes('built') && liveText.includes('1 command'),
+ liveText.slice(0, 160) || 'the streamed reply never rendered',
+ );
+
+ root.unmount();
+ host.remove();
+}
+
/* -------------------------------------------------------------------------
* The phone (issue #51)
*
diff --git a/test/browser/run.js b/test/browser/run.js
index 1c84d82..dc3033f 100755
--- a/test/browser/run.js
+++ b/test/browser/run.js
@@ -58,7 +58,11 @@ const out = execFileSync(
// `elementFromPoint` returns null off-screen, which reads as "nothing is
// covering this control" for a control that is not on screen at all.
'--window-size=1600,1000',
- '--virtual-time-budget=20000',
+ // Virtual milliseconds, so this costs wall-clock only while something is
+ // actually waiting. It is a deadline for the whole suite, and a suite that
+ // outgrows it does not report failures — it dumps a page with no results
+ // at all, which is why this has room over what the checks currently need.
+ '--virtual-time-budget=40000',
'--dump-dom',
`file://${path.join(dir, 'page.html')}`,
],
diff --git a/test/chat-messages.test.js b/test/chat-messages.test.js
index 17a262f..b46c9e6 100644
--- a/test/chat-messages.test.js
+++ b/test/chat-messages.test.js
@@ -196,6 +196,44 @@ describe('MessageBubble', function () {
assert.strictEqual(html, '');
});
+ // Issue #46. A step the agent spent entirely on tools said nothing, and a row
+ // with a glyph, a clock and a pill on it is a row the eye stops on to learn
+ // that nothing was said.
+ it('gives a step that only ran commands no row of its own', function () {
+ const tools = [EVERY_BLOCK[1], EVERY_BLOCK[2]];
+ assert.strictEqual(renderBubble(message({ blocks: tools }), { onShowWork: () => {} }), '');
+ // While it is still running, too — the live ribbon is what says the agent
+ // is working, and a row that appears and then vanishes is worse than none.
+ assert.strictEqual(
+ renderBubble(message({ blocks: tools, streaming: true }), { onShowWork: () => {} }),
+ '',
+ );
+ });
+
+ it('still shows the caret for a reply that has opened but produced nothing', function () {
+ // The gap between sending and the first block: a reply about to happen, not
+ // machinery, so suppressing tool-only steps must not take it with them.
+ const html = renderBubble(message({ blocks: [], streaming: true }), { onShowWork: () => {} });
+ assert.ok(/relay-cursor-blink/.test(html));
+ });
+
+ it('counts the silent steps it was handed, not only its own work', function () {
+ const { renderToStaticMarkup, React, MessageBubble } = mod;
+ const silent = message({ blocks: [EVERY_BLOCK[2], EVERY_BLOCK[2]] });
+ const spoke = message({ blocks: [{ kind: 'text', text: 'done' }, EVERY_BLOCK[2]] });
+ const t = transcriptOf([silent, spoke]);
+ const html = renderToStaticMarkup(
+ React.createElement(MessageBubble, {
+ message: spoke,
+ transcript: t,
+ onShowWork: () => {},
+ carriedIds: silent.id,
+ }),
+ );
+ assert.ok(/3 commands/.test(html), 'the pill speaks for the whole stretch');
+ assert.ok(/show work/.test(html));
+ });
+
it('tells the retry handler which message it belongs to', function () {
// It used to take no argument, so the handler guessed — and it guessed
// "whichever turn is selected", which is not the one you clicked.
@@ -408,6 +446,46 @@ describe('MessageList', function () {
assert.ok(/relay-cursor-blink/.test(html));
});
+ // Issue #46: the whole point of dropping the silent rows is that their work
+ // still has to be reachable from the conversation.
+ it('hands a silent step’s work to the reply that follows it', function () {
+ const tool = () => ({
+ kind: 'tool',
+ toolId: `t${Math.random()}`,
+ name: 'bash',
+ toolKind: 'execute',
+ status: 'completed',
+ input: { command: 'npm test' },
+ });
+ const html = renderList(
+ [
+ message({ role: 'user', blocks: [{ kind: 'text', text: 'run the tests' }] }),
+ message({ blocks: [tool(), tool()] }),
+ message({ blocks: [{ kind: 'text', text: 'all green' }, tool()] }),
+ ],
+ { onShowWork: () => {} },
+ );
+
+ const rows = html.match(/aria-label="Assistant message"/g) || [];
+ assert.strictEqual(rows.length, 1, 'the silent step must not be a row of its own');
+ assert.ok(/all green/.test(html), 'the reply is still there');
+ assert.ok(/3 commands/.test(html), 'and its pill counts the stretch that led to it');
+ });
+
+ it('drops silent steps a turn never followed with a reply', function () {
+ // Nothing to hang them on, and inventing a row for them is the thing this
+ // removes. The turn strip still counts them and the rail still holds them.
+ const html = renderList(
+ [
+ message({ role: 'user', blocks: [{ kind: 'text', text: 'go' }] }),
+ message({ blocks: [EVERY_BLOCK[2]] }),
+ ],
+ { onShowWork: () => {} },
+ );
+ assert.ok(!/aria-label="Assistant message"/.test(html));
+ assert.ok(/turn 1/.test(html), 'the turn itself is still on screen');
+ });
+
it('renders a 500-message transcript', function () {
this.timeout(30000);
const many = [];
diff --git a/test/chat-view.test.js b/test/chat-view.test.js
index f13bba4..ff96e00 100644
--- a/test/chat-view.test.js
+++ b/test/chat-view.test.js
@@ -21,7 +21,7 @@ before(function () {
const contents = [
`export { renderToStaticMarkup } from 'react-dom/server';`,
`export * as React from 'react';`,
- `export { ChatView } from ${JSON.stringify(path.join(CHAT_DIR, 'ChatView'))};`,
+ `export { ChatView, rowFor } from ${JSON.stringify(path.join(CHAT_DIR, 'ChatView'))};`,
`export { ChatController } from ${JSON.stringify(path.join(ROOT, 'src', 'client', 'chat', 'controller'))};`,
`export * as viewSettings from ${JSON.stringify(path.join(ROOT, 'src', 'client', 'chat', 'view-settings'))};`,
].join('\n');
@@ -612,6 +612,46 @@ describe('ChatView', function () {
assert.deepStrictEqual(hex, null, `hardcoded colours found: ${hex && hex.join(', ')}`);
});
+ // Issue #46: a step that only ran commands has no row, so a trace row or a
+ // search hit pointing at one has to land on the reply that speaks for it —
+ // otherwise clicking it scrolls to an element that is not in the document,
+ // which looks exactly like a control that does nothing.
+ it('resolves a silent step to the row that carries its work', function () {
+ const tool = {
+ kind: 'tool', toolId: 'x1', name: 'bash', toolKind: 'execute',
+ status: 'completed', input: { command: 'npm test' },
+ };
+ const controller = controllerWith({
+ messages: [
+ message('u1', 1, 'user', 'run the tests'),
+ { ...message('a1', 2, 'assistant', ''), blocks: [tool] },
+ { ...message('a2', 3, 'assistant', ''), blocks: [tool] },
+ message('a3', 4, 'assistant', 'all green'),
+ ],
+ cursor: 4,
+ });
+ const turn = { messageIds: ['u1', 'a1', 'a2', 'a3'] };
+ const { rowFor } = mod;
+
+ assert.strictEqual(rowFor('a1', turn, controller.transcript), 'a3', 'forward to the reply');
+ assert.strictEqual(rowFor('a3', turn, controller.transcript), 'a3', 'a row stands for itself');
+
+ // A turn whose tail is all machinery has no reply to fall forward to, so it
+ // lands on the last row above rather than nowhere at all.
+ const trailing = controllerWith({
+ messages: [
+ message('u1', 1, 'user', 'go'),
+ message('a1', 2, 'assistant', 'starting'),
+ { ...message('a2', 3, 'assistant', ''), blocks: [tool] },
+ ],
+ cursor: 3,
+ });
+ assert.strictEqual(
+ rowFor('a2', { messageIds: ['u1', 'a1', 'a2'] }, trailing.transcript),
+ 'a1',
+ );
+ });
+
it('wires the composer and the approval buttons to the controller', function () {
// The rendered markup cannot prove a callback landed, so the wiring is
// checked against the messages the controller would put on the socket.