diff --git a/ui-tui/src/components/agentsOverlay.tsx b/ui-tui/src/components/agentsOverlay.tsx index a8ad917582..6d3917bf73 100644 --- a/ui-tui/src/components/agentsOverlay.tsx +++ b/ui-tui/src/components/agentsOverlay.tsx @@ -10,7 +10,7 @@ import { } from '../app/delegationStore.js' import { patchOverlayState } from '../app/overlayStore.js' import { $spawnDiff, $spawnHistory, clearDiffPair, type SpawnSnapshot } from '../app/spawnHistoryStore.js' -import { $turnState } from '../app/turnStore.js' +import { useTurnSelector } from '../app/turnStore.js' import type { GatewayClient } from '../gatewayClient.js' import type { DelegationPauseResponse, DelegationStatusResponse, SubagentInterruptResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' @@ -683,7 +683,7 @@ function DiffView({ // ── Main overlay ───────────────────────────────────────────────────── export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: AgentsOverlayProps) { - const turn = useStore($turnState) + const liveSubagents = useTurnSelector(state => state.subagents) const delegation = useStore($delegationState) const history = useStore($spawnHistory) const diffPair = useStore($spawnDiff) @@ -705,17 +705,17 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent const [mode, setMode] = useState<'detail' | 'list'>('list') const detailScrollRef = useRef(null) - const prevLiveCountRef = useRef(turn.subagents.length) + const prevLiveCountRef = useRef(liveSubagents.length) // ── Derived state ────────────────────────────────────────────────── const activeSnapshot = historyIndex > 0 ? history[historyIndex - 1] : null // Instant fallback to history[0] the moment the live list clears — avoids // a one-frame "no subagents" flash while the auto-follow effect fires. - const justFinishedSnapshot = historyIndex === 0 && turn.subagents.length === 0 ? (history[0] ?? null) : null + const justFinishedSnapshot = historyIndex === 0 && liveSubagents.length === 0 ? (history[0] ?? null) : null const effectiveSnapshot = activeSnapshot ?? justFinishedSnapshot const replayMode = effectiveSnapshot != null - const subagents = replayMode ? effectiveSnapshot.subagents : turn.subagents + const subagents = replayMode ? effectiveSnapshot.subagents : liveSubagents const tree = useMemo(() => buildSubagentTree(subagents), [subagents]) const totals = useMemo(() => treeTotals(tree), [tree]) @@ -753,14 +753,14 @@ export function AgentsOverlay({ gw, initialHistoryIndex = 0, onClose, t }: Agent // dropped into an empty live view. Fires only when transitioning from // "had live subagents" → "live empty" while in live mode. const prev = prevLiveCountRef.current - prevLiveCountRef.current = turn.subagents.length + prevLiveCountRef.current = liveSubagents.length - if (historyIndex === 0 && prev > 0 && turn.subagents.length === 0 && history.length > 0) { + if (historyIndex === 0 && prev > 0 && liveSubagents.length === 0 && history.length > 0) { setHistoryIndex(1) setCursor(0) setFlash('turn finished · inspect freely · q to close') } - }, [history.length, historyIndex, turn.subagents.length]) + }, [history.length, historyIndex, liveSubagents.length]) useEffect(() => { // Reset detail scroll on navigation so the top of the new node shows. diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 17ba966d8c..42015e11f4 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react' import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react' import { $delegationState } from '../app/delegationStore.js' -import { $turnState } from '../app/turnStore.js' +import { useTurnSelector } from '../app/turnStore.js' import { FACES } from '../content/faces.js' import { VERBS } from '../content/verbs.js' import { fmtDuration } from '../domain/messages.js' @@ -69,9 +69,9 @@ function SpawnHud({ t }: { t: Theme }) { // Tight HUD that only appears when the session is actually fanning out. // Colour escalates to warn/error as depth or concurrency approaches the cap. const delegation = useStore($delegationState) - const turn = useStore($turnState) + const subagents = useTurnSelector(state => state.subagents) - const tree = useMemo(() => buildSubagentTree(turn.subagents), [turn.subagents]) + const tree = useMemo(() => buildSubagentTree(subagents), [subagents]) const totals = useMemo(() => treeTotals(tree), [tree]) if (!totals.descendantCount && !delegation.paused) { diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 2608a9dabe..9e716583c9 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -66,13 +66,11 @@ const TranscriptPane = memo(function TranscriptPane({ progress={progress} sections={ui.sections} /> + + - - - - diff --git a/ui-tui/src/components/streamingAssistant.tsx b/ui-tui/src/components/streamingAssistant.tsx index 8b5f261115..d691138bca 100644 --- a/ui-tui/src/components/streamingAssistant.tsx +++ b/ui-tui/src/components/streamingAssistant.tsx @@ -4,24 +4,14 @@ import { memo } from 'react' import type { AppLayoutProgressProps } from '../app/interfaces.js' import { toggleTodoCollapsed, useTurnSelector } from '../app/turnStore.js' import { $uiState } from '../app/uiStore.js' +import { appendToolShelfMessage } from '../lib/liveProgress.js' import type { DetailsMode, Msg, SectionVisibility } from '../types.js' import { MessageLine } from './messageLine.js' import { TodoPanel } from './todoPanel.js' -const isToolOnly = (msg: Msg | undefined) => - Boolean(msg && msg.kind === 'trail' && !msg.thinking?.trim() && !msg.text && msg.tools?.length) - -const groupedSegments = (segments: Msg[]) => - segments.reduce((acc, msg) => { - if (isToolOnly(msg) && isToolOnly(acc.at(-1))) { - const prev = acc.at(-1)! - - return [...acc.slice(0, -1), { ...prev, tools: [...(prev.tools ?? []), ...(msg.tools ?? [])] }] - } - - return [...acc, msg] - }, []) +const groupedSegments = (segments: Msg[]): Msg[] => + segments.reduce((acc, msg) => appendToolShelfMessage(acc, msg), []) export const StreamingAssistant = memo(function StreamingAssistant({ cols, diff --git a/ui-tui/src/lib/liveProgress.test.ts b/ui-tui/src/lib/liveProgress.test.ts index 141fb7acdc..eec209baf0 100644 --- a/ui-tui/src/lib/liveProgress.test.ts +++ b/ui-tui/src/lib/liveProgress.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' +import type { Msg } from '../types.js' + import { appendToolShelfMessage, canHoldToolShelf, isTodoDone, mergeToolShelfInto } from './liveProgress.js' describe('isTodoDone', () => { @@ -54,6 +56,52 @@ describe('appendToolShelfMessage', () => { expect(merged).toEqual([{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓', 'two ✓'] }]) }) + it('merges through intervening thinking-only rows back into the nearest holder', () => { + const prev: Msg[] = [ + { kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓'] }, + { kind: 'trail', role: 'system', text: '', thinking: 'more plan' } + ] + + const merged = appendToolShelfMessage(prev, { + kind: 'trail', + role: 'system', + text: '', + tools: ['two ✓'] + }) + + expect(merged).toHaveLength(2) + expect(merged[0]).toEqual({ + kind: 'trail', + role: 'system', + text: '', + thinking: 'plan', + tools: ['one ✓', 'two ✓'] + }) + expect(merged[1]).toEqual({ kind: 'trail', role: 'system', text: '', thinking: 'more plan' }) + }) + + it('collapses a chronological thinking/tool/thinking/tool stream into one shelf', () => { + const events: Msg[] = [ + { kind: 'trail', role: 'system', text: '', thinking: 'plan' }, + { kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }, + { kind: 'trail', role: 'system', text: '', thinking: 'more plan' }, + { kind: 'trail', role: 'system', text: '', tools: ['two ✓'] }, + { kind: 'trail', role: 'system', text: '', tools: ['three ✓'] } + ] + + const reduced = events.reduce((acc, msg) => appendToolShelfMessage(acc, msg), []) + + expect(reduced).toHaveLength(2) + expect(reduced[0]).toEqual({ + kind: 'trail', + role: 'system', + text: '', + thinking: 'plan', + tools: ['one ✓', 'two ✓', 'three ✓'] + }) + expect(reduced[1]).toEqual({ kind: 'trail', role: 'system', text: '', thinking: 'more plan' }) + }) + it('starts a new shelf across assistant text boundaries', () => { const merged = appendToolShelfMessage( [{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }, { role: 'assistant', text: 'done' }], diff --git a/ui-tui/src/lib/liveProgress.ts b/ui-tui/src/lib/liveProgress.ts index 9666e4312c..2177d21307 100644 --- a/ui-tui/src/lib/liveProgress.ts +++ b/ui-tui/src/lib/liveProgress.ts @@ -14,15 +14,41 @@ export const mergeToolShelfInto = (target: Msg, source: Msg): Msg => ({ tools: [...(target.tools ?? []), ...(source.tools ?? [])] }) +const isBarrierMessage = (msg: Msg | undefined) => { + if (!msg) { + return true + } + + // Assistant text, user input, intro/panel rows all terminate the shelf. + if (msg.kind === 'intro' || msg.kind === 'panel' || msg.kind === 'diff') { + return true + } + + if (msg.role && msg.role !== 'system') { + return true + } + + if (msg.text) { + return true + } + + return false +} + +const isToolCarryingTrail = (msg: Msg | undefined) => + Boolean(msg?.kind === 'trail' && !msg.text && msg.tools?.length) + export const appendToolShelfMessage = (prev: readonly Msg[], msg: Msg): Msg[] => { if (!isToolShelfMessage(msg)) { return [...prev, msg] } + let fallbackHolder: number | null = null + for (let index = prev.length - 1; index >= 0; index--) { const candidate = prev[index] - if (canHoldToolShelf(candidate)) { + if (isToolCarryingTrail(candidate)) { const next = [...prev] next[index] = mergeToolShelfInto(candidate!, msg) @@ -30,10 +56,22 @@ export const appendToolShelfMessage = (prev: readonly Msg[], msg: Msg): Msg[] => return next } - if (candidate?.kind !== 'trail' || candidate.text) { + if (fallbackHolder === null && canHoldToolShelf(candidate)) { + fallbackHolder = index + } + + if (isBarrierMessage(candidate)) { break } } + if (fallbackHolder !== null) { + const next = [...prev] + + next[fallbackHolder] = mergeToolShelfInto(prev[fallbackHolder]!, msg) + + return next + } + return [...prev, msg] }