Skip to content

Commit e47c9e7

Browse files
Merge pull request #15 from chriswritescode-dev/feature/pending-message-handling
v0.3.6 - Message queuing and retry handling
2 parents 7474256 + 8d3a6bb commit e47c9e7

13 files changed

Lines changed: 399 additions & 65 deletions

File tree

frontend/src/components/file-browser/FilePreview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
300300
)}
301301

302302
{showSaveButton && (
303-
<Button variant="outline" size="sm" onClick={(e) => { e.stopPropagation(); e.preventDefault(); shouldVirtualize ? handleVirtualizedSaveClick() : handleSave() }} disabled={isSaving || (shouldVirtualize && !hasVirtualizedChanges)} className="border-green-600 bg-green-600/10 text-green-600 hover:bg-green-600/20 h-7 w-7 p-0">
303+
<Button variant="outline" size="sm" onClick={(e) => { e.stopPropagation(); e.preventDefault(); if (shouldVirtualize) { handleVirtualizedSaveClick(); } else { handleSave(); } }} disabled={isSaving || (shouldVirtualize && !hasVirtualizedChanges)} className="border-green-600 bg-green-600/10 text-green-600 hover:bg-green-600/20 h-7 w-7 p-0">
304304
<Save className="w-3 h-3" />
305305
</Button>
306306
)}

frontend/src/components/message/MessagePart.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import { Volume2, Square, Loader2 } from 'lucide-react'
44
import { TextPart } from './TextPart'
55
import { PatchPart } from './PatchPart'
66
import { ToolCallPart } from './ToolCallPart'
7+
import { RetryPart } from './RetryPart'
78
import { useTTS } from '@/hooks/useTTS'
89
import { CopyButton } from '@/components/ui/copy-button'
910

11+
type RetryPartType = components['schemas']['RetryPart']
12+
1013
type Part = components['schemas']['Part']
1114

