Skip to content

Commit 10acfeb

Browse files
author
zjx
committed
feat: add retry button on last assistant message
1 parent e2ead25 commit 10acfeb

6 files changed

Lines changed: 89 additions & 26 deletions

File tree

src/components/chat/ChatView.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1433,11 +1433,16 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
14331433
// the panel to disappear (or update to the new run state)
14341434
// instead of staying frozen on the cancelled row.
14351435
onTaskRunAction={reconcileWithDb}
1436+
onRetryLastMessage={() => {
1437+
const lastUserMsg = findLastUserMessage();
1438+
if (lastUserMsg) sendMessageRef.current?.(lastUserMsg);
1439+
}}
14361440
/>
14371441
{/* End-of-turn terminal reason chip (only shown when stream is not active) */}
14381442
{!isStreaming && (
14391443
<TerminalReasonChip
14401444
reason={streamSnapshot?.terminalReason}
1445+
phase={streamSnapshot?.phase}
14411446
onAction={handleTerminalAction}
14421447
/>
14431448
)}

src/components/chat/MessageItem.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -413,10 +413,10 @@ function extractTruncatedWidget(fenceBody: string): ShowWidgetData | null {
413413
interface MessageItemProps {
414414
message: Message;
415415
sessionId?: string;
416-
/** Whether this is an assistant workspace project */
417416
isAssistantProject?: boolean;
418-
/** Assistant name for avatar */
419417
assistantName?: string;
418+
isLastAssistant?: boolean;
419+
onRetry?: () => void;
420420
}
421421

422422
interface ToolBlock {
@@ -618,7 +618,7 @@ function TokenUsageDisplay({ usage }: { usage: TokenUsage }) {
618618

619619
const COLLAPSE_HEIGHT = 300;
620620

621-
export const MessageItem = memo(function MessageItem({ message, sessionId, isAssistantProject, assistantName }: MessageItemProps) {
621+
export const MessageItem = memo(function MessageItem({ message, sessionId, isAssistantProject, assistantName, isLastAssistant, onRetry }: MessageItemProps) {
622622
const isUser = message.role === 'user';
623623

624624
// Collapse/expand state for long user messages (hooks must be called unconditionally)
@@ -843,10 +843,23 @@ export const MessageItem = memo(function MessageItem({ message, sessionId, isAss
843843
);
844844
})()}
845845

846-
{/* Footer with copy, timestamp and token usage */}
846+
{/* Footer with copy, retry, timestamp and token usage */}
847847
<div className={`flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ${isUser ? 'justify-end' : ''}`}>
848848
{!isUser && <span className="text-xs text-muted-foreground/50">{timestamp}</span>}
849849
{!isUser && tokenUsage && <TokenUsageDisplay usage={tokenUsage} />}
850+
{!isUser && isLastAssistant && onRetry && (
851+
<Button
852+
variant="ghost"
853+
size="sm"
854+
onClick={onRetry}
855+
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs text-muted-foreground/60 hover:text-muted-foreground h-auto"
856+
title="Retry"
857+
>
858+
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
859+
<path d="M13.65 2.35A8 8 0 1 0 15.94 9H13.9a6 6 0 1 1-1.63-5.27L10 6h6V0l-2.35 2.35z" fill="currentColor"/>
860+
</svg>
861+
</Button>
862+
)}
850863
{displayText && <CopyButton text={displayText} />}
851864
</div>
852865
</AIMessage>

src/components/chat/MessageList.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ interface MessageListProps {
229229
* and the panel never disappears even after the abandon PATCH lands.
230230
*/
231231
onTaskRunAction?: () => void;
232+
onRetryLastMessage?: () => void;
232233
}
233234

234235
export function MessageList({
@@ -251,6 +252,7 @@ export function MessageList({
251252
startedAt,
252253
isAssistantProject,
253254
assistantName,
255+
onRetryLastMessage,
254256
}: MessageListProps) {
255257
const { t } = useTranslation();
256258

@@ -328,6 +330,7 @@ export function MessageList({
328330
statusText={statusText}
329331
onForceStop={onForceStop}
330332
startedAt={startedAt}
333+
onRetryLastMessage={onRetryLastMessage}
331334
/>
332335
</ConversationContent>
333336
<ConversationScrollButton />
@@ -355,6 +358,7 @@ interface VirtualTranscriptProps {
355358
statusText?: string;
356359
onForceStop?: () => void;
357360
startedAt?: number;
361+
onRetryLastMessage?: () => void;
358362
}
359363

360364
/**
@@ -388,6 +392,7 @@ function VirtualTranscript({
388392
statusText,
389393
onForceStop,
390394
startedAt,
395+
onRetryLastMessage,
391396
}: VirtualTranscriptProps) {
392397
const { t } = useTranslation();
393398
const { scrollRef } = useStickToBottomContext();
@@ -483,7 +488,14 @@ function VirtualTranscript({
483488
return (
484489
<div id={`msg-${message.id}`} className="group pb-6">
485490
{leadingMarker}
486-
<MessageItem message={message} sessionId={sessionId} isAssistantProject={isAssistantProject} assistantName={assistantName} />
491+
<MessageItem
492+
message={message}
493+
sessionId={sessionId}
494+
isAssistantProject={isAssistantProject}
495+
assistantName={assistantName}
496+
isLastAssistant={!isStreaming && message.role === 'assistant' && idx === messages.length - 1}
497+
onRetry={!isStreaming && message.role === 'assistant' && idx === messages.length - 1 ? onRetryLastMessage : undefined}
498+
/>
487499
{rewindSdkUuid && sessionId && !isStreaming && (
488500
<RewindButton sessionId={sessionId} userMessageId={rewindSdkUuid} />
489501
)}

src/components/chat/TerminalReasonChip.tsx

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,16 @@
88
*
99
* Only renders for reasons that carry information users can act on or interpret.
1010
* Silent for `completed` (normal) and `aborted_*` (user-initiated).
11+
*
12+
* When `phase` is 'error' or 'stopped' without a known reason, a generic
13+
* retry button is shown so users can quickly re-send the last message after
14+
* an interruption or network error.
1115
*/
1216

1317
import { useTranslation } from '@/hooks/useTranslation';
1418
import type { TranslationKey } from '@/i18n';
19+
import type { StreamPhase } from '@/types';
1520

16-
/**
17-
* Actions a user can take from the chip. Each maps to a handler in
18-
* ChatView that wires it to the corresponding subsystem (compressor,
19-
* context1m toggle, model switch, router, etc.). "requiresConfirm"
20-
* actions pop a 2nd-step AlertDialog before the destructive step runs
21-
* (per feedback_no_silent_auto_irreversible memory).
22-
*/
2321
export type TerminalActionId =
2422
| 'compress_and_retry'
2523
| 'enable_1m_and_retry'
@@ -32,20 +30,18 @@ export type TerminalActionId =
3230

3331
interface Props {
3432
reason: string | undefined;
33+
phase?: StreamPhase;
3534
onAction?: (actionId: TerminalActionId) => void;
3635
}
3736

3837
type Tone = 'warning' | 'error' | 'info' | 'muted';
3938

4039
interface ActionDescriptor {
4140
id: TerminalActionId;
42-
/** i18n key under 'terminalAction.*' for the button label */
4341
labelKey: TranslationKey;
44-
/** Primary actions use a filled button; secondary use ghost */
4542
variant: 'primary' | 'secondary';
4643
}
4744

48-
// Per-reason action mapping. Order in the array = visual order.
4945
const ACTIONS_BY_REASON: Record<string, ActionDescriptor[]> = {
5046
prompt_too_long: [
5147
{ id: 'compress_and_retry', labelKey: 'terminalAction.compressAndRetry' as TranslationKey, variant: 'primary' },
@@ -73,7 +69,6 @@ const ACTIONS_BY_REASON: Record<string, ActionDescriptor[]> = {
7369
model_error: [
7470
{ id: 'retry_simple', labelKey: 'terminalAction.retry' as TranslationKey, variant: 'primary' },
7571
],
76-
// tool_deferred handled by Phase 7b's deferred-tool card, no action here.
7772
};
7873

7974
const TONE_BY_REASON: Record<string, Tone> = {
@@ -88,13 +83,8 @@ const TONE_BY_REASON: Record<string, Tone> = {
8883
tool_deferred: 'info',
8984
};
9085

91-
// Reasons that should render silently (no chip). Users either already know
92-
// (they cancelled) or the turn completed normally.
9386
const SILENT_REASONS = new Set(['completed', 'aborted_streaming', 'aborted_tools']);
9487

95-
// Whitelist of reasons we have explicit i18n labels for. Anything else
96-
// (e.g. a future SDK value we haven't translated yet) renders under the
97-
// 'unknown' key so the UI never leaks the raw reason string.
9888
const KNOWN_REASONS = new Set([
9989
'max_turns',
10090
'prompt_too_long',
@@ -114,21 +104,47 @@ const TONE_CLASSES: Record<Tone, string> = {
114104
muted: 'bg-muted text-muted-foreground border-border',
115105
};
116106

117-
export function TerminalReasonChip({ reason, onAction }: Props) {
107+
const RETRY_ACTION: ActionDescriptor = {
108+
id: 'retry_simple',
109+
labelKey: 'terminalAction.retry' as TranslationKey,
110+
variant: 'primary',
111+
};
112+
113+
export function TerminalReasonChip({ reason, phase, onAction }: Props) {
118114
const { t } = useTranslation();
119115

120-
if (!reason || SILENT_REASONS.has(reason)) return null;
116+
const isInterrupted = phase === 'error' || phase === 'stopped';
117+
118+
if (!reason || SILENT_REASONS.has(reason)) {
119+
if (isInterrupted && onAction) {
120+
return (
121+
<div className="mx-auto mt-2 flex w-full max-w-3xl flex-wrap items-center justify-start gap-2 px-4">
122+
<button
123+
type="button"
124+
onClick={() => onAction(RETRY_ACTION.id)}
125+
data-terminal-action={RETRY_ACTION.id}
126+
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-muted"
127+
>
128+
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="shrink-0">
129+
<path d="M13.65 2.35A8 8 0 1 0 15.94 9H13.9a6 6 0 1 1-1.63-5.27L10 6h6V0l-2.35 2.35z" fill="currentColor"/>
130+
</svg>
131+
{t(RETRY_ACTION.labelKey)}
132+
</button>
133+
</div>
134+
);
135+
}
136+
return null;
137+
}
121138

122-
// Use whitelist rather than `t(key) || t(fallback)` because translate()
123-
// returns the raw key when missing, so the `||` branch would never fire
124-
// and a new SDK reason would leak a "terminal.new_reason" string to the UI.
125139
const isKnown = KNOWN_REASONS.has(reason);
126140
const tone = TONE_BY_REASON[reason] ?? 'warning';
127141
const label = isKnown
128142
? t(`terminal.${reason}` as TranslationKey)
129143
: t('terminal.unknown' as TranslationKey);
130144
const actions = onAction ? (ACTIONS_BY_REASON[reason] || []) : [];
131145

146+
const showRetry = isInterrupted && actions.every(a => a.id !== 'retry_simple') && onAction;
147+
132148
return (
133149
<div className="mx-auto mt-2 flex w-full max-w-3xl flex-wrap items-center justify-start gap-2 px-4">
134150
<span
@@ -152,6 +168,19 @@ export function TerminalReasonChip({ reason, onAction }: Props) {
152168
{t(action.labelKey)}
153169
</button>
154170
))}
171+
{showRetry && (
172+
<button
173+
type="button"
174+
onClick={() => onAction(RETRY_ACTION.id)}
175+
data-terminal-action={RETRY_ACTION.id}
176+
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors ${TONE_CLASSES[tone]} hover:opacity-90`}
177+
>
178+
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="shrink-0">
179+
<path d="M13.65 2.35A8 8 0 1 0 15.94 9H13.9a6 6 0 1 1-1.63-5.27L10 6h6V0l-2.35 2.35z" fill="currentColor"/>
180+
</svg>
181+
{t(RETRY_ACTION.labelKey)}
182+
</button>
183+
)}
155184
</div>
156185
);
157186
}

src/i18n/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,6 +1372,8 @@ const en = {
13721372
'terminal.hook_stopped': 'Hook stopped this turn',
13731373
'terminal.tool_deferred': 'Tool awaiting response',
13741374
'terminal.unknown': 'Turn ended',
1375+
'terminal.streamError': 'Response interrupted — connection error',
1376+
'terminal.streamStopped': 'Response interrupted',
13751377

13761378
// ── TerminalReason action buttons (Phase 1b) ──
13771379
'terminalAction.compressAndRetry': 'Compress & retry',

src/i18n/zh.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1353,6 +1353,8 @@ const zh: Record<TranslationKey, string> = {
13531353
'terminal.hook_stopped': 'Hook 中断本轮',
13541354
'terminal.tool_deferred': '有工具等待响应',
13551355
'terminal.unknown': '本轮已结束',
1356+
'terminal.streamError': '回复中断,连接异常',
1357+
'terminal.streamStopped': '回复已中断',
13561358

13571359
// ── TerminalReason action buttons (Phase 1b) ──
13581360
'terminalAction.compressAndRetry': '压缩并重试',

0 commit comments

Comments
 (0)