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..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 @@ -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 @@ -93,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 7dc0bec53ad..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 @@ -1,17 +1,48 @@ +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 +} + +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 }: ElapsedCountProps) { + const elapsedMs = useElapsedMs({ active: since !== undefined, startedAt: 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..370c877fcab --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.dom.test.tsx @@ -0,0 +1,158 @@ +/** + * @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) + }) +} + +function hasElapsedCount(): boolean { + return container.querySelector('[data-wait-elapsed]') !== 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(() => { + 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 from send and adds the elapsed count only once the wait runs long', () => { + renderTurn([], true) + + expect(isIndicatorExposed()).toBe(true) + expect(hasElapsedCount()).toBe(false) + + advance(ELAPSED_VISIBLE_AFTER_MS - 1_000) + expect(hasElapsedCount()).toBe(false) + + advance(1_000) + expect(hasElapsedCount()).toBe(true) + }) + + it('keeps counting through hidden reasoning, which is not visible output', () => { + renderTurn([], true) + advance(ELAPSED_VISIBLE_AFTER_MS - 1_000) + + renderTurn([THINKING_BLOCK], true) + advance(1_000) + + expect(isIndicatorExposed()).toBe(true) + expect(hasElapsedCount()).toBe(true) + }) + + it('hides the indicator and drops the count when the first output arrives', () => { + renderTurn([], true) + advance(8_000) + expect(hasElapsedCount()).toBe(true) + + renderTurn([THINKING_BLOCK, TEXT_BLOCK], true) + + 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(isIndicatorExposed()).toBe(false) + expect(hasElapsedCount()).toBe(false) + 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(hasElapsedCount()).toBe(false) + 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], false) + + expect(container.querySelector('output')).toBeNull() + expect(hasElapsedCount()).toBe(false) + expect(vi.getTimerCount()).toBe(0) + }) + + 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..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 @@ -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..4a5cacd51c4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-elapsed-ms.ts @@ -0,0 +1,42 @@ +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. + */ +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(() => { + 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 +}