fix(desktop): time the gaps a working turn spends producing nothing

The transcript's activity row went silent for most of an agentic turn. Two
gates were too narrow. It mounted only while the tail bubble's own status was
`running`, so a turn that seals a bubble mid-flight (message.interim) or
finishes one while the agent keeps going left a settled message at the tail and
unmounted the row entirely. And its activity signature was part count plus text
length, which a landing tool result does not change — a result mutates the part
that was already there — so the pause after a call ended was read as more of
the same silence and dated from whenever the call started.

Between them, the app spent whole stretches with the composer's arc border lit
and Stop armed while the thread showed nothing and counted nothing.

The row now follows the same busy signal the composer does, is mounted by the
tail unconditionally, and decides for itself whether the turn owes the user a
line. A named wait (compaction, provider wait, a tool being drafted) says so
immediately; an unnamed gap earns two seconds of quiet first, so a run of quick
calls doesn't strobe. It still defers to the two waits already accounted for
elsewhere: a prompt the user is answering, and a tool call in flight carrying
its own row and timer — anywhere in the message now, not just the last part,
and excluding the silent tools that render nothing to defer to.

Renamed to TurnActivityIndicator / `aui_turn-activity`: it hasn't measured a
stall since it started measuring the whole turn.
This commit is contained in:
Brooklyn Nicholson
2026-08-19 01:19:33 -05:00
parent 7c3c2d112b
commit ccaa4872d8
8 changed files with 356 additions and 66 deletions
@@ -17,7 +17,7 @@ import {
} from '@/components/assistant-ui/thread/content'
import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread/message-parts'
import { ReactionPicker } from '@/components/assistant-ui/thread/message-reactions'
import { ResponseLoadingIndicator, StreamStallIndicator } from '@/components/assistant-ui/thread/status'
import { ResponseLoadingIndicator, TurnActivityIndicator } from '@/components/assistant-ui/thread/status'
import { MessageTimelineTimestamp } from '@/components/assistant-ui/thread/timeline-timestamp'
import { useMessageReactions, useTapbackDoubleClick } from '@/components/assistant-ui/thread/use-message-reactions'
import { AGENT_MESSAGE_RE } from '@/components/assistant-ui/thread/user-message'
@@ -96,7 +96,7 @@ export const AssistantMessage: FC<{
// selector here returns a value that stays referentially stable across
// token flushes (booleans, status strings, '' while running), so the
// 30 Hz delta stream only re-renders the markdown part and the tiny
// StreamStallIndicator leaf — not the footer/preview/root subtree.
// TurnActivityIndicator leaf — not the footer/preview/root subtree.
const messageStatus = useAuiState(s => s.message.status?.type)
const isRunning = messageStatus === 'running'
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
@@ -199,7 +199,14 @@ export const AssistantMessage: FC<{
>
{/* Todos render in the composer status stack now, not inline. */}
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
{isLastMessage && (isPlaceholder ? <ResponseLoadingIndicator /> : isRunning && <StreamStallIndicator />)}
{/* The activity row is mounted by the TAIL of the thread and decides
for itself whether the turn owes the user a line. Gating the mount
on this bubble's own `running` status was the hole: a turn that
seals a bubble mid-flight (message.interim) or finishes one while
the agent keeps going leaves a settled message at the tail, so the
row unmounted and the seconds went uncounted while the composer's
arc border and Stop button said work was still happening. */}
{isLastMessage && (isPlaceholder ? <ResponseLoadingIndicator /> : <TurnActivityIndicator />)}
{previewTargets.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{previewTargets.map(target => (
@@ -1,4 +1,4 @@
// Loading and stall indicators mount only on the thread's last message.
// Loading and activity indicators mount only on the thread's last message.
// The tail-only gate from ba756333 keeps non-tail running bubbles silent,
// including assistants followed only by a user or system row. The optimistic
// placeholder flow renders exactly one status row. These contracts pin the
@@ -101,13 +101,13 @@ function runningAssistantMessage(id: string, text: string): ThreadMessage {
function Harness({ messages, isRunning = false }: { messages: ThreadMessage[]; isRunning?: boolean }) {
// isRunning: false at the runtime level. Per-message `status: {type:
// 'running'}` is what drives StreamStallIndicator mounting.
// 'running'}` is what drives TurnActivityIndicator mounting.
// Passing isRunning: true makes useExternalStoreRuntime auto-append a
// synthetic empty trailing assistant placeholder whenever the last message
// is not already a running assistant, such as the trailing user prompt
// cases below. That is the real production flow. The isRunning:true tests
// prove that the placeholder is treated as the tail and renders its own
// loading row while the real bubble's stall row stays silent.
// loading row while the real bubble's activity row stays silent.
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning,
@@ -121,7 +121,7 @@ function Harness({ messages, isRunning = false }: { messages: ThreadMessage[]; i
)
}
describe('StreamStallIndicator tail gating (#68634)', () => {
describe('TurnActivityIndicator tail gating (#68634)', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
@@ -162,8 +162,8 @@ describe('StreamStallIndicator tail gating (#68634)', () => {
const roots = container.querySelectorAll('[data-slot="aui_assistant-message-root"]')
expect(roots.length).toBe(2)
// The second assistant root is also the thread's any-role tail.
expect(roots[0]?.querySelector('[data-slot="aui_stream-stall"]')).toBeNull()
expect(roots[1]?.querySelector('[data-slot="aui_stream-stall"]')).not.toBeNull()
expect(roots[0]?.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
expect(roots[1]?.querySelector('[data-slot="aui_turn-activity"]')).not.toBeNull()
})
it('keeps a running assistant silent when a queued user prompt trails it and the runtime is idle', () => {
@@ -182,7 +182,7 @@ describe('StreamStallIndicator tail gating (#68634)', () => {
})
expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeNull()
expect(container.querySelector('[data-slot="aui_stream-stall"]')).toBeNull()
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
it('keeps a running assistant silent when a steer system note trails it and the runtime is idle', () => {
@@ -201,7 +201,7 @@ describe('StreamStallIndicator tail gating (#68634)', () => {
})
expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeNull()
expect(container.querySelector('[data-slot="aui_stream-stall"]')).toBeNull()
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
// In the production flow, isRunning: true with a trailing queued user prompt
@@ -231,7 +231,7 @@ describe('StreamStallIndicator tail gating (#68634)', () => {
// The surviving row belongs to the placeholder, while the real running
// bubble's stall row stays silent.
expect(document.querySelectorAll('[data-slot="aui_response-loading"]').length).toBe(1)
expect(document.querySelectorAll('[data-slot="aui_stream-stall"]').length).toBe(0)
expect(document.querySelectorAll('[data-slot="aui_turn-activity"]').length).toBe(0)
})
// Outside compaction, the placeholder uses the plain loading label and the
@@ -255,6 +255,6 @@ describe('StreamStallIndicator tail gating (#68634)', () => {
})
expect(document.querySelectorAll('[data-slot="aui_response-loading"]').length).toBe(1)
expect(document.querySelectorAll('[data-slot="aui_stream-stall"]').length).toBe(0)
expect(document.querySelectorAll('[data-slot="aui_turn-activity"]').length).toBe(0)
})
})
@@ -102,6 +102,6 @@ describe('thinking indicator is tail-only', () => {
await screen.findByText('answered')
expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeNull()
expect(container.querySelector('[data-slot="aui_stream-stall"]')).toBeNull()
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
})
@@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useEffect, useMemo, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { activitySignature, toolNarratesWait, TURN_QUIET_S } from '@/components/assistant-ui/thread/turn-activity'
import { toolPresentVerb } from '@/components/assistant-ui/tool/run-summary'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
@@ -16,7 +17,6 @@ import { $backgroundResume } from '@/store/background-delegation'
import { sessionCompacting } from '@/store/compaction'
import { sessionAwaitingInput } from '@/store/prompts'
import { sessionProviderWait } from '@/store/provider-wait'
import { $turnStartedAt } from '@/store/session'
import { type DraftingTool, sessionDraftingTool } from '@/store/tool-drafting'
// A status line is scaffolding like any other — "Editing" while the model
@@ -55,8 +55,15 @@ const HintText: FC<{ children: ReactNode }> = ({ children }) => (
* session-scoped signal comes from that surface's view — a tile must never
* show the primary chat's compaction, prompt-wait, or turn timer. */
function useThreadSessionStatus() {
const sessionId = useStore(useSessionView().$runtimeId)
const turnStartedAt = useStore($turnStartedAt)
const view = useSessionView()
const sessionId = useStore(view.$runtimeId)
// The same turn-busy the composer's arc border and Stop button read. The
// message-level `running` flag is a weaker signal: it goes false in the gaps
// between bubbles (a sealed interim row, a settled turn the backend hasn't
// finished with), which is exactly when the transcript used to fall silent
// while the app still said it was working.
const busy = useStore(view.$busy)
const turnStartedAt = useStore(view.$turnStartedAt)
const compacting = useStore(useMemo(() => sessionCompacting(sessionId), [sessionId]))
const drafting = useStore(useMemo(() => sessionDraftingTool(sessionId), [sessionId]))
const providerWait = useStore(useMemo(() => sessionProviderWait(sessionId), [sessionId]))
@@ -67,10 +74,15 @@ function useThreadSessionStatus() {
return {
awaitingInput,
busy,
compacting,
drafting,
providerWait,
turnTimerKey: sessionId && turnStartedAt ? `turn:${sessionId}:${turnStartedAt}` : undefined
// Epoch ms this surface's turn began, or undefined between turns. The
// origin for anything measuring the WHOLE turn rather than one phase of
// it — including the first seconds of a brand-new chat, where the value is
// seeded at submit and there is no runtime session to key off yet.
turnStartedAt: turnStartedAt ?? undefined
}
}
@@ -132,8 +144,8 @@ export const CenteredThreadSpinner: FC = () => {
export const ResponseLoadingIndicator: FC = () => {
const { t } = useI18n()
const { compacting, drafting, providerWait, turnTimerKey } = useThreadSessionStatus()
const elapsed = useElapsedSeconds(true, turnTimerKey)
const { compacting, drafting, providerWait, turnStartedAt } = useThreadSessionStatus()
const elapsed = useElapsedSeconds(true, undefined, turnStartedAt)
const hint = useStatusHint(compacting, drafting, providerWait)
return (
@@ -179,67 +191,65 @@ export const BackgroundResumeNotice: FC = () => {
)
}
// Seconds of no visible output (text or part count) before a still-running turn
// is treated as stalled and the thinking indicator returns at the tail.
const STREAM_STALL_S = 2
// Tail "still thinking" indicator: the pre-first-token spinner goes away once
// text flows, but if the stream then goes quiet mid-turn (tool think-time,
// provider stall) nothing signals that work continues. Watch a per-flush
// activity signal; when it hasn't changed for STREAM_STALL_S, re-show the
// dither + a timer counting from the last activity.
// Tail activity row. The pre-first-token spinner goes away once content flows,
// but a turn keeps working through gaps it produces nothing during — between
// one tool result landing and the next call arriving, while the provider
// thinks, while a sealed bubble waits on the next one. The composer's arc
// border and Stop button are lit through all of it; the transcript used to be
// silent for most of it, and those seconds went uncounted.
//
// So this row follows the SAME busy signal the composer does, and times every
// gap from the moment the turn last showed something rather than from its own
// mount. What it doesn't do is double-narrate: a tool call in flight already
// carries its own row and timer.
//
// Subscribes to the activity signal ITSELF (rather than taking it as a prop)
// so that per-token updates re-render only this leaf, not the whole
// AssistantMessage subtree.
export const StreamStallIndicator: FC = () => {
const activity = useAuiState(s => {
let textLength = 0
export const TurnActivityIndicator: FC = () => {
const activity = useAuiState(s => activitySignature(s.message.content))
for (const part of s.message.content) {
const text = (part as { text?: unknown }).text
if (typeof text === 'string') {
textLength += text.length
}
}
return `${s.message.content.length}:${textLength}`
})
// Timestamp of the activity that preceded the current quiet spell, set once
// the spell qualifies as a stall. Holding the timestamp (not a boolean) is
// what lets the timer read "quiet for 12s" rather than the age of this
// component, which is the whole turn so far.
// Timestamp of the last visible progress, held from the moment the quiet
// spell qualifies. Holding the timestamp (not a boolean) is what lets the
// timer read "quiet for 12s" rather than the age of this component, which is
// the whole turn so far.
const [quietSince, setQuietSince] = useState<number | undefined>(undefined)
const { awaitingInput, compacting, drafting, providerWait, turnTimerKey } = useThreadSessionStatus()
const { awaitingInput, busy, compacting, drafting, providerWait, turnStartedAt } = useThreadSessionStatus()
const hint = useStatusHint(compacting, drafting, providerWait)
// A tool run at the tail already narrates the wait — its summary counts the
// calls, its ticker names the current one, and it carries its own timer. A
// second spinner under that adds a line and says nothing new.
const toolNarrating = useAuiState(s => s.message.content.at(-1)?.type === 'tool-call')
// second spinner under that adds a line and says nothing new. Silent tools
// (`todo`, reactions) render nothing, so they narrate nothing.
const toolNarrating = useAuiState(s => toolNarratesWait(s.message.content))
// Streaming counts as working too, and it leads busy by a flush on the first
// turn of a fresh chat — so the row can't wait for the store to catch up.
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
useEffect(() => {
setQuietSince(undefined)
const seenAt = Date.now()
const id = window.setTimeout(() => setQuietSince(seenAt), STREAM_STALL_S * 1000)
const id = window.setTimeout(() => setQuietSince(seenAt), TURN_QUIET_S * 1000)
return () => window.clearTimeout(id)
}, [activity])
// A named wait doesn't have to earn the stall threshold first — we already
// know what the turn is doing, so say it as soon as the label is ready rather
// than leaving the transcript silent for STREAM_STALL_S.
const active = (quietSince !== undefined || Boolean(hint)) && !awaitingInput && !toolNarrating
// Every second the app claims to be working belongs to something. A named
// wait says what it is straight away; an unnamed gap has to go quiet for
// TURN_QUIET_S first, or a run of quick calls would strobe a row between
// each one. The two exemptions are waits already accounted for elsewhere: a
// question the user is answering, and a tool call carrying its own timer.
const working = busy || messageRunning
const active = working && !awaitingInput && !toolNarrating && (Boolean(hint) || quietSince !== undefined)
// Compaction owns the whole turn, so it keeps counting from the turn's start;
// anything else counts from the moment the stream went quiet — the stall's own
// mark, or the draft's, whichever named the wait first.
// anything else counts from the moment the turn last produced something — the
// gap's own mark, or the draft's, whichever named the wait first.
const elapsed = useElapsedSeconds(
active,
compacting ? turnTimerKey : undefined,
compacting ? undefined : (quietSince ?? drafting?.since)
undefined,
compacting ? turnStartedAt : (quietSince ?? drafting?.since ?? turnStartedAt)
)
if (!active) {
@@ -247,7 +257,7 @@ export const StreamStallIndicator: FC = () => {
}
return (
<StatusRow data-slot="aui_stream-stall" label={hint || 'Hermes is thinking'}>
<StatusRow data-slot="aui_turn-activity" label={hint || 'Hermes is working'}>
<StatusPulse
aria-hidden="true"
className="dither inline-block size-3 rounded-[2px] text-midground/80"
@@ -0,0 +1,60 @@
// The transcript's activity row has to account for every second the app spends
// claiming to work. Before this, it ran off a text-length signature and only
// deferred to the tail part, so it went silent — and stopped counting — in the
// gaps: between a tool result landing and the next call arriving, and while a
// hoisted tool that renders nothing was in flight.
import { describe, expect, it } from 'vitest'
import { activitySignature, toolNarratesWait } from './turn-activity'
const text = (value: string) => ({ text: value, type: 'text' })
const call = (toolName: string, settled: boolean) => ({
toolName,
type: 'tool-call',
...(settled ? { result: 'ok' } : {})
})
describe('activitySignature', () => {
it('changes when a tool call lands its result', () => {
// The result MUTATES the existing part: same part count, same text. A
// signature blind to it reads the finished call as more of the same
// silence, so the gap after it gets dated from when the call started.
expect(activitySignature([text('working'), call('terminal', false)])).not.toBe(
activitySignature([text('working'), call('terminal', true)])
)
})
it('changes when prose streams and when a part is appended', () => {
const base = activitySignature([text('wor')])
expect(activitySignature([text('working')])).not.toBe(base)
expect(activitySignature([text('wor'), call('read_file', false)])).not.toBe(base)
})
it('is stable while nothing visible happens', () => {
const parts = [text('working'), call('terminal', true)]
expect(activitySignature(parts)).toBe(activitySignature([...parts]))
})
})
describe('toolNarratesWait', () => {
it('defers to a tool call in flight — it has its own row and timer', () => {
expect(toolNarratesWait([text('working'), call('terminal', false)])).toBe(true)
})
it('does not defer to a settled call: the gap after it belongs to nobody', () => {
expect(toolNarratesWait([text('working'), call('terminal', true)])).toBe(false)
})
it('does not defer to silent tools, which render nothing to narrate with', () => {
expect(toolNarratesWait([call('todo', false)])).toBe(false)
expect(toolNarratesWait([call('react_to_message', false)])).toBe(false)
})
it('defers to a call in flight even when a later part follows it', () => {
// Tail-part-only detection missed this: a turn that starts prose while a
// call is still running showed the row under the tool's own timer.
expect(toolNarratesWait([call('terminal', false), text('meanwhile')])).toBe(true)
})
})
@@ -0,0 +1,60 @@
import { isSilentTool } from '@/lib/tool-render-class'
/**
* Seconds of silence before an unnamed wait earns a row of its own.
*
* Long enough that the pause between one tool call finishing and the next
* arriving stays silent — a row that appeared for 300ms between every call
* would strobe down a long run — short enough that a real gap is timed almost
* as soon as it starts.
*/
export const TURN_QUIET_S = 2
export interface ActivityPart {
result?: unknown
text?: unknown
toolName?: string
type: string
}
/**
* What the tail message has produced so far, as a value that changes exactly
* when the turn makes visible progress.
*
* Part count and text length cover streamed prose and each new call. Settled
* calls are counted separately because a result lands by MUTATING the part
* that was already there: neither the count nor the text changes, so a
* signature without it reads a finished tool call as more of the same silence
* and dates the gap after it from whenever the call started.
*/
export function activitySignature(content: readonly ActivityPart[]): string {
let textLength = 0
let settledTools = 0
for (const part of content) {
if (typeof part.text === 'string') {
textLength += part.text.length
}
if (part.type === 'tool-call' && part.result !== undefined) {
settledTools += 1
}
}
return `${content.length}:${textLength}:${settledTools}`
}
/**
* Whether a tool call is already narrating this wait.
*
* A call in flight renders its own row, with its own timer, so a second
* spinner under it would count the same seconds twice. Silent tools don't:
* `todo` is hoisted to its own panel and a reaction's UI is the emoji landing
* on the bubble, so neither leaves anything on screen to time — a wait on one
* of those is as unnarrated as a wait on nothing at all.
*/
export function toolNarratesWait(content: readonly ActivityPart[]): boolean {
return content.some(
part => part.type === 'tool-call' && part.result === undefined && !isSilentTool(part.toolName ?? '')
)
}
@@ -0,0 +1,149 @@
// The gap case: the agent is working, the composer's arc border is on and Stop
// is armed, but the tail bubble has settled — a sealed interim row, or a turn
// whose last message completed while the agent kept going. The transcript used
// to show nothing there, and the seconds went uncounted.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetElapsedTimerRegistryForTests } from '@/components/chat/activity-timer'
import { $activeSessionId, $busy, $messages, $turnStartedAt } from '@/store/session'
import { Thread } from '.'
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
vi.stubGlobal('CSS', { escape: (str: string) => str })
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return { cancel() {}, finished: Promise.resolve() } as unknown as Animation
}
const createdAt = new Date('2026-05-01T00:00:00.000Z')
const sessionId = 'session-turn-gap'
function user(id: string, text: string): ThreadMessage {
return {
id,
role: 'user',
content: [{ type: 'text', text }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistant(id: string, content: unknown[], running: boolean): ThreadMessage {
return {
id,
role: 'assistant',
content,
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
createdAt,
metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
} as unknown as ThreadMessage
}
const toolCall = (toolName: string, settled: boolean) => ({
type: 'tool-call',
toolCallId: `${toolName}-1`,
toolName,
args: {},
...(settled ? { result: 'ok' } : {})
})
function Harness({ messages }: { messages: ThreadMessage[] }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning: messages.at(-1)?.status?.type === 'running',
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
const timerText = (value: string) => screen.getAllByText((_, node) => node?.textContent === value)
describe('the turn timer covers the gaps, not just the streaming', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
vi.spyOn(globalThis.document, 'hasFocus').mockReturnValue(true)
__resetElapsedTimerRegistryForTests()
$activeSessionId.set(sessionId)
$messages.set([])
$turnStartedAt.set(Date.now())
$busy.set(true)
})
afterEach(() => {
cleanup()
$activeSessionId.set(null)
$turnStartedAt.set(null)
$busy.set(false)
__resetElapsedTimerRegistryForTests()
vi.restoreAllMocks()
vi.useRealTimers()
})
it('times a settled tail bubble while the session is still working', () => {
// The sealed-bubble gap. Nothing is running at message level; the session
// is busy, so the transcript owes the user a line and a count — measured
// from the last thing the turn produced, not from when the row appeared.
const { container } = render(
<Harness messages={[user('u1', 'do the thing'), assistant('a1', [{ type: 'text', text: 'On it.' }], false)]} />
)
act(() => vi.advanceTimersByTime(7_000))
expect(container.querySelector('[data-slot="aui_turn-activity"]')).not.toBeNull()
expect(timerText('7s').length).toBeGreaterThan(0)
})
it('times the gap between a finished tool call and the next thing', () => {
const { container } = render(
<Harness messages={[user('u1', 'read it'), assistant('a1', [toolCall('read_file', true)], true)]} />
)
act(() => vi.advanceTimersByTime(9_000))
expect(container.querySelector('[data-slot="aui_turn-activity"]')).not.toBeNull()
expect(timerText('9s').length).toBeGreaterThan(0)
})
it('stays silent under a tool call still in flight — that row has its own timer', () => {
const { container } = render(
<Harness messages={[user('u1', 'run it'), assistant('a1', [toolCall('terminal', false)], true)]} />
)
act(() => vi.advanceTimersByTime(9_000))
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
it('stops when the session stops working', () => {
$busy.set(false)
const { container } = render(
<Harness messages={[user('u1', 'do the thing'), assistant('a1', [{ type: 'text', text: 'Done.' }], false)]} />
)
act(() => vi.advanceTimersByTime(7_000))
expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
})
})
+10 -6
View File
@@ -1932,8 +1932,8 @@ body.guest-pointer-lock :is(webview, iframe) {
*pairs* qualified meant the live status line (not tool, thinking or prose)
fell through every branch and carried its own half-size margin instead. */
[data-slot='aui_assistant-message-content']
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall'], .aui-md)
+ :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall'], .aui-md) {
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_turn-activity'], .aui-md)
+ :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_turn-activity'], .aui-md) {
margin-top: var(--turn-block-gap);
}
@@ -1951,8 +1951,8 @@ body.guest-pointer-lock :is(webview, iframe) {
is the wrong hook here: it's absent on a multi-call tool GROUP, which is most
of a real run, and present on rows nested inside one. */
[data-slot='aui_assistant-message-content']
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall'])
+ :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']) {
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_turn-activity'])
+ :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_turn-activity']) {
margin-top: var(--scaffold-block-gap);
}
@@ -1992,11 +1992,15 @@ body.guest-pointer-lock :is(webview, iframe) {
[data-slot='aui_turn-pair']
> [data-slot='aui_assistant-message-root']:has(
> [data-slot='aui_assistant-message-content']
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):last-child
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_turn-activity']):last-child
):not(:has([data-file-edit]))
+ [data-slot='aui_assistant-message-root']:has(
> [data-slot='aui_assistant-message-content']
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):first-child
> :is(
[data-slot='tool-block'],
[data-slot='aui_thinking-disclosure'],
[data-slot='aui_turn-activity']
):first-child
):not(:has([data-file-edit])) {
margin-top: calc(var(--scaffold-block-gap) - var(--conversation-turn-gap));
}