Skip to content

Commit 2216619

Browse files
committed
feat(chat): keep the prose a run writes between calls on the page, one work block per run of calls
A turn's block spanned its first tool call to its last, so everything the model said in between folded away and a live turn read as one status line that finally yielded one paragraph. Fold each run of consecutive calls into its own block instead and leave the prose between runs where it was said, the Codex/Cursor layout: text, Worked for 12s, text, Worked for 5s, answer. While running, prose after a block is followed by a fresh empty block until the next call fills it, and only the tail block reads the live activity. Each block's duration is measured from the prose before it; the streaming row keeps its first delta's timestamp so that duration does not tick while the answer streams.
1 parent eb940ff commit 2216619

8 files changed

Lines changed: 220 additions & 110 deletions

File tree

docs/V2-SESSION-HANDOFF.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,9 +285,19 @@ lived on that strip. Now there is one progress surface:
285285
- Transcript: at compact/balanced the last turn gets a work block from the
286286
first send — empty `Thinking… · 3s` row (`RunningActivityRow`,
287287
`variant="pending-block"`) until a tool lands, then
288-
`Working · <live activity> · <elapsed>`. A finished turn with no tools
288+
`<live activity> · <elapsed>`. A finished turn with no tools
289289
has no block. Detailed density still has no block; it shows an inline
290290
running row instead (`variant="inline"`).
291+
- Since the 2026-09-02 late-night pass a turn has **one block per run of
292+
consecutive calls**, not one per turn: prose the model writes between
293+
calls stays outside, in order (`Let me look.` / `Worked for 12s · 3 files
294+
read` / `Found it, fixing.` / `Worked for 5s · 2 edits` / answer), the
295+
Codex/Cursor layout. While running, prose after a block is followed by a
296+
fresh empty block until the next call fills it. `TurnWorkBlockItem` is
297+
`{ startedAt, endedAt, isTail }` — each block's `Worked for` is measured
298+
from the prose before it, and only the tail block reads the live activity.
299+
`updateStreaming` keeps the first delta's timestamp so that duration does
300+
not tick while the answer streams.
291301
- `ActivityIndicator.tsx` and its CSS (`chat-activity-*`) are gone.
292302

293303
Tests: composer static markup (Stop / no strip),

src/components/chat/tests/turnWork.test.ts

