Skip to content

Commit 8d93f2f

Browse files
fix: follow up prompt preservation changes (#292)
* fix: remove unused disabled prop, throttle SW update checks, precache icon * fix: retract transient send error and restored prompt once server confirms send When a long-running prompt request drops (gateway timeout or network error) while the agent is actually working, the send error banner and the restored prompt text were left in place. Tag send errors as 'network' and clear them when the server confirms session activity (message.updated / session.idle), also clearing the restored input text. Genuine server-reported session errors (kind 'session') are preserved. * style: restyle ErrorBanner with neutral card fill and destructive accent bar Replace the all-red destructive Alert variant (transparent background, full red text) with a neutral card-filled banner, a left destructive accent bar, red icon, foreground title, and muted body/detail text. Improves the send-error and git-error banners on the dark theme.
1 parent 71ba737 commit 8d93f2f

11 files changed

Lines changed: 215 additions & 31 deletions

File tree

frontend/plugins/sw-precache-manifest.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import type { Plugin } from "vite";
55

66
const SW_FILENAME = "sw.js";
77
const PRECACHE_DIR_PREFIXES = ["assets/"];
8-
const PRECACHE_ROOT_FILES = ["index.html", "manifest.json", "favicon.svg"];
8+
const PRECACHE_ROOT_FILES = [
9+
"index.html",
10+
"manifest.json",
11+
"favicon.svg",
12+
"icons/icon-192x192.png",
13+
];
914

1015
async function collectFiles(dir: string, base = dir): Promise<string[]> {
1116
const entries = await readdir(dir, { withFileTypes: true });

frontend/src/components/agent/AgentQuickSelect.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ interface AgentQuickSelectProps {
1717
currentAgent: string
1818
onAgentChange: (agent: string) => void
1919
isBashMode?: boolean
20-
disabled?: boolean
2120
}
2221

2322
interface AgentInfo {
@@ -55,7 +54,6 @@ export function AgentQuickSelect({
5554
currentAgent,
5655
onAgentChange,
5756
isBashMode = false,
58-
disabled = false,
5957
}: AgentQuickSelectProps) {
6058
const { data: agents = [] } = useAgents(opcodeUrl, directory)
6159

@@ -79,7 +77,6 @@ export function AgentQuickSelect({
7977
const buttonContent = (
8078
<button
8179
data-toggle-mode
82-
disabled={disabled}
8380
style={styleVars as React.CSSProperties}
8481
className="px-2 md:px-3.5 py-1 h-[36px] rounded-lg text-sm font-medium border min-w-[56px] max-w-[80px] md:max-w-[100px] flex-shrink-0 flex items-center justify-center transition-all duration-200 active:scale-95 hover:scale-105 shadow-md text-[var(--agent-color-light)] dark:text-[var(--agent-color-dark)] bg-[var(--agent-bg-light)] dark:bg-[var(--agent-bg-dark)] border-[var(--agent-border-light)] dark:border-[var(--agent-border-dark)] hover:bg-[var(--agent-bg-hover-light)] dark:hover:bg-[var(--agent-bg-hover-dark)] hover:border-[var(--agent-border-hover-light)] dark:hover:border-[var(--agent-border-hover-dark)] shadow-[var(--agent-shadow-light)] dark:shadow-[var(--agent-shadow-dark)] hover:shadow-[var(--agent-shadow-hover-light)] dark:hover:shadow-[var(--agent-shadow-hover-dark)]"
8582
>
@@ -89,7 +86,7 @@ export function AgentQuickSelect({
8986

9087
return (
9188
<DropdownMenu>
92-
<DropdownMenuTrigger asChild disabled={disabled}>
89+
<DropdownMenuTrigger asChild>
9390
{buttonContent}
9491
</DropdownMenuTrigger>
9592
<DropdownMenuContent align="start" className="w-64">

frontend/src/components/message/PromptInput.stt.test.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,6 @@ describe('PromptInput STT Gesture Tests', () => {
139139
directory: '/test',
140140
sessionID: 'test-session',
141141
repoId: 1,
142-
disabled: false,
143142
showScrollButton: false,
144143
isSessionActive: false,
145144
isStreamingResponse: false,
@@ -367,6 +366,43 @@ describe('PromptInput STT Gesture Tests', () => {
367366
})
368367
})
369368

369+
it('clears the restored prompt when the send error resolves', async () => {
370+
const queryClient = createTestQueryClient()
371+
mocks.useSendErrorStore.mockImplementation((selector) => selector({
372+
errors: {
373+
'test-session': {
374+
sessionID: 'test-session',
375+
title: 'Connection Failed',
376+
message: 'Could not connect.',
377+
failedPrompt: 'recovered prompt',
378+
kind: 'network',
379+
},
380+
},
381+
}))
382+
383+
const { rerender } = render(
384+
<QueryClientProvider client={queryClient}>
385+
<PromptInput {...defaultProps} />
386+
</QueryClientProvider>,
387+
)
388+
389+
await waitFor(() => {
390+
expect(screen.getByPlaceholderText('Send a message...')).toHaveValue('recovered prompt')
391+
})
392+
393+
mocks.useSendErrorStore.mockImplementation((selector) => selector({ errors: {} }))
394+
395+
rerender(
396+
<QueryClientProvider client={queryClient}>
397+
<PromptInput {...defaultProps} />
398+
</QueryClientProvider>,
399+
)
400+
401+
await waitFor(() => {
402+
expect(screen.getByPlaceholderText('Send a message...')).toHaveValue('')
403+
})
404+
})
405+
370406
it('keeps stop available while active with prompt content', async () => {
371407
render(
372408
<QueryClientProvider client={createTestQueryClient()}>

frontend/src/components/message/PromptInput.tsx

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ interface PromptInputProps {
6565
opcodeUrl: string
6666
directory?: string
6767
sessionID: string
68-
disabled?: boolean
6968
showScrollButton?: boolean
7069
isSessionActive?: boolean
7170
isStreamingResponse?: boolean
@@ -81,7 +80,6 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
8180
opcodeUrl,
8281
directory,
8382
sessionID,
84-
disabled,
8583
showScrollButton,
8684
isSessionActive = false,
8785
isStreamingResponse = false,
@@ -241,11 +239,20 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
241239
const restoredFailedPromptRef = useRef<string | null>(null)
242240

243241
useEffect(() => {
244-
if (!failedPrompt || restoredFailedPromptRef.current === failedPrompt) return
245-
restoredFailedPromptRef.current = failedPrompt
246-
if (promptRef.current) return
247-
setPrompt(failedPrompt)
248-
textareaRef.current?.focus()
242+
if (failedPrompt) {
243+
if (restoredFailedPromptRef.current === failedPrompt) return
244+
restoredFailedPromptRef.current = failedPrompt
245+
if (promptRef.current) return
246+
setPrompt(failedPrompt)
247+
textareaRef.current?.focus()
248+
return
249+
}
250+
if (restoredFailedPromptRef.current !== null) {
251+
if (promptRef.current === restoredFailedPromptRef.current) {
252+
setPrompt('')
253+
}
254+
restoredFailedPromptRef.current = null
255+
}
249256
}, [failedPrompt])
250257

251258
const mentionItems = useMemo((): MentionItem[] => {
@@ -275,7 +282,6 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
275282
const addUserBashCommand = useUserBash((s) => s.addUserBashCommand)
276283

277284
const handleSubmit = () => {
278-
if (disabled) return
279285
if (!prompt.trim() && imageAttachments.length === 0) return
280286

281287
pendingVoiceAutoSubmitRef.current = false
@@ -572,7 +578,7 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
572578
}
573579

574580
const handleVoicePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
575-
if ((event.pointerType === 'mouse' && event.button !== 0) || disabled || isProcessing || !isRecording) {
581+
if ((event.pointerType === 'mouse' && event.button !== 0) || isProcessing || !isRecording) {
576582
return
577583
}
578584

@@ -1196,7 +1202,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read)
11961202
<button
11971203
type="button"
11981204
onClick={handleVoiceClick}
1199-
disabled={disabled || isProcessing}
1205+
disabled={isProcessing}
12001206
className={buttonClassName}
12011207
title={voiceButtonTitle}
12021208
>
@@ -1245,7 +1251,6 @@ return (
12451251
{showStopButton && (
12461252
<button
12471253
onClick={handleStop}
1248-
disabled={disabled}
12491254
className="border fixed bottom-19 right-0 md:hidden z-50 p-3 rounded-xl transition-all duration-200 active:scale-95 hover:scale-105 bg-gradient-to-br from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 text-destructive-foreground border border-red-500/60 shadow-lg shadow-red-500/30"
12501255
title="Stop"
12511256
>
@@ -1267,7 +1272,6 @@ return (
12671272
? "Enter bash command..."
12681273
: "Send a message..."
12691274
}
1270-
disabled={disabled}
12711275
className={`w-full bg-muted/50 pl-2 md:pl-3 pr-3 py-2 text-[16px] text-foreground placeholder-muted-foreground focus:outline-none focus:bg-muted/70 resize-none min-h-[40px] max-h-[120px] disabled:opacity-50 disabled:cursor-not-allowed md:text-sm rounded-lg [field-sizing:content] ${
12721276
isBashMode
12731277
? 'border-purple-500/50 bg-purple-500/5 focus:bg-purple-500/10'
@@ -1324,7 +1328,6 @@ return (
13241328
currentAgent={currentMode}
13251329
onAgentChange={handleAgentChange}
13261330
isBashMode={isBashMode}
1327-
disabled={disabled}
13281331
/>
13291332
{isSessionActive ? (
13301333
<div className="px-2.5 py-1.5 md:px-3 md:py-2 rounded-lg text-xs md:text-sm font-medium text-muted-foreground max-w-[120px] md:max-w-[180px]">
@@ -1363,7 +1366,6 @@ return (
13631366
{showStopButton && (
13641367
<button
13651368
onClick={handleStop}
1366-
disabled={disabled}
13671369
className="hidden md:block p-1.5 px-5 md:p-2 md:px-6 rounded-lg transition-all duration-200 active:scale-95 hover:scale-105 bg-gradient-to-br from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 text-destructive-foreground border border-red-500/60 hover:border-red-400 shadow-md shadow-red-500/30 hover:shadow-red-500/40 ring-1 ring-red-500/20 hover:ring-red-500/30"
13681370
title="Stop"
13691371
>
@@ -1394,7 +1396,7 @@ return (
13941396
<button
13951397
data-submit-prompt
13961398
onClick={hasPendingPermissionForSession ? () => setShowDialog(true) : handleSubmit}
1397-
disabled={hasPendingPermissionForSession ? false : ((!prompt.trim() && imageAttachments.length === 0) || disabled || (isPromptSubmitPending && !isStreamingResponse))}
1399+
disabled={hasPendingPermissionForSession ? false : ((!prompt.trim() && imageAttachments.length === 0) || (isPromptSubmitPending && !isStreamingResponse))}
13981400
className={`px-4 md:px-5 py-1.5 md:py-2 rounded-lg text-sm font-medium transition-colors dark:border flex-shrink-0 min-w-[52px] ${
13991401
hasPendingPermissionForSession
14001402
? 'bg-orange-500 hover:bg-orange-600 border-orange-400 text-primary-foreground ring-orange-500/20'

frontend/src/components/ui/error-banner.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
22
import { Button } from '@/components/ui/button'
33
import { AlertCircle, X } from 'lucide-react'
4+
import { cn } from '@/lib/utils'
45

56
export interface ErrorBannerProps {
67
title?: string
@@ -12,29 +13,29 @@ export interface ErrorBannerProps {
1213

1314
export function ErrorBanner({ title, summary, detail, onDismiss, className }: ErrorBannerProps) {
1415
return (
15-
<Alert variant="destructive" className={className}>
16+
<Alert className={cn('bg-card border-border border-l-[3px] border-l-destructive', className)}>
1617
<div className="flex flex-col gap-2">
1718
<div className="flex items-start gap-2">
18-
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
19+
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0 text-red-400" />
1920
<div className="flex-1 min-w-0">
2021
{title && (
21-
<AlertTitle className="mb-1 font-medium leading-none tracking-tight">{title}</AlertTitle>
22+
<AlertTitle className="mb-1 font-medium leading-none tracking-tight text-foreground">{title}</AlertTitle>
2223
)}
23-
<AlertDescription className="text-sm">{summary}</AlertDescription>
24+
<AlertDescription className="text-sm text-muted-foreground">{summary}</AlertDescription>
2425
</div>
2526
{onDismiss && (
2627
<Button
2728
variant="ghost"
2829
size="sm"
29-
className="h-7 w-7 p-0 flex-shrink-0"
30+
className="h-7 w-7 p-0 flex-shrink-0 text-muted-foreground"
3031
onClick={onDismiss}
3132
>
3233
<X className="w-3.5 h-3.5" />
3334
</Button>
3435
)}
3536
</div>
3637
{detail && (
37-
<pre className="p-2 rounded border bg-destructive/5 border-destructive/20 text-xs font-mono overflow-auto max-h-32">
38+
<pre className="p-2 rounded border bg-background border-border text-xs font-mono text-muted-foreground overflow-auto max-h-32">
3839
{detail}
3940
</pre>
4041
)}

frontend/src/hooks/useOpenCode.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,7 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?:
506506
message: parsed.message,
507507
detail: error instanceof FetchError ? error.detail : undefined,
508508
failedPrompt: failedPrompt || undefined,
509+
kind: 'network',
509510
});
510511
},
511512
onSuccess: async (data, variables) => {
@@ -535,7 +536,6 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?:
535536
return [...old, { info: response.info, parts: response.parts }];
536537
},
537538
);
538-
539539
},
540540
});
541541
};

frontend/src/hooks/useSSE.test.tsx

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,11 +396,128 @@ describe('useSSE', () => {
396396
title: 'Error',
397397
message: 'Queued send failed',
398398
failedPrompt: 'queued message',
399+
kind: 'session',
399400
})
400401

401402
unmount()
402403
})
403404

405+
it('retracts a network send error when the server confirms session activity', async () => {
406+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
407+
const wrapper = ({ children }: { children: ReactNode }) => (
408+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
409+
)
410+
411+
useSendErrorStore.getState().setError({
412+
sessionID: 'session-1',
413+
title: 'Connection Failed',
414+
message: 'Could not connect to the server.',
415+
failedPrompt: 'in-flight prompt',
416+
kind: 'network',
417+
})
418+
419+
const { result, unmount } = renderHook(
420+
() => useSSE('http://localhost:5551', '/repo', 'session-1'),
421+
{ wrapper },
422+
)
423+
424+
await waitFor(() => expect(MockEventSource.instances).toHaveLength(1))
425+
act(() => {
426+
MockEventSource.instances[0].emit('connected', { clientId: 'client-1' })
427+
})
428+
await waitFor(() => expect(result.current.isConnected).toBe(true))
429+
430+
act(() => {
431+
MockEventSource.instances[0].emit('message', {
432+
type: 'message.updated',
433+
properties: {
434+
info: { id: 'assistant-1', role: 'assistant', sessionID: 'session-1', time: { created: 1 } },
435+
},
436+
})
437+
})
438+
439+
expect(useSendErrorStore.getState().getError('session-1')).toBeNull()
440+
441+
unmount()
442+
})
443+
444+
it('retracts a network send error when the session goes idle', async () => {
445+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
446+
const wrapper = ({ children }: { children: ReactNode }) => (
447+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
448+
)
449+
450+
useSendErrorStore.getState().setError({
451+
sessionID: 'session-1',
452+
title: 'Request Timeout',
453+
message: 'The request took too long.',
454+
kind: 'network',
455+
})
456+
457+
const { result, unmount } = renderHook(
458+
() => useSSE('http://localhost:5551', '/repo', 'session-1'),
459+
{ wrapper },
460+
)
461+
462+
await waitFor(() => expect(MockEventSource.instances).toHaveLength(1))
463+
act(() => {
464+
MockEventSource.instances[0].emit('connected', { clientId: 'client-1' })
465+
})
466+
await waitFor(() => expect(result.current.isConnected).toBe(true))
467+
468+
act(() => {
469+
MockEventSource.instances[0].emit('message', {
470+
type: 'session.idle',
471+
properties: { sessionID: 'session-1' },
472+
})
473+
})
474+
475+
expect(useSendErrorStore.getState().getError('session-1')).toBeNull()
476+
477+
unmount()
478+
})
479+
480+
it('preserves a server-reported session error when the session later goes idle', async () => {
481+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
482+
const wrapper = ({ children }: { children: ReactNode }) => (
483+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
484+
)
485+
486+
useSendErrorStore.getState().setQueuedPrompt('session-1', 'queued message')
487+
488+
const { result, unmount } = renderHook(
489+
() => useSSE('http://localhost:5551', '/repo', 'session-1'),
490+
{ wrapper },
491+
)
492+
493+
await waitFor(() => expect(MockEventSource.instances).toHaveLength(1))
494+
act(() => {
495+
MockEventSource.instances[0].emit('connected', { clientId: 'client-1' })
496+
})
497+
await waitFor(() => expect(result.current.isConnected).toBe(true))
498+
499+
act(() => {
500+
MockEventSource.instances[0].emit('message', {
501+
type: 'session.error',
502+
properties: {
503+
sessionID: 'session-1',
504+
error: { name: 'UnknownError', data: { message: 'Queued send failed' } },
505+
},
506+
})
507+
})
508+
509+
act(() => {
510+
MockEventSource.instances[0].emit('message', {
511+
type: 'session.idle',
512+
properties: { sessionID: 'session-1' },
513+
})
514+
})
515+
516+
expect(useSendErrorStore.getState().getError('session-1')).not.toBeNull()
517+
518+
unmount()
519+
})
520+
404521
it('does not create a send error banner once the queued prompt has been cleared', async () => {
405522
const queryClient = new QueryClient({
406523
defaultOptions: {

0 commit comments

Comments
 (0)