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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/app/chat/session-tile-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -253,6 +254,7 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
clearSessionTodos(sessionId)
clearSessionSubagents(sessionId)
resetSessionBackground(sessionId)
setSessionDraftingTool(sessionId, '')
clearAllPrompts(sessionId)
clearClarifyRequest(undefined, sessionId)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
59 changes: 37 additions & 22 deletions apps/desktop/src/components/assistant-ui/thread/message-parts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand All @@ -66,13 +68,31 @@ 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)

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(() => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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>
)
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/components/assistant-ui/thread/status.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Loading
Loading