Lines changed: 77 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import { buildPaneList, foldTurnWork, formatTurnWorkCounts, isPendingWorkBlock,
1010
import type { PaneListItem, TurnWorkBlockItem } from '../utils/turnWork';
1111

1212
/*
13-
* The work block is a pure fold over the message list: where a turn's tool
14-
* calls start and end, what stays outside, and what the summary row counts.
13+
* The work block is a pure fold over the message list: which runs of tool
14+
* calls become blocks, what stays outside, and what the summary row counts.
1515
*/
1616

1717
const at = (seconds: number) => new Date(Date.UTC(2026, 8, 2, 0, 0, seconds)).toISOString();
@@ -35,7 +35,7 @@ const label = (item: PaneListItem | ChatMessage | TurnWorkBlockItem | MessageLis
3535
return `${message.type}:${message.content}`;
3636
};
3737

38-
test('a turn with tool calls folds them into one block, from the first call to the last', () => {
38+
test('a turn with tool calls folds each run of consecutive calls into one block', () => {
3939
const items = foldTurnWork([
4040
user('go', 0),
4141
text('Let me look.', 1),
@@ -46,27 +46,33 @@ test('a turn with tool calls folds them into one block, from the first call to t
4646

4747
assert.deepEqual(items.map(label), ['user:go', 'assistant:Let me look.', '[work read bash]', 'assistant:Done.']);
4848
const block = items[2] as TurnWorkBlockItem;
49-
assert.equal(block.turnStartedAt, at(0));
50-
assert.equal(block.turnEndedAt, at(4));
51-
assert.equal(block.isLastTurn, true);
49+
// The block's clock starts at the prose before it, not the user message.
50+
assert.equal(block.startedAt, at(1));
51+
assert.equal(block.endedAt, at(4));
52+
assert.equal(block.isTail, true);
5253
});
5354

54-
test('narration between calls stays inside the block, in order; the answer after the last call stays out', () => {
55-
// The transcript is read in order and the model writes between calls, so the
56-
// block cannot know a sentence is "the answer" until nothing follows it.
57-
// Everything between the first and last call is scaffolding around the
58-
// answer; what comes after the last call is the answer.
55+
test('prose between calls stays outside, in order, and cuts the work into one block per run of calls', () => {
56+
// What the model says is never behind a fold: the turn reads as prose and
57+
// work alternating, the way Codex and Cursor lay it out.
5958
const items = foldTurnWork([
6059
user('go', 0),
6160
call('read', 1),
6261
text('Found it, now checking the tests.', 2),
6362
call('bash', 3),
64-
text('All green.', 4),
63+
call('bash', 4),
64+
text('All green.', 5),
6565
]);
6666

67-
assert.deepEqual(items.map(label), ['user:go', '[work read assistant:Found it, now checking the tests. bash]', 'assistant:All green.']);
67+
assert.deepEqual(items.map(label), [
68+
'user:go', '[work read]', 'assistant:Found it, now checking the tests.', '[work bash bash]', 'assistant:All green.',
69+
]);
70+
const blocks = items.filter(isTurnWorkBlockItem);
71+
assert.deepEqual(blocks.map((block) => [block.startedAt, block.endedAt]), [[at(0), at(2)], [at(2), at(5)]]);
72+
// Only the last block can be the one a run is working on.
73+
assert.deepEqual(blocks.map((block) => block.isTail), [false, true]);
6874

69-
// A call arriving after that answer turns it into narration: it moves inside.
75+
// A call arriving after that prose does not move it: it opens a new block below.
7076
const later = foldTurnWork([
7177
user('go', 0),
7278
call('read', 1),
@@ -75,13 +81,19 @@ test('narration between calls stays inside the block, in order; the answer after
7581
text('All green.', 4),
7682
call('edit', 5),
7783
]);
78-
assert.deepEqual(later.map(label), ['user:go', '[work read assistant:Found it, now checking the tests. bash assistant:All green. edit]']);
84+
assert.deepEqual(later.map(label), [
85+
'user:go', '[work read]', 'assistant:Found it, now checking the tests.', '[work bash]', 'assistant:All green.', '[work edit]',
86+
]);
87+
assert.deepEqual(later.filter(isTurnWorkBlockItem).map((block) => block.isTail), [false, false, true]);
7988
});
8089

81-
test('reasoning is not work: a thought inside the span is hoisted ahead of the block', () => {
90+
test('reasoning is not work: a thought between calls is hoisted ahead of the block and does not split it', () => {
8291
const items = foldTurnWork([user('go', 0), call('read', 1), thought(2), call('bash', 3)]);
83-
8492
assert.deepEqual(items.map(label), ['user:go', 'thought', '[work read bash]']);
93+
94+
// A thought after the run's last call falls outside, after the block.
95+
const trailing = foldTurnWork([user('go', 0), call('read', 1), thought(2), text('so', 3), thought(4)]);
96+
assert.deepEqual(trailing.map(label), ['user:go', '[work read]', 'thought', 'assistant:so', 'thought']);
8597
});
8698

8799
test('every turn gets its own block, and only the last turn can be the live one', () => {
@@ -97,16 +109,16 @@ test('every turn gets its own block, and only the last turn can be the live one'
97109
'user:third', '[work bash]',
98110
]);
99111
const blocks = items.filter(isTurnWorkBlockItem);
100-
assert.deepEqual(blocks.map((block) => block.isLastTurn), [false, true]);
101-
assert.deepEqual(blocks.map((block) => block.turnStartedAt), [at(0), at(5)]);
102-
assert.deepEqual(blocks.map((block) => block.turnEndedAt), [at(2), null]);
112+
assert.deepEqual(blocks.map((block) => block.isTail), [false, true]);
113+
assert.deepEqual(blocks.map((block) => block.startedAt), [at(0), at(5)]);
114+
assert.deepEqual(blocks.map((block) => block.endedAt), [at(2), null]);
103115
});
104116

105117
test('a window that starts mid-turn still folds, without a turn start', () => {
106118
const items = foldTurnWork([call('read', 1), call('bash', 2), text('done', 3)]);
107119

108120
assert.deepEqual(items.map(label), ['[work read bash]', 'assistant:done']);
109-
assert.equal((items[0] as TurnWorkBlockItem).turnStartedAt, null);
121+
assert.equal((items[0] as TurnWorkBlockItem).startedAt, null);
110122
});
111123

112124
test('a lone read is still a block: one rule, no special case', () => {
@@ -119,9 +131,9 @@ test('a live turn has a block before its first tool call: empty, at the end of t
119131
assert.deepEqual(justSent.map(label), ['user:go', '[work ]']);
120132
const block = justSent[1] as TurnWorkBlockItem;
121133
assert.equal(isPendingWorkBlock(block), true);
122-
assert.equal(block.isLastTurn, true);
123-
assert.equal(block.turnStartedAt, at(0));
124-
assert.equal(block.turnEndedAt, null);
134+
assert.equal(block.isTail, true);
135+
assert.equal(block.startedAt, at(0));
136+
assert.equal(block.endedAt, null);
125137
assert.equal(block.timestamp, at(0));
126138

127139
// Prose before the first call stays above the block, as it does once a call lands.
@@ -133,6 +145,18 @@ test('a live turn has a block before its first tool call: empty, at the end of t
133145
const started = foldTurnWork([user('go', 0), text('Let me look.', 1), call('read', 2)], 'balanced', { running: true });
134146
assert.deepEqual(started.map(label), ['user:go', 'assistant:Let me look.', '[work read]']);
135147
assert.equal(isPendingWorkBlock(started[2] as TurnWorkBlockItem), false);
148+
assert.equal((started[2] as TurnWorkBlockItem).isTail, true);
149+
150+
// Prose after a block closes it - it reads `Worked for` - and a new empty
151+
// block follows the prose until the run says what comes next.
152+
const narratingAgain = foldTurnWork([user('go', 0), call('read', 1), text('Found it.', 2)], 'balanced', { running: true });
153+
assert.deepEqual(narratingAgain.map(label), ['user:go', '[work read]', 'assistant:Found it.', '[work ]']);
154+
assert.deepEqual(narratingAgain.filter(isTurnWorkBlockItem).map((block) => block.isTail), [false, true]);
155+
assert.equal((narratingAgain[3] as TurnWorkBlockItem).startedAt, at(2));
156+
// The next call fills it in place.
157+
const secondRun = foldTurnWork([user('go', 0), call('read', 1), text('Found it.', 2), call('edit', 3)], 'balanced', { running: true });
158+
assert.deepEqual(secondRun.map(label), ['user:go', '[work read]', 'assistant:Found it.', '[work edit]']);
159+
assert.deepEqual(secondRun.filter(isTurnWorkBlockItem).map((block) => [isPendingWorkBlock(block), block.isTail]), [[false, false], [false, true]]);
136160

137161
// Only the last turn can be live: earlier tool-less turns never get one.
138162
const history = foldTurnWork([user('first', 0), text('no tools', 1), user('second', 2)], 'compact', { running: true });
@@ -153,9 +177,11 @@ test('a turn that finishes with no tool call has no block: the answer stands alo
153177
// While it streams there is one; when the run ends it is gone.
154178
assert.deepEqual(buildPaneList(pureText, 'balanced', { running: true }).map(label), ['user:go', 'assistant:Sure, here is the answer.', '[work ]']);
155179
assert.equal(buildPaneList(pureText, 'balanced', { running: false }).some(isTurnWorkBlockItem), false);
156-
// Finished blocks are never pending, whatever the run state says.
180+
// A block with calls in it is never pending, whatever the run state says;
181+
// while running, the prose after it gets the pending one.
157182
const finished = buildPaneList([user('go', 0), call('read', 1), text('done', 2)], 'balanced', { running: true });
158-
assert.deepEqual(finished.filter(isTurnWorkBlockItem).map(isPendingWorkBlock), [false]);
183+
assert.deepEqual(finished.filter(isTurnWorkBlockItem).map(isPendingWorkBlock), [false, true]);
184+
assert.deepEqual(buildPaneList([user('go', 0), call('read', 1), text('done', 2)], 'balanced').filter(isTurnWorkBlockItem).map(isPendingWorkBlock), [false]);
159185
});
160186

161187
test('detailed never folds, live or not: the pane renders a running row of its own instead', () => {
@@ -204,8 +230,8 @@ test('the summary counts calls by category, edits and writes together', () => {
204230
call('todo_write', 13),
205231
text('narration is not counted', 14),
206232
],
207-
turnStartedAt: null,
208-
turnEndedAt: null,
233+
startedAt: null,
234+
endedAt: null,
209235
});
210236

211237
assert.equal(summary.total, 13);
@@ -217,56 +243,63 @@ test('the summary counts calls by category, edits and writes together', () => {
217243
});
218244

219245
test('the summary omits zero counts and pluralises the rest', () => {
220-
const one = summarizeTurnWork({ messages: [call('read', 1), call('bash', 2)], turnStartedAt: null, turnEndedAt: null });
246+
const one = summarizeTurnWork({ messages: [call('read', 1), call('bash', 2)], startedAt: null, endedAt: null });
221247
assert.deepEqual(formatTurnWorkCounts(one, t), ['1 file read', '1 command']);
222248

223-
const many = summarizeTurnWork({ messages: [call('edit', 1), call('edit', 2), call('search', 3)], turnStartedAt: null, turnEndedAt: null });
249+
const many = summarizeTurnWork({ messages: [call('edit', 1), call('edit', 2), call('search', 3)], startedAt: null, endedAt: null });
224250
assert.deepEqual(formatTurnWorkCounts(many, t), ['1 search', '2 edits']);
225251
});
226252

227253
test('failures are counted, whatever the tool', () => {
228254
const summary = summarizeTurnWork({
229255
messages: [failedCall('bash', 1), call('read', 2), failedCall('edit', 3), call('bash', 4, { toolResult: null })],
230-
turnStartedAt: null,
231-
turnEndedAt: null,
256+
startedAt: null,
257+
endedAt: null,
232258
});
233259

234260
assert.equal(summary.failed, 2);
235261
assert.equal(summary.total, 4);
236262
});
237263

238-
test('the duration runs from the turn start to the last thing the transcript saw', () => {
239-
// User message at 0, last result at 42: worked for 42s.
264+
test('the duration runs from the block start to the last thing the transcript saw', () => {
265+
// Block started at 0, last result at 42: worked for 42s.
240266
const withResults = summarizeTurnWork({
241267
messages: [
242268
call('read', 5, { toolResult: { content: '', isError: false, timestamp: at(7) } }),
243269
call('bash', 10, { toolResult: { content: '', isError: false, timestamp: at(42) } }),
244270
],
245-
turnStartedAt: at(0),
246-
turnEndedAt: null,
271+
startedAt: at(0),
272+
endedAt: null,
247273
});
248274
assert.equal(withResults.durationMs, 42_000);
249275

250276
// The answer's own timestamp extends it when it is later still.
251-
const withAnswer = summarizeTurnWork({ messages: [call('read', 5)], turnStartedAt: at(0), turnEndedAt: at(50) });
277+
const withAnswer = summarizeTurnWork({ messages: [call('read', 5)], startedAt: at(0), endedAt: at(50) });
252278
assert.equal(withAnswer.durationMs, 50_000);
253279

254-
// No turn start in the window: measured from the first call instead.
255-
const midWindow = summarizeTurnWork({ messages: [call('read', 5), call('bash', 30)], turnStartedAt: null, turnEndedAt: null });
280+
// No start in the window: measured from the first call instead.
281+
const midWindow = summarizeTurnWork({ messages: [call('read', 5), call('bash', 30)], startedAt: null, endedAt: null });
256282
assert.equal(midWindow.durationMs, 25_000);
283+
284+
// Each block of a turn measures its own run: from the prose before it to the prose after.
285+
const [first, second] = foldTurnWork([
286+
user('go', 0), call('read', 5), text('Found it.', 12), call('edit', 20), text('Done.', 25),
287+
]).filter(isTurnWorkBlockItem);
288+
assert.equal(summarizeTurnWork(first).durationMs, 12_000);
289+
assert.equal(summarizeTurnWork(second).durationMs, 13_000);
257290
});
258291

259292
test('a duration is omitted rather than guessed when the timestamps cannot support one', () => {
260293
// A single instant is not a duration.
261-
assert.equal(summarizeTurnWork({ messages: [call('read', 5)], turnStartedAt: null, turnEndedAt: null }).durationMs, null);
262-
assert.equal(summarizeTurnWork({ messages: [call('read', 5)], turnStartedAt: at(5), turnEndedAt: null }).durationMs, null);
294+
assert.equal(summarizeTurnWork({ messages: [call('read', 5)], startedAt: null, endedAt: null }).durationMs, null);
295+
assert.equal(summarizeTurnWork({ messages: [call('read', 5)], startedAt: at(5), endedAt: null }).durationMs, null);
263296
// Unparseable timestamps are ignored, not turned into NaN.
264297
const garbage = summarizeTurnWork({
265298
messages: [call('read', 1, { timestamp: 'not a date', toolResult: { content: '', isError: false, timestamp: 'nope' } })],
266-
turnStartedAt: 'when?',
267-
turnEndedAt: null,
299+
startedAt: 'when?',
300+
endedAt: null,
268301
});
269302
assert.equal(garbage.durationMs, null);
270303
// Out-of-order clocks (end before start) give nothing rather than a negative.
271-
assert.equal(summarizeTurnWork({ messages: [call('read', 1)], turnStartedAt: at(9), turnEndedAt: null }).durationMs, null);
304+
assert.equal(summarizeTurnWork({ messages: [call('read', 1)], startedAt: at(9), endedAt: null }).durationMs, null);
272305
});

src/components/chat/tests/turnWorkBlock.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,9 @@ test('no usable timestamps: "Worked" without a duration rather than a made-up on
129129
_isWorkBlock: true,
130130
messages: [call('read', 1, { toolResult: { content: 'ok', isError: false } })],
131131
timestamp: at(1),
132-
turnStartedAt: null,
133-
turnEndedAt: null,
134-
isLastTurn: true,
132+
startedAt: null,
133+
endedAt: null,
134+
isTail: true,
135135
};
136136
const html = render(block, 'balanced');
137137

0 commit comments

Comments
 (0)