Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
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 {
getToolInProgressTitle,
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<Record<string, string>> = {
create: 'Creating',
Expand All @@ -25,32 +19,6 @@ const FILE_EDIT_VERBS: Readonly<Record<string, string>> = {
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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<span
aria-hidden='true'
data-wait-elapsed
className='animate-stream-fade-in text-[var(--text-muted)] text-sm tabular-nums'
>
{formatDuration(elapsedMs)}
</span>
)
}

/**
* 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 (
<div className='animate-stream-fade-in py-2'>
<div className='flex animate-stream-fade-in items-center gap-2 py-2'>
<ThinkingLoader size={20} startVariant='corners' label={label} labelRatio={0.7} />
<ElapsedCount since={waitingSince} />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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(
<MessageContent blocks={blocks} fallbackContent='' isStreaming={isStreaming} isLast />
)
})
}

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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>(Date.now())

const segments = useMemo<MessageSegment[]>(
() =>
Expand Down Expand Up @@ -1055,6 +1061,7 @@ function MessageContentInner({
}

setIsStreamIdle(false)
waitStartedAtRef.current = Date.now()
Comment thread
waleedlatif1 marked this conversation as resolved.
const timeout = setTimeout(() => setIsStreamIdle(true), STREAM_IDLE_DELAY_MS)
return () => clearTimeout(timeout)
}, [visibleStreamActivityKey, isStreaming])
Expand Down Expand Up @@ -1214,7 +1221,10 @@ function MessageContentInner({
showShimmer ? 'opacity-100' : 'opacity-0'
)}
>
<PendingTagIndicator label={thinkingLabel} />
<PendingTagIndicator
label={thinkingLabel}
waitingSince={showShimmer ? waitStartedAtRef.current : undefined}
/>
</div>
</div>
) : // The settled tail takes the slot's place in the SAME render and at the
Expand Down
42 changes: 42 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/home/hooks/use-elapsed-ms.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading