mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge pull request #72893 from NousResearch/bb/desktop-activity-grouping
Collapse a turn's tool activity into one grouped, live-ticking line
This commit is contained in:
commit
fe71e32a87
29 changed files with 2081 additions and 562 deletions
|
|
@ -29,6 +29,7 @@ import { $sessionStates, sessionTileDelegate } from '@/store/session-states'
|
|||
import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||
import { clearSessionSubagents } from '@/store/subagents'
|
||||
import { clearSessionTodos } from '@/store/todos'
|
||||
import { setSessionDraftingTool } from '@/store/tool-drafting'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { uploadComposerAttachment } from '../session/hooks/use-prompt-actions'
|
||||
|
|
@ -253,6 +254,7 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
|
|||
clearSessionTodos(sessionId)
|
||||
clearSessionSubagents(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
setSessionDraftingTool(sessionId, '')
|
||||
clearAllPrompts(sessionId)
|
||||
clearClarifyRequest(undefined, sessionId)
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
import { pruneDelegateFallbackSubagents, pruneFinishedSessionSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { clearActiveSessionTodos } from '@/store/todos'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
import { setSessionDraftingTool } from '@/store/tool-drafting'
|
||||
import { reportInstallMethodWarning } from '@/store/updates'
|
||||
import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events'
|
||||
// Leaf import (not the `@/themes` barrel) to avoid pulling the ThemeProvider
|
||||
|
|
@ -105,6 +106,28 @@ function surfaceBillingBlock(sessionId: string, raw: unknown): void {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Events that retire a "drafting a tool call" claim.
|
||||
*
|
||||
* `tool.generating` opens the claim and nothing closes it — a draft can be
|
||||
* abandoned without ever reaching `tool.start`, so enumerating the ways one
|
||||
* *ends* left the label on screen for the rest of the turn. Inverted: the
|
||||
* claim only covers what the model is emitting right now, and any other output
|
||||
* from the session proves it moved on. Same rule the TUI applies to its
|
||||
* transient trail lines (`turnController.pruneTransient`).
|
||||
*/
|
||||
const DRAFT_SUPERSEDING_EVENT_TYPES = new Set([
|
||||
'error',
|
||||
'message.complete',
|
||||
'message.delta',
|
||||
'message.start',
|
||||
'reasoning.delta',
|
||||
'thinking.delta',
|
||||
'tool.complete',
|
||||
'tool.progress',
|
||||
'tool.start'
|
||||
])
|
||||
|
||||
const COMPACTION_RESUME_EVENT_TYPES = new Set([
|
||||
'message.delta',
|
||||
'message.interim',
|
||||
|
|
@ -243,6 +266,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
setSessionCompacting(sessionId, false)
|
||||
}
|
||||
|
||||
if (sessionId && DRAFT_SUPERSEDING_EVENT_TYPES.has(event.type)) {
|
||||
setSessionDraftingTool(sessionId, '')
|
||||
}
|
||||
|
||||
if (event.type === 'gateway.ready') {
|
||||
// Seed the active skin into the desktop theme registry without applying,
|
||||
// so a fresh connect never overrides the user's persisted desktop theme.
|
||||
|
|
@ -677,7 +704,26 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
if (storedId && nextTitle) {
|
||||
setSessions(prev => prev.map(s => (sessionMatchesStoredId(s, storedId) ? { ...s, title: nextTitle } : s)))
|
||||
}
|
||||
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
|
||||
} else if (event.type === 'tool.generating') {
|
||||
// Announced while the model is still emitting the call's JSON, so it
|
||||
// carries a name and nothing else — no id, no args. Materializing a row
|
||||
// from it strands an argless placeholder whenever the bubble is sealed
|
||||
// before the real `tool.start` arrives, because the two can no longer be
|
||||
// reconciled across the boundary. It's a status, so say it as one.
|
||||
// A stopped turn can still emit a frame or two before the backend
|
||||
// notices, and naming a tool we will never run leaves the label up
|
||||
// until something else retires it. `mutateStream` drops late tool rows
|
||||
// on the same condition; the status line has to agree with it.
|
||||
if (!sessionId || sessionInterrupted(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
setSessionDraftingTool(sessionId, typeof payload?.name === 'string' ? payload.name : '')
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: false, toolRunning: true })
|
||||
}
|
||||
} else if (event.type === 'tool.start' || event.type === 'tool.progress') {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $draftingToolSessions } from '@/store/tool-drafting'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
|
||||
const SID = 'session-1'
|
||||
const OTHER_SID = 'session-2'
|
||||
|
||||
// Module-scoped so a test can seed session state (e.g. interrupted) before the
|
||||
// handler reads it — `sessionInterrupted` resolves against this map.
|
||||
const sessionStates = new Map<string, ClientSessionState>()
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(sessionStates)
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const next = updater(sessionStates.get(sessionId) ?? createClientSessionState())
|
||||
sessionStates.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
}
|
||||
|
||||
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}, sessionId = SID) {
|
||||
act(() => handleEvent!({ payload, session_id: sessionId, type }))
|
||||
}
|
||||
|
||||
function draftedTool(sessionId = SID) {
|
||||
return $draftingToolSessions.get()[sessionId]?.name
|
||||
}
|
||||
|
||||
describe('drafting-tool label lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
sessionStates.clear()
|
||||
$draftingToolSessions.set({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
sessionStates.clear()
|
||||
$draftingToolSessions.set({})
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('names the tool the model is drafting', async () => {
|
||||
await mountStream()
|
||||
|
||||
emit('tool.generating', { name: 'write_file' })
|
||||
|
||||
expect(draftedTool()).toBe('write_file')
|
||||
})
|
||||
|
||||
// The label used to be retired only by the events that mean "this tool ran".
|
||||
// A tool can be abandoned without ever reaching `tool.start` — a mid-stream
|
||||
// retry drops a partial call, a guardrail-blocked tool skips the lifecycle
|
||||
// callbacks — and the name then sat on screen for the rest of the turn.
|
||||
it.each([
|
||||
['message.delta', { text: 'never mind' }],
|
||||
['reasoning.delta', { text: 'reconsidering' }],
|
||||
['thinking.delta', { text: 'reconsidering' }],
|
||||
['tool.start', { name: 'terminal', tool_id: 'tool-1' }],
|
||||
['tool.complete', { name: 'terminal', tool_id: 'tool-1' }],
|
||||
['message.complete', { text: 'done' }],
|
||||
['error', { message: 'boom' }]
|
||||
] as const)('retires the label when %s proves the model moved on', async (type, payload) => {
|
||||
await mountStream()
|
||||
emit('tool.generating', { name: 'write_file' })
|
||||
|
||||
emit(type, payload)
|
||||
|
||||
expect(draftedTool()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves another session’s label alone', async () => {
|
||||
await mountStream()
|
||||
emit('tool.generating', { name: 'patch' }, OTHER_SID)
|
||||
emit('tool.generating', { name: 'write_file' })
|
||||
|
||||
emit('message.delta', { text: 'moving on' })
|
||||
|
||||
expect(draftedTool()).toBeUndefined()
|
||||
expect(draftedTool(OTHER_SID)).toBe('patch')
|
||||
})
|
||||
|
||||
// A stopped turn can still emit a frame or two before the backend notices.
|
||||
it('ignores a tool announced after the user hit stop', async () => {
|
||||
sessionStates.set(SID, { ...createClientSessionState(), interrupted: true })
|
||||
await mountStream()
|
||||
|
||||
emit('tool.generating', { name: 'write_file' })
|
||||
|
||||
expect(draftedTool()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -33,6 +33,7 @@ import {
|
|||
} from '@/store/session'
|
||||
import { clearSessionSubagents } from '@/store/subagents'
|
||||
import { clearSessionTodos } from '@/store/todos'
|
||||
import { setSessionDraftingTool } from '@/store/tool-drafting'
|
||||
|
||||
import type {
|
||||
ClientSessionState,
|
||||
|
|
@ -592,6 +593,7 @@ export function usePromptActions({
|
|||
clearSessionTodos(sessionId)
|
||||
clearSessionSubagents(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
setSessionDraftingTool(sessionId, '')
|
||||
// Stop ends the turn, so the gateway is no longer blocked on any prompt it
|
||||
// raised. Drop this session's pending clarify / approval / sudo / secret so
|
||||
// a dead panel (and the sidebar "needs input" dot) can't linger and accept
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useSta
|
|||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
|
||||
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { formatElapsed, useElapsedSeconds, useMeasuredDuration } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { GeneratedImage } from '@/components/chat/generated-image-result'
|
||||
import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { generatedImageFromResult } from '@/lib/generated-images'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
|
|
@ -57,7 +57,9 @@ const ThinkingDisclosure: FC<{
|
|||
children: ReactNode
|
||||
messageRunning?: boolean
|
||||
pending?: boolean
|
||||
timerKey?: string
|
||||
// Required: the block's duration is remembered against this key, so a
|
||||
// component that mounts after the block finished can still report it.
|
||||
timerKey: string
|
||||
}> = ({ children, messageRunning = false, pending = false, timerKey }) => {
|
||||
const { t } = useI18n()
|
||||
// `null` = no explicit user toggle yet, defer to the streaming default.
|
||||
|
|
@ -66,6 +68,7 @@ const ThinkingDisclosure: FC<{
|
|||
// explicit toggle wins from then on.
|
||||
const [userOpen, setUserOpen] = useState<boolean | null>(null)
|
||||
const elapsed = useElapsedSeconds(pending, timerKey)
|
||||
const thoughtFor = useMeasuredDuration(pending, timerKey)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement | null>(null)
|
||||
const enterRef = useEnterAnimation(messageRunning, timerKey)
|
||||
|
|
@ -73,6 +76,23 @@ const ThinkingDisclosure: FC<{
|
|||
const open = userOpen ?? pending
|
||||
const isPreview = pending && userOpen === null
|
||||
|
||||
// Three ways a finished block can report itself. With a measured duration it
|
||||
// says so, unless the timer's whole seconds round it to "0s" — accurate and
|
||||
// useless — in which case it just says it was quick. With no duration at all
|
||||
// it still has to read as finished; a turn that ended must not go on saying
|
||||
// "Thinking".
|
||||
let thoughtLabel = t.assistant.thread.thinking
|
||||
|
||||
if (!pending) {
|
||||
if (thoughtFor === null) {
|
||||
thoughtLabel = t.assistant.thread.thought
|
||||
} else if (thoughtFor < 1) {
|
||||
thoughtLabel = t.assistant.thread.thoughtBriefly
|
||||
} else {
|
||||
thoughtLabel = t.assistant.thread.thoughtFor(formatElapsed(thoughtFor))
|
||||
}
|
||||
}
|
||||
|
||||
// While the preview is live, pin the scroll container to the bottom on
|
||||
// every content growth so the latest tokens are always visible.
|
||||
useEffect(() => {
|
||||
|
|
@ -116,27 +136,14 @@ const ThinkingDisclosure: FC<{
|
|||
return (
|
||||
<div
|
||||
className="text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)"
|
||||
data-conversation-scaffold=""
|
||||
data-slot="aui_thinking-disclosure"
|
||||
ref={enterRef}
|
||||
>
|
||||
<DisclosureRow onToggle={() => setUserOpen(!open)} open={open}>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)',
|
||||
pending && 'shimmer text-foreground/55'
|
||||
)}
|
||||
>
|
||||
{t.assistant.thread.thinking}
|
||||
</span>
|
||||
{pending && (
|
||||
<ActivityTimerText
|
||||
className="text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)"
|
||||
seconds={elapsed}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</DisclosureRow>
|
||||
<ScaffoldRow onToggle={() => setUserOpen(!open)} open={open}>
|
||||
<span className={cn(SCAFFOLD_LABEL_CLASS, pending && 'shimmer')}>{thoughtLabel}</span>
|
||||
{pending && <ActivityTimerText className={SCAFFOLD_META_CLASS} seconds={elapsed} />}
|
||||
</ScaffoldRow>
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -193,7 +200,15 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
|
|||
}
|
||||
|
||||
return (
|
||||
<ThinkingDisclosure messageRunning={messageRunning} pending={pending} timerKey={`reasoning:${messageId}`}>
|
||||
// Keyed per block, not per message: the timer registry hands every caller
|
||||
// of a key the same origin, so a turn that thinks three separate times used
|
||||
// to measure the second and third blocks from the first one's start and
|
||||
// report the running total as each block's duration.
|
||||
<ThinkingDisclosure
|
||||
messageRunning={messageRunning}
|
||||
pending={pending}
|
||||
timerKey={`reasoning:${messageId}:${startIndex}`}
|
||||
>
|
||||
{children}
|
||||
</ThinkingDisclosure>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -54,3 +54,18 @@ describe('ResponseLoadingIndicator timer', () => {
|
|||
expect(screen.getAllByText((_, node) => node?.textContent === '8s').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// The status line sits between tool rows and thinking headers, which the
|
||||
// transcript rests at a fade. Without the mark it reads a shade brighter than
|
||||
// both — the one line in the column claiming emphasis it hasn't earned.
|
||||
describe('status line', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('is marked as transcript scaffolding', () => {
|
||||
$activeSessionId.set('session-a')
|
||||
$turnStartedAt.set(Date.now())
|
||||
const { container } = renderIndicator()
|
||||
|
||||
expect(container.querySelector('[role="status"]')?.hasAttribute('data-conversation-scaffold')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import { useStore } from '@nanostores/react'
|
|||
import { type FC, type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { toolPresentVerb } from '@/components/assistant-ui/tool/run-summary'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { SCAFFOLD_LABEL_CLASS } from '@/components/chat/scaffold-row'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
|
@ -13,7 +15,11 @@ import { $backgroundResume } from '@/store/background-delegation'
|
|||
import { sessionCompacting } from '@/store/compaction'
|
||||
import { sessionAwaitingInput } from '@/store/prompts'
|
||||
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
|
||||
// drafts a call is the same kind of line as "Explored 3 files" once it has run,
|
||||
// and reads as one continuous column only if it shares their type and colour.
|
||||
const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
|
||||
children,
|
||||
label,
|
||||
|
|
@ -23,7 +29,12 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
|
|||
<div
|
||||
aria-label={label}
|
||||
aria-live="polite"
|
||||
className={cn('flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70', className)}
|
||||
className={cn(
|
||||
'flex max-w-full items-center gap-1.5 self-start leading-(--conversation-line-height)',
|
||||
'text-(--conversation-scaffold-text)',
|
||||
className
|
||||
)}
|
||||
data-conversation-scaffold=""
|
||||
role="status"
|
||||
{...rest}
|
||||
>
|
||||
|
|
@ -34,8 +45,8 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
|
|||
// Fixed label while auto-compaction runs — decoupled from backend status text.
|
||||
const COMPACTION_LABEL = 'Summarizing thread'
|
||||
|
||||
const CompactionHint: FC = () => (
|
||||
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
|
||||
const HintText: FC<{ children: ReactNode }> = ({ children }) => (
|
||||
<span className={cn(SCAFFOLD_LABEL_CLASS, 'shimmer min-w-0 truncate')}>{children}</span>
|
||||
)
|
||||
|
||||
/** These indicators render inside whichever transcript mounted them, so every
|
||||
|
|
@ -45,6 +56,7 @@ function useThreadSessionStatus() {
|
|||
const sessionId = useStore(useSessionView().$runtimeId)
|
||||
const turnStartedAt = useStore($turnStartedAt)
|
||||
const compacting = useStore(useMemo(() => sessionCompacting(sessionId), [sessionId]))
|
||||
const drafting = useStore(useMemo(() => sessionDraftingTool(sessionId), [sessionId]))
|
||||
// A pending clarify / approval / sudo / secret means the turn is paused on the
|
||||
// user, not working — so don't resurrect the "thinking" timer while they
|
||||
// decide (matches the pet's awaitingInput pose taking priority over busy).
|
||||
|
|
@ -53,10 +65,42 @@ function useThreadSessionStatus() {
|
|||
return {
|
||||
awaitingInput,
|
||||
compacting,
|
||||
drafting,
|
||||
turnTimerKey: sessionId && turnStartedAt ? `turn:${sessionId}:${turnStartedAt}` : undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Long enough that a tool whose arguments arrive in a few frames never gets to
|
||||
// strobe a label, short enough that a real wait is named almost immediately.
|
||||
const DRAFTING_REVEAL_MS = 200
|
||||
|
||||
/**
|
||||
* What to call the wait, if it deserves a name. Compaction outranks a draft —
|
||||
* it's rarer, slower, and explains a transcript that looks like it reset.
|
||||
*/
|
||||
function useStatusHint(compacting: boolean, drafting: DraftingTool | null): string {
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const name = drafting?.name ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
setRevealed(false)
|
||||
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setTimeout(() => setRevealed(true), DRAFTING_REVEAL_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}, [name])
|
||||
|
||||
if (compacting) {
|
||||
return COMPACTION_LABEL
|
||||
}
|
||||
|
||||
return revealed && name ? toolPresentVerb(name) : ''
|
||||
}
|
||||
|
||||
export const CenteredThreadSpinner: FC = () => {
|
||||
const { t } = useI18n()
|
||||
|
||||
|
|
@ -80,17 +124,14 @@ export const CenteredThreadSpinner: FC = () => {
|
|||
|
||||
export const ResponseLoadingIndicator: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const { compacting, turnTimerKey } = useThreadSessionStatus()
|
||||
const { compacting, drafting, turnTimerKey } = useThreadSessionStatus()
|
||||
const elapsed = useElapsedSeconds(true, turnTimerKey)
|
||||
const hint = useStatusHint(compacting, drafting)
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
className="text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height)"
|
||||
data-slot="aui_response-loading"
|
||||
label={compacting ? COMPACTION_LABEL : t.assistant.thread.loadingResponse}
|
||||
>
|
||||
<StatusRow data-slot="aui_response-loading" label={hint || t.assistant.thread.loadingResponse}>
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
{compacting && <CompactionHint />}
|
||||
{hint && <HintText>{hint}</HintText>}
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
|
|
@ -159,7 +200,13 @@ export const StreamStallIndicator: FC = () => {
|
|||
// 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<number | undefined>(undefined)
|
||||
const { awaitingInput, compacting, turnTimerKey } = useThreadSessionStatus()
|
||||
const { awaitingInput, compacting, drafting, turnTimerKey } = useThreadSessionStatus()
|
||||
const hint = useStatusHint(compacting, drafting)
|
||||
|
||||
// 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')
|
||||
|
||||
useEffect(() => {
|
||||
setQuietSince(undefined)
|
||||
|
|
@ -169,24 +216,28 @@ export const StreamStallIndicator: FC = () => {
|
|||
return () => window.clearTimeout(id)
|
||||
}, [activity])
|
||||
|
||||
const active = (quietSince !== undefined || compacting) && !awaitingInput
|
||||
// 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
|
||||
|
||||
// Compaction owns the whole turn, so it keeps counting from the turn's start;
|
||||
// a plain stall counts from the last thing the stream produced.
|
||||
const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined, compacting ? undefined : quietSince)
|
||||
// anything else counts from the moment the stream went quiet — the stall's own
|
||||
// mark, or the draft's, whichever named the wait first.
|
||||
const elapsed = useElapsedSeconds(
|
||||
active,
|
||||
compacting ? turnTimerKey : undefined,
|
||||
compacting ? undefined : (quietSince ?? drafting?.since)
|
||||
)
|
||||
|
||||
if (!active) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
className="mt-1.5"
|
||||
data-slot="aui_stream-stall"
|
||||
label={compacting ? COMPACTION_LABEL : 'Hermes is thinking'}
|
||||
>
|
||||
<StatusRow data-slot="aui_stream-stall" label={hint || 'Hermes is thinking'}>
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
{compacting && <CompactionHint />}
|
||||
{hint && <HintText>{hint}</HintText>}
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -606,7 +606,8 @@ describe('assistant-ui streaming renderer', () => {
|
|||
const { container } = render(<ReasoningHarness />)
|
||||
const ui = within(container)
|
||||
|
||||
fireEvent.click(ui.getByRole('button', { name: /thinking/i }))
|
||||
// Settled, so the header is past tense — a running block says "Thinking".
|
||||
fireEvent.click(ui.getByRole('button', { name: /thought/i }))
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toBe(
|
||||
'The user is asking what this file is.'
|
||||
|
|
|
|||
|
|
@ -1,299 +0,0 @@
|
|||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import { clearDismissedToolRows } from '@/store/tool-dismiss'
|
||||
import { $toolDisclosureStates } from '@/store/tool-view'
|
||||
|
||||
import { Thread } from '../thread'
|
||||
|
||||
// Regression coverage for the "approval must never be buried" bug. Tools now
|
||||
// render as a flat list (no collapsible "N steps" group), so a pending tool's
|
||||
// inline ApprovalBar is always in the visual flow — never inside a `hidden`
|
||||
// body. These assert the bar shows only when an approval is live and is never
|
||||
// trapped under a `hidden` ancestor.
|
||||
|
||||
const createdAt = new Date('2026-06-03T00:00:00.000Z')
|
||||
|
||||
const resizeObservers = new Set<TestResizeObserver>()
|
||||
|
||||
class TestResizeObserver {
|
||||
private target: Element | null = null
|
||||
|
||||
constructor(private readonly callback: ResizeObserverCallback) {
|
||||
resizeObservers.add(this)
|
||||
}
|
||||
|
||||
observe(target: Element) {
|
||||
this.target = target
|
||||
}
|
||||
|
||||
unobserve() {}
|
||||
|
||||
disconnect() {
|
||||
resizeObservers.delete(this)
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
|
||||
Element.prototype.scrollTo = function scrollTo() {}
|
||||
|
||||
Element.prototype.animate = function animate() {
|
||||
return {
|
||||
cancel: () => {},
|
||||
finished: Promise.resolve()
|
||||
} as unknown as Animation
|
||||
}
|
||||
|
||||
function stubOffsetDimension(
|
||||
prop: 'offsetHeight' | 'offsetWidth',
|
||||
clientProp: 'clientHeight' | 'clientWidth',
|
||||
fallback: number
|
||||
) {
|
||||
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, prop, {
|
||||
configurable: true,
|
||||
get() {
|
||||
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
|
||||
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
|
||||
|
||||
// A running assistant message with two tools: a completed read_file plus a
|
||||
// pending terminal (no result), rendered as a flat two-row list.
|
||||
function groupedPendingMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-group-1',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-1',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/etc/hosts' },
|
||||
argsText: JSON.stringify({ path: '/etc/hosts' }),
|
||||
result: { content: '127.0.0.1 localhost' }
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-1',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'rm -rf /tmp/x' },
|
||||
argsText: JSON.stringify({ command: 'rm -rf /tmp/x' })
|
||||
}
|
||||
],
|
||||
status: { type: 'running' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function pendingOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-pending-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-only',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'sleep 10' },
|
||||
argsText: JSON.stringify({ command: 'sleep 10' })
|
||||
}
|
||||
],
|
||||
status: { type: 'running' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function completedOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-completed-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-only',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/etc/hosts' },
|
||||
argsText: JSON.stringify({ path: '/etc/hosts' }),
|
||||
result: { content: '127.0.0.1 localhost' }
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function failedOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-failed-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-failed',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'exit 1' },
|
||||
argsText: JSON.stringify({ command: 'exit 1' }),
|
||||
isError: true,
|
||||
result: { stderr: 'boom' }
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function GroupHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
isRunning: message.status?.type === 'running',
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set('sess-1')
|
||||
$toolDisclosureStates.set({})
|
||||
clearDismissedToolRows()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set(null)
|
||||
clearDismissedToolRows()
|
||||
})
|
||||
|
||||
describe('flat tool list approval surfacing', () => {
|
||||
it('renders no inline approval bar when there is no live approval', async () => {
|
||||
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
|
||||
|
||||
// The pending terminal row mounts immediately, but its inline ApprovalBar
|
||||
// returns null while $approvalRequest is empty.
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
|
||||
})
|
||||
expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces the approval inline and never under a hidden ancestor', async () => {
|
||||
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
|
||||
|
||||
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
const bar = container.querySelector('[data-slot="tool-approval-inline"]')
|
||||
expect(bar).not.toBeNull()
|
||||
// Flat rows live directly in the flow — nothing should ever wrap the bar
|
||||
// in a `hidden` subtree.
|
||||
expect(bar?.closest('[hidden]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('lets completed tool rows be dismissed', async () => {
|
||||
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
|
||||
|
||||
const dismiss = await screen.findByLabelText('Dismiss')
|
||||
|
||||
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(1)
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a dismissed row hidden after a remount (virtualization)', async () => {
|
||||
// The thread virtualizes, so a row's component unmounts/remounts as it
|
||||
// scrolls. Dismissal must persist across that — component-local state would
|
||||
// forget it and the row would pop back. Simulate the remount by unmounting
|
||||
// and rendering the same message fresh.
|
||||
const first = render(<GroupHarness message={completedOnlyMessage()} />)
|
||||
|
||||
fireEvent.click(await screen.findByLabelText('Dismiss'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
|
||||
first.unmount()
|
||||
|
||||
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
|
||||
it('lets failed tool rows be dismissed', async () => {
|
||||
render(<GroupHarness message={failedOnlyMessage()} />)
|
||||
|
||||
const dismiss = await screen.findByLabelText('Dismiss')
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show dismiss for pending tool rows', async () => {
|
||||
const { container } = render(<GroupHarness message={pendingOnlyMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -65,7 +65,7 @@ function fileEditPath(args: Record<string, unknown>, result: Record<string, unkn
|
|||
)
|
||||
}
|
||||
|
||||
function fileEditBasename(path: string): string {
|
||||
export function fileEditBasename(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/').trim()
|
||||
|
||||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||
|
|
@ -585,7 +585,7 @@ function summarizeBrowserSnapshot(snapshot: string): string {
|
|||
return labels.length ? `${stats}\nTop controls: ${labels.join(', ')}` : stats
|
||||
}
|
||||
|
||||
function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string {
|
||||
export function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,42 +1,56 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isUnboundableTool, shouldBoundToolGroup, technicalTrace } from './fallback'
|
||||
import { isCardTool, splitRunItems, technicalTrace } from './fallback'
|
||||
|
||||
describe('shouldBoundToolGroup', () => {
|
||||
it('bounds long runs of ordinary tool calls', () => {
|
||||
expect(shouldBoundToolGroup(3, false)).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves short runs unbounded', () => {
|
||||
expect(shouldBoundToolGroup(2, false)).toBe(false)
|
||||
})
|
||||
|
||||
it('never bounds a run holding an unboundable tool', () => {
|
||||
expect(shouldBoundToolGroup(3, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUnboundableTool', () => {
|
||||
it('exempts clarify forms and generated images from the window', () => {
|
||||
expect(isUnboundableTool('clarify')).toBe(true)
|
||||
expect(isUnboundableTool('image_generate')).toBe(true)
|
||||
})
|
||||
|
||||
// Everything ToolEntry renders carries `data-tool-row`, so the
|
||||
// `:has([data-tool-row][data-tool-open])` rule in styles.css lifts the cap
|
||||
// on its own. A diff row mounts open and frees the group immediately; a
|
||||
// collapsed row has no body in the DOM to clip. Exempting these in JS
|
||||
// instead vetoed grouping for the whole run — and since reads and edits are
|
||||
// most of a coding session, runs of 19 calls never collapsed at all.
|
||||
it('bounds the rows the CSS break-out already covers', () => {
|
||||
for (const toolName of ['read_file', 'execute_code', 'edit_file', 'patch', 'write_file']) {
|
||||
expect(isUnboundableTool(toolName)).toBe(false)
|
||||
describe('isCardTool', () => {
|
||||
it('keeps what the user has to look at out of a summary', () => {
|
||||
// A diff is the deliverable, a clarify is a question waiting on an answer,
|
||||
// an image is the thing that was asked for. None of them survives being
|
||||
// folded into "used 3 tools".
|
||||
for (const toolName of ['clarify', 'image_generate', 'edit_file', 'patch', 'write_file']) {
|
||||
expect(isCardTool(toolName)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('still bounds console output and other ordinary rows', () => {
|
||||
expect(isUnboundableTool('terminal')).toBe(false)
|
||||
expect(isUnboundableTool('web_search')).toBe(false)
|
||||
it('treats reads, searches and commands as ephemeral activity', () => {
|
||||
for (const toolName of ['read_file', 'search_files', 'terminal', 'execute_code', 'web_search']) {
|
||||
expect(isCardTool(toolName)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitRunItems', () => {
|
||||
it('collapses a stretch of activity into one run', () => {
|
||||
expect(splitRunItems(['read_file', 'search_files', 'terminal'])).toEqual([{ end: 2, kind: 'run', start: 0 }])
|
||||
})
|
||||
|
||||
it('keeps a card at the point in the turn where it happened', () => {
|
||||
// Read, edit, read has to stay in that order — a summary, the diff, then a
|
||||
// second summary — rather than sorting the diffs to one end.
|
||||
expect(splitRunItems(['read_file', 'patch', 'read_file', 'terminal'])).toEqual([
|
||||
{ end: 0, kind: 'run', start: 0 },
|
||||
{ index: 1, kind: 'card' },
|
||||
{ end: 3, kind: 'run', start: 2 }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let adjacent cards merge into a run', () => {
|
||||
expect(splitRunItems(['patch', 'write_file'])).toEqual([
|
||||
{ index: 0, kind: 'card' },
|
||||
{ index: 1, kind: 'card' }
|
||||
])
|
||||
})
|
||||
|
||||
it('passes a part that is not a tool call through as its own card', () => {
|
||||
expect(splitRunItems(['read_file', '', 'read_file'])).toEqual([
|
||||
{ end: 0, kind: 'run', start: 0 },
|
||||
{ index: 1, kind: 'card' },
|
||||
{ end: 2, kind: 'run', start: 2 }
|
||||
])
|
||||
})
|
||||
|
||||
it('has nothing to split when the range is empty', () => {
|
||||
expect(splitRunItems([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ import { useStore } from '@nanostores/react'
|
|||
import {
|
||||
Children,
|
||||
createContext,
|
||||
type CSSProperties,
|
||||
type FC,
|
||||
Fragment,
|
||||
isValidElement,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
|
|
@ -23,6 +25,7 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
|||
import { CompactMarkdown } from '@/components/chat/compact-markdown'
|
||||
import { FileDiffPanel } from '@/components/chat/diff-lines'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
|
|
@ -40,11 +43,12 @@ import { normalize } from '@/lib/text'
|
|||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { recordPreviewArtifact } from '@/store/preview-status'
|
||||
import { sessionApprovalRequest } from '@/store/prompts'
|
||||
import { $toolInlineDiff } from '@/store/tool-diffs'
|
||||
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
|
||||
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
|
||||
|
||||
import { PendingToolApproval } from './approval'
|
||||
import { APPROVAL_TOOLS, PendingToolApproval } from './approval'
|
||||
import {
|
||||
buildToolView,
|
||||
clampForDisplay,
|
||||
|
|
@ -63,6 +67,7 @@ import {
|
|||
type ToolStatus,
|
||||
type ToolTitleAction
|
||||
} from './fallback-model'
|
||||
import { isToolCallPart, summarizeToolRun } from './run-summary'
|
||||
|
||||
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
|
||||
// the per-row chrome (timer / preview). The flat ToolGroupSlot sets this
|
||||
|
|
@ -70,14 +75,11 @@ import {
|
|||
// future embedding surface.
|
||||
const ToolEmbedContext = createContext(false)
|
||||
|
||||
// Shared header chrome for tool rows. Both the single-tool DisclosureRow
|
||||
// and the multi-tool group header pass through these constants so a
|
||||
// "Patch" row and a "Tool actions · 2 steps" row are visually identical.
|
||||
const TOOL_HEADER_TITLE_CLASS =
|
||||
// A search hit's title is result *content* inside an expanded row, not one of
|
||||
// the scaffolding lines, so it keeps the brighter secondary grey.
|
||||
const SEARCH_HIT_TITLE_CLASS =
|
||||
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)'
|
||||
|
||||
const TOOL_HEADER_DURATION_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--ui-text-tertiary)'
|
||||
|
||||
const TOOL_HEADER_SUBTITLE_CLASS =
|
||||
'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)'
|
||||
|
||||
|
|
@ -252,13 +254,13 @@ function SearchResultsList({ hits }: { hits: SearchResultRow[] }) {
|
|||
<li className="grid min-w-0 gap-0.5" key={key}>
|
||||
{hit.url ? (
|
||||
<PrettyLink
|
||||
className={cn(TOOL_HEADER_TITLE_CLASS, 'block max-w-full')}
|
||||
className={cn(SEARCH_HIT_TITLE_CLASS, 'block max-w-full')}
|
||||
fallbackLabel={trimmedTitle || urlSlugTitleLabel(hit.url)}
|
||||
href={hit.url}
|
||||
label={trimmedTitle || undefined}
|
||||
/>
|
||||
) : (
|
||||
<span className={TOOL_HEADER_TITLE_CLASS}>{trimmedTitle}</span>
|
||||
<span className={SEARCH_HIT_TITLE_CLASS}>{trimmedTitle}</span>
|
||||
)}
|
||||
{hit.snippet && <p className={cn(TOOL_HEADER_SUBTITLE_CLASS, 'm-0 line-clamp-3')}>{hit.snippet}</p>}
|
||||
</li>
|
||||
|
|
@ -286,8 +288,8 @@ function ToolTitle({
|
|||
return (
|
||||
<FadeText
|
||||
className={cn(
|
||||
TOOL_HEADER_TITLE_CLASS,
|
||||
isPending && 'text-(--ui-text-tertiary)',
|
||||
SCAFFOLD_LABEL_CLASS,
|
||||
isPending && 'text-(--conversation-scaffold-meta)',
|
||||
status === 'error' && 'text-destructive',
|
||||
status === 'warning' && 'text-amber-700 dark:text-amber-300'
|
||||
)}
|
||||
|
|
@ -460,7 +462,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
// the disclosure caret hard to hit. Copy now lives in the expanded body's
|
||||
// top-right, where it can't fight the caret for the right edge.
|
||||
const trailing =
|
||||
isPending && !embedded ? <ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} /> : undefined
|
||||
isPending && !embedded ? <ActivityTimerText className={SCAFFOLD_META_CLASS} seconds={elapsed} /> : undefined
|
||||
|
||||
// Once a turn has settled, a hover/focus-revealed dismiss lets the user clear
|
||||
// a completed/failed row that would otherwise sit at the tail of the chat.
|
||||
|
|
@ -508,6 +510,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
'group/tool-block min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
|
||||
open && TOOL_EXPANDED_SHELL_CLASS
|
||||
)}
|
||||
data-conversation-scaffold=""
|
||||
data-file-edit={isFileEdit && open ? '' : undefined}
|
||||
data-slot="tool-block"
|
||||
data-tool-open={open ? '' : undefined}
|
||||
|
|
@ -532,7 +535,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
status={leadingStatus(isPending, view.status)}
|
||||
/>
|
||||
<ToolTitle isPending={isPending} status={view.status} title={view.title} titleAction={view.titleAction} />
|
||||
{!isPending && view.countLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>}
|
||||
{!isPending && view.countLabel && <span className={SCAFFOLD_META_CLASS}>{view.countLabel}</span>}
|
||||
{showDiffStats && diffStats && (
|
||||
<span className="flex shrink-0 items-center gap-1 font-mono text-[0.625rem] tabular-nums">
|
||||
{diffStats.added > 0 && (
|
||||
|
|
@ -544,7 +547,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
</span>
|
||||
)}
|
||||
{!isFileEdit && !isPending && view.durationLabel && (
|
||||
<span className={TOOL_HEADER_DURATION_CLASS}>{view.durationLabel}</span>
|
||||
<span className={SCAFFOLD_META_CLASS}>{view.durationLabel}</span>
|
||||
)}
|
||||
</span>
|
||||
</DisclosureRow>
|
||||
|
|
@ -699,155 +702,260 @@ function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// A back-to-back run of this many tool calls collapses into the bounded,
|
||||
// auto-scrolling window; fewer than this stays a plain inline stack.
|
||||
const TOOL_GROUP_SCROLL_THRESHOLD = 3
|
||||
|
||||
// Tools whose body must never be trapped behind the window's max-height +
|
||||
// fade mask. A run holding any of them stays a plain, fully-visible stack no
|
||||
// matter how long it is.
|
||||
// Tools that draw their own surface and must never be folded into a run's
|
||||
// summary. Two kinds, for the same reason — the thing on screen IS the point:
|
||||
//
|
||||
// This list is deliberately tiny. A row rendered by ToolEntry carries
|
||||
// `data-tool-row`, and the `:has([data-tool-row][data-tool-open])` rule in
|
||||
// styles.css lifts the cap whenever one is open — so anything ToolEntry
|
||||
// renders takes care of itself. A code/diff row mounts open (see
|
||||
// `defaultOpen`), lifting the cap the moment it appears; a collapsed row is a
|
||||
// one-line status with no body in the DOM at all, so there is nothing to clip.
|
||||
// - File edits are the deliverable, not scaffolding. The diff is what the
|
||||
// user reviews, so it stays visible at its place in the turn, live and
|
||||
// settled, the way a PR shows its changes.
|
||||
// - `clarify` and `image_generate` bypass ToolEntry to render their own
|
||||
// markup: a question the user has to answer, an image they asked for.
|
||||
//
|
||||
// Only components that bypass ToolEntry need this opt-out: `clarify` and
|
||||
// `image_generate` render their own markup, never emit `data-tool-row`, and so
|
||||
// the CSS escape hatch can never reach them.
|
||||
const UNBOUNDABLE_TOOLS = new Set(['clarify', 'image_generate'])
|
||||
// Everything else is ephemeral activity — reads, searches, commands — which is
|
||||
// what a run summarizes and what the live ticker cycles through.
|
||||
const CARD_TOOLS = new Set(['clarify', 'image_generate'])
|
||||
|
||||
export function isUnboundableTool(toolName: string): boolean {
|
||||
return UNBOUNDABLE_TOOLS.has(toolName)
|
||||
export function isCardTool(toolName: string): boolean {
|
||||
return CARD_TOOLS.has(toolName) || isFileEditTool(toolName)
|
||||
}
|
||||
|
||||
export function shouldBoundToolGroup(childCount: number, hasUnboundable: boolean) {
|
||||
return childCount >= TOOL_GROUP_SCROLL_THRESHOLD && !hasUnboundable
|
||||
}
|
||||
export type RunItem = { end: number; kind: 'run'; start: number } | { index: number; kind: 'card' }
|
||||
|
||||
// Pin-to-bottom + top-fade for the bounded tool window. Pins the newest row on
|
||||
// growth (a call lands or a row expands) unless the user scrolled up, and fades
|
||||
// the top edge once anything sits above it. Mirrors ThinkingDisclosure's live
|
||||
// preview. `enabled` is false for short runs, leaving the plain flat stack.
|
||||
function useToolWindow(enabled: boolean) {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement | null>(null)
|
||||
const stickRef = useRef(true)
|
||||
const [faded, setFaded] = useState(false)
|
||||
/**
|
||||
* Split a range of parts into cards and the runs of activity between them.
|
||||
*
|
||||
* Order is preserved rather than sorted into "all the runs, then all the
|
||||
* cards": a turn that reads, edits, then reads again shows a summary, the
|
||||
* diff, then a second summary, in the sequence it happened. Indices are
|
||||
* relative to the range. An empty name is a part that isn't a tool call at
|
||||
* all, which passes through as its own card.
|
||||
*/
|
||||
export function splitRunItems(toolNames: readonly string[]): RunItem[] {
|
||||
const items: RunItem[] = []
|
||||
let run: null | Extract<RunItem, { kind: 'run' }> = null
|
||||
|
||||
const syncFade = useCallback(() => setFaded((scrollRef.current?.scrollTop ?? 0) > 4), [])
|
||||
toolNames.forEach((name, index) => {
|
||||
if (!name || isCardTool(name)) {
|
||||
run = null
|
||||
items.push({ index, kind: 'card' })
|
||||
|
||||
const onScroll = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= 8
|
||||
syncFade()
|
||||
}, [syncFade])
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
const content = contentRef.current
|
||||
|
||||
if (!enabled || !el || !content) {
|
||||
return
|
||||
if (run) {
|
||||
run.end = index
|
||||
} else {
|
||||
run = { end: index, kind: 'run', start: index }
|
||||
items.push(run)
|
||||
}
|
||||
})
|
||||
|
||||
// Track the content's HEIGHT and only pin when it grows. The observer also
|
||||
// fires for width changes — a sidebar sash drag resizes every tool window
|
||||
// once per frame — and pinning there is (a) pointless, the list didn't
|
||||
// grow, and (b) expensive: `pin` writes scrollTop then `syncFade` reads it
|
||||
// back, a write->read forced reflow per tool group per frame. Measured on
|
||||
// a real session while dragging the sash: 927ms of `pin` script plus
|
||||
// 2.7s of style recalc across one 60-frame drag. Reading the height off
|
||||
// the RO entry keeps the check reflow-free.
|
||||
let lastHeight = -1
|
||||
|
||||
const pin = (entries: readonly ResizeObserverEntry[]) => {
|
||||
const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1
|
||||
const grew = height < 0 || height > lastHeight
|
||||
lastHeight = height
|
||||
|
||||
if (!grew) {
|
||||
return
|
||||
}
|
||||
|
||||
if (stickRef.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
syncFade()
|
||||
}
|
||||
|
||||
// No sync pin() here: the observer's guaranteed initial delivery runs it
|
||||
// inside RO timing (layout already clean, still before paint). A sync call
|
||||
// at effect time reads scrollHeight while the commit's layout is dirty —
|
||||
// one forced reflow per tool group, which cascades on a session switch.
|
||||
const observer = new ResizeObserver(pin)
|
||||
observer.observe(content)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [enabled, syncFade])
|
||||
|
||||
return { contentRef, faded, onScroll, scrollRef }
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat, Cursor-style tool list. assistant-ui hands us a *range* of
|
||||
* consecutive tool-call parts, but how that range is sliced is unstable: a
|
||||
* live stream interleaves narration/reasoning between calls (many tiny
|
||||
* ranges), while the settled message reconstructs every tool_call back-to-back
|
||||
* (one big range). Rendering a "Tool actions · N steps" group off that range
|
||||
* therefore reshuffled the whole turn the instant it settled.
|
||||
* The live run, as one line.
|
||||
*
|
||||
* So we still never *label* the group: each tool is a standalone row on the
|
||||
* tight `--tool-row-gap` rhythm. Once a run reaches `TOOL_GROUP_SCROLL_THRESHOLD`
|
||||
* rows it collapses into a fixed-height, auto-scrolling window so a long run
|
||||
* doesn't shove the reply off screen; shorter runs are byte-identical to before.
|
||||
* The DOM shape is the same either way — only classes flip — so a run that
|
||||
* crosses the threshold mid-stream never remounts a row. `ToolEmbedContext` is
|
||||
* false so every row owns its own chrome (timer / preview / copy / approval).
|
||||
* A run in progress shows only what it is doing right now; each new action
|
||||
* slides the one before it up and out of a single-line window, so a turn that
|
||||
* touches thirty files reads as one line ticking over in place instead of a
|
||||
* list growing down the page. When the run settles the ticker goes away and
|
||||
* the summary above it is all that's left.
|
||||
*
|
||||
* Rows are clipped to a uniform line box so the reel's offset stays exact
|
||||
* whatever a row happens to contain.
|
||||
*/
|
||||
function ToolRunTicker({ children }: { children: ReactNode }) {
|
||||
const rows = Children.toArray(children)
|
||||
|
||||
return (
|
||||
<div className="tool-ticker" data-tool-ticker="">
|
||||
<div className="tool-ticker__reel" style={{ '--tool-ticker-index': rows.length - 1 } as CSSProperties}>
|
||||
{rows.map((row, index) => (
|
||||
<div className="tool-ticker__row" key={isValidElement(row) ? (row.key ?? index) : index}>
|
||||
{row}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The one grey line that stands in for a run of tool calls — "Explored 3
|
||||
// files, ran 5 commands". Live, it narrates in the present tense above the
|
||||
// ticker and offers no toggle, since there is nothing settled to unfold yet.
|
||||
function ToolRunHeader({
|
||||
live,
|
||||
onToggle,
|
||||
open,
|
||||
summary
|
||||
}: {
|
||||
live: boolean
|
||||
onToggle?: () => void
|
||||
open: boolean
|
||||
summary: string
|
||||
}) {
|
||||
return (
|
||||
<div data-conversation-scaffold="" data-tool-summary="">
|
||||
<ScaffoldRow onToggle={onToggle} open={open}>
|
||||
<FadeText className={cn(SCAFFOLD_LABEL_CLASS, 'truncate')}>
|
||||
{live ? <span className="shimmer">{summary}</span> : summary}
|
||||
</FadeText>
|
||||
</ScaffoldRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ToolRunState {
|
||||
count: number
|
||||
key: string
|
||||
live: boolean
|
||||
/** A call still awaiting a result that could be the one blocking on approval. */
|
||||
pendingApprovalTool: boolean
|
||||
summary: string
|
||||
}
|
||||
|
||||
// assistant-ui compares selector results with `Object.is` and calls the
|
||||
// selector on every store update, so returning a fresh object here would
|
||||
// re-render the group on every text delta in the turn. The run only changes
|
||||
// when a call arrives or one finishes; cache on exactly that.
|
||||
function useToolRun(startIndex: number, endIndex: number): ToolRunState {
|
||||
const cache = useRef<null | { signature: string; value: ToolRunState }>(null)
|
||||
|
||||
return useAuiState(state => {
|
||||
const parts = state.message.parts
|
||||
const tools = parts.slice(Math.max(0, startIndex), endIndex + 1).filter(isToolCallPart)
|
||||
|
||||
// Live means the turn is still working and nothing has come after this run
|
||||
// — not that some call is unresolved. Those differ in the gap between one
|
||||
// call finishing and the next arriving, which for sequential calls is most
|
||||
// of the run: it fell back to past tense there, unmounting the ticker and
|
||||
// dropping its reel to the top instead of scrolling.
|
||||
//
|
||||
// The tail bound is what keeps this honest — a turn that ends, or an agent
|
||||
// that moves on to later parts, leaves the run settled and collapsible.
|
||||
const live = selectMessageRunning(state) && endIndex >= parts.length - 1
|
||||
|
||||
const signature = tools
|
||||
.map(tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}`)
|
||||
.concat(String(live))
|
||||
.join('|')
|
||||
|
||||
if (cache.current?.signature !== signature) {
|
||||
cache.current = {
|
||||
signature,
|
||||
value: {
|
||||
count: tools.length,
|
||||
key: tools[0]?.toolCallId ?? '',
|
||||
live,
|
||||
pendingApprovalTool: tools.some(tool => tool.result === undefined && APPROVAL_TOOLS.has(tool.toolName)),
|
||||
summary: summarizeToolRun(tools, live)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cache.current.value
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One run of consecutive activity calls, headed by the line that summarizes it.
|
||||
*
|
||||
* The run is identified by its FIRST tool call, never by its position: a live
|
||||
* stream and the same turn rehydrated from history agree on which calls belong
|
||||
* together, but not on the indices they land at, because rehydration folds a
|
||||
* turn into one bubble that the live view spreads over several. Keying off the
|
||||
* index is what made an earlier attempt at this reshuffle the moment a turn
|
||||
* settled. `lib/tool-run-continuity.test.ts` locks that agreement down.
|
||||
*
|
||||
* Live, the run is a summary plus the one-line ticker. Settled, the summary is
|
||||
* the whole of it until the user opens it. `ToolEmbedContext` is false so each
|
||||
* row still owns its own chrome (timer / copy / approval) when shown.
|
||||
*/
|
||||
const ToolRun: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({
|
||||
children,
|
||||
endIndex,
|
||||
startIndex
|
||||
}) => {
|
||||
const messageRunning = useAuiState(selectMessageRunning)
|
||||
const { count, key, live, pendingApprovalTool, summary } = useToolRun(startIndex, endIndex)
|
||||
const sessionId = useStore(useSessionView().$runtimeId)
|
||||
const approval = useStore(useMemo(() => sessionApprovalRequest(sessionId), [sessionId]))
|
||||
const disclosureId = `tool-run:${key}`
|
||||
const persistedOpen = useStore($toolDisclosureOpen(disclosureId))
|
||||
const enterRef = useEnterAnimation(messageRunning, `tool-run:${key}`)
|
||||
|
||||
// A lone call is already its own one-line summary; heading it with a second
|
||||
// line would say the same thing twice.
|
||||
if (count < 2) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// An approval is a question the user has to answer, and the ticker only ever
|
||||
// shows one line — the row carrying the approval bar could tick straight
|
||||
// past it. Show the whole run until it's answered.
|
||||
const blocked = Boolean(approval) && pendingApprovalTool
|
||||
const expanded = live ? blocked : (persistedOpen ?? false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid min-w-0 max-w-full gap-(--tool-row-gap) overflow-hidden"
|
||||
data-slot="tool-block"
|
||||
data-tool-group=""
|
||||
ref={enterRef}
|
||||
>
|
||||
<ToolRunHeader
|
||||
live={live}
|
||||
onToggle={live ? undefined : () => setToolDisclosureOpen(disclosureId, !expanded)}
|
||||
open={expanded}
|
||||
summary={summary}
|
||||
/>
|
||||
{live && !blocked && <ToolRunTicker>{children}</ToolRunTicker>}
|
||||
{expanded && <div className="grid min-w-0 max-w-full gap-(--tool-row-gap)">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A range of consecutive tool calls, split into the cards that must stay on
|
||||
* screen and the runs of activity between them.
|
||||
*
|
||||
* assistant-ui hands the whole adjacent range over as one group; what belongs
|
||||
* together is a narrower question than adjacency. A diff or a question for the
|
||||
* user is the point of the turn and renders in place, while the reads and
|
||||
* commands around it collapse into a line. Splitting here rather than asking
|
||||
* for different ranges keeps the decision next to the rendering that depends
|
||||
* on it.
|
||||
*/
|
||||
export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({
|
||||
children,
|
||||
endIndex,
|
||||
startIndex
|
||||
}) => {
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const messageRunning = useAuiState(selectMessageRunning)
|
||||
|
||||
const hasUnboundable = useAuiState(s =>
|
||||
s.message.parts
|
||||
// Joined rather than returned as an array: assistant-ui compares selector
|
||||
// results with `Object.is` and re-runs them on every store update, so a
|
||||
// fresh array would re-render the whole group on every text delta.
|
||||
const toolNameKey = useAuiState(state =>
|
||||
state.message.parts
|
||||
.slice(Math.max(0, startIndex), endIndex + 1)
|
||||
.some(part => part.type === 'tool-call' && isUnboundableTool(part.toolName))
|
||||
.map(part => (part.type === 'tool-call' ? part.toolName : ''))
|
||||
.join('\u0000')
|
||||
)
|
||||
|
||||
const enterRef = useEnterAnimation(messageRunning, `tool-group:${messageId}:${startIndex}`)
|
||||
|
||||
const bounded = shouldBoundToolGroup(Children.count(children), hasUnboundable)
|
||||
const { contentRef, faded, onScroll, scrollRef } = useToolWindow(bounded)
|
||||
const items = useMemo(() => splitRunItems(toolNameKey.split('\u0000')), [toolNameKey])
|
||||
const rows = Children.toArray(children)
|
||||
|
||||
return (
|
||||
<ToolEmbedContext.Provider value={false}>
|
||||
<div className="min-w-0 max-w-full overflow-hidden" data-slot="tool-block" data-tool-group="" ref={enterRef}>
|
||||
<div
|
||||
className={cn(
|
||||
bounded && 'tool-group-scroll max-h-(--tool-group-scroll-max-h) overflow-y-auto',
|
||||
bounded && faded && 'tool-group-scroll--faded'
|
||||
)}
|
||||
onScroll={bounded ? onScroll : undefined}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div className="grid min-w-0 max-w-full gap-(--tool-row-gap)" ref={contentRef}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{items.map(item =>
|
||||
item.kind === 'card' ? (
|
||||
<Fragment key={`card:${item.index}`}>{rows[item.index]}</Fragment>
|
||||
) : (
|
||||
<ToolRun endIndex={startIndex + item.end} key={`run:${item.start}`} startIndex={startIndex + item.start}>
|
||||
{rows.slice(item.start, item.end + 1)}
|
||||
</ToolRun>
|
||||
)
|
||||
)}
|
||||
</ToolEmbedContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { summarizeToolRun, type ToolCallLike } from './run-summary'
|
||||
|
||||
function tool(toolName: string, args: Record<string, unknown> = {}, result?: unknown): ToolCallLike {
|
||||
return { args, result, toolCallId: `${toolName}-${Math.random()}`, toolName }
|
||||
}
|
||||
|
||||
const read = (path: string) => tool('read_file', { path }, { content: '' })
|
||||
const searched = (query: string) => tool('search_files', { query }, { hits: [] })
|
||||
const ran = (command: string) => tool('terminal', { command }, { exit_code: 0 })
|
||||
|
||||
const settled = (tools: ToolCallLike[]) => summarizeToolRun(tools, false)
|
||||
const running = (tools: ToolCallLike[]) => summarizeToolRun(tools, true)
|
||||
|
||||
// A run only ever holds ephemeral activity: reads, searches, commands. File
|
||||
// edits and other cards are split out before a run is summarized, so there is
|
||||
// no "Edited …" clause to test here — that work shows as its own diff card.
|
||||
describe('summarizeToolRun', () => {
|
||||
it('names a lone target and counts the rest', () => {
|
||||
expect(settled([searched('toolRuns'), read('a.ts'), read('b.ts'), read('c.ts')])).toBe('Explored 4 files')
|
||||
})
|
||||
|
||||
it('orders clauses explore then run regardless of call order', () => {
|
||||
expect(settled([ran('ls'), read('a.ts'), read('b.ts'), ran('pwd'), ran('id')])).toBe(
|
||||
'Explored 2 files, ran 3 commands'
|
||||
)
|
||||
})
|
||||
|
||||
it('counts commands rather than naming them once they have run', () => {
|
||||
expect(settled([ran('git status')])).toBe('Ran 1 command')
|
||||
expect(settled([read('status.ts'), ran('a'), ran('b'), ran('c'), ran('d'), ran('e')])).toBe(
|
||||
'Explored status.ts, ran 5 commands'
|
||||
)
|
||||
})
|
||||
|
||||
it('puts the running category in the present tense and leaves the rest past', () => {
|
||||
expect(running([read('a.ts'), tool('read_file', { path: 'b.ts' }), ran('x'), ran('y')])).toBe(
|
||||
'Exploring 2 files, ran 2 commands'
|
||||
)
|
||||
})
|
||||
|
||||
it('names the command that is still running', () => {
|
||||
expect(running([tool('terminal', { command: 'npm run typecheck' })])).toMatch(/^Running /)
|
||||
})
|
||||
|
||||
// Sequential calls leave a gap where the run is still going but nothing is
|
||||
// pending. Falling back to past tense there contradicted the ticker still
|
||||
// scrolling underneath, so the most recent call carries the present tense.
|
||||
it('stays in the present tense between two sequential calls', () => {
|
||||
expect(running([read('a.ts'), ran('x'), ran('y')])).toBe('Explored a.ts, running 2 commands')
|
||||
})
|
||||
|
||||
// A turn can end — or the agent can simply move on — with a call that never
|
||||
// got a result. The run is history at that point and has to read as history,
|
||||
// or it narrates work that stopped happening and never offers its toggle.
|
||||
it('reads a run the turn left unresolved as finished', () => {
|
||||
expect(settled([read('a.ts'), tool('search_files', { query: 'toolRuns' })])).toBe('Explored 2 files')
|
||||
})
|
||||
})
|
||||
156
apps/desktop/src/components/assistant-ui/tool/run-summary.ts
Normal file
156
apps/desktop/src/components/assistant-ui/tool/run-summary.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { summarizeShellCommand } from '@/lib/summarize-command'
|
||||
|
||||
import { fileEditBasename, firstStringField, isFileEditTool, parseMaybeObject } from './fallback-model'
|
||||
|
||||
/**
|
||||
* The little a summary needs from a tool call, stated structurally so both
|
||||
* shapes of tool part satisfy it — the stored `ChatMessagePart` and the live
|
||||
* one assistant-ui hands to a renderer.
|
||||
*/
|
||||
export interface ToolCallLike {
|
||||
args?: unknown
|
||||
result?: unknown
|
||||
toolCallId?: string
|
||||
toolName: string
|
||||
}
|
||||
|
||||
export function isToolCallPart<T extends { type: string }>(part: T): part is Extract<T, { type: 'tool-call' }> {
|
||||
return part.type === 'tool-call'
|
||||
}
|
||||
|
||||
type RunCategory = 'delegate' | 'edit' | 'explore' | 'other' | 'run'
|
||||
|
||||
// Clause order is fixed so the same run always reads the same way, whichever
|
||||
// category happens to be live.
|
||||
const CATEGORY_ORDER: readonly RunCategory[] = ['edit', 'explore', 'run', 'delegate', 'other']
|
||||
|
||||
const CATEGORY_COPY: Record<RunCategory, { noun: [string, string]; past: string; present: string }> = {
|
||||
delegate: { noun: ['task', 'tasks'], past: 'Delegated', present: 'Delegating' },
|
||||
edit: { noun: ['file', 'files'], past: 'Edited', present: 'Editing' },
|
||||
explore: { noun: ['file', 'files'], past: 'Explored', present: 'Exploring' },
|
||||
other: { noun: ['tool', 'tools'], past: 'Used', present: 'Using' },
|
||||
run: { noun: ['command', 'commands'], past: 'Ran', present: 'Running' }
|
||||
}
|
||||
|
||||
const EXPLORE_TOOLS = new Set([
|
||||
'list_files',
|
||||
'read_file',
|
||||
'search_files',
|
||||
'session_search_recall',
|
||||
'vision_analyze',
|
||||
'web_extract',
|
||||
'web_search'
|
||||
])
|
||||
|
||||
function toolCategory(toolName: string): RunCategory {
|
||||
if (isFileEditTool(toolName)) {
|
||||
return 'edit'
|
||||
}
|
||||
|
||||
if (toolName === 'terminal' || toolName === 'execute_code') {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
if (toolName === 'delegate_task') {
|
||||
return 'delegate'
|
||||
}
|
||||
|
||||
if (EXPLORE_TOOLS.has(toolName) || toolName.startsWith('browser_')) {
|
||||
return 'explore'
|
||||
}
|
||||
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function isPending(tool: ToolCallLike): boolean {
|
||||
return tool.result === undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* How a tool reads while it is happening — "Editing", "Exploring". Shared with
|
||||
* the status line that covers the gap before a tool starts, so the same run is
|
||||
* described in the same words from the moment the model drafts it.
|
||||
*/
|
||||
export function toolPresentVerb(toolName: string): string {
|
||||
return CATEGORY_COPY[toolCategory(toolName)].present
|
||||
}
|
||||
|
||||
/** The thing a tool acted on, as the header should name it. */
|
||||
function toolTarget(tool: ToolCallLike): string {
|
||||
const args = parseMaybeObject(tool.args)
|
||||
|
||||
if (toolCategory(tool.toolName) === 'run') {
|
||||
return summarizeShellCommand(firstStringField(args, ['command', 'code']))
|
||||
}
|
||||
|
||||
const path = firstStringField(args, ['path', 'file', 'filepath'])
|
||||
|
||||
return path ? fileEditBasename(path) : firstStringField(args, ['query', 'url'])
|
||||
}
|
||||
|
||||
/**
|
||||
* One clause per category. A category holding a single thing says what it was
|
||||
* ("Edited wiring.tsx"); anything else counts ("explored 3 files"). A settled
|
||||
* command is the exception — "ran 5 commands" is the useful reading, and a
|
||||
* command line only earns its space while it's the thing you're waiting on.
|
||||
*/
|
||||
function clause(category: RunCategory, tools: ToolCallLike[], live: boolean): string {
|
||||
const copy = CATEGORY_COPY[category]
|
||||
const verb = live ? copy.present : copy.past
|
||||
const target = tools.length === 1 ? toolTarget(tools[0]) : ''
|
||||
|
||||
if (target && (live || category !== 'run')) {
|
||||
return `${verb} ${target}`
|
||||
}
|
||||
|
||||
return `${verb} ${tools.length} ${copy.noun[tools.length === 1 ? 0 : 1]}`
|
||||
}
|
||||
|
||||
function lowerFirst(text: string): string {
|
||||
return text.charAt(0).toLowerCase() + text.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a run of tool calls into the single grey line that stands in for it
|
||||
* — "Explored 3 files, ran 5 commands". While the run is live, the category
|
||||
* holding its most recent call speaks in the present tense so the line reads as
|
||||
* work in progress rather than work already done.
|
||||
*
|
||||
* Whether the run is `live` is the caller's to say, not something readable off
|
||||
* the calls: a call can be left without a result by a turn that ended or an
|
||||
* agent that moved on, and a run like that has to read as finished rather than
|
||||
* narrate work that stopped happening.
|
||||
*
|
||||
* A run only ever holds ephemeral activity — file edits and other cards are
|
||||
* split out before this sees them (`splitRunItems`), so there is no aggregate
|
||||
* diff to report here; each edit carries its own +N/−M on its card.
|
||||
*/
|
||||
export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): string {
|
||||
// Which clause narrates in the present tense: normally the outstanding call,
|
||||
// but sequential calls leave gaps where the run is still going and nothing is
|
||||
// pending. The most recent call covers those, and it's the one the ticker is
|
||||
// showing anyway.
|
||||
const narrating = live ? (tools.find(isPending) ?? tools.at(-1)) : undefined
|
||||
const liveCategory = narrating ? toolCategory(narrating.toolName) : null
|
||||
|
||||
const byCategory = new Map<RunCategory, ToolCallLike[]>()
|
||||
|
||||
for (const tool of tools) {
|
||||
const category = toolCategory(tool.toolName)
|
||||
const group = byCategory.get(category)
|
||||
|
||||
if (group) {
|
||||
group.push(tool)
|
||||
} else {
|
||||
byCategory.set(category, [tool])
|
||||
}
|
||||
}
|
||||
|
||||
const clauses = CATEGORY_ORDER.flatMap(category => {
|
||||
const group = byCategory.get(category)
|
||||
|
||||
return group ? [clause(category, group, category === liveCategory)] : []
|
||||
})
|
||||
|
||||
return clauses.map((text, index) => (index === 0 ? text : lowerFirst(text))).join(', ')
|
||||
}
|
||||
|
|
@ -0,0 +1,633 @@
|
|||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import { clearDismissedToolRows } from '@/store/tool-dismiss'
|
||||
import { $toolDisclosureStates } from '@/store/tool-view'
|
||||
|
||||
import { Thread } from '../thread'
|
||||
|
||||
// A run of tool calls collapses to a one-line summary once it has settled, but
|
||||
// a run with anything still pending always renders its rows. That rule is what
|
||||
// keeps the "approval must never be buried" bug fixed: an inline ApprovalBar
|
||||
// only ever exists on a pending tool, and a pending tool's run is never behind
|
||||
// a chevron. These cover both halves — the collapse itself, and the approval
|
||||
// staying in the visual flow.
|
||||
|
||||
const createdAt = new Date('2026-06-03T00:00:00.000Z')
|
||||
|
||||
const resizeObservers = new Set<TestResizeObserver>()
|
||||
|
||||
class TestResizeObserver {
|
||||
private target: Element | null = null
|
||||
|
||||
constructor(private readonly callback: ResizeObserverCallback) {
|
||||
resizeObservers.add(this)
|
||||
}
|
||||
|
||||
observe(target: Element) {
|
||||
this.target = target
|
||||
}
|
||||
|
||||
unobserve() {}
|
||||
|
||||
disconnect() {
|
||||
resizeObservers.delete(this)
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
|
||||
Element.prototype.scrollTo = function scrollTo() {}
|
||||
|
||||
Element.prototype.animate = function animate() {
|
||||
return {
|
||||
cancel: () => {},
|
||||
finished: Promise.resolve()
|
||||
} as unknown as Animation
|
||||
}
|
||||
|
||||
function stubOffsetDimension(
|
||||
prop: 'offsetHeight' | 'offsetWidth',
|
||||
clientProp: 'clientHeight' | 'clientWidth',
|
||||
fallback: number
|
||||
) {
|
||||
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, prop, {
|
||||
configurable: true,
|
||||
get() {
|
||||
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
|
||||
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
|
||||
|
||||
// A running assistant message with two tools: a completed read_file plus a
|
||||
// pending terminal (no result), rendered as a flat two-row list.
|
||||
function groupedPendingMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-group-1',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-1',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/etc/hosts' },
|
||||
argsText: JSON.stringify({ path: '/etc/hosts' }),
|
||||
result: { content: '127.0.0.1 localhost' }
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-1',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'rm -rf /tmp/x' },
|
||||
argsText: JSON.stringify({ command: 'rm -rf /tmp/x' })
|
||||
}
|
||||
],
|
||||
status: { type: 'running' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function pendingOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-pending-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-only',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'sleep 10' },
|
||||
argsText: JSON.stringify({ command: 'sleep 10' })
|
||||
}
|
||||
],
|
||||
status: { type: 'running' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function completedOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-completed-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-only',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/etc/hosts' },
|
||||
argsText: JSON.stringify({ path: '/etc/hosts' }),
|
||||
result: { content: '127.0.0.1 localhost' }
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function failedOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-failed-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-failed',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'exit 1' },
|
||||
argsText: JSON.stringify({ command: 'exit 1' }),
|
||||
isError: true,
|
||||
result: { stderr: 'boom' }
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
// Two settled activity calls in a row, so the run earns a summary line and
|
||||
// collapses behind it.
|
||||
function settledRunMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-settled-run',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-2',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/repo/src/wiring.tsx' },
|
||||
argsText: JSON.stringify({ path: '/repo/src/wiring.tsx' }),
|
||||
result: { content: 'export const Wiring = () => null' }
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-3',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'ls -la' },
|
||||
argsText: JSON.stringify({ command: 'ls -la' }),
|
||||
result: { exit_code: 0, stdout: 'wiring.tsx' }
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
// Activity, an edit, then more activity — all adjacent, so assistant-ui hands
|
||||
// the whole stretch over as one group. The edit is the deliverable and has to
|
||||
// survive that as its own card.
|
||||
function editBetweenRunsMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-edit-between-runs',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-5',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/repo/src/a.ts' },
|
||||
argsText: JSON.stringify({ path: '/repo/src/a.ts' }),
|
||||
result: { content: 'a' }
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'search-3',
|
||||
toolName: 'search_files',
|
||||
args: { query: 'toolRuns' },
|
||||
argsText: JSON.stringify({ query: 'toolRuns' }),
|
||||
result: { hits: [] }
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'patch-2',
|
||||
toolName: 'patch',
|
||||
args: { path: '/repo/src/wiring.tsx' },
|
||||
argsText: JSON.stringify({ path: '/repo/src/wiring.tsx' }),
|
||||
result: { path: '/repo/src/wiring.tsx', inline_diff: '--- a\n+++ b\n+added line\n-removed line' }
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-6',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/repo/src/b.ts' },
|
||||
argsText: JSON.stringify({ path: '/repo/src/b.ts' }),
|
||||
result: { content: 'b' }
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-4',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'ls' },
|
||||
argsText: JSON.stringify({ command: 'ls' }),
|
||||
result: { exit_code: 0 }
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
// A finished turn that left a call without a result — interrupted, or the
|
||||
// result landed elsewhere. The run is history and has to behave like it.
|
||||
function abandonedRunMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-abandoned-run',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-3',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/repo/src/status.tsx' },
|
||||
argsText: JSON.stringify({ path: '/repo/src/status.tsx' }),
|
||||
result: { content: 'export const Status = () => null' }
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'search-1',
|
||||
toolName: 'search_files',
|
||||
args: { query: 'toolRuns' },
|
||||
argsText: JSON.stringify({ query: 'toolRuns' })
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
// The gap between one sequential call finishing and the next arriving: the
|
||||
// turn is still running, the run is still the tail, but for this instant every
|
||||
// call has a result.
|
||||
function betweenSequentialCallsMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-between-calls',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-a',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'sleep 2; echo alpha' },
|
||||
argsText: JSON.stringify({ command: 'sleep 2; echo alpha' }),
|
||||
result: { exit_code: 0, stdout: 'alpha' }
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-b',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'sleep 2; echo bravo' },
|
||||
argsText: JSON.stringify({ command: 'sleep 2; echo bravo' }),
|
||||
result: { exit_code: 0, stdout: 'bravo' }
|
||||
}
|
||||
],
|
||||
status: { type: 'running' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
// Still streaming, but the agent has moved past its first run and left both of
|
||||
// its calls unresolved. Only the run at the tail is still live.
|
||||
function movedOnMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-moved-on',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-4',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/repo/src/status.tsx' },
|
||||
argsText: JSON.stringify({ path: '/repo/src/status.tsx' })
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'search-2',
|
||||
toolName: 'search_files',
|
||||
args: { query: 'toolRuns' },
|
||||
argsText: JSON.stringify({ query: 'toolRuns' })
|
||||
},
|
||||
{ type: 'text', text: 'Let me read the rest of the file.' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-2',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'ls' },
|
||||
argsText: JSON.stringify({ command: 'ls' })
|
||||
}
|
||||
],
|
||||
status: { type: 'running' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function GroupHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
isRunning: message.status?.type === 'running',
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set('sess-1')
|
||||
$toolDisclosureStates.set({})
|
||||
clearDismissedToolRows()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set(null)
|
||||
clearDismissedToolRows()
|
||||
})
|
||||
|
||||
describe('settled tool run', () => {
|
||||
it('collapses to a summary line naming the work', async () => {
|
||||
const { container } = render(<GroupHarness message={settledRunMessage()} />)
|
||||
|
||||
expect(await screen.findByText('Explored wiring.tsx, ran 1 command')).toBeTruthy()
|
||||
expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('expands to the underlying rows when the summary is clicked', async () => {
|
||||
const { container } = render(<GroupHarness message={settledRunMessage()} />)
|
||||
|
||||
fireEvent.click(await screen.findByText('Explored wiring.tsx, ran 1 command'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('[data-tool-row]').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a lone tool call as its own row, with no summary above it', async () => {
|
||||
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('[data-tool-row]').length).toBe(1)
|
||||
})
|
||||
expect(container.querySelector('[data-tool-summary]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// A diff is what the user reviews, so it is never what gets summarized away.
|
||||
// It stays on screen at the point in the turn where it happened, with the
|
||||
// activity either side of it collapsing around it.
|
||||
describe('a file edit among ordinary activity', () => {
|
||||
it('stays visible between the two runs it interrupted', async () => {
|
||||
const { container } = render(<GroupHarness message={editBetweenRunsMessage()} />)
|
||||
|
||||
await screen.findByText('Explored 2 files')
|
||||
|
||||
const shape = [...container.querySelectorAll('[data-tool-summary],[data-tool-row]')].map(node =>
|
||||
node.hasAttribute('data-tool-summary') ? 'summary' : 'row'
|
||||
)
|
||||
|
||||
expect(shape).toEqual(['summary', 'row', 'summary'])
|
||||
})
|
||||
|
||||
it('keeps the diff itself on screen rather than behind the summary', async () => {
|
||||
const { container } = render(<GroupHarness message={editBetweenRunsMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-tool-row][data-file-edit]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// The transcript rests its scaffolding at a fade, keyed off one attribute. A
|
||||
// surface that renders without it is brighter than everything around it, which
|
||||
// is how two adjacent, identical rows came to sit at two opacities.
|
||||
describe('transcript fade', () => {
|
||||
it('marks every row and summary as scaffolding', async () => {
|
||||
const { container } = render(<GroupHarness message={editBetweenRunsMessage()} />)
|
||||
|
||||
await screen.findByText('Explored 2 files')
|
||||
|
||||
const unmarked = [...container.querySelectorAll('[data-tool-summary],[data-tool-row]')].filter(
|
||||
node => !node.hasAttribute('data-conversation-scaffold')
|
||||
)
|
||||
|
||||
expect(unmarked).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('live tool run', () => {
|
||||
it('keeps its rows on screen instead of hiding them behind the summary', async () => {
|
||||
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('[data-tool-row]').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('cannot be collapsed while a tool is still running', async () => {
|
||||
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-tool-summary]')).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull()
|
||||
})
|
||||
|
||||
// Liveness used to also require an unresolved call, which is false for the
|
||||
// instant between one sequential call finishing and the next arriving — so a
|
||||
// string of commands settled and re-opened between every one, unmounting the
|
||||
// ticker and dropping its reel back to the first row instead of scrolling.
|
||||
it('stays live in the gap between two sequential calls', async () => {
|
||||
const { container } = render(<GroupHarness message={betweenSequentialCallsMessage()} />)
|
||||
|
||||
expect(await screen.findByText('Running 2 commands')).toBeTruthy()
|
||||
expect(container.querySelector('[data-tool-ticker]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// A run whose calls never resolved used to read as live forever, which stranded
|
||||
// it in the present tense and — because a live run withholds its toggle — left
|
||||
// it permanently expanded with no way to collapse it.
|
||||
describe('tool run left unresolved', () => {
|
||||
it('settles with the turn rather than narrating work that stopped', async () => {
|
||||
const { container } = render(<GroupHarness message={abandonedRunMessage()} />)
|
||||
|
||||
expect(await screen.findByText('Explored 2 files')).toBeTruthy()
|
||||
expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(0)
|
||||
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('settles once the agent moves on, even mid-turn', async () => {
|
||||
const { container } = render(<GroupHarness message={movedOnMessage()} />)
|
||||
|
||||
expect(await screen.findByText('Explored 2 files')).toBeTruthy()
|
||||
expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('flat tool list approval surfacing', () => {
|
||||
it('renders no inline approval bar when there is no live approval', async () => {
|
||||
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
|
||||
|
||||
// The pending terminal row mounts immediately, but its inline ApprovalBar
|
||||
// returns null while $approvalRequest is empty.
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
|
||||
})
|
||||
expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces the approval inline and never under a hidden ancestor', async () => {
|
||||
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
|
||||
|
||||
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
const bar = container.querySelector('[data-slot="tool-approval-inline"]')
|
||||
expect(bar).not.toBeNull()
|
||||
// Flat rows live directly in the flow — nothing should ever wrap the bar
|
||||
// in a `hidden` subtree.
|
||||
expect(bar?.closest('[hidden]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('lets completed tool rows be dismissed', async () => {
|
||||
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
|
||||
|
||||
const dismiss = await screen.findByLabelText('Dismiss')
|
||||
|
||||
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a dismissed row hidden after a remount (virtualization)', async () => {
|
||||
// The thread virtualizes, so a row's component unmounts/remounts as it
|
||||
// scrolls. Dismissal must persist across that — component-local state would
|
||||
// forget it and the row would pop back. Simulate the remount by unmounting
|
||||
// and rendering the same message fresh.
|
||||
const first = render(<GroupHarness message={completedOnlyMessage()} />)
|
||||
|
||||
fireEvent.click(await screen.findByLabelText('Dismiss'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
|
||||
first.unmount()
|
||||
|
||||
render(<GroupHarness message={completedOnlyMessage()} />)
|
||||
|
||||
// The row is the only thing this message renders, so staying dismissed
|
||||
// means nothing comes back — including its dismiss control.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('lets failed tool rows be dismissed', async () => {
|
||||
render(<GroupHarness message={failedOnlyMessage()} />)
|
||||
|
||||
const dismiss = await screen.findByLabelText('Dismiss')
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show dismiss for pending tool rows', async () => {
|
||||
const { container } = render(<GroupHarness message={pendingOnlyMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { act, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { __resetElapsedTimerRegistryForTests, useElapsedSeconds } from './activity-timer'
|
||||
import { __resetElapsedTimerRegistryForTests, useElapsedSeconds, useMeasuredDuration } from './activity-timer'
|
||||
|
||||
function Probe({ active, since, timerKey }: { active: boolean; since?: number; timerKey?: string }) {
|
||||
const elapsed = useElapsedSeconds(active, timerKey, since)
|
||||
|
|
@ -9,6 +9,12 @@ function Probe({ active, since, timerKey }: { active: boolean; since?: number; t
|
|||
return <span data-testid="elapsed">{elapsed}</span>
|
||||
}
|
||||
|
||||
function DurationProbe({ active, timerKey }: { active: boolean; timerKey: string }) {
|
||||
const measured = useMeasuredDuration(active, timerKey)
|
||||
|
||||
return <span data-testid="measured">{measured === null ? 'unknown' : measured}</span>
|
||||
}
|
||||
|
||||
describe('useElapsedSeconds', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
|
@ -67,3 +73,84 @@ describe('useElapsedSeconds', () => {
|
|||
expect(screen.getByTestId('elapsed').textContent).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMeasuredDuration', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
|
||||
__resetElapsedTimerRegistryForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
__resetElapsedTimerRegistryForTests()
|
||||
})
|
||||
|
||||
it('has nothing to report until it has watched something finish', () => {
|
||||
render(<DurationProbe active timerKey="reasoning:m1:0" />)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4_000)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('measured').textContent).toBe('unknown')
|
||||
})
|
||||
|
||||
it('freezes the duration at the moment the thing finishes', () => {
|
||||
const { rerender } = render(<DurationProbe active timerKey="reasoning:m1:0" />)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4_000)
|
||||
})
|
||||
|
||||
rerender(<DurationProbe active={false} timerKey="reasoning:m1:0" />)
|
||||
|
||||
expect(screen.getByTestId('measured').textContent).toBe('4')
|
||||
|
||||
// Time keeps passing; the block is over and its duration must not creep up
|
||||
// with it.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(9_000)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('measured').textContent).toBe('4')
|
||||
})
|
||||
|
||||
// The thread virtualizes, so the component that watched a block finish is
|
||||
// usually gone by the time anyone scrolls back to read it.
|
||||
it('remembers the duration for a component that mounts after the fact', () => {
|
||||
const first = render(<DurationProbe active timerKey="reasoning:m1:0" />)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(6_000)
|
||||
})
|
||||
|
||||
first.rerender(<DurationProbe active={false} timerKey="reasoning:m1:0" />)
|
||||
first.unmount()
|
||||
|
||||
render(<DurationProbe active={false} timerKey="reasoning:m1:0" />)
|
||||
|
||||
expect(screen.getByTestId('measured').textContent).toBe('6')
|
||||
})
|
||||
|
||||
it('measures each key separately', () => {
|
||||
const first = render(<DurationProbe active timerKey="reasoning:m1:0" />)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3_000)
|
||||
})
|
||||
|
||||
first.rerender(<DurationProbe active={false} timerKey="reasoning:m1:0" />)
|
||||
first.unmount()
|
||||
|
||||
const second = render(<DurationProbe active timerKey="reasoning:m1:7" />)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2_000)
|
||||
})
|
||||
|
||||
second.rerender(<DurationProbe active={false} timerKey="reasoning:m1:7" />)
|
||||
|
||||
expect(screen.getByTestId('measured').textContent).toBe('2')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import { useEffect, useRef, useState } from 'react'
|
|||
// anonymous timers (no key) start fresh each mount.
|
||||
const startedAtByKey = new Map<string, number>()
|
||||
|
||||
// Durations of things that have already finished, kept beside the origins that
|
||||
// measured them. See `useMeasuredDuration`.
|
||||
const durationByKey = new Map<string, number>()
|
||||
|
||||
function startedAt(key?: string): number {
|
||||
if (!key) {
|
||||
return Date.now()
|
||||
|
|
@ -71,6 +75,41 @@ export function useElapsedSeconds(active = true, timerKey?: string, since?: numb
|
|||
return elapsed
|
||||
}
|
||||
|
||||
/**
|
||||
* How long something took, measured by watching it finish and remembered
|
||||
* afterwards. `null` until it has been watched at least once.
|
||||
*
|
||||
* Some durations exist nowhere but in the watching. A reasoning block is the
|
||||
* case this was written for: the persisted turn records the text the model
|
||||
* thought, never how long it spent thinking it, so the only way to know is to
|
||||
* have been there. Watching alone isn't enough either — the thread virtualizes,
|
||||
* so the component that saw a block finish is usually gone by the time anyone
|
||||
* scrolls back to read it. Keeping the number in the same registry as the
|
||||
* timer's origin lets it outlive the component that measured it.
|
||||
*
|
||||
* A block that was never watched running — history loaded from an earlier app
|
||||
* session, or reasoning that arrived already complete — has no duration and
|
||||
* says so, rather than reporting a timer that never ran.
|
||||
*/
|
||||
export function useMeasuredDuration(active: boolean, timerKey: string): null | number {
|
||||
const elapsed = useElapsedSeconds(active, timerKey)
|
||||
const [watching, setWatching] = useState(false)
|
||||
const [measured, setMeasured] = useState<null | number>(() => durationByKey.get(timerKey) ?? null)
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
setWatching(true)
|
||||
} else if (watching) {
|
||||
setWatching(false)
|
||||
durationByKey.set(timerKey, elapsed)
|
||||
setMeasured(elapsed)
|
||||
}
|
||||
}, [active, elapsed, timerKey, watching])
|
||||
|
||||
return measured
|
||||
}
|
||||
|
||||
export function __resetElapsedTimerRegistryForTests() {
|
||||
startedAtByKey.clear()
|
||||
durationByKey.clear()
|
||||
}
|
||||
|
|
|
|||
45
apps/desktop/src/components/chat/scaffold-row.tsx
Normal file
45
apps/desktop/src/components/chat/scaffold-row.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
|
||||
/**
|
||||
* Transcript scaffolding: the quiet lines around the reply that say what the
|
||||
* agent did rather than what it said — a thinking header, a settled tool run,
|
||||
* the live activity ticker.
|
||||
*
|
||||
* They all render through here so they cannot drift apart. They used to each
|
||||
* pick their own grey — a thinking header painted `--ui-text-secondary`, a tool
|
||||
* summary `--ui-text-tertiary`, both under the same opacity — which read as two
|
||||
* different kinds of line for what is one kind of thing.
|
||||
*
|
||||
* The resting fade is the other half and lives in CSS, on the *block* that
|
||||
* holds the row rather than on the row: mark it `data-conversation-scaffold`.
|
||||
* A surface that skips the mark reads a shade brighter than its neighbours.
|
||||
*/
|
||||
export const SCAFFOLD_LABEL_CLASS =
|
||||
'text-[length:var(--conversation-tool-font-size)] leading-(--conversation-line-height) text-(--conversation-scaffold-text)'
|
||||
|
||||
/** Durations, counts and diff stats trailing a scaffold label. */
|
||||
export const SCAFFOLD_META_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--conversation-scaffold-meta)'
|
||||
|
||||
/**
|
||||
* One scaffold line. `children` is the label and whatever trails it in flow
|
||||
* (meta, diff counts); `trailing` overlays the right edge for a live timer.
|
||||
*/
|
||||
export function ScaffoldRow({
|
||||
children,
|
||||
onToggle,
|
||||
open = false,
|
||||
trailing
|
||||
}: {
|
||||
children: ReactNode
|
||||
onToggle?: () => void
|
||||
open?: boolean
|
||||
trailing?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<DisclosureRow onToggle={onToggle} open={open} trailing={trailing}>
|
||||
<span className="flex min-w-0 items-center gap-1.5">{children}</span>
|
||||
</DisclosureRow>
|
||||
)
|
||||
}
|
||||
|
|
@ -2273,6 +2273,9 @@ export const ar = defineLocale({
|
|||
resumeWhenBackgroundDone: count =>
|
||||
count === 1 ? 'سيُستأنف عند انتهاء المهمة الخلفية' : `سيُستأنف عند انتهاء ${count} مهام خلفية`,
|
||||
thinking: 'يفكر...',
|
||||
thought: 'فكّر',
|
||||
thoughtBriefly: 'فكّر قليلاً',
|
||||
thoughtFor: duration => `فكّر لمدة ${duration}`,
|
||||
today: time => `اليوم ${time}`,
|
||||
yesterday: time => `أمس ${time}`,
|
||||
copy: 'نسخ',
|
||||
|
|
|
|||
|
|
@ -2670,6 +2670,9 @@ export const en: Translations = {
|
|||
? 'Will resume when the background task finishes'
|
||||
: `Will resume when ${count} background tasks finish`,
|
||||
thinking: 'Thinking',
|
||||
thought: 'Thought',
|
||||
thoughtBriefly: 'Thought briefly',
|
||||
thoughtFor: duration => `Thought for ${duration}`,
|
||||
today: time => `Today, ${time}`,
|
||||
yesterday: time => `Yesterday, ${time}`,
|
||||
copy: 'Copy',
|
||||
|
|
|
|||
|
|
@ -2517,6 +2517,9 @@ export const ja = defineLocale({
|
|||
? 'バックグラウンドタスクの完了後に再開します'
|
||||
: `${count} 件のバックグラウンドタスクの完了後に再開します`,
|
||||
thinking: '考え中',
|
||||
thought: '思考済み',
|
||||
thoughtBriefly: '少し思考',
|
||||
thoughtFor: duration => `${duration} 思考`,
|
||||
today: time => `今日 ${time}`,
|
||||
yesterday: time => `昨日 ${time}`,
|
||||
copy: 'コピー',
|
||||
|
|
|
|||
|
|
@ -2268,6 +2268,9 @@ export interface Translations {
|
|||
loadingResponse: string
|
||||
resumeWhenBackgroundDone: (count: number) => string
|
||||
thinking: string
|
||||
thought: string
|
||||
thoughtBriefly: string
|
||||
thoughtFor: (duration: string) => string
|
||||
today: (time: string) => string
|
||||
yesterday: (time: string) => string
|
||||
copy: string
|
||||
|
|
|
|||
|
|
@ -2438,6 +2438,9 @@ export const zhHant = defineLocale({
|
|||
resumeWhenBackgroundDone: count =>
|
||||
count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`,
|
||||
thinking: '思考中',
|
||||
thought: '已思考',
|
||||
thoughtBriefly: '思考了片刻',
|
||||
thoughtFor: duration => `思考了 ${duration}`,
|
||||
today: time => `今天,${time}`,
|
||||
yesterday: time => `昨天,${time}`,
|
||||
copy: '複製',
|
||||
|
|
|
|||
|
|
@ -2841,6 +2841,9 @@ export const zh: Translations = {
|
|||
resumeWhenBackgroundDone: count =>
|
||||
count === 1 ? '后台任务完成后将自动继续' : `${count} 个后台任务完成后将自动继续`,
|
||||
thinking: '思考中',
|
||||
thought: '已思考',
|
||||
thoughtBriefly: '思考了片刻',
|
||||
thoughtFor: duration => `思考了 ${duration}`,
|
||||
today: time => `今天,${time}`,
|
||||
yesterday: time => `昨天,${time}`,
|
||||
copy: '复制',
|
||||
|
|
|
|||
250
apps/desktop/src/lib/tool-run-continuity.test.ts
Normal file
250
apps/desktop/src/lib/tool-run-continuity.test.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isToolCallPart, type ToolCallLike } from '@/components/assistant-ui/tool/run-summary'
|
||||
import type { SessionMessage } from '@/types/hermes'
|
||||
|
||||
import type { ChatMessage, ChatMessagePart } from './chat-messages'
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
appendReasoningPart,
|
||||
assistantTextPart,
|
||||
mergeFinalAssistantText,
|
||||
toChatMessages,
|
||||
upsertToolPart
|
||||
} from './chat-messages'
|
||||
import { coalesceToolOnlyAssistants, createToolMergeCache } from './chat-runtime'
|
||||
|
||||
/**
|
||||
* A turn described once, replayed two ways: as the gateway event stream the
|
||||
* live view builds bubbles from, and as the persisted rows `toChatMessages`
|
||||
* rehydrates on resume. Grouping is only stable if both produce the same runs.
|
||||
*/
|
||||
type TurnStep =
|
||||
| { kind: 'interim'; text: string }
|
||||
| { kind: 'final'; text: string }
|
||||
| { kind: 'reasoning'; text: string }
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'tool'; id: string; name: string }
|
||||
|
||||
// Mirrors use-message-stream: deltas and tool events accumulate on one pending
|
||||
// bubble; `message.interim` seals it in place and starts a fresh one; and
|
||||
// `message.complete` merges the final text onto whatever bubble is open.
|
||||
function replayLive(steps: TurnStep[]): ChatMessage[] {
|
||||
const messages: ChatMessage[] = []
|
||||
let streamIndex = 0
|
||||
let open: ChatMessage | null = null
|
||||
|
||||
const openBubble = (): ChatMessage => {
|
||||
if (open) {
|
||||
return open
|
||||
}
|
||||
|
||||
streamIndex += 1
|
||||
open = { id: `assistant-stream-${streamIndex}`, role: 'assistant', parts: [], pending: true }
|
||||
messages.push(open)
|
||||
|
||||
return open
|
||||
}
|
||||
|
||||
const seal = (text: string, interim: boolean) => {
|
||||
const bubble = open ?? openBubble()
|
||||
|
||||
bubble.parts = mergeFinalAssistantText(bubble.parts, text)
|
||||
bubble.pending = false
|
||||
bubble.interim = interim
|
||||
open = null
|
||||
}
|
||||
|
||||
for (const step of steps) {
|
||||
switch (step.kind) {
|
||||
case 'interim':
|
||||
seal(step.text, true)
|
||||
|
||||
break
|
||||
|
||||
case 'final':
|
||||
seal(step.text, false)
|
||||
|
||||
break
|
||||
case 'reasoning': {
|
||||
const bubble = openBubble()
|
||||
|
||||
bubble.parts = appendReasoningPart(bubble.parts, step.text)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
const bubble = openBubble()
|
||||
|
||||
bubble.parts = appendAssistantTextPart(bubble.parts, step.text)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool': {
|
||||
const bubble = openBubble()
|
||||
|
||||
bubble.parts = upsertToolPart(bubble.parts, { tool_id: step.id, name: step.name }, 'complete')
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return coalesceToolOnlyAssistants(messages, createToolMergeCache())
|
||||
}
|
||||
|
||||
// The same turn as the gateway persists it: one row per agent iteration. A row
|
||||
// is a single API response, so its reasoning and content always precede its own
|
||||
// tool_calls — anything the agent says after a tool ran belongs to the next row.
|
||||
function replayStored(steps: TurnStep[]): ChatMessage[] {
|
||||
const rows: SessionMessage[] = []
|
||||
let timestamp = 0
|
||||
let row: (SessionMessage & { tool_calls?: unknown[] }) | null = null
|
||||
|
||||
const openRow = (afterTools: boolean) => {
|
||||
if (row && !(afterTools && row.tool_calls)) {
|
||||
return row
|
||||
}
|
||||
|
||||
timestamp += 1
|
||||
row = { role: 'assistant', content: '', timestamp }
|
||||
rows.push(row)
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
for (const step of steps) {
|
||||
switch (step.kind) {
|
||||
case 'interim':
|
||||
|
||||
case 'final':
|
||||
case 'text': {
|
||||
const current = openRow(true)
|
||||
|
||||
current.content = `${current.content ?? ''}${step.text}`
|
||||
|
||||
if (step.kind !== 'text') {
|
||||
row = null
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'reasoning':
|
||||
openRow(true).reasoning = step.text
|
||||
|
||||
break
|
||||
case 'tool': {
|
||||
const current = openRow(false)
|
||||
|
||||
current.tool_calls = [
|
||||
...(current.tool_calls ?? []),
|
||||
{ id: step.id, function: { name: step.name, arguments: '{}' } }
|
||||
]
|
||||
timestamp += 1
|
||||
rows.push({ role: 'tool', tool_call_id: step.id, tool_name: step.name, content: '{}', timestamp })
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return coalesceToolOnlyAssistants(toChatMessages(rows), createToolMergeCache())
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximal spans of back-to-back tool calls — the same rule assistant-ui applies
|
||||
* when it hands `ToolGroupSlot` a range, restated here so these tests check our
|
||||
* two part streams against each other rather than against the renderer.
|
||||
*/
|
||||
function toolRuns(parts: ChatMessagePart[]): ToolCallLike[][] {
|
||||
const runs: ToolCallLike[][] = []
|
||||
let previousWasTool = false
|
||||
|
||||
for (const part of parts) {
|
||||
if (!isToolCallPart(part)) {
|
||||
previousWasTool = false
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (previousWasTool) {
|
||||
runs[runs.length - 1].push(part)
|
||||
} else {
|
||||
runs.push([part])
|
||||
}
|
||||
|
||||
previousWasTool = true
|
||||
}
|
||||
|
||||
return runs
|
||||
}
|
||||
|
||||
function runsAcross(messages: ChatMessage[]): string[][] {
|
||||
return messages
|
||||
.filter(message => message.role === 'assistant')
|
||||
.flatMap(message => toolRuns(message.parts))
|
||||
.map(run => run.map(tool => tool.toolCallId ?? ''))
|
||||
}
|
||||
|
||||
const TURNS: Record<string, TurnStep[]> = {
|
||||
'narration between two tool runs': [
|
||||
{ kind: 'interim', text: 'Let me check the config.' },
|
||||
{ kind: 'tool', id: 'a', name: 'read_file' },
|
||||
{ kind: 'tool', id: 'b', name: 'read_file' },
|
||||
{ kind: 'interim', text: 'Now let me edit it.' },
|
||||
{ kind: 'tool', id: 'c', name: 'write_file' },
|
||||
{ kind: 'final', text: 'Done.' }
|
||||
],
|
||||
'reasoning between two tool runs': [
|
||||
{ kind: 'tool', id: 'a', name: 'terminal' },
|
||||
{ kind: 'reasoning', text: 'That failed, try the other path.' },
|
||||
{ kind: 'tool', id: 'b', name: 'terminal' },
|
||||
{ kind: 'final', text: 'Fixed.' }
|
||||
],
|
||||
'unbroken run of tool calls': [
|
||||
{ kind: 'tool', id: 'a', name: 'read_file' },
|
||||
{ kind: 'tool', id: 'b', name: 'read_file' },
|
||||
{ kind: 'tool', id: 'c', name: 'search_files' },
|
||||
{ kind: 'final', text: 'Here is what I found.' }
|
||||
],
|
||||
'reasoning then tools then final': [
|
||||
{ kind: 'reasoning', text: 'The user wants the lint config.' },
|
||||
{ kind: 'tool', id: 'a', name: 'search_files' },
|
||||
{ kind: 'tool', id: 'b', name: 'read_file' },
|
||||
{ kind: 'final', text: 'It lives in eslint.config.js.' }
|
||||
],
|
||||
'tools with no narration at all': [
|
||||
{ kind: 'tool', id: 'a', name: 'terminal' },
|
||||
{ kind: 'final', text: 'Clean.' }
|
||||
]
|
||||
}
|
||||
|
||||
describe('tool run segmentation survives rehydration', () => {
|
||||
for (const [name, steps] of Object.entries(TURNS)) {
|
||||
it(name, () => {
|
||||
expect(runsAcross(replayLive(steps))).toEqual(runsAcross(replayStored(steps)))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('run identity', () => {
|
||||
const tool = (id: string, name: string): ChatMessagePart =>
|
||||
({ args: {}, toolCallId: id, toolName: name, type: 'tool-call' }) as ChatMessagePart
|
||||
|
||||
it('keeps the first tool call at the head as the run grows', () => {
|
||||
const [run] = toolRuns([tool('a', 'read_file'), tool('b', 'read_file')])
|
||||
const [grown] = toolRuns([tool('a', 'read_file'), tool('b', 'read_file'), tool('c', 'terminal')])
|
||||
|
||||
expect(grown[0].toolCallId).toBe(run[0].toolCallId)
|
||||
expect(grown).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('breaks a run on any non-tool part', () => {
|
||||
const runs = toolRuns([tool('a', 'read_file'), assistantTextPart('Now editing.'), tool('b', 'write_file')])
|
||||
|
||||
expect(runs.map(run => run.map(t => t.toolCallId))).toEqual([['a'], ['b']])
|
||||
})
|
||||
})
|
||||
82
apps/desktop/src/lib/use-enter-animation.test.tsx
Normal file
82
apps/desktop/src/lib/use-enter-animation.test.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useEnterAnimation } from './use-enter-animation'
|
||||
|
||||
interface PlayedAnimation {
|
||||
keyframes: Keyframe[]
|
||||
options: KeyframeAnimationOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts one element through the hook and reports the animation it played, if
|
||||
* any. jsdom has no Web Animations API, so `animate` is the seam.
|
||||
*/
|
||||
function mountAnimated(enabled: boolean, animationKey?: string): PlayedAnimation | undefined {
|
||||
let played: PlayedAnimation | undefined
|
||||
|
||||
function Probe() {
|
||||
const ref = useEnterAnimation(enabled, animationKey)
|
||||
|
||||
return <div ref={ref} />
|
||||
}
|
||||
|
||||
// Defined rather than spied on: jsdom ships no Web Animations API at all, so
|
||||
// there is no `animate` to wrap.
|
||||
Object.defineProperty(HTMLElement.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: (keyframes: Keyframe[], options: KeyframeAnimationOptions) => {
|
||||
played = { keyframes, options }
|
||||
|
||||
return {} as Animation
|
||||
},
|
||||
writable: true
|
||||
})
|
||||
render(<Probe />)
|
||||
|
||||
return played
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
Reflect.deleteProperty(HTMLElement.prototype, 'animate')
|
||||
})
|
||||
|
||||
describe('useEnterAnimation', () => {
|
||||
it('plays once on mount', () => {
|
||||
const played = mountAnimated(true, 'plays-once')
|
||||
|
||||
expect(played).toBeDefined()
|
||||
expect(played?.keyframes[0]).toMatchObject({ opacity: 0 })
|
||||
})
|
||||
|
||||
it('stays out of the way when disabled', () => {
|
||||
expect(mountAnimated(false, 'disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
// A key is only banked once the node survives a microtask, so that a mount
|
||||
// React immediately tears down doesn't burn it.
|
||||
it('does not replay for a key that already animated', async () => {
|
||||
expect(mountAnimated(true, 'replay')).toBeDefined()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mountAnimated(true, 'replay')).toBeUndefined()
|
||||
})
|
||||
|
||||
/**
|
||||
* The animation fills forwards, so any value in its last keyframe is held in
|
||||
* the animation origin of the cascade for the life of the element — above
|
||||
* the stylesheet. Naming an end opacity therefore doesn't just finish the
|
||||
* fade, it permanently overrules whatever opacity CSS wants the element to
|
||||
* rest at, and transcript scaffolding rests dimmed. Rows that animated in
|
||||
* during the turn stayed bright while their rehydrated neighbours faded, and
|
||||
* no hover could lift the bright ones because the sheet had lost the
|
||||
* argument. Opacity has to be left to CSS at the end.
|
||||
*/
|
||||
it('leaves the resting opacity to the stylesheet', () => {
|
||||
const played = mountAnimated(true, 'resting-opacity')
|
||||
|
||||
expect(played?.options.fill).toBe('both')
|
||||
expect(played?.keyframes.at(-1)).not.toHaveProperty('opacity')
|
||||
})
|
||||
})
|
||||
|
|
@ -82,7 +82,17 @@ export function useEnterAnimation(enabled: boolean, animationKey?: string): (el:
|
|||
el.animate(
|
||||
[
|
||||
{ opacity: 0, transform: 'translateY(0.375rem)' },
|
||||
{ opacity: 1, transform: 'translateY(0)' }
|
||||
// No `opacity` on the way out, deliberately. A filled animation holds
|
||||
// its final value in the animation origin of the cascade, which
|
||||
// outranks the stylesheet for as long as the element lives — naming 1
|
||||
// here permanently pinned full opacity onto everything the sheet dims.
|
||||
// Transcript scaffolding is dimmed that way, so a tool row or thinking
|
||||
// header kept whichever opacity it happened to mount with: full if it
|
||||
// animated in during the turn, faded if it was rehydrated or remounted
|
||||
// past its one-shot key. Adjacent identical rows disagreed, and hover
|
||||
// couldn't lift the pinned ones. Left neutral, opacity rises to
|
||||
// whatever CSS says it should be and answers hover afterwards.
|
||||
{ transform: 'translateY(0)' }
|
||||
],
|
||||
{ duration: 180, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'both' }
|
||||
)
|
||||
|
|
|
|||
45
apps/desktop/src/store/tool-drafting.ts
Normal file
45
apps/desktop/src/store/tool-drafting.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { atom, computed } from 'nanostores'
|
||||
|
||||
// The tool whose arguments the model is streaming right now, and when it
|
||||
// started. Generating a large payload (a 45 KB write_file) takes seconds during
|
||||
// which the tool has not begun and the transcript has nothing to say.
|
||||
// Per-session because a transcript may be a tile, and a tile must never narrate
|
||||
// the primary chat's work.
|
||||
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
|
||||
|
||||
export interface DraftingTool {
|
||||
name: string
|
||||
since: number
|
||||
}
|
||||
|
||||
export const $draftingToolSessions = atom<Record<string, DraftingTool>>({})
|
||||
|
||||
/** What `sessionId` is drafting, if anything. */
|
||||
export function sessionDraftingTool(sessionId: null | string) {
|
||||
return computed($draftingToolSessions, sessions => sessions[keyFor(sessionId)] ?? null)
|
||||
}
|
||||
|
||||
export function setSessionDraftingTool(sessionId: string | null | undefined, name: string): void {
|
||||
const key = keyFor(sessionId)
|
||||
const sessions = $draftingToolSessions.get()
|
||||
|
||||
if (!name) {
|
||||
if (!(key in sessions)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { ...sessions }
|
||||
delete next[key]
|
||||
$draftingToolSessions.set(next)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Re-announcing the same tool keeps the original timestamp, so the elapsed
|
||||
// count reads as one continuous wait rather than restarting.
|
||||
if (sessions[key]?.name === name) {
|
||||
return
|
||||
}
|
||||
|
||||
$draftingToolSessions.set({ ...sessions, [key]: { name, since: Date.now() } })
|
||||
}
|
||||
|
|
@ -287,6 +287,13 @@
|
|||
--ui-text-secondary: color-mix(in srgb, var(--ui-base) 74%, transparent);
|
||||
--ui-text-tertiary: color-mix(in srgb, var(--ui-base) 54%, transparent);
|
||||
--ui-text-quaternary: color-mix(in srgb, var(--ui-base) 36%, transparent);
|
||||
/* Transcript scaffolding — thinking headers, settled tool runs, the live
|
||||
activity ticker. One colour for all of them (see `ScaffoldRow`), pitched
|
||||
between the secondary and tertiary greys those lines used to pick
|
||||
individually, and dimmed once more by the fade rule below so the prose
|
||||
reading column stays primary. */
|
||||
--conversation-scaffold-text: color-mix(in srgb, var(--ui-base) 64%, transparent);
|
||||
--conversation-scaffold-meta: color-mix(in srgb, var(--ui-base) 44%, transparent);
|
||||
--ui-stroke-primary: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) var(--theme-stroke-primary-accent-mix),
|
||||
|
|
@ -402,10 +409,6 @@
|
|||
/* Tight gap between tool rows inside a single action group, so a back-to-back
|
||||
run still reads as one cohesive sequence. */
|
||||
--tool-row-gap: 0.375rem;
|
||||
/* Height of the bounded, auto-scrolling window a long adjacent tool-call run
|
||||
collapses into (see `.tool-group-scroll`). Sized to show a few rows before
|
||||
the scroll + top fade kick in. */
|
||||
--tool-group-scroll-max-h: 6.75rem;
|
||||
/* Paragraph spacing — vertical gap between prose paragraphs, both inside a
|
||||
markdown block and between consecutive prose parts. Single knob; tweak
|
||||
freely. */
|
||||
|
|
@ -1413,15 +1416,18 @@ text-* variant utilities. */ .btn-arc {
|
|||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Fade scaffolding so the prose reading column stays primary. Two targets:
|
||||
a thinking disclosure fades as one block, and each *individual* tool row
|
||||
(`[data-tool-row]`) fades on its own. We deliberately do NOT fade the tool
|
||||
group wrapper (`[data-tool-group]`): opacity on a parent opens a stacking
|
||||
context, so a child row can never be more opaque than the group — that made
|
||||
it impossible to keep one row lit (an open diff) while its siblings faded.
|
||||
With the fade per-row, each row hovers/focuses independently. */
|
||||
[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'],
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row] {
|
||||
/* Fade scaffolding so the prose reading column stays primary. Each surface
|
||||
opts in with `data-conversation-scaffold` — thinking header, tool row, run
|
||||
summary, live status line — instead of being named by its own selector here.
|
||||
Spelling them out individually is how the status line came to sit a shade
|
||||
brighter than the rows either side of it.
|
||||
|
||||
Marked per surface and never on a container: opacity opens a stacking
|
||||
context, so nothing inside a faded parent can be more opaque than it, and
|
||||
one row (an open diff) could not stay lit while its siblings dimmed. Hence
|
||||
the tool group wrapper carries no mark and each row inside it carries its
|
||||
own — no two marked elements nest, so the fade never compounds. */
|
||||
[data-slot='aui_assistant-message-content'] [data-conversation-scaffold] {
|
||||
opacity: 0.67;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
|
|
@ -1429,8 +1435,7 @@ text-* variant utilities. */ .btn-arc {
|
|||
/* Lift on hover or *keyboard* focus only. `:focus-within` also matches the
|
||||
focus a mouse click leaves on the disclosure toggle, which kept a row lit
|
||||
after you clicked to collapse it; `:has(:focus-visible)` excludes that. */
|
||||
[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure']:is(:hover, :has(:focus-visible)),
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row]:is(:hover, :has(:focus-visible)) {
|
||||
[data-slot='aui_assistant-message-content'] [data-conversation-scaffold]:is(:hover, :has(:focus-visible)) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
|
@ -1496,24 +1501,33 @@ text-* variant utilities. */ .btn-arc {
|
|||
/* Conversation block rhythm. assistant-ui renders each range as a direct child
|
||||
of the message content with no per-part wrapper, so adjacency rules cover
|
||||
every pairing — first block needs no reset, nested tool rows are untouched.
|
||||
Two tiers: scaffolding (tool / thinking) gets a roomy block gap so it reads
|
||||
as separate from the reply; consecutive prose collapses to a tight paragraph
|
||||
rhythm so split-out text parts don't look like a big gap. */
|
||||
/* Scaffolding adjacent to anything → roomy block gap. */
|
||||
|
||||
One gap between top-level blocks, whatever they are. Spelling out which
|
||||
*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'])
|
||||
+ :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], .aui-md),
|
||||
[data-slot='aui_assistant-message-content']
|
||||
> .aui-md
|
||||
+ :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
|
||||
> :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) {
|
||||
margin-top: var(--turn-block-gap);
|
||||
}
|
||||
|
||||
/* Prose ↔ prose → tight paragraph rhythm, matching in-block paragraph spacing. */
|
||||
/* Except prose ↔ prose: one reading column that happens to arrive as several
|
||||
parts, so it keeps the in-block paragraph rhythm. Same specificity as the
|
||||
rule above and deliberately after it. */
|
||||
[data-slot='aui_assistant-message-content'] > .aui-md + .aui-md {
|
||||
margin-top: var(--paragraph-gap);
|
||||
}
|
||||
|
||||
/* A streaming turn is sealed into several assistant bubbles as it goes
|
||||
(`message.interim`) and rehydrates into fewer, so the same two blocks are
|
||||
sometimes siblings inside one bubble and sometimes split across two. The
|
||||
flex gap between bubbles is the turn gap — half the block gap — which made
|
||||
the rhythm visibly tighten and then relax as a turn settled. Top it up so
|
||||
the column ticks at one interval either way. */
|
||||
[data-slot='aui_turn-pair'] > [data-slot='aui_assistant-message-root'] + [data-slot='aui_assistant-message-root'] {
|
||||
margin-top: calc(var(--turn-block-gap) - var(--conversation-turn-gap));
|
||||
}
|
||||
|
||||
/* Message action bars — flat icon hits with default dim; only the hovered/focused control is full-strength. */
|
||||
[data-slot='aui_msg-actions'] button {
|
||||
border: 0;
|
||||
|
|
@ -1565,32 +1579,33 @@ text-* variant utilities. */ .btn-arc {
|
|||
--thread-last-message-clearance: calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 2rem);
|
||||
}
|
||||
|
||||
/* Long adjacent tool-call run collapsed into a fixed, auto-scrolling window.
|
||||
ToolGroupSlot pins the newest call to the bottom (unless the user scrolls
|
||||
up), so a back-to-back run stays compact instead of pushing the reply off
|
||||
screen. A thin scrollbar keeps the affordance discoverable. */
|
||||
.tool-group-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--ui-stroke-tertiary) transparent;
|
||||
/* A live tool run, shown as one line. `ToolRunTicker` stacks the run's rows
|
||||
into a reel and offsets it by the newest row's index, so each new action
|
||||
slides the one before it up and out of a single-line window — the run reads
|
||||
as one line ticking over in place rather than a list growing down the page.
|
||||
Rows are clipped to a uniform line box so the offset stays exact whatever a
|
||||
row contains. */
|
||||
.tool-ticker {
|
||||
height: var(--conversation-line-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Break out of the fixed window the moment any row inside is expanded: the
|
||||
user is now reading a diff/output, so let it grow to full height instead of
|
||||
peering at it through a ~2-row viewport. Collapsing the row drops it back
|
||||
into the compact, auto-scrolling window. Beats a larger fixed cap since an
|
||||
open row already bounds its own payload with an inner scroll. */
|
||||
.tool-group-scroll:has([data-tool-row][data-tool-open]) {
|
||||
max-height: none;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
.tool-ticker__reel {
|
||||
transform: translateY(calc(var(--tool-ticker-index, 0) * var(--conversation-line-height) * -1));
|
||||
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
/* Top gradient — only applied once the user is scrolled down off the top, so
|
||||
the oldest visible call fades up under the fade while the first row stays
|
||||
fully legible when scrolled all the way up. */
|
||||
.tool-group-scroll--faded {
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 2rem, black 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0, black 2rem, black 100%);
|
||||
.tool-ticker__row {
|
||||
display: flex;
|
||||
height: var(--conversation-line-height);
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tool-ticker__reel {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes code-card-stream-enter {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue