diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx
index 55b49e6bd1..e75121de8d 100644
--- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx
@@ -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. */}
- {isLastMessage && (isPlaceholder ? : isRunning && )}
+ {/* 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 ? : )}
{previewTargets.length > 0 && (
{previewTargets.map(target => (
diff --git a/apps/desktop/src/components/assistant-ui/thread/duplicate-activity-indicator.test.tsx b/apps/desktop/src/components/assistant-ui/thread/duplicate-activity-indicator.test.tsx
index 024c8eab39..ac659b99a5 100644
--- a/apps/desktop/src/components/assistant-ui/thread/duplicate-activity-indicator.test.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/duplicate-activity-indicator.test.tsx
@@ -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
({
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)
})
})
diff --git a/apps/desktop/src/components/assistant-ui/thread/status-tail-only.test.tsx b/apps/desktop/src/components/assistant-ui/thread/status-tail-only.test.tsx
index d48ff5a095..1ec8feb7fc 100644
--- a/apps/desktop/src/components/assistant-ui/thread/status-tail-only.test.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/status-tail-only.test.tsx
@@ -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()
})
})
diff --git a/apps/desktop/src/components/assistant-ui/thread/status.tsx b/apps/desktop/src/components/assistant-ui/thread/status.tsx
index 1eb53d3ea3..d7ee789ba1 100644
--- a/apps/desktop/src/components/assistant-ui/thread/status.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/status.tsx
@@ -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(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 (
-
+
({ 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)
+ })
+})
diff --git a/apps/desktop/src/components/assistant-ui/thread/turn-activity.ts b/apps/desktop/src/components/assistant-ui/thread/turn-activity.ts
new file mode 100644
index 0000000000..74948efe58
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/thread/turn-activity.ts
@@ -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 ?? '')
+ )
+}
diff --git a/apps/desktop/src/components/assistant-ui/thread/turn-gap-indicator.test.tsx b/apps/desktop/src/components/assistant-ui/thread/turn-gap-indicator.test.tsx
new file mode 100644
index 0000000000..6c9909dfa8
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/thread/turn-gap-indicator.test.tsx
@@ -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({
+ messages,
+ isRunning: messages.at(-1)?.status?.type === 'running',
+ onNew: async () => {}
+ })
+
+ return (
+
+
+
+ )
+}
+
+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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+
+ )
+
+ act(() => vi.advanceTimersByTime(7_000))
+
+ expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index be7286e6f7..24364e00e2 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -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));
}