fix(desktop): keep turn progress feedback visible

This commit is contained in:
程序员阿江(Relakkes)
2026-08-07 06:57:29 +08:00
parent deee000c73
commit d3ba73af31
11 changed files with 247 additions and 16 deletions
+14 -2
View File
@@ -8,6 +8,7 @@ import {
buildActivitySegments,
countFailedToolCalls,
hasUnresolvedToolCalls,
THINKING_SEGMENT_KEY,
toolCallDurationMs,
type ActivityStep,
} from './activityGroupModel'
@@ -51,7 +52,13 @@ export const ActivityGroup = memo(function ActivityGroup({
const toolCalls = useMemo(() => activityStepToolCalls(steps), [steps])
const segments = useMemo(() => buildActivitySegments(steps, t), [steps, t])
const failedCount = countFailedToolCalls(toolCalls, resultMap, childToolCallsByParent)
const isRunning = Boolean(isStreaming) || hasUnresolvedToolCalls(toolCalls, resultMap, childToolCallsByParent)
const hasActiveThinking = Boolean(activeThinkingId) && steps.some(
(step) => step.kind === 'thinking' && step.message.id === activeThinkingId,
)
const isRunning =
Boolean(isStreaming) ||
hasActiveThinking ||
hasUnresolvedToolCalls(toolCalls, resultMap, childToolCallsByParent)
const soleToolCall = steps.length === 1 && steps[0]?.kind === 'tool' ? steps[0].toolCall : null
if (soleToolCall) {
@@ -101,7 +108,12 @@ export const ActivityGroup = memo(function ActivityGroup({
<span aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]">·</span>
)}
<span className="flex shrink-0 items-center gap-[6px] whitespace-nowrap">
<segment.icon size={13} strokeWidth={1.8} aria-hidden="true" />
<segment.icon
size={13}
strokeWidth={1.8}
className={hasActiveThinking && segment.key === THINKING_SEGMENT_KEY ? 'animate-pulse' : undefined}
aria-hidden="true"
/>
{segment.label}
</span>
</Fragment>
+20 -6
View File
@@ -1115,7 +1115,10 @@ describe('ChatInput file mentions', () => {
})
it('starts an empty active session on the selected branch without an isolated worktree', async () => {
mocks.create.mockResolvedValueOnce({ sessionId: 'created-direct', workDir: '/repo' })
let resolveCreate!: (value: { sessionId: string; workDir: string }) => void
mocks.create.mockReturnValueOnce(new Promise((resolve) => {
resolveCreate = resolve
}))
useSessionStore.setState({
sessions: [{
id: sessionId,
@@ -1166,12 +1169,23 @@ describe('ChatInput file mentions', () => {
repository: { branch: 'feature/a', worktree: false },
})
})
expect(mocks.delete).toHaveBeenCalledWith(sessionId)
expect(mocks.wsSend).toHaveBeenCalledWith('created-direct', {
type: 'user_message',
content: 'run on feature branch',
attachments: [],
// Session creation can take seconds. The click must cross the store
// boundary immediately so the transcript can replace its empty hero with
// a live placeholder before the new session id exists.
expect(useChatStore.getState().sessions[sessionId]?.isPreparingTurn).toBe(true)
resolveCreate({ sessionId: 'created-direct', workDir: '/repo' })
await waitFor(() => {
expect(mocks.wsSend).toHaveBeenCalledWith('created-direct', {
type: 'user_message',
content: 'run on feature branch',
attachments: [],
})
})
expect(useChatStore.getState().sessions['created-direct']?.isPreparingTurn).toBe(false)
expect(mocks.delete).toHaveBeenCalledWith(sessionId)
})
it('preserves explicit permission mode when replacing an empty session for branch launch', async () => {
+15 -3
View File
@@ -185,6 +185,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
updateQueuedUserMessage,
removeQueuedUserMessage,
sendQueuedUserMessage,
setPreparingTurn,
} = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
@@ -193,6 +194,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const launchBranch = repositoryLaunchDraft?.branch ?? null
const launchUseWorktree = repositoryLaunchDraft?.useWorktree ?? false
const chatState = sessionState?.chatState ?? 'idle'
const isPreparingTurn = Boolean(sessionState?.isPreparingTurn)
const slashCommands = sessionState?.slashCommands ?? []
const composerPrefill = sessionState?.composerPrefill ?? null
const composerInsertion = sessionState?.composerInsertion ?? null
@@ -295,6 +297,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
: null
const canSubmit = !isWorkspaceMissing &&
!launchTransitioning &&
!isPreparingTurn &&
(!showLaunchControls || launchReady || !!pendingSlashUiAction) &&
(input.trim().length > 0 || (!isMemberSession && (attachments.length > 0 || hasWorkspaceReferences)))
const composerAttachments = useMemo(
@@ -790,14 +793,23 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
(gitInfo?.branch ? launchBranch !== gitInfo.branch : true)
if (shouldReplaceForRepositoryLaunch) {
setLaunchTransitioning(true)
const placeholderSessionId = targetSessionId
setPreparingTurn(placeholderSessionId, true)
try {
const newSessionId = await replaceEmptySession(activeLaunchWorkDir, {
branch: launchBranch,
worktree: launchUseWorktree,
})
if (!newSessionId) return
if (!newSessionId) {
setPreparingTurn(placeholderSessionId, false)
return
}
targetSessionId = newSessionId
// Keep the placeholder continuous across the old-id -> new-id swap.
// sendMessage clears it in the same turn that appends the user row.
setPreparingTurn(targetSessionId, true)
} catch (error) {
setPreparingTurn(placeholderSessionId, false)
useUIStore.getState().addToast({
type: 'error',
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
@@ -1291,7 +1303,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
placeholder={composerPlaceholder}
disabled={isWorkspaceMissing}
disabled={isWorkspaceMissing || launchTransitioning || isPreparingTurn}
className="flex-1"
editorClassName="max-h-[200px] overflow-y-auto py-2 leading-relaxed text-[var(--color-text-primary)]"
aria={{
@@ -1317,7 +1329,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
placeholder={composerPlaceholder}
disabled={isWorkspaceMissing}
disabled={isWorkspaceMissing || launchTransitioning || isPreparingTurn}
editorClassName={`max-h-[200px] overflow-y-auto text-sm leading-relaxed text-[var(--color-text-primary)] ${
useCompactChrome ? 'py-1.5' : 'py-2'
}`}
@@ -1648,6 +1648,18 @@ describe('MessageList nested tool calls', () => {
expect(screen.getByText(/waiting \d+s/)).toBeTruthy()
})
it('shows a thinking placeholder as soon as first-turn preparation begins', () => {
render(<MessageList />)
act(() => {
useChatStore.getState().setPreparingTurn(ACTIVE_TAB, true)
})
const status = screen.getByTestId('turn-status-indicator')
expect(status.getAttribute('role')).toBe('status')
expect(status.textContent).toContain('Thinking')
})
it('shows the non-streaming fallback notice in the active turn indicator', () => {
useChatStore.setState({
sessions: {
@@ -2022,6 +2034,48 @@ describe('MessageList nested tool calls', () => {
expect(group.toolCalls.map((toolCall) => toolCall.toolUseId)).toEqual(['read-1', 'bash-1'])
})
it('keeps a completed tool group visibly live while post-tool thinking streams', () => {
render(<MessageList sessionId={ACTIVE_TAB} />)
const store = useChatStore.getState()
act(() => {
store.handleServerMessage(ACTIVE_TAB, {
type: 'content_start',
blockType: 'tool_use',
toolName: 'Bash',
toolUseId: 'bash-live-1',
})
store.handleServerMessage(ACTIVE_TAB, {
type: 'tool_use_complete',
toolName: 'Bash',
toolUseId: 'bash-live-1',
input: { command: 'git status --short' },
})
store.handleServerMessage(ACTIVE_TAB, {
type: 'tool_result',
toolUseId: 'bash-live-1',
content: '',
isError: false,
})
store.handleServerMessage(ACTIVE_TAB, {
type: 'thinking',
text: 'Now inspect the remaining UI paths.',
})
})
const group = screen.getByTestId('activity-group')
expect(group.getAttribute('data-running')).toBe('true')
expect(group.querySelector('.animate-spin')).not.toBeNull()
expect(group.querySelector('.animate-pulse')).not.toBeNull()
act(() => {
store.handleServerMessage(ACTIVE_TAB, { type: 'status', state: 'idle' })
})
expect(group.getAttribute('data-running')).toBe('false')
expect(group.querySelector('.animate-spin')).toBeNull()
})
it('leaves a run of pure reasoning as standalone thinking blocks', () => {
const messages: UIMessage[] = [
{ id: 'think-1', type: 'thinking', content: 'First consider the call sites.', timestamp: 1 },
@@ -1690,6 +1690,7 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
const addToast = useUIStore((s) => s.addToast)
const messages = sessionState?.messages ?? EMPTY_MESSAGES
const chatState = sessionState?.chatState ?? 'idle'
const isPreparingTurn = Boolean(sessionState?.isPreparingTurn)
const historyMutationEpoch = sessionState?.historyMutationEpoch ?? 0
const streamingText = sessionState?.streamingText ?? ''
const streamingToolInput = sessionState?.streamingToolInput ?? ''
@@ -1714,6 +1715,7 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
(permission) => permission.toolName !== 'AskUserQuestion',
)
const shouldFollowContentResize =
isPreparingTurn ||
streamingText.trim().length > 0 ||
chatState === 'streaming' ||
chatState === 'compacting' ||
@@ -1774,6 +1776,7 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
const [messageListWidth, setMessageListWidth] = useState<number | null>(null)
const branchActionsDisabled =
isMemberSession ||
isPreparingTurn ||
chatState !== 'idle' ||
hasRunningBackgroundTasks ||
streamingText.trim().length > 0 ||
@@ -2937,6 +2940,7 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
describing — it is part of the conversation, not composer chrome. */}
{(hasApiRetry ||
hasStreamingFallback ||
isPreparingTurn ||
chatState === 'tool_executing' ||
(chatState === 'thinking' && !activeThinkingId)) && (
<StreamingIndicator />
@@ -50,7 +50,9 @@ export function StreamingIndicator() {
const [now, setNow] = useState(() => Date.now())
const activeTabId = useTabStore((s) => s.activeTabId)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const chatState = sessionState?.chatState ?? 'idle'
const chatState = sessionState?.isPreparingTurn
? 'thinking'
: sessionState?.chatState ?? 'idle'
const statusVerb = sessionState?.statusVerb ?? ''
const apiRetry = sessionState?.apiRetry ?? null
const streamingFallback = sessionState?.streamingFallback ?? null
@@ -136,7 +138,12 @@ export function StreamingIndicator() {
const verb = resolveTurnStatusVerb(t, chatState, statusVerb)
return (
<div className="mb-2 flex w-fit items-center gap-[9px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-2 text-[13.5px] text-[var(--color-text-secondary)]">
<div
data-testid="turn-status-indicator"
role="status"
aria-live="polite"
className="mb-2 flex w-fit items-center gap-[9px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-2 text-[13.5px] text-[var(--color-text-secondary)]"
>
<span className="animate-pulse-dot text-[var(--color-brand)]" aria-hidden="true">✦</span>
<span className="font-medium text-[var(--color-text-primary)]">{verb}...</span>
{elapsedSeconds > 0 && (
+19 -1
View File
@@ -2,6 +2,7 @@ import { memo, useCallback, useMemo, useState } from 'react'
import { BookMarked, ChevronDown, ChevronRight, CircleCheck, Settings } from 'lucide-react'
import { ToolCallBlock } from './ToolCallBlock'
import { ActivityGroup } from './ActivityGroup'
import { ThinkingBlock } from './ThinkingBlock'
import {
activityStepToolCalls,
toActivitySteps,
@@ -163,7 +164,10 @@ function ToolCallGroupContent({
}: ContentProps) {
const toolCalls = activityStepToolCalls(steps)
const hasImageGeneration = toolCalls.some((toolCall) => isImageGenerationToolName(toolCall.toolName))
if (hasImageGeneration && !toolCalls.every((toolCall) => isImageGenerationToolName(toolCall.toolName))) {
const hasNonImageSteps = steps.some(
(step) => step.kind === 'thinking' || !isImageGenerationToolName(step.toolCall.toolName),
)
if (hasImageGeneration && hasNonImageSteps) {
const segments: Array<
| { kind: 'images'; toolCalls: ToolCall[] }
| { kind: 'regular'; steps: ActivityStep[] }
@@ -218,6 +222,20 @@ function ToolCallGroupContent({
)
}
if (toolCalls.length === 0) {
return (
<>
{steps.map((step) => step.kind === 'thinking' ? (
<ThinkingBlock
key={step.message.id}
content={step.message.content}
isActive={step.message.id === activeThinkingId}
/>
) : null)}
</>
)
}
const allAgents = toolCalls.length > 0 && toolCalls.every((toolCall) => toolCall.toolName === 'Agent')
if (allAgents) {
@@ -138,6 +138,51 @@ describe('chat blocks', () => {
expect(screen.queryByRole('button', { name: /ToolSearch \(1\), ImageEdit \(1\)/ })).toBeNull()
})
it('keeps post-image thinking visible and live after the image result arrives', () => {
const imageCall: Extract<UIMessage, { type: 'tool_use' }> = {
id: 'image-use',
type: 'tool_use',
toolName: 'ImageGen',
toolUseId: 'image-1',
input: { prompt: 'A quiet mountain study' },
timestamp: 1,
}
const thinking: Extract<UIMessage, { type: 'thinking' }> = {
id: 'thinking-after-image',
type: 'thinking',
content: 'Check whether the generated image matches the requested composition.',
timestamp: 3,
}
render(
<ToolCallGroup
toolCalls={[imageCall]}
steps={[
{ kind: 'tool', toolCall: imageCall },
{ kind: 'thinking', message: thinking },
]}
resultMap={new Map([[
'image-1',
{
id: 'image-result',
type: 'tool_result' as const,
toolUseId: 'image-1',
content: JSON.stringify({ type: 'image_generation_result', images: [] }),
isError: false,
timestamp: 2,
},
]])}
childToolCallsByParent={new Map()}
agentTaskNotifications={{}}
activeThinkingId="thinking-after-image"
/>,
)
const thinkingRow = screen.getByRole('button', { name: /Thinking/ })
expect(thinkingRow).toBeTruthy()
expect(thinkingRow.querySelector('.thinking-dots')).not.toBeNull()
})
it('replaces every image placeholder with the saved tool result', () => {
const content = JSON.stringify({
type: 'image_generation_result',
+43
View File
@@ -213,6 +213,49 @@ describe('ActiveSession task polling', () => {
expect(screen.getByTestId('chat-input')).toHaveAttribute('data-variant', 'default')
})
it('replaces the empty hero with the transcript as soon as first-turn preparation begins', () => {
const sessionId = 'preparing-first-turn-session'
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'New Session',
createdAt: '2026-08-07T00:00:00.000Z',
modifiedAt: '2026-08-07T00:00:00.000Z',
messageCount: 0,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: sessionId,
isLoading: false,
error: null,
})
useTabStore.setState({
tabs: [{ sessionId, title: 'New Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useChatStore.setState({
sessions: {
[sessionId]: {
...useChatStore.getState().getSession(sessionId),
connectionState: 'connected',
historyStatus: 'ready',
},
},
})
render(<ActiveSession />)
expect(screen.getByTestId('empty-session-hero')).toBeInTheDocument()
act(() => {
useChatStore.getState().setPreparingTurn(sessionId, true)
})
expect(screen.queryByTestId('empty-session-hero')).not.toBeInTheDocument()
expect(screen.getByTestId('message-list')).toBeInTheDocument()
expect(screen.getByTestId('chat-input')).toHaveAttribute('data-variant', 'default')
})
it('shows the session token badge when usage is cache-only', () => {
const sessionId = 'cache-only-token-session'
+7 -2
View File
@@ -387,6 +387,7 @@ export function ActiveSession() {
const t = useTranslation()
const messages = sessionState?.messages ?? EMPTY_MESSAGES
const streamingText = sessionState?.streamingText ?? ''
const isPreparingTurn = Boolean(sessionState?.isPreparingTurn)
const backgroundTasks = useMemo(
() => Object.values(sessionState?.backgroundAgentTasks ?? {}),
[sessionState?.backgroundAgentTasks],
@@ -397,7 +398,11 @@ export function ActiveSession() {
)
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? EMPTY_AGENT_TASK_NOTIFICATIONS
const activeGoal = sessionState?.activeGoal ?? null
const isEmpty = messages.length === 0 && !streamingText && (session?.messageCount ?? 0) === 0
const isEmpty =
messages.length === 0 &&
!streamingText &&
!isPreparingTurn &&
(session?.messageCount ?? 0) === 0
const compactEmptyHero = isEmpty && showTerminalPanel
const isHistoryLoading =
!isMemberSession &&
@@ -414,7 +419,7 @@ export function ActiveSession() {
const visibleMessageCount = messages.length > 0 ? messages.length : session?.messageCount ?? 0
const headerTitle = session?.title || t('session.untitled')
const isActive = chatState !== 'idle' || hasRunningBackgroundTasks
const isActive = isPreparingTurn || chatState !== 'idle' || hasRunningBackgroundTasks
const totalTokens = getTokenUsageTotal(tokenUsage)
const cachedTokens = (tokenUsage.cache_read_tokens ?? 0) +
(tokenUsage.cache_creation_tokens ?? 0)
+17
View File
@@ -106,6 +106,12 @@ type PendingComputerUsePermissions = Record<string, PendingComputerUsePermission
export type PerSessionState = {
messages: UIMessage[]
chatState: ChatState
/**
* The first prompt is waiting for an empty placeholder session to be
* replaced with its selected branch/worktree session. This is UI-only turn
* state: no request has been sent to the old session.
*/
isPreparingTurn?: boolean
connectionState: ConnectionState
/** True after the server's authoritative reconnect snapshot has arrived. */
connectionSnapshotReady?: boolean
@@ -173,6 +179,7 @@ export type PerSessionState = {
const DEFAULT_SESSION_STATE: PerSessionState = {
messages: [],
chatState: 'idle',
isPreparingTurn: false,
connectionState: 'disconnected',
connectionSnapshotReady: false,
historyStatus: 'idle',
@@ -349,6 +356,7 @@ type ChatStore = {
clearComposerDraft: (sessionId: string) => void
setRepositoryLaunchDraft: (sessionId: string, draft: RepositoryLaunchDraftState) => void
clearRepositoryLaunchDraft: (sessionId: string) => void
setPreparingTurn: (sessionId: string, preparing: boolean) => void
queueUserMessage: (
sessionId: string,
message: Omit<QueuedUserMessage, 'id' | 'createdAt'>,
@@ -1559,6 +1567,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
...session,
messages: newMessages,
chatState: 'thinking',
isPreparingTurn: false,
historyMutationEpoch: (session.historyMutationEpoch ?? 0) + 1,
elapsedSeconds: 0,
suppressNextTaskNotificationResponse: false,
@@ -2085,6 +2094,14 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}))
},
setPreparingTurn: (sessionId, preparing) => {
set((state) => ({
sessions: updateSessionIn(state.sessions, sessionId, () => ({
isPreparingTurn: preparing,
})),
}))
},
queueUserMessage: (sessionId, message) => {
const id = `queued-user-${Date.now()}-${Math.random().toString(36).slice(2)}`
set((state) => {