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
1317import { useTranslation } from '@/hooks/useTranslation' ;
1418import 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- */
2321export type TerminalActionId =
2422 | 'compress_and_retry'
2523 | 'enable_1m_and_retry'
@@ -32,20 +30,18 @@ export type TerminalActionId =
3230
3331interface Props {
3432 reason : string | undefined ;
33+ phase ?: StreamPhase ;
3534 onAction ?: ( actionId : TerminalActionId ) => void ;
3635}
3736
3837type Tone = 'warning' | 'error' | 'info' | 'muted' ;
3938
4039interface 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.
4945const 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
7974const 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.
9386const 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.
9888const 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}
0 commit comments