1215
interface MessagePartProps {
@@ -152,6 +155,8 @@ export const MessagePart = memo(function MessagePart({ part, role, allParts, par
152155
<span className="font-medium">{part.filename || 'File'}</span>
153156
</span>
154157
)
158+
case 'retry':
159+
return <RetryPart part={part as RetryPartType} />
155160
default:
156161
return
157162
}

frontend/src/components/message/MessageThread.tsx

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo } from 'react'
1+
import { memo, useMemo } from 'react'
22
import { MessagePart } from './MessagePart'
33
import type { MessageWithParts } from '@/api/types'
44

@@ -24,12 +24,24 @@ const isMessageStreaming = (msg: MessageWithParts): boolean => {
2424
return !('completed' in msg.info.time && msg.info.time.completed)
2525
}
2626

27-
const isMessageThinking = (msg: MessageWithParts): boolean => {
28-
if (msg.info.role !== 'assistant') return false
29-
return msg.parts.length === 0 && isMessageStreaming(msg)
27+
28+
29+
const findPendingAssistantMessageId = (messages: MessageWithParts[]): string | undefined => {
30+
for (let i = messages.length - 1; i >= 0; i--) {
31+
const msg = messages[i]
32+
if (msg.info.role === 'assistant' && isMessageStreaming(msg)) {
33+
return msg.info.id
34+
}
35+
}
36+
return undefined
3037
}
3138

3239
export const MessageThread = memo(function MessageThread({ messages, onFileClick, onChildSessionClick }: MessageThreadProps) {
40+
const pendingAssistantId = useMemo(() => {
41+
if (!messages) return undefined
42+
return findPendingAssistantMessageId(messages)
43+
}, [messages])
44+
3345
if (!messages || messages.length === 0) {
3446
return (
3547
<div className="flex items-center justify-center h-full text-muted-foreground">
@@ -42,7 +54,7 @@ export const MessageThread = memo(function MessageThread({ messages, onFileClick
4254
<div className="flex flex-col space-y-2 p-2 overflow-x-hidden">
4355
{messages.map((msg) => {
4456
const streaming = isMessageStreaming(msg)
45-
const thinking = isMessageThinking(msg)
57+
const isQueued = msg.info.role === 'user' && pendingAssistantId && msg.info.id > pendingAssistantId
4658

4759
return (
4860
<div
@@ -52,7 +64,9 @@ export const MessageThread = memo(function MessageThread({ messages, onFileClick
5264
<div
5365
className={`w-full rounded-lg p-1.5 ${
5466
msg.info.role === 'user'
55-
? 'bg-blue-600/20 border border-blue-600/30'
67+
? isQueued
68+
? 'bg-amber-500/10 border border-amber-500/30'
69+
: 'bg-blue-600/20 border border-blue-600/30'
5670
: 'bg-card/50 border border-border'
5771
} ${streaming ? 'animate-pulse-subtle' : ''}`}
5872
>
@@ -65,35 +79,28 @@ export const MessageThread = memo(function MessageThread({ messages, onFileClick
6579
{new Date(msg.info.time.created).toLocaleTimeString()}
6680
</span>
6781
)}
68-
{streaming && (
69-
<span className="text-xs text-blue-600 dark:text-blue-400 flex items-center gap-1">
70-
<span className="animate-pulse"></span> <span className="shine-loading">Generating...</span>
82+
{isQueued && (
83+
<span className="text-xs font-semibold bg-amber-500 text-amber-950 px-1.5 py-0.5 rounded">
84+
QUEUED
7185
</span>
7286
)}
7387
</div>
7488

75-
{thinking ? (
76-
<div className="flex items-center gap-2 text-muted-foreground">
77-
<span className="animate-pulse"></span>
78-
<span className="text-sm shine-loading">Thinking...</span>
79-
</div>
80-
) : (
81-
<div className="space-y-2">
82-
{msg.parts.map((part, index) => (
83-
<div key={`${msg.info.id}-${part.id}-${index}`}>
84-
<MessagePart
85-
part={part}
86-
role={msg.info.role}
87-
allParts={msg.parts}
88-
partIndex={index}
89-
onFileClick={onFileClick}
90-
onChildSessionClick={onChildSessionClick}
91-
messageTextContent={msg.info.role === 'assistant' ? getMessageTextContent(msg) : undefined}
92-
/>
93-
</div>
94-
))}
95-
</div>
96-
)}
89+
<div className="space-y-2">
90+
{msg.parts.map((part, index) => (
91+
<div key={`${msg.info.id}-${part.id}-${index}`}>
92+
<MessagePart
93+
part={part}
94+
role={msg.info.role}
95+
allParts={msg.parts}
96+
partIndex={index}
97+
onFileClick={onFileClick}
98+
onChildSessionClick={onChildSessionClick}
99+
messageTextContent={msg.info.role === 'assistant' ? getMessageTextContent(msg) : undefined}
100+
/>
101+
</div>
102+
))}
103+
</div>
97104
</div>
98105
</div>
99106
)

frontend/src/components/message/PromptInput.tsx

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ChevronDown } from 'lucide-react'
1111

1212
import { CommandSuggestions } from '@/components/command/CommandSuggestions'
1313
import { MentionSuggestions, type MentionItem } from './MentionSuggestions'
14+
import { SessionStatusIndicator } from '@/components/ui/session-status-indicator'
1415
import { detectMentionTrigger, parsePromptToParts, getFilename, filterAgentsByQuery } from '@/lib/promptParser'
1516
import { getModel, formatModelName } from '@/api/providers'
1617
import type { components } from '@/api/opencode-types'
@@ -119,6 +120,23 @@ export function PromptInput({
119120

120121
const handleSubmit = () => {
121122
if (!prompt.trim() || disabled) return
123+
124+
if (hasActiveStream) {
125+
const parts = parsePromptToParts(prompt, attachedFiles)
126+
sendPrompt.mutate({
127+
sessionID,
128+
parts,
129+
model: currentModel,
130+
agent: selectedAgent || currentMode
131+
})
132+
setPrompt('')
133+
setAttachedFiles(new Map())
134+
setSelectedAgent(null)
135+
if (textareaRef.current) {
136+
textareaRef.current.style.height = 'auto'
137+
}
138+
return
139+
}
122140

123141
if (isBashMode) {
124142
const command = prompt.startsWith('!') ? prompt.slice(1) : prompt
@@ -454,16 +472,20 @@ export function PromptInput({
454472
}, [selectedModel, currentModel])
455473

456474
useEffect(() => {
457-
if (textareaRef.current && !disabled && !hasActiveStream) {
475+
if (textareaRef.current && !disabled) {
458476
textareaRef.current.focus()
459477
}
460-
}, [disabled, hasActiveStream])
478+
}, [disabled])
461479

462480

463481

464482
return (
465483
<div className="relative backdrop-blur-md bg-background opacity-95 border border-border rounded-xl p-2 md:p-3 mx-2 md:mx-4 mb-2 md:mb-5 w-[90%] md:max-w-4xl">
466-
484+
{hasActiveStream && (
485+
<div className="mb-2">
486+
<SessionStatusIndicator sessionID={sessionID} />
487+
</div>
488+
)}
467489

468490
<textarea
469491
ref={textareaRef}
@@ -475,7 +497,7 @@ export function PromptInput({
475497
? "Enter bash command..."
476498
: "Send a message..."
477499
}
478-
disabled={disabled || hasActiveStream}
500+
disabled={disabled}
479501
className={`w-full bg-background/90 px-2 md:px-3 py-2 text-[16px] text-foreground placeholder-muted-foreground focus:outline-none focus:bg-background resize-none min-h-[40px] max-h-[120px] disabled:opacity-50 disabled:cursor-not-allowed md:text-sm rounded-lg ${
480502
isBashMode
481503
? 'border-purple-500/50 bg-purple-500/5 focus:bg-background'
@@ -537,18 +559,24 @@ export function PromptInput({
537559
<ChevronDown className="w-5 h-5" />
538560
</button>
539561
)}
562+
{hasActiveStream && (
563+
<button
564+
onClick={handleStop}
565+
disabled={disabled}
566+
className="px-3 md:px-4 py-1.5 rounded-lg text-sm font-medium transition-colors bg-destructive hover:bg-destructive/90 text-destructive-foreground"
567+
title="Stop"
568+
>
569+
Stop
570+
</button>
571+
)}
540572
<button
541573
data-submit-prompt
542-
onClick={hasActiveStream ? handleStop : handleSubmit}
543-
disabled={(!prompt.trim() && !hasActiveStream) || disabled}
544-
className={`px-5 md:px-6 py-1.5 rounded-lg text-sm font-medium transition-colors ${
545-
hasActiveStream
546-
? 'bg-destructive hover:bg-destructive/90 text-destructive-foreground'
547-
: 'bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed text-primary-foreground'
548-
}`}
549-
title={hasActiveStream ? 'Stop' : 'Send'}
574+
onClick={handleSubmit}
575+
disabled={!prompt.trim() || disabled}
576+
className="px-5 md:px-6 py-1.5 rounded-lg text-sm font-medium transition-colors bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed text-primary-foreground"
577+
title={hasActiveStream ? 'Queue message' : 'Send'}
550578
>
551-
{hasActiveStream ? 'Stop' : 'Send'}
579+
{hasActiveStream ? 'Queue' : 'Send'}
552580
</button>
553581
</div>
554582
</div>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { memo, useState, useEffect } from 'react'
2+
import type { components } from '@/api/opencode-types'
3+
import { RefreshCw, AlertTriangle } from 'lucide-react'
4+
5+
type RetryPartType = components['schemas']['RetryPart']
6+
7+
interface RetryPartProps {
8+
part: RetryPartType
9+
}
10+
11+
export const RetryPart = memo(function RetryPart({ part }: RetryPartProps) {
12+
const [countdown, setCountdown] = useState(5)
13+
14+
useEffect(() => {
15+
if (countdown <= 0) return
16+
17+
const timer = setInterval(() => {
18+
setCountdown(prev => Math.max(0, prev - 1))
19+
}, 1000)
20+
21+
return () => clearInterval(timer)
22+
}, [countdown])
23+
24+
const errorMessage = part.error?.data?.message || 'An error occurred'
25+
26+
return (
27+
<div className="flex items-center gap-3 p-3 my-2 rounded-lg bg-amber-500/10 border border-amber-500/30">
28+
<div className="flex-shrink-0">
29+
<div className="relative">
30+
<RefreshCw className="w-5 h-5 text-amber-500 animate-spin" style={{ animationDuration: '2s' }} />
31+
<AlertTriangle className="w-3 h-3 text-amber-600 absolute -bottom-0.5 -right-0.5" />
32+
</div>
33+
</div>
34+
<div className="flex-1 min-w-0">
35+
<div className="flex items-center gap-2">
36+
<span className="text-sm font-medium text-amber-600 dark:text-amber-400">
37+
Retry attempt {part.attempt}
38+
</span>
39+
{countdown > 0 && (
40+
<span className="text-xs text-amber-500/80">
41+
(retrying in {countdown}s)
42+
</span>
43+
)}
44+
</div>
45+
<p className="text-xs text-muted-foreground truncate mt-0.5">
46+
{errorMessage}
47+
</p>
48+
</div>
49+
</div>
50+
)
51+
})

frontend/src/components/message/TextPart.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ function CodeBlock({ children, className, ...props }: CodeBlockProps) {
2727
if (typeof node === 'number') return node.toString()
2828
if (Array.isArray(node)) return node.map(extractTextContent).join('')
2929
if (React.isValidElement(node)) {
30-
const element = node as React.ReactElement<any, any>
30+
const element = node as React.ReactElement<Record<string, unknown>>
3131
if (element.props.children) {
3232
return extractTextContent(element.props.children as React.ReactNode)
3333
}

frontend/src/components/message/ToolCallPart.tsx

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { components } from '@/api/opencode-types'
33
import { useSettings } from '@/hooks/useSettings'
44
import { useUserBash } from '@/stores/userBashStore'
55
import { detectFileReferences } from '@/lib/fileReferences'
6-
import { ExternalLink } from 'lucide-react'
6+
import { ExternalLink, Loader2 } from 'lucide-react'
77
import { CopyButton } from '@/components/ui/copy-button'
88

99
type ToolPart = components['schemas']['ToolPart']
@@ -87,13 +87,15 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal
8787
const getStatusIcon = () => {
8888
switch (part.state.status) {
8989
case 'completed':
90-
return '✓'
90+
return <span></span>
9191
case 'error':
92-
return '✗'
92+
return <span></span>
9393
case 'running':
94-
return '⟳'
94+
return <Loader2 className="w-3.5 h-3.5 animate-spin" />
95+
case 'pending':
96+
return <span className="inline-block w-2 h-2 rounded-full bg-current animate-pulse" />
9597
default:
96-
return '○'
98+
return <span></span>
9799
}
98100
}
99101

@@ -150,8 +152,23 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal
150152
)
151153
}
152154

155+
const getBorderStyle = () => {
156+
switch (part.state.status) {
157+
case 'running':
158+
return 'border-yellow-500/50 shadow-sm shadow-yellow-500/10'
159+
case 'pending':
160+
return 'border-blue-500/30'
161+
case 'error':
162+
return 'border-red-500/30'
163+
case 'completed':
164+
return 'border-border'
165+
default:
166+
return 'border-border'
167+
}
168+
}
169+
153170
return (
154-
<div className="border border-border rounded-lg overflow-hidden my-2">
171+
<div className={`border rounded-lg overflow-hidden my-2 transition-all ${getBorderStyle()}`}>
155172
<button
156173
onClick={() => setExpanded(!expanded)}
157174
className="w-full px-4 py-2 bg-card hover:bg-card-hover text-left flex items-center gap-2 text-sm min-w-0"
@@ -194,7 +211,18 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal
194211
</button>
195212

196213
{expanded && (
197-
<div className="bg-card space-y-2">
214+
<div className="bg-card space-y-2 p-3">
215+
{part.state.status === 'pending' && (
216+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
217+
<div className="flex gap-0.5">
218+
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-bounce" style={{ animationDelay: '0ms' }} />
219+
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-bounce" style={{ animationDelay: '150ms' }} />
220+
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-bounce" style={{ animationDelay: '300ms' }} />
221+
</div>
222+
<span>Preparing tool call...</span>
223+
</div>
224+
)}
225+
198226
{part.state.status === 'running' && (
199227
<div className="text-sm">
200228
<div className="text-zinc-400 mb-1">Input:</div>

0 commit comments

Comments
 (0)