diff --git a/src/renderer/src/RoundtableView.tsx b/src/renderer/src/RoundtableView.tsx index 8feacca8..f18a64bb 100644 --- a/src/renderer/src/RoundtableView.tsx +++ b/src/renderer/src/RoundtableView.tsx @@ -31,17 +31,29 @@ import { BranchChip, ChatIcon, ProviderLogo, PROVIDER_LABEL } from './logos' /** Same DOM bound as ChatView, scaled to discussion-length transcripts. */ const RENDER_LAST = 200 -type LiveTool = { readonly toolName: string; readonly detail: string; readonly preview?: string } +type LivePart = + | { readonly kind: 'text'; readonly text: string } + | { readonly kind: 'tool'; readonly toolName: string; readonly detail: string; readonly preview?: string } /** One seat's in-flight turn as the view sees it. */ type LiveTurn = { - readonly text: string - readonly tools: readonly LiveTool[] + /** What the seat has said and run so far, in the order it happened */ + readonly parts: readonly LivePart[] /** Epoch ms the turn started — how long the seat has been at it */ readonly since?: number } /** Keyed by participant index — several seats may share a provider. */ type LiveMap = Partial> +/** Streamed text grows the passage it continues; after a tool call it starts a new one. */ +function withText(turn: LiveTurn | undefined, text: string): LiveTurn { + const cur = turn ?? { parts: [] } + if (text === '') return cur + const last = cur.parts[cur.parts.length - 1] + return last?.kind === 'text' + ? { ...cur, parts: [...cur.parts.slice(0, -1), { kind: 'text', text: last.text + text }] } + : { ...cur, parts: [...cur.parts, { kind: 'text', text }] } +} + /** UI seat name: "Claude", or "Claude · opus" / "Claude #2" when a provider repeats. */ function uiSeatName(participants: readonly RoundtableParticipant[], index: number): string { return seatDisplayName(participants, index, PROVIDER_LABEL) @@ -107,10 +119,7 @@ export function RoundtableView({ id }: { id: string }): JSX.Element { if (drained.length === 0) return setLive((prev) => { const next: LiveMap = { ...prev } - for (const [seat, chunk] of drained) { - const cur = next[seat] ?? { text: '', tools: [] } - next[seat] = { ...cur, text: cur.text + chunk } - } + for (const [seat, chunk] of drained) next[seat] = withText(next[seat], chunk) return next }) }, []) @@ -131,7 +140,7 @@ export function RoundtableView({ id }: { id: string }): JSX.Element { } } else if (ev.type === 'turn') { clearPendingText(ev.seat) - setLive((prev) => ({ ...prev, [ev.seat]: { text: '', tools: [], since: ev.at } })) + setLive((prev) => ({ ...prev, [ev.seat]: { parts: [], since: ev.at } })) } else if (ev.type === 'queued') { setQueued(ev.queued) if (ev.error) setNote(`Your waiting message didn’t go out: ${ev.error}`) @@ -145,15 +154,13 @@ export function RoundtableView({ id }: { id: string }): JSX.Element { bufRef.current.set(ev.seat, (bufRef.current.get(ev.seat) ?? '') + ev.text) if (!flushRef.current) flushRef.current = setTimeout(flushDelta, 40) } else if (ev.type === 'tool') { + // text still waiting in the batch was said before this call, so it lands first + const said = bufRef.current.get(ev.seat) ?? '' + clearPendingText(ev.seat) setLive((prev) => { - const cur = prev[ev.seat] ?? { text: '', tools: [] } - return { - ...prev, - [ev.seat]: { - ...cur, - tools: [...cur.tools, { toolName: ev.toolName, detail: ev.detail, preview: ev.preview }] - } - } + const cur = withText(prev[ev.seat], said) + const tool: LivePart = { kind: 'tool', toolName: ev.toolName, detail: ev.detail, preview: ev.preview } + return { ...prev, [ev.seat]: { ...cur, parts: [...cur.parts, tool] } } }) } else if (ev.type === 'entry') { if (ev.entry.speaker !== 'user' && ev.entry.seat !== undefined) { @@ -196,7 +203,7 @@ export function RoundtableView({ id }: { id: string }): JSX.Element { setCycle({ roundsRun: snap.roundsRun, concluded: snap.concluded }) const liveNow: LiveMap = {} for (const seat of snap.speaking) { - liveNow[seat] = { text: '', tools: [], since: snap.speakingSince?.[seat] } + liveNow[seat] = { parts: [], since: snap.speakingSince?.[seat] } } setQueued(snap.queued ?? null) setRoundsDraft(snap.maxRounds) @@ -471,36 +478,38 @@ export function RoundtableView({ id }: { id: string }): JSX.Element { {speaking.map((seatIdx) => { const turn = live[seatIdx] const seat = rt.participants[seatIdx] - if (!turn || !seat || (turn.text === '' && turn.tools.length === 0)) return null + if (!turn || !seat || turn.parts.length === 0) return null return (
- {turn.tools.map((tool, i) => ( - - ))} - {turn.text && ( -
- -
-
- {uiSeatName(rt.participants, seatIdx)} + {/* parts only ever append, so an index is a stable key */} + {turn.parts.map((part, i) => + part.kind === 'tool' ? ( + + ) : ( +
+ +
+
+ {uiSeatName(rt.participants, seatIdx)} +
+

{part.text}

-

{turn.text}

-
+ ) )}
) diff --git a/src/renderer/src/style.css b/src/renderer/src/style.css index 74ab79e4..3bd645f7 100644 --- a/src/renderer/src/style.css +++ b/src/renderer/src/style.css @@ -935,8 +935,10 @@ body.rail-dragging { cursor: col-resize; user-select: none; } the browser's own anchoring would add the same height a second time */ overflow-anchor: none; } /* rows must never shrink to fit the viewport — overflow scrolls instead; without this, - tool-rows (overflow:hidden → tiny min-content) compress to slivers in long transcripts */ -.messages > * { flex-shrink: 0; } + tool-rows (overflow:hidden → tiny min-content) compress to slivers in long transcripts. + A roundtable's live block is `display: contents`, so its rows are flex items here + too without being children — a seat's tool calls were drawn as bare border lines. */ +.messages > *, .messages > .rt-live > * { flex-shrink: 0; } /* "New messages" for a reader who scrolled up while the agent wrote: the transcript's last child is a sticky, zero-height line (the negative margin cancels the gap it would add), and the key hangs above it — absolute, so it takes no room, and centred diff --git a/tests/component/roundtable-view.test.tsx b/tests/component/roundtable-view.test.tsx index cdb79bdf..3d5c297e 100644 --- a/tests/component/roundtable-view.test.tsx +++ b/tests/component/roundtable-view.test.tsx @@ -179,6 +179,40 @@ describe('RoundtableView', () => { expect(screen.getByText('Codex is thinking…')).toBeInTheDocument() }) + it("a seat's live block keeps its text and tool calls in the order they happened", async () => { + let handler: ((ev: RoundtableEvent) => void) | null = null + vi.mocked(window.cockpit.onRoundtableEvent).mockImplementation((cb) => { + handler = cb + return () => {} + }) + vi.mocked(window.cockpit.getRoundtable).mockResolvedValue(fixture({ running: true, speaking: [0] })) + render() + await waitFor(() => expect(screen.getByText('Claude is thinking…')).toBeInTheDocument()) + + // one burst: the first passage is still in the text batch when the tool call arrives + act(() => { + handler!({ id: 'rt-1', type: 'delta', speaker: 'claude', seat: 0, text: 'Checking the ' }) + handler!({ id: 'rt-1', type: 'delta', speaker: 'claude', seat: 0, text: 'registry first.' }) + handler!({ id: 'rt-1', type: 'tool', speaker: 'claude', seat: 0, toolName: 'Bash', detail: 'npm view takt' }) + handler!({ id: 'rt-1', type: 'tool', speaker: 'claude', seat: 0, toolName: 'WebSearch', detail: 'takt software' }) + handler!({ id: 'rt-1', type: 'delta', speaker: 'claude', seat: 0, text: 'Takt is taken.' }) + }) + await waitFor(() => expect(screen.getByText('Takt is taken.')).toBeInTheDocument()) + + const block = document.querySelector('.rt-live')! + const order = [...block.children].map((el) => + el.classList.contains('tool-row') + ? `tool: ${el.querySelector('summary')!.textContent}` + : `text: ${el.querySelector('.streaming-plain')!.textContent}` + ) + expect(order).toEqual([ + 'text: Checking the registry first.', + expect.stringMatching(/^tool: .*Bash.*npm view takt/), + expect.stringMatching(/^tool: .*WebSearch.*takt software/), + 'text: Takt is taken.' + ]) + }) + it('a concluded cycle renders the app-assembled outcome from the seats\' own lines', async () => { vi.mocked(window.cockpit.getRoundtable).mockResolvedValue( fixture({