diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 95e34173..7bddf9e3 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -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() + + 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() + + 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() + + 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: [{ diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index a17c7d99..f200db2a 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -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(null) + const [editingQueuedMessageText, setEditingQueuedMessageText] = useState('') const composingRef = useRef(false) const textareaRef = useRef(null) const panelRef = useRef(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 )} + {!isMemberSession && activeTabId && queuedUserMessages.length > 0 && ( +
+ {queuedUserMessages.map((message) => { + const isEditing = editingQueuedMessageId === message.id + return ( +
+ + {isEditing ? ( + <> + 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 + /> + + + + ) : ( + <> + + {message.displayContent} + + + + + + )} +
+ ) + })} +
+ )} + {composerAttachments.length > 0 && ( isHeroComposer ? ( diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 34a820b6..5ee633d2 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index ffa6906a..dcf51b5e 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1161,6 +1161,11 @@ export const jp: Record = { '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': 'ゴールのステータス', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 5b914005..9ace308b 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1161,6 +1161,11 @@ export const kr: Record = { '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': '목표 상태', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index a208a343..c69a3249 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1161,6 +1161,11 @@ export const zh: Record = { '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': '目標狀態', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index a68a0a57..7c714b98 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1161,6 +1161,11 @@ export const zh: Record = { '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': '目标状态', diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 53998b03..3b99de6f 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -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: { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index a9cdb4ea..9abf540a 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -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, + ) => 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((set, get) => ({ messages: existing?.messages ?? [], activeGoal: existing?.activeGoal ?? null, composerDraft: existing?.composerDraft ?? null, + queuedUserMessages: existing?.queuedUserMessages ?? [], }, }, })) @@ -1279,6 +1303,82 @@ export const useChatStore = create((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((set, get) => ({ streamingText: '', chatState: 'idle', apiRetry: null, + queuedUserMessages: [], })) })) }, @@ -1690,6 +1791,24 @@ export const useChatStore = create((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. * diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index 80609f40..e3525458 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -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 } diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts index 93aae215..b022e462 100644 --- a/src/server/__tests__/ws-memory-events.test.ts +++ b/src/server/__tests__/ws-memory-events.test.ts @@ -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({ diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index 7f0e88d7..1b66c236 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -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 } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 758272d4..c0bdcde0 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -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,