From 0766ff678486060fbb1a6c6f40689c9af73ea835 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 00:42:15 -0700 Subject: [PATCH 1/2] improvement(mothership): show elapsed time on the chat's Thinking indicator during long silent waits --- .../components/agent-group/tool-call-title.ts | 36 +--- .../special-tags/pending-tag-indicator.tsx | 32 +++- .../message-content.dom.test.tsx | 165 ++++++++++++++++++ .../message-content/message-content.tsx | 12 +- .../home/hooks/use-elapsed-ms.ts | 35 ++++ 5 files changed, 242 insertions(+), 38 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.dom.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-elapsed-ms.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title.ts index 6ed9702b6c5..2b582ab2e9f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' import { PrepareFileEdit, Wait as WaitTool } from '@/lib/mothership/generated/tool-catalog-v1' import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming-args' import { @@ -6,15 +6,9 @@ import { getToolStatusDisplayTitle, getWaitCountdownTitle, } from '@/lib/mothership/tools/tool-display' +import { useElapsedMs } from '@/app/workspace/[workspaceId]/home/hooks/use-elapsed-ms' import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' -/** - * How often the countdown re-reads the clock. Comfortably under a second so - * the displayed number turns over close to when it actually should, rather - * than drifting by most of a second against an interval that started late. - */ -const COUNTDOWN_TICK_MS = 250 - /** Present participle for each `prepare_file_edit` operation, read from the streaming args. */ const FILE_EDIT_VERBS: Readonly> = { create: 'Creating', @@ -25,32 +19,6 @@ const FILE_EDIT_VERBS: Readonly> = { delete: 'Deleting', } -/** - * Milliseconds elapsed since the call started, while `active`. - * - * Anchors to `startedAt` so a row that mounts partway through a pause resumes - * mid-countdown instead of restarting; falls back to activation time when the - * caller has no start to give. - */ -function useElapsedMs( - active: boolean, - startedAt: number | undefined, - toolCallId: string | undefined -): number { - const [sample, setSample] = useState({ toolCallId, elapsedMs: 0 }) - - useEffect(() => { - if (!active) return - const anchor = startedAt ?? Date.now() - const tick = () => setSample({ toolCallId, elapsedMs: Date.now() - anchor }) - tick() - const interval = setInterval(tick, COUNTDOWN_TICK_MS) - return () => clearInterval(interval) - }, [active, startedAt, toolCallId]) - - return active && sample.toolCallId === toolCallId ? sample.elapsedMs : 0 -} - /** * A `prepare_file_edit` title read live from its streaming arguments, before * the parsed title exists; that path bypasses the completed-title rewrite in diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/pending-tag-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/pending-tag-indicator.tsx index 7dc0bec53ad..11b617fb141 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/pending-tag-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/pending-tag-indicator.tsx @@ -1,17 +1,43 @@ +import { formatDuration } from '@sim/utils/formatting' import { ThinkingLoader } from '@/components/ui' +import { useElapsedMs } from '@/app/workspace/[workspaceId]/home/hooks/use-elapsed-ms' + +/** Past this, a silent wait (e.g. the model reasoning before it speaks) shows its elapsed time. */ +export const ELAPSED_VISIBLE_AFTER_MS = 5_000 interface PendingTagIndicatorProps { /** Activity phrase next to the loader; crossfades on change. */ label: string + /** When the current wait began (epoch ms); undefined while nothing is being waited on. */ + waitingSince?: number +} + +/** + * The wait's elapsed time, ticking in its own component so the loader beside it + * never re-renders on the clock. Hidden from assistive tech: the loader's status + * already announces the wait, and a count changing every second would be noise. + */ +function ElapsedCount({ since }: { since: number | undefined }) { + const elapsedMs = useElapsedMs(since !== undefined, since) + if (elapsedMs < ELAPSED_VISIBLE_AFTER_MS) return null + return ( + + ) } /** - * Renders the turn-level activity shimmer. + * Renders the turn-level activity shimmer, with the elapsed time once the wait runs long. */ -export function PendingTagIndicator({ label }: PendingTagIndicatorProps) { +export function PendingTagIndicator({ label, waitingSince }: PendingTagIndicatorProps) { return ( -
+
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.dom.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.dom.test.tsx new file mode 100644 index 00000000000..8a81cbf6aff --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.dom.test.tsx @@ -0,0 +1,165 @@ +/** + * @vitest-environment jsdom + * + * The turn's wait indicator: Thinking from send, plus elapsed time once a silent wait runs long. + */ + +import { act } from 'react' +import { authClientMock } from '@sim/testing/mocks/auth-client.mock' +import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/auth/auth-client', () => authClientMock) +vi.mock('next/navigation', () => nextNavigationMock) +vi.mock('@/app/workspace/[workspaceId]/home/components/chat-surface-context', () => ({ + useChatSurface: () => ({}), +})) + +import { ELAPSED_VISIBLE_AFTER_MS } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/pending-tag-indicator' +import { MessageContent } from '@/app/workspace/[workspaceId]/home/components/message-content/message-content' +import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' + +const THINKING_BLOCK: ContentBlock = { + type: 'thinking', + content: 'Weighing the options', + timestamp: 1, +} +const TEXT_BLOCK: ContentBlock = { type: 'text', content: 'Here is the plan.', timestamp: 2 } +const RUNNING_TOOL_BLOCK: ContentBlock = { + type: 'tool_call', + toolCall: { id: 'tool-1', name: 'grep', status: 'executing' }, + timestamp: 2, +} +const STOPPED_BLOCK: ContentBlock = { type: 'stopped', timestamp: 3 } + +let container: HTMLDivElement +let root: Root + +function renderTurn(blocks: ContentBlock[], isStreaming: boolean) { + act(() => { + root.render( + + ) + }) +} + +function advance(ms: number) { + act(() => { + vi.advanceTimersByTime(ms) + }) +} + +/** The elapsed count is the only `aria-hidden` span whose text is a duration. */ +function elapsedText(): string | null { + const spans = container.querySelectorAll('span[aria-hidden="true"]') + for (const span of spans) { + if (/^\d+(m \d+)?s$/.test(span.textContent ?? '')) return span.textContent + } + return null +} + +function isThinkingShown(): boolean { + return container.querySelector('output')?.closest('[aria-hidden="false"]') !== null +} + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + // Reduced motion: the loader holds one shape instead of running its morph + // clock, so the timer count below is the wait indicator's alone. + vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: true })) + vi.useFakeTimers() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('turn wait indicator', () => { + it('shows Thinking at send and adds the elapsed time once the wait runs long', () => { + renderTurn([], true) + + expect(isThinkingShown()).toBe(true) + expect(container.textContent).toContain('Thinking') + expect(elapsedText()).toBeNull() + + advance(ELAPSED_VISIBLE_AFTER_MS - 1_000) + expect(elapsedText()).toBeNull() + + advance(1_000) + expect(elapsedText()).toBe('5s') + + advance(7_000) + expect(elapsedText()).toBe('12s') + }) + + it('keeps counting through hidden reasoning, which is not visible output', () => { + renderTurn([], true) + advance(6_000) + + renderTurn([THINKING_BLOCK], true) + advance(1_000) + + expect(isThinkingShown()).toBe(true) + expect(elapsedText()).toBe('7s') + }) + + it('hides the indicator and its count when the first output arrives', () => { + renderTurn([], true) + advance(8_000) + expect(elapsedText()).toBe('8s') + + renderTurn([THINKING_BLOCK, TEXT_BLOCK], true) + + expect(isThinkingShown()).toBe(false) + expect(elapsedText()).toBeNull() + }) + + it('does not count while a running tool row owns the wait', () => { + renderTurn([RUNNING_TOOL_BLOCK], true) + advance(10_000) + + expect(isThinkingShown()).toBe(false) + expect(elapsedText()).toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('removes the indicator and stops ticking when the turn ends without output', () => { + renderTurn([], true) + advance(8_000) + + renderTurn([], false) + + expect(container.querySelector('output')).toBeNull() + expect(elapsedText()).toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('removes the indicator and stops ticking when the user stops the turn', () => { + renderTurn([], true) + advance(8_000) + + renderTurn([STOPPED_BLOCK], true) + + expect(container.querySelector('output')).toBeNull() + expect(container.textContent).toContain('Stopped by user') + expect(elapsedText()).toBeNull() + }) + + it('clears its clock on unmount', () => { + renderTurn([], true) + advance(8_000) + expect(vi.getTimerCount()).toBeGreaterThan(0) + + act(() => root.unmount()) + root = createRoot(container) + + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index e04ea68b9bd..e57e4cfa62e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -1015,6 +1015,12 @@ function MessageContentInner({ setTrailingPendingTag(pending) }, []) const [isStreamIdle, setIsStreamIdle] = useState(false) + /** + * When the turn last showed new output (or mounted): the start of the current + * wait. A ref, not state — every stream chunk moves it, and it is only read on + * the renders that show the shimmer. + */ + const waitStartedAtRef = useRef(Date.now()) const segments = useMemo( () => @@ -1055,6 +1061,7 @@ function MessageContentInner({ } setIsStreamIdle(false) + waitStartedAtRef.current = Date.now() const timeout = setTimeout(() => setIsStreamIdle(true), STREAM_IDLE_DELAY_MS) return () => clearTimeout(timeout) }, [visibleStreamActivityKey, isStreaming]) @@ -1214,7 +1221,10 @@ function MessageContentInner({ showShimmer ? 'opacity-100' : 'opacity-0' )} > - +
) : // The settled tail takes the slot's place in the SAME render and at the diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-elapsed-ms.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-elapsed-ms.ts new file mode 100644 index 00000000000..586d65b1845 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-elapsed-ms.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react' + +/** + * How often the clock is re-read. Comfortably under a second so a displayed + * second turns over close to when it actually should, rather than drifting by + * most of a second against an interval that started late. + */ +const ELAPSED_TICK_MS = 250 + +/** + * Milliseconds elapsed since `startedAt`, while `active`; 0 otherwise. + * + * Anchors to `startedAt` so a consumer that mounts partway through resumes + * mid-count instead of restarting; falls back to activation time when there is + * no start to give. A sample taken under a different `resetKey` reads as 0, so + * switching subjects never flashes the previous subject's count. + */ +export function useElapsedMs( + active: boolean, + startedAt: number | undefined, + resetKey: string | number | undefined = startedAt +): number { + const [sample, setSample] = useState({ resetKey, elapsedMs: 0 }) + + useEffect(() => { + if (!active) return + const anchor = startedAt ?? Date.now() + const tick = () => setSample({ resetKey, elapsedMs: Date.now() - anchor }) + tick() + const interval = setInterval(tick, ELAPSED_TICK_MS) + return () => clearInterval(interval) + }, [active, startedAt, resetKey]) + + return active && sample.resetKey === resetKey ? sample.elapsedMs : 0 +} From 8bb6d309362b5eeb475552cbc3f823a2e5670426 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 00:56:22 -0700 Subject: [PATCH 2/2] =?UTF-8?q?improvement(mothership):=20address=20review?= =?UTF-8?q?=20=E2=80=94=20hook=20props=20interface,=20text-sm=20token,=20b?= =?UTF-8?q?ehavior-only=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/agent-group/tool-call-title.ts | 6 +- .../special-tags/pending-tag-indicator.tsx | 11 +++- .../message-content.dom.test.tsx | 55 ++++++++----------- .../message-content/message-content.tsx | 2 +- .../home/hooks/use-elapsed-ms.ts | 17 ++++-- 5 files changed, 50 insertions(+), 41 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title.ts index 2b582ab2e9f..2e26d14d4ce 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-title.ts @@ -61,7 +61,11 @@ export function useToolCallTitle(tool: ToolCallTitleInput | undefined): ToolCall [toolName, streamingArgs] ) const isCountingDown = toolName === WaitTool.id && tool?.status === 'executing' - const elapsedMs = useElapsedMs(isCountingDown, tool?.startedAt, tool?.toolCallId) + const elapsedMs = useElapsedMs({ + active: isCountingDown, + startedAt: tool?.startedAt, + resetKey: tool?.toolCallId, + }) if (!tool) return undefined const liveTitle = isCountingDown ? getWaitCountdownTitle(tool.params, elapsedMs) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/pending-tag-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/pending-tag-indicator.tsx index 11b617fb141..ad5ac930d5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/pending-tag-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/pending-tag-indicator.tsx @@ -12,18 +12,23 @@ interface PendingTagIndicatorProps { waitingSince?: number } +interface ElapsedCountProps { + since?: number +} + /** * The wait's elapsed time, ticking in its own component so the loader beside it * never re-renders on the clock. Hidden from assistive tech: the loader's status * already announces the wait, and a count changing every second would be noise. */ -function ElapsedCount({ since }: { since: number | undefined }) { - const elapsedMs = useElapsedMs(since !== undefined, since) +function ElapsedCount({ since }: ElapsedCountProps) { + const elapsedMs = useElapsedMs({ active: since !== undefined, startedAt: since }) if (elapsedMs < ELAPSED_VISIBLE_AFTER_MS) return null return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.dom.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.dom.test.tsx index 8a81cbf6aff..370c877fcab 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.dom.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.dom.test.tsx @@ -50,17 +50,14 @@ function advance(ms: number) { }) } -/** The elapsed count is the only `aria-hidden` span whose text is a duration. */ -function elapsedText(): string | null { - const spans = container.querySelectorAll('span[aria-hidden="true"]') - for (const span of spans) { - if (/^\d+(m \d+)?s$/.test(span.textContent ?? '')) return span.textContent - } - return null +function hasElapsedCount(): boolean { + return container.querySelector('[data-wait-elapsed]') !== null } -function isThinkingShown(): boolean { - return container.querySelector('output')?.closest('[aria-hidden="false"]') !== null +/** The loader's status sits in the tail slot, which is exposed only while the indicator shows. */ +function isIndicatorExposed(): boolean { + const status = container.querySelector('output') + return status !== null && status.closest('[aria-hidden="false"]') !== null } beforeEach(() => { @@ -82,51 +79,47 @@ afterEach(() => { }) describe('turn wait indicator', () => { - it('shows Thinking at send and adds the elapsed time once the wait runs long', () => { + it('shows from send and adds the elapsed count only once the wait runs long', () => { renderTurn([], true) - expect(isThinkingShown()).toBe(true) - expect(container.textContent).toContain('Thinking') - expect(elapsedText()).toBeNull() + expect(isIndicatorExposed()).toBe(true) + expect(hasElapsedCount()).toBe(false) advance(ELAPSED_VISIBLE_AFTER_MS - 1_000) - expect(elapsedText()).toBeNull() + expect(hasElapsedCount()).toBe(false) advance(1_000) - expect(elapsedText()).toBe('5s') - - advance(7_000) - expect(elapsedText()).toBe('12s') + expect(hasElapsedCount()).toBe(true) }) it('keeps counting through hidden reasoning, which is not visible output', () => { renderTurn([], true) - advance(6_000) + advance(ELAPSED_VISIBLE_AFTER_MS - 1_000) renderTurn([THINKING_BLOCK], true) advance(1_000) - expect(isThinkingShown()).toBe(true) - expect(elapsedText()).toBe('7s') + expect(isIndicatorExposed()).toBe(true) + expect(hasElapsedCount()).toBe(true) }) - it('hides the indicator and its count when the first output arrives', () => { + it('hides the indicator and drops the count when the first output arrives', () => { renderTurn([], true) advance(8_000) - expect(elapsedText()).toBe('8s') + expect(hasElapsedCount()).toBe(true) renderTurn([THINKING_BLOCK, TEXT_BLOCK], true) - expect(isThinkingShown()).toBe(false) - expect(elapsedText()).toBeNull() + expect(isIndicatorExposed()).toBe(false) + expect(hasElapsedCount()).toBe(false) }) it('does not count while a running tool row owns the wait', () => { renderTurn([RUNNING_TOOL_BLOCK], true) advance(10_000) - expect(isThinkingShown()).toBe(false) - expect(elapsedText()).toBeNull() + expect(isIndicatorExposed()).toBe(false) + expect(hasElapsedCount()).toBe(false) expect(vi.getTimerCount()).toBe(0) }) @@ -137,7 +130,7 @@ describe('turn wait indicator', () => { renderTurn([], false) expect(container.querySelector('output')).toBeNull() - expect(elapsedText()).toBeNull() + expect(hasElapsedCount()).toBe(false) expect(vi.getTimerCount()).toBe(0) }) @@ -145,11 +138,11 @@ describe('turn wait indicator', () => { renderTurn([], true) advance(8_000) - renderTurn([STOPPED_BLOCK], true) + renderTurn([STOPPED_BLOCK], false) expect(container.querySelector('output')).toBeNull() - expect(container.textContent).toContain('Stopped by user') - expect(elapsedText()).toBeNull() + expect(hasElapsedCount()).toBe(false) + expect(vi.getTimerCount()).toBe(0) }) it('clears its clock on unmount', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index e57e4cfa62e..fdb55f4d341 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -1020,7 +1020,7 @@ function MessageContentInner({ * wait. A ref, not state — every stream chunk moves it, and it is only read on * the renders that show the shimmer. */ - const waitStartedAtRef = useRef(Date.now()) + const waitStartedAtRef = useRef(Date.now()) const segments = useMemo( () => diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-elapsed-ms.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-elapsed-ms.ts index 586d65b1845..4a5cacd51c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-elapsed-ms.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-elapsed-ms.ts @@ -15,11 +15,18 @@ const ELAPSED_TICK_MS = 250 * no start to give. A sample taken under a different `resetKey` reads as 0, so * switching subjects never flashes the previous subject's count. */ -export function useElapsedMs( - active: boolean, - startedAt: number | undefined, - resetKey: string | number | undefined = startedAt -): number { +interface UseElapsedMsProps { + active: boolean + startedAt?: number + /** Identity of what is being timed; defaults to `startedAt`. */ + resetKey?: string | number +} + +export function useElapsedMs({ + active, + startedAt, + resetKey = startedAt, +}: UseElapsedMsProps): number { const [sample, setSample] = useState({ resetKey, elapsedMs: 0 }) useEffect(() => {