Skip to content
Merged
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
30 changes: 23 additions & 7 deletions src/web-react/chat-messages-segments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ describe('ChatMessages segmented turns', () => {
const { container } = render(<ChatMessages messages={[message]} />)

const pre = indexIn(container, 'Checking the workflow format first.')
const schema = indexIn(container, 'get_workflow_schema')
// Unmapped tool names render as humanized titles, e.g. "Get workflow schema".
const schema = indexIn(container, 'Get workflow schema')
const mid = indexIn(container, 'Now validating the definition.')
const validate = indexIn(container, 'validate_workflow')
const validate = indexIn(container, 'Validate workflow')
const post = indexIn(container, 'Validated. Here is the plan.')
// Every needle is present...
expect(Math.min(pre, schema, mid, validate, post)).toBeGreaterThanOrEqual(0)
Expand All @@ -68,7 +69,7 @@ describe('ChatMessages segmented turns', () => {
const { container } = render(<ChatMessages messages={[message]} />)

const body = indexIn(container, 'All done.')
const tool = indexIn(container, 'list_workflows')
const tool = indexIn(container, 'List workflows')
expect(body).toBeGreaterThanOrEqual(0)
expect(tool).toBeGreaterThanOrEqual(0)
// Legacy producers keep the prior layout: content first, tool chips after.
Expand Down Expand Up @@ -100,8 +101,8 @@ describe('ChatMessages segmented turns', () => {
}
const { container } = render(<ChatMessages messages={[message]} />)
const text = container.textContent ?? ''
expect(text).toContain('list_skills')
expect(text.indexOf('After.')).toBeGreaterThan(text.indexOf('list_skills'))
expect(text).toContain('List skills')
expect(text.indexOf('After.')).toBeGreaterThan(text.indexOf('List skills'))
})

it('renders a toolCall not represented in segments rather than dropping it', () => {
Expand All @@ -114,7 +115,7 @@ describe('ChatMessages segmented turns', () => {
toolCalls: [{ id: 'orphan', name: 'list_workflows', status: 'done' }],
}
const { container } = render(<ChatMessages messages={[message]} />)
expect(container.textContent).toContain('list_workflows')
expect(container.textContent).toContain('List workflows')
})

it('does not duplicate a toolCall already present as a segment', () => {
Expand All @@ -128,10 +129,25 @@ describe('ChatMessages segmented turns', () => {
toolCalls: [{ id: 't1', name: 'validate_workflow', status: 'done' }],
}
const { container } = render(<ChatMessages messages={[message]} />)
const matches = (container.textContent ?? '').match(/validate_workflow/g) ?? []
const matches = (container.textContent ?? '').match(/Validate workflow/g) ?? []
expect(matches).toHaveLength(1)
})

it('humanizes an unmapped tool name for the chip title', () => {
const message: ChatUiMessage = {
id: 'm1',
role: 'assistant',
content: '',
segments: [
{ kind: 'tool', call: { id: 't1', name: 'get_credit_balance', status: 'done' } },
],
}
const { container } = render(<ChatMessages messages={[message]} />)
// The snake_case slug shows as a sentence-cased label, never the raw name.
expect(container.textContent).toContain('Get credit balance')
expect(container.textContent).not.toContain('get_credit_balance')
})

it('does not leave the reasoning panel Thinking for a segmented message with empty content', () => {
const message: ChatUiMessage = {
id: 'm1',
Expand Down
45 changes: 40 additions & 5 deletions src/web-react/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -363,9 +363,24 @@ function blockKindOf(call: ChatToolCallInfo): BlockKind {
return 'generic'
}

/** Humanize an otherwise-unmapped tool name for display: `get_credit_balance`
* → "Get credit balance". Splits on separators and camelCase, then sentence-
* cases — domain-agnostic, so a host's tool reads as a label without this
* shared renderer knowing that host's tool taxonomy. Falls back to the raw name
* when there's nothing to humanize. */
function humanizeToolName(name: string): string {
const words = name
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.trim()
if (!words) return name
return words.charAt(0).toUpperCase() + words.slice(1)
}

/** Human title for a call, derived from its real arguments. Proposals lead with
* the decision verb (docs/product-surfaces.md) rather than the internal tool
* taxonomy, so the user reads "Approve: publish …?" not "submit_proposal". */
* taxonomy, so the user reads "Approve: publish …?" not "submit_proposal". An
* unmapped tool falls back to its humanized name rather than the raw slug. */
function friendlyToolTitle(call: ChatToolCallInfo): string {
const a = call.args ?? {}
switch (call.name) {
Expand All @@ -384,7 +399,7 @@ function friendlyToolTitle(call: ChatToolCallInfo): string {
case 'add_citation':
return `Cited ${String(a.path ?? '')}`
default:
return call.name
return humanizeToolName(call.name)
}
}

Expand Down Expand Up @@ -878,6 +893,11 @@ function AssistantMessageImpl({
const el = reasoningScrollRef.current
if (el && streaming && !hasAnswerText) el.scrollTop = el.scrollHeight
}, [reasoning, streaming, hasAnswerText])
// Live seconds while the model is reasoning before its answer starts, so a
// long thinking gap shows progress rather than a static "Thinking…".
const thinkingSeconds = useThinkingSeconds(
streaming && !!reasoning && !hasAnswerText,
)

return (
<div className="mx-auto w-full max-w-3xl px-6 py-3">
Expand All @@ -891,7 +911,9 @@ function AssistantMessageImpl({
<details className="mb-2 rounded-lg border-l-2 border-border/70 bg-muted/20 px-3 py-2" open={!hasAnswerText}>
<summary className="cursor-pointer select-none text-xs font-medium text-muted-foreground">
{!hasAnswerText ? (
<span className="animate-pulse">Thinking…</span>
<span className="animate-pulse">
Thinking{thinkingSeconds >= 3 ? ` · ${thinkingSeconds}s` : '…'}
</span>
) : thinkMsRef.current != null ? (
`Thought for ${Math.max(1, Math.round(thinkMsRef.current / 1000))}s`
) : (
Expand Down Expand Up @@ -949,12 +971,25 @@ function AssistantMessageImpl({
*/
const AssistantMessage = memo(AssistantMessageImpl)

function ThinkingRow({ agentLabel }: { agentLabel: string }) {
/** Whole seconds elapsed while `active`, ticking once a second. Powers the live
* "thinking" timers (the pre-first-token row and the reasoning box) so a long
* thinking gap shows progress instead of a frozen label. Counts from when
* `active` first turns true; freezes when it clears. */
export function useThinkingSeconds(active: boolean): number {
const [seconds, setSeconds] = useState(0)
useEffect(() => {
if (!active) return
// Reset on each (re)activation so a reused component resuming "thinking"
// counts from 0 rather than showing the prior phase's stale elapsed time.
setSeconds(0)
const id = setInterval(() => setSeconds((s) => s + 1), 1000)
return () => clearInterval(id)
}, [])
}, [active])
return seconds
}

function ThinkingRow({ agentLabel }: { agentLabel: string }) {
const seconds = useThinkingSeconds(true)
return (
<div className="mx-auto w-full max-w-3xl px-6 py-3">
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{agentLabel}</p>
Expand Down
52 changes: 52 additions & 0 deletions src/web-react/use-thinking-seconds.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useThinkingSeconds } from './index'

describe('useThinkingSeconds', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())

it('counts whole seconds while active', () => {
const { result } = renderHook(() => useThinkingSeconds(true))
expect(result.current).toBe(0)
act(() => {
vi.advanceTimersByTime(3000)
})
expect(result.current).toBe(3)
})

it('does not advance while inactive', () => {
const { result } = renderHook(() => useThinkingSeconds(false))
act(() => {
vi.advanceTimersByTime(5000)
})
expect(result.current).toBe(0)
})

it('resets to 0 when reactivated rather than resuming the stale count', () => {
const { result, rerender } = renderHook(
({ active }) => useThinkingSeconds(active),
{ initialProps: { active: true } },
)
act(() => {
vi.advanceTimersByTime(4000)
})
expect(result.current).toBe(4)
// Deactivate (freezes), then reactivate — the counter must restart at 0.
rerender({ active: false })
rerender({ active: true })
expect(result.current).toBe(0)
act(() => {
vi.advanceTimersByTime(1000)
})
expect(result.current).toBe(1)
})

it('clears its interval on unmount', () => {
const clearSpy = vi.spyOn(globalThis, 'clearInterval')
const { unmount } = renderHook(() => useThinkingSeconds(true))
unmount()
expect(clearSpy).toHaveBeenCalled()
})
})