From 7fdf776b50651188c44abcdb4e4ce12a023d48c2 Mon Sep 17 00:00:00 2001 From: Chris Scott Date: Sun, 7 Dec 2025 23:47:23 -0500 Subject: [PATCH 1/4] Add message queuing and retry handling with status indicators --- .../src/components/message/MessagePart.tsx | 5 ++ .../src/components/message/MessageThread.tsx | 69 ++++++++++--------- .../src/components/message/PromptInput.tsx | 54 +++++++++++---- .../src/components/message/ToolCallPart.tsx | 42 +++++++++-- frontend/src/hooks/useSSE.ts | 24 ++++++- 5 files changed, 142 insertions(+), 52 deletions(-) diff --git a/frontend/src/components/message/MessagePart.tsx b/frontend/src/components/message/MessagePart.tsx index aaf68ff3..4522a4b4 100644 --- a/frontend/src/components/message/MessagePart.tsx +++ b/frontend/src/components/message/MessagePart.tsx @@ -4,9 +4,12 @@ import { Volume2, Square, Loader2 } from 'lucide-react' import { TextPart } from './TextPart' import { PatchPart } from './PatchPart' import { ToolCallPart } from './ToolCallPart' +import { RetryPart } from './RetryPart' import { useTTS } from '@/hooks/useTTS' import { CopyButton } from '@/components/ui/copy-button' +type RetryPartType = components['schemas']['RetryPart'] + type Part = components['schemas']['Part'] interface MessagePartProps { @@ -152,6 +155,8 @@ export const MessagePart = memo(function MessagePart({ part, role, allParts, par {part.filename || 'File'} ) + case 'retry': + return default: return } diff --git a/frontend/src/components/message/MessageThread.tsx b/frontend/src/components/message/MessageThread.tsx index 1ca1792e..391dab9a 100644 --- a/frontend/src/components/message/MessageThread.tsx +++ b/frontend/src/components/message/MessageThread.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react' +import { memo, useMemo } from 'react' import { MessagePart } from './MessagePart' import type { MessageWithParts } from '@/api/types' @@ -24,12 +24,24 @@ const isMessageStreaming = (msg: MessageWithParts): boolean => { return !('completed' in msg.info.time && msg.info.time.completed) } -const isMessageThinking = (msg: MessageWithParts): boolean => { - if (msg.info.role !== 'assistant') return false - return msg.parts.length === 0 && isMessageStreaming(msg) + + +const findPendingAssistantMessageId = (messages: MessageWithParts[]): string | undefined => { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.info.role === 'assistant' && isMessageStreaming(msg)) { + return msg.info.id + } + } + return undefined } export const MessageThread = memo(function MessageThread({ messages, onFileClick, onChildSessionClick }: MessageThreadProps) { + const pendingAssistantId = useMemo(() => { + if (!messages) return undefined + return findPendingAssistantMessageId(messages) + }, [messages]) + if (!messages || messages.length === 0) { return (
@@ -42,7 +54,7 @@ export const MessageThread = memo(function MessageThread({ messages, onFileClick
{messages.map((msg) => { const streaming = isMessageStreaming(msg) - const thinking = isMessageThinking(msg) + const isQueued = msg.info.role === 'user' && pendingAssistantId && msg.info.id > pendingAssistantId return (
@@ -65,35 +79,28 @@ export const MessageThread = memo(function MessageThread({ messages, onFileClick {new Date(msg.info.time.created).toLocaleTimeString()} )} - {streaming && ( - - Generating... + {isQueued && ( + + QUEUED )}
- {thinking ? ( -
- - Thinking... -
- ) : ( -
- {msg.parts.map((part, index) => ( -
- -
- ))} -
- )} +
+ {msg.parts.map((part, index) => ( +
+ +
+ ))} +
) diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 277646c7..77d65c04 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -11,6 +11,7 @@ import { ChevronDown } from 'lucide-react' import { CommandSuggestions } from '@/components/command/CommandSuggestions' import { MentionSuggestions, type MentionItem } from './MentionSuggestions' +import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' import { detectMentionTrigger, parsePromptToParts, getFilename, filterAgentsByQuery } from '@/lib/promptParser' import { getModel, formatModelName } from '@/api/providers' import type { components } from '@/api/opencode-types' @@ -119,6 +120,23 @@ export function PromptInput({ const handleSubmit = () => { if (!prompt.trim() || disabled) return + + if (hasActiveStream) { + const parts = parsePromptToParts(prompt, attachedFiles) + sendPrompt.mutate({ + sessionID, + parts, + model: currentModel, + agent: selectedAgent || currentMode + }) + setPrompt('') + setAttachedFiles(new Map()) + setSelectedAgent(null) + if (textareaRef.current) { + textareaRef.current.style.height = 'auto' + } + return + } if (isBashMode) { const command = prompt.startsWith('!') ? prompt.slice(1) : prompt @@ -454,16 +472,20 @@ export function PromptInput({ }, [selectedModel, currentModel]) useEffect(() => { - if (textareaRef.current && !disabled && !hasActiveStream) { + if (textareaRef.current && !disabled) { textareaRef.current.focus() } - }, [disabled, hasActiveStream]) + }, [disabled]) return (
- + {hasActiveStream && ( +
+ +
+ )}