mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
88d04a6267
commit
5cf8c1bb67
@ -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: [{
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': 'ゴールのステータス',
|
||||
|
||||
@ -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': '목표 상태',
|
||||
|
||||
@ -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': '目標狀態',
|
||||
|
||||
@ -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': '目标状态',
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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>,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user