fix(desktop): queue active-turn prompts (#755)

Add pending user-message queue controls while a desktop turn is running, replay queued prompts into the transcript when the CLI accepts them, and flush remaining prompts after turn completion.

Tested: cd desktop && bun run test -- src/components/chat/ChatInput.test.tsx
Tested: cd desktop && bun run test -- src/stores/chatStore.test.ts
Tested: bun test src/server/__tests__/ws-memory-events.test.ts
Tested: bun run check:desktop
Not-tested: bun run check:server remains blocked by unrelated E2E CORS preflight expectation, expected 403 but got 204.
Confidence: medium
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 16:44:33 +08:00
parent 88d04a6267
commit 5cf8c1bb67
13 changed files with 646 additions and 6 deletions

View File

@ -405,6 +405,177 @@ describe('ChatInput file mentions', () => {
})
})
it('queues prompts submitted while a turn is running until the user guides them', async () => {
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'assistant-stream', type: 'assistant_text', content: 'working', timestamp: 1 }],
chatState: 'streaming',
connectionState: 'connected',
streamingText: 'still answering',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 12,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: 'please adjust the current direction', selectionStart: 35 },
})
fireEvent.keyDown(input, { key: 'Enter' })
expect(mocks.wsSend).not.toHaveBeenCalledWith(sessionId, expect.objectContaining({
type: 'user_message',
}))
expect(input.value).toBe('')
expect(screen.getByTestId('pending-user-message')).toHaveTextContent('please adjust the current direction')
fireEvent.click(screen.getByRole('button', { name: /Guide now/i }))
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message',
content: 'please adjust the current direction',
attachments: [],
})
expect(screen.queryByTestId('pending-user-message')).not.toBeInTheDocument()
expect(useChatStore.getState().sessions[sessionId]?.messages.at(-1)).toMatchObject({
type: 'assistant_text',
content: 'working',
})
act(() => {
useChatStore.getState().handleServerMessage(sessionId, {
type: 'user_message_replay',
content: 'please adjust the current direction',
})
})
expect(useChatStore.getState().sessions[sessionId]?.messages.at(-1)).toMatchObject({
type: 'user_text',
content: 'please adjust the current direction',
})
})
it('edits and deletes queued prompts without sending them', async () => {
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'assistant-stream', type: 'assistant_text', content: 'working', timestamp: 1 }],
chatState: 'streaming',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 12,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: 'first queued draft', selectionStart: 18 },
})
fireEvent.keyDown(input, { key: 'Enter' })
fireEvent.click(screen.getByRole('button', { name: /Edit queued message/i }))
const editInput = screen.getByLabelText('Queued message text')
fireEvent.change(editInput, {
target: { value: 'edited queued draft' },
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(screen.getByTestId('pending-user-message')).toHaveTextContent('edited queued draft')
fireEvent.click(screen.getByRole('button', { name: /Delete queued message/i }))
expect(screen.queryByTestId('pending-user-message')).not.toBeInTheDocument()
expect(mocks.wsSend).not.toHaveBeenCalledWith(sessionId, expect.objectContaining({
type: 'user_message',
}))
})
it('sends a queued prompt as the next tail message when the running turn completes', async () => {
useChatStore.setState({
sessions: {
[sessionId]: {
messages: [{ id: 'assistant-stream', type: 'assistant_text', content: 'working', timestamp: 1 }],
chatState: 'streaming',
connectionState: 'connected',
streamingText: 'done now',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 12,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ChatInput compact />)
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: { value: 'continue after completion', selectionStart: 25 },
})
fireEvent.keyDown(input, { key: 'Enter' })
expect(screen.getByTestId('pending-user-message')).toHaveTextContent('continue after completion')
expect(mocks.wsSend).not.toHaveBeenCalledWith(sessionId, expect.objectContaining({
type: 'user_message',
}))
act(() => {
useChatStore.getState().handleServerMessage(sessionId, {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 2 },
})
})
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
type: 'user_message',
content: 'continue after completion',
attachments: [],
})
expect(useChatStore.getState().sessions[sessionId]?.messages).toMatchObject([
{ type: 'assistant_text', content: 'workingdone now' },
{ type: 'user_text', content: 'continue after completion' },
])
})
it('shows branch and worktree launch controls for an empty active Git session', async () => {
useSessionStore.setState({
sessions: [{

View File

@ -103,6 +103,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const [launchUseWorktree, setLaunchUseWorktree] = useState(false)
const [launchReady, setLaunchReady] = useState(true)
const [launchTransitioning, setLaunchTransitioning] = useState(false)
const [editingQueuedMessageId, setEditingQueuedMessageId] = useState<string | null>(null)
const [editingQueuedMessageText, setEditingQueuedMessageText] = useState('')
const composingRef = useRef(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
@ -125,13 +127,23 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
return next
})
}, [])
const { sendMessage, stopGeneration, clearComposerPrefill, clearComposerInsertion } = useChatStore()
const {
sendMessage,
stopGeneration,
clearComposerPrefill,
clearComposerInsertion,
queueUserMessage,
updateQueuedUserMessage,
removeQueuedUserMessage,
sendQueuedUserMessage,
} = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const chatState = sessionState?.chatState ?? 'idle'
const slashCommands = sessionState?.slashCommands ?? []
const composerPrefill = sessionState?.composerPrefill ?? null
const composerInsertion = sessionState?.composerInsertion ?? null
const queuedUserMessages = sessionState?.queuedUserMessages ?? []
const runtimeSelection = useSessionRuntimeStore((state) =>
activeTabId ? state.selections[activeTabId] : undefined,
)
@ -219,6 +231,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
setSlashFilter('')
setAtFilter('')
setAtCursorPos(-1)
setEditingQueuedMessageId(null)
setEditingQueuedMessageText('')
previousActiveTabIdRef.current = activeTabId
}, [activeTabId, saveComposerDraft, setComposerAttachments, setComposerInput])
@ -677,10 +691,20 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
}
}
sendMessage(targetSessionId, contentForModel, [...uploadAttachmentPayload, ...workspaceAttachmentPayload], {
displayContent,
displayAttachments: visibleAttachmentPayload,
})
const targetChatState = useChatStore.getState().sessions[targetSessionId]?.chatState ?? 'idle'
if (!isMemberSession && targetChatState !== 'idle') {
queueUserMessage(targetSessionId, {
content: contentForModel,
attachments: [...uploadAttachmentPayload, ...workspaceAttachmentPayload],
displayContent,
displayAttachments: visibleAttachmentPayload,
})
} else {
sendMessage(targetSessionId, contentForModel, [...uploadAttachmentPayload, ...workspaceAttachmentPayload], {
displayContent,
displayAttachments: visibleAttachmentPayload,
})
}
setComposerInput('')
setComposerAttachments([])
useChatStore.getState().clearComposerDraft(activeTabId!)
@ -865,6 +889,25 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
if (activeTabId) removeWorkspaceReference(activeTabId, id)
}
const startEditingQueuedMessage = (messageId: string, content: string) => {
setEditingQueuedMessageId(messageId)
setEditingQueuedMessageText(content)
}
const saveQueuedMessageEdit = () => {
if (!activeTabId || !editingQueuedMessageId) return
const nextContent = editingQueuedMessageText.trim()
if (!nextContent) return
updateQueuedUserMessage(activeTabId, editingQueuedMessageId, nextContent)
setEditingQueuedMessageId(null)
setEditingQueuedMessageText('')
}
const cancelQueuedMessageEdit = () => {
setEditingQueuedMessageId(null)
setEditingQueuedMessageText('')
}
const insertSlashCommand = () => {
if (isMemberSession) return
const el = textareaRef.current
@ -1039,6 +1082,105 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
</div>
)}
{!isMemberSession && activeTabId && queuedUserMessages.length > 0 && (
<div
data-testid="pending-user-message-list"
className={[
'overflow-hidden border-b border-[var(--color-border-separator)]',
isHeroComposer ? '-mx-4 -mt-4' : useCompactControls ? '-mx-3 -mt-3' : '-mx-4 -mt-4',
].join(' ')}
>
{queuedUserMessages.map((message) => {
const isEditing = editingQueuedMessageId === message.id
return (
<div
key={message.id}
data-testid="pending-user-message"
className={[
'flex min-w-0 items-center gap-2 px-3 py-2 text-xs',
'border-t border-[var(--color-border-separator)] first:border-t-0',
'bg-[var(--color-surface-container-lowest)]/70 text-[var(--color-text-secondary)]',
].join(' ')}
>
<span className="material-symbols-outlined shrink-0 text-[16px] text-[var(--color-text-tertiary)]" aria-hidden="true">
subdirectory_arrow_right
</span>
{isEditing ? (
<>
<input
value={editingQueuedMessageText}
onChange={(event) => setEditingQueuedMessageText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
saveQueuedMessageEdit()
}
if (event.key === 'Escape') {
event.preventDefault()
cancelQueuedMessageEdit()
}
}}
aria-label={t('chat.pendingMessageEditInput')}
className="min-w-0 flex-1 rounded-[6px] border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
autoFocus
/>
<button
type="button"
onClick={saveQueuedMessageEdit}
disabled={!editingQueuedMessageText.trim()}
className="shrink-0 rounded-[6px] px-2 py-1 font-semibold text-[var(--color-brand)] hover:bg-[var(--color-surface-hover)] disabled:opacity-40"
>
{t('common.save')}
</button>
<button
type="button"
onClick={cancelQueuedMessageEdit}
className="shrink-0 rounded-[6px] px-2 py-1 font-medium text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)]"
>
{t('common.cancel')}
</button>
</>
) : (
<>
<span className="min-w-0 flex-1 truncate font-medium" title={message.displayContent}>
{message.displayContent}
</span>
<button
type="button"
onClick={() => sendQueuedUserMessage(activeTabId, message.id)}
aria-label={t('chat.pendingMessageGuideNow')}
title={t('chat.pendingMessageGuideNow')}
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-[6px] px-2 font-semibold text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<span className="material-symbols-outlined text-[15px]" aria-hidden="true">subdirectory_arrow_right</span>
<span>{t('chat.pendingMessageGuide')}</span>
</button>
<button
type="button"
onClick={() => startEditingQueuedMessage(message.id, message.displayContent)}
aria-label={t('chat.pendingMessageEdit')}
title={t('chat.pendingMessageEdit')}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<span className="material-symbols-outlined text-[15px]" aria-hidden="true">edit</span>
</button>
<button
type="button"
onClick={() => removeQueuedUserMessage(activeTabId, message.id)}
aria-label={t('chat.pendingMessageDelete')}
title={t('chat.pendingMessageDelete')}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-error)]"
>
<span className="material-symbols-outlined text-[15px]" aria-hidden="true">delete</span>
</button>
</>
)}
</div>
)
})}
</div>
)}
{composerAttachments.length > 0 && (
isHeroComposer ? (
<AttachmentGallery attachments={composerAttachments} variant="composer" onRemove={removeAttachment} />

View File

@ -1159,6 +1159,11 @@ export const en = {
'chat.userMessageReference': 'User message',
'chat.assistantMessageReference': 'Assistant message',
'chat.slashCommands': 'Slash commands',
'chat.pendingMessageGuide': 'Guide',
'chat.pendingMessageGuideNow': 'Guide now',
'chat.pendingMessageEdit': 'Edit queued message',
'chat.pendingMessageDelete': 'Delete queued message',
'chat.pendingMessageEditInput': 'Queued message text',
'chat.goalEvent.created': 'Goal set',
'chat.goalEvent.replaced': 'Goal set',
'chat.goalEvent.statusTitle': 'Goal status',

View File

@ -1161,6 +1161,11 @@ export const jp: Record<TranslationKey, string> = {
'chat.userMessageReference': 'ユーザーメッセージ',
'chat.assistantMessageReference': 'アシスタントメッセージ',
'chat.slashCommands': 'スラッシュコマンド',
'chat.pendingMessageGuide': '誘導',
'chat.pendingMessageGuideNow': '今すぐ誘導',
'chat.pendingMessageEdit': 'キュー中のメッセージを編集',
'chat.pendingMessageDelete': 'キュー中のメッセージを削除',
'chat.pendingMessageEditInput': 'キュー中のメッセージ本文',
'chat.goalEvent.created': 'ゴールを設定しました',
'chat.goalEvent.replaced': 'ゴールを設定しました',
'chat.goalEvent.statusTitle': 'ゴールのステータス',

View File

@ -1161,6 +1161,11 @@ export const kr: Record<TranslationKey, string> = {
'chat.userMessageReference': '사용자 메시지',
'chat.assistantMessageReference': '어시스턴트 메시지',
'chat.slashCommands': '슬래시 명령',
'chat.pendingMessageGuide': '가이드',
'chat.pendingMessageGuideNow': '지금 가이드',
'chat.pendingMessageEdit': '대기 메시지 편집',
'chat.pendingMessageDelete': '대기 메시지 삭제',
'chat.pendingMessageEditInput': '대기 메시지 텍스트',
'chat.goalEvent.created': '목표 설정됨',
'chat.goalEvent.replaced': '목표 설정됨',
'chat.goalEvent.statusTitle': '목표 상태',

View File

@ -1161,6 +1161,11 @@ export const zh: Record<TranslationKey, string> = {
'chat.userMessageReference': '使用者訊息',
'chat.assistantMessageReference': 'AI 回覆',
'chat.slashCommands': '斜槓命令',
'chat.pendingMessageGuide': '引導',
'chat.pendingMessageGuideNow': '立即引導',
'chat.pendingMessageEdit': '編輯排隊訊息',
'chat.pendingMessageDelete': '刪除排隊訊息',
'chat.pendingMessageEditInput': '排隊訊息文字',
'chat.goalEvent.created': '目標已設定',
'chat.goalEvent.replaced': '目標已設定',
'chat.goalEvent.statusTitle': '目標狀態',

View File

@ -1161,6 +1161,11 @@ export const zh: Record<TranslationKey, string> = {
'chat.userMessageReference': '用户消息',
'chat.assistantMessageReference': 'AI 回复',
'chat.slashCommands': '斜杠命令',
'chat.pendingMessageGuide': '引导',
'chat.pendingMessageGuideNow': '立即引导',
'chat.pendingMessageEdit': '编辑排队消息',
'chat.pendingMessageDelete': '删除排队消息',
'chat.pendingMessageEditInput': '排队消息文本',
'chat.goalEvent.created': '目标已设置',
'chat.goalEvent.replaced': '目标已设置',
'chat.goalEvent.statusTitle': '目标状态',

View File

@ -1363,6 +1363,56 @@ describe('chatStore history mapping', () => {
)
})
it('keeps queued message model context when editing the visible prompt text', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
chatState: 'streaming',
}),
},
})
const id = useChatStore.getState().queueUserMessage(TEST_SESSION_ID, {
content: 'Referenced workspace context:\n@"src/App.tsx:L4":\n```tsx\nconst value = 1\n```\n\nfix this',
attachments: [{
type: 'file',
name: 'App.tsx',
path: '/repo/src/App.tsx',
lineStart: 4,
lineEnd: 4,
}],
displayContent: 'fix this',
displayAttachments: [{
type: 'file',
name: 'App.tsx',
path: 'src/App.tsx',
lineStart: 4,
lineEnd: 4,
}],
})
useChatStore.getState().updateQueuedUserMessage(TEST_SESSION_ID, id, 'tighten this')
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.queuedUserMessages?.[0]).toMatchObject({
displayContent: 'tighten this',
content: 'Referenced workspace context:\n@"src/App.tsx:L4":\n```tsx\nconst value = 1\n```\n\ntighten this',
})
useChatStore.getState().sendQueuedUserMessage(TEST_SESSION_ID, id)
expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, {
type: 'user_message',
content: 'Referenced workspace context:\n@"src/App.tsx:L4":\n```tsx\nconst value = 1\n```\n\ntighten this',
attachments: [{
type: 'file',
name: 'App.tsx',
path: '/repo/src/App.tsx',
lineStart: 4,
lineEnd: 4,
}],
})
})
it('can send a visual selection turn without rendering the full model prompt as user text', () => {
useChatStore.setState({
sessions: {

View File

@ -42,6 +42,15 @@ export type ComposerDraftState = {
attachments: ComposerAttachment[]
}
export type QueuedUserMessage = {
id: string
content: string
attachments?: AttachmentRef[]
displayContent: string
displayAttachments?: AttachmentRef[]
createdAt: number
}
export type ComposerReferenceInsertion = {
text: string
reference?: {
@ -101,6 +110,7 @@ export type PerSessionState = {
} | null
composerInsertion?: ComposerReferenceInsertion | null
composerDraft?: ComposerDraftState | null
queuedUserMessages?: QueuedUserMessage[]
}
const DEFAULT_SESSION_STATE: PerSessionState = {
@ -129,10 +139,16 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
composerPrefill: null,
composerInsertion: null,
composerDraft: null,
queuedUserMessages: [],
}
function createDefaultSessionState(): PerSessionState {
return { ...DEFAULT_SESSION_STATE, messages: [], tokenUsage: { input_tokens: 0, output_tokens: 0 } }
return {
...DEFAULT_SESSION_STATE,
messages: [],
tokenUsage: { input_tokens: 0, output_tokens: 0 },
queuedUserMessages: [],
}
}
type ChatStore = {
@ -180,6 +196,13 @@ type ChatStore = {
clearComposerInsertion: (sessionId: string, nonce?: number) => void
setComposerDraft: (sessionId: string, draft: ComposerDraftState) => void
clearComposerDraft: (sessionId: string) => void
queueUserMessage: (
sessionId: string,
message: Omit<QueuedUserMessage, 'id' | 'createdAt'>,
) => string
updateQueuedUserMessage: (sessionId: string, messageId: string, content: string) => void
removeQueuedUserMessage: (sessionId: string, messageId: string) => void
sendQueuedUserMessage: (sessionId: string, messageId: string) => void
clearMessages: (sessionId: string) => void
handleServerMessage: (sessionId: string, msg: ServerMessage) => void
}
@ -839,6 +862,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
messages: existing?.messages ?? [],
activeGoal: existing?.activeGoal ?? null,
composerDraft: existing?.composerDraft ?? null,
queuedUserMessages: existing?.queuedUserMessages ?? [],
},
},
}))
@ -1279,6 +1303,82 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}))
},
queueUserMessage: (sessionId, message) => {
const id = `queued-user-${Date.now()}-${Math.random().toString(36).slice(2)}`
set((state) => {
const session = state.sessions[sessionId] ?? createDefaultSessionState()
return {
sessions: {
...state.sessions,
[sessionId]: {
...session,
queuedUserMessages: [
...(session.queuedUserMessages ?? []),
{
...message,
id,
createdAt: Date.now(),
},
],
},
},
}
})
return id
},
updateQueuedUserMessage: (sessionId, messageId, content) => {
const nextContent = content.trim()
if (!nextContent) return
set((state) => ({
sessions: updateSessionIn(state.sessions, sessionId, (session) => ({
queuedUserMessages: (session.queuedUserMessages ?? []).map((message) =>
message.id === messageId
? {
...message,
content: replaceQueuedMessageDisplayContent(message, nextContent),
displayContent: nextContent,
}
: message),
})),
}))
},
removeQueuedUserMessage: (sessionId, messageId) => {
set((state) => ({
sessions: updateSessionIn(state.sessions, sessionId, (session) => ({
queuedUserMessages: (session.queuedUserMessages ?? []).filter((message) => message.id !== messageId),
})),
}))
},
sendQueuedUserMessage: (sessionId, messageId) => {
const session = get().sessions[sessionId]
const queuedMessage = (session?.queuedUserMessages ?? []).find((message) => message.id === messageId)
if (!session || !queuedMessage) return
get().removeQueuedUserMessage(sessionId, messageId)
if (session.chatState === 'idle') {
get().sendMessage(
sessionId,
queuedMessage.content,
queuedMessage.attachments,
{
displayContent: queuedMessage.displayContent,
displayAttachments: queuedMessage.displayAttachments,
},
)
return
}
wsManager.send(sessionId, {
type: 'user_message',
content: queuedMessage.content,
attachments: queuedMessage.attachments,
})
},
clearMessages: (sessionId) => {
clearPendingTaskToolUseIds(sessionId)
clearPendingToolParentUseIds(sessionId)
@ -1289,6 +1389,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
streamingText: '',
chatState: 'idle',
apiRetry: null,
queuedUserMessages: [],
})) }))
},
@ -1690,6 +1791,24 @@ export const useChatStore = create<ChatStore>((set, get) => ({
})
}
refreshCompletedTranscriptHistory(get, sessionId)
for (const queuedMessage of get().sessions[sessionId]?.queuedUserMessages ?? []) {
get().sendQueuedUserMessage(sessionId, queuedMessage.id)
}
break
}
case 'user_message_replay': {
update((session) => {
const pendingText = `${session.streamingText}${consumePendingDelta(sessionId)}`
const baseMessages = pendingText.trim()
? appendAssistantTextMessage(session.messages, pendingText, Date.now())
: session.messages
return {
messages: appendReplayedUserMessage(baseMessages, msg.content, Date.now()),
...(pendingText.trim() ? { streamingText: '' } : {}),
activeThinkingId: null,
}
})
break
}
@ -2570,6 +2689,57 @@ function extractLeadingFileReferences(text: string): {
}
}
function appendReplayedUserMessage(
messages: UIMessage[],
content: string,
timestamp: number,
): UIMessage[] {
const parsed = extractLeadingFileReferences(content)
const displayContent = parsed.content.trim() || content.trim()
if (!displayContent) return messages
const modelContent = parsed.modelContent ?? content.trim()
const last = messages[messages.length - 1]
if (
last?.type === 'user_text' &&
(last.modelContent ?? last.content).trim() === modelContent
) {
return messages
}
return [
...messages,
{
id: nextId(),
type: 'user_text',
content: displayContent,
...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}),
...(parsed.attachments ? { attachments: parsed.attachments } : {}),
timestamp,
},
]
}
function replaceQueuedMessageDisplayContent(
message: QueuedUserMessage,
nextDisplayContent: string,
): string {
const currentModelContent = message.content.trim()
const currentDisplayContent = message.displayContent.trim()
if (!currentModelContent) return nextDisplayContent
if (!currentDisplayContent) return `${currentModelContent}\n\n${nextDisplayContent}`
if (currentModelContent === currentDisplayContent) return nextDisplayContent
const displaySuffix = `\n\n${currentDisplayContent}`
if (currentModelContent.endsWith(displaySuffix)) {
return `${currentModelContent.slice(0, -currentDisplayContent.length)}${nextDisplayContent}`
}
if (currentModelContent.endsWith(currentDisplayContent)) {
return `${currentModelContent.slice(0, -currentDisplayContent.length)}${nextDisplayContent}`
}
return `${currentModelContent}\n\n${nextDisplayContent}`
}
/**
* Reconstruct agentTaskNotifications from history.
*

View File

@ -92,6 +92,7 @@ export type ServerMessage =
requestId: string
request: ComputerUsePermissionRequest
}
| { type: 'user_message_replay'; content: string }
| { type: 'message_complete'; usage: TokenUsage }
| { type: 'thinking'; text: string }
| { type: 'status'; state: ChatState; verb?: string }

View File

@ -93,6 +93,45 @@ describe('WebSocket AskUserQuestion events', () => {
})
})
describe('WebSocket queued user replay events', () => {
it('forwards ordinary queued user replays to the desktop client', () => {
expect(translateCliMessage({
type: 'user',
isReplay: true,
message: {
role: 'user',
content: 'please adjust the current direction',
},
}, 'session-1')).toEqual([
{
type: 'user_message_replay',
content: 'please adjust the current direction',
},
])
})
it('does not turn replayed tool results into user text messages', () => {
expect(translateCliMessage({
type: 'user',
isReplay: true,
message: {
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' },
],
},
}, 'session-1')).toEqual([
{
type: 'tool_result',
toolUseId: 'tool-1',
content: 'ok',
isError: false,
parentToolUseId: undefined,
},
])
})
})
describe('WebSocket compact events', () => {
it('forwards CLI compacting status to the desktop client', () => {
expect(translateCliMessage({

View File

@ -62,6 +62,7 @@ export type ServerMessage =
requestId: string
request: ComputerUsePermissionRequest
}
| { type: 'user_message_replay'; content: string }
| { type: 'message_complete'; usage: TokenUsage }
| { type: 'thinking'; text: string }
| { type: 'status'; state: ChatState; verb?: string }

View File

@ -1491,6 +1491,14 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
}
}
const replayText = extractReplayUserText(cliMsg)
if (replayText) {
messages.push({
type: 'user_message_replay',
content: replayText,
})
}
return messages
}
@ -2203,6 +2211,39 @@ function isCompactSummaryMessageContent(content: unknown): content is string {
)
}
function hasToolResultBlock(content: unknown): boolean {
return Array.isArray(content) &&
content.some((block) =>
Boolean(block) &&
typeof block === 'object' &&
(block as { type?: unknown }).type === 'tool_result')
}
function extractReplayUserText(cliMsg: any): string | null {
if (cliMsg?.isReplay !== true) return null
const content = cliMsg.message?.content
if (isCompactSummaryMessageContent(content)) return null
if (hasToolResultBlock(content)) return null
if (extractLocalCommandOutput(content)) return null
const text = typeof content === 'string'
? content
: Array.isArray(content)
? content
.flatMap((block) => {
if (!block || typeof block !== 'object') return []
const typedBlock = block as { type?: unknown; text?: unknown }
return typedBlock.type === 'text' && typeof typedBlock.text === 'string'
? [typedBlock.text]
: []
})
.join('\n')
: ''
const trimmed = text.trim()
return trimmed || null
}
function addActiveClient(
sessionId: string,
ws: ServerWebSocket<WebSocketData>,