From 076fc919c6df7800b83aabae8213f424c3767491 Mon Sep 17 00:00:00 2001 From: titan-ron <30556071+titan-ron@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:34:05 +0300 Subject: [PATCH 1/2] fix(roundtable): keep a seat's live tool calls from collapsing to lines A roundtable's live block is display: contents, so its tool rows are flex items of the transcript without being its children. The rule that keeps transcript rows from shrinking only matched direct children, so once the transcript overflowed, each streamed tool call shrank to its two border pixels and a seat mid-tool-run read as a stack of empty lines above "is thinking". --- src/renderer/src/style.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 From 4c9b8325c98676a1b04a0fac471590305c14f645 Mon Sep 17 00:00:00 2001 From: titan-ron <30556071+titan-ron@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:40:09 +0300 Subject: [PATCH 2/2] fix(roundtable): show a seat's live text and tool calls in the order they happen A seat's in-flight turn kept its streamed text and its tool calls in two separate lists and drew every tool call above all of the text, so a seat that said what it was about to do, ran it, then reported back read as tools first and both passages run together underneath. The live turn is now one ordered list of parts; text still waiting in the 40ms stream batch is placed before a tool call that arrives after it. --- src/renderer/src/RoundtableView.tsx | 95 +++++++++++++----------- tests/component/roundtable-view.test.tsx | 34 +++++++++ 2 files changed, 86 insertions(+), 43 deletions(-) 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/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({