@steerable/agent-shell-web 0.7.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-web",
3
- "version": "0.7.4",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "Steerable framework — product-neutral renderer SPA source for @steerable/agent-shell (React + Vite). Consumed by product web entry points via the `@/` alias (anchored at this package's src/) and the createProductViteConfig factory. Published as a source package (src/), plus the compiled vite.base config factory (vite.base.js + .d.ts) for Node-side config loading.",
6
6
  "license": "Apache-2.0",
@@ -45,8 +45,8 @@
45
45
  "react-router-dom": "^6.30.0",
46
46
  "rehype-raw": "^7.0.0",
47
47
  "remark-gfm": "^4.0.1",
48
- "@steerable/agent-protocol": "0.7.4",
49
- "@steerable/agent-ui": "0.7.4"
48
+ "@steerable/agent-protocol": "0.8.1",
49
+ "@steerable/agent-ui": "0.8.1"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@tailwindcss/vite": "^4.3.0",
@@ -59,7 +59,7 @@
59
59
  "typescript": "^5.8.3",
60
60
  "vite": "^5.4.21",
61
61
  "vitest": "^3.2.4",
62
- "@steerable/pack-sdk": "0.7.4"
62
+ "@steerable/pack-sdk": "0.8.1"
63
63
  },
64
64
  "scripts": {
65
65
  "gen": "echo agent-shell-web",
@@ -1,8 +1,11 @@
1
- import { useEffect } from 'react';
1
+ import { useEffect, useRef } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { LuX } from 'react-icons/lu';
4
4
  import type { LlmSettings } from '@/lib/local-api';
5
- import { LlmSettingsPanel } from '@/components/settings/LlmSettingsPanel';
5
+ import {
6
+ LlmSettingsPanel,
7
+ type LlmSettingsPanelHandle,
8
+ } from '@/components/settings/LlmSettingsPanel';
6
9
  import { t } from '@/i18n';
7
10
 
8
11
  interface LocalLlmSettingsModalProps {
@@ -16,6 +19,7 @@ interface LocalLlmSettingsModalProps {
16
19
  * 侧栏「设置」走 `/settings` 页(模型 + 洞察 / 遥测 / 用量 / 搜索 / 安全)。
17
20
  */
18
21
  export function LocalLlmSettingsModal({ open, onClose, onSaved }: LocalLlmSettingsModalProps) {
22
+ const panelRef = useRef<LlmSettingsPanelHandle | null>(null);
19
23
  useEffect(() => {
20
24
  if (!open) return;
21
25
  const onKey = (e: KeyboardEvent) => {
@@ -25,6 +29,39 @@ export function LocalLlmSettingsModal({ open, onClose, onSaved }: LocalLlmSettin
25
29
  return () => window.removeEventListener('keydown', onKey);
26
30
  }, [open, onClose]);
27
31
 
32
+ // An authentication failure opens this modal specifically to fix the key,
33
+ // so put the cursor there instead of at the top of a form the reader then
34
+ // has to scan.
35
+ //
36
+ // It goes through the panel's handle rather than a document query: the panel
37
+ // owns the field, mounts it asynchronously once settings load, and knows when
38
+ // it is actually focusable. Polling the DOM found an element that had already
39
+ // been replaced by a re-render, so the focus call landed on a detached node.
40
+ useEffect(() => {
41
+ if (!open) return;
42
+ let cancelled = false;
43
+ let timer: number | undefined;
44
+ const focusKey = () => {
45
+ if (cancelled) return;
46
+ // 用户已经开始在别处输入时不要抢焦点。
47
+ const active = document.activeElement;
48
+ const userTyping =
49
+ active instanceof HTMLElement &&
50
+ active !== document.body &&
51
+ (active.tagName === 'INPUT' ||
52
+ active.tagName === 'TEXTAREA' ||
53
+ active.isContentEditable);
54
+ if (userTyping) return;
55
+ if (panelRef.current?.focusApiKey()) return;
56
+ timer = window.setTimeout(focusKey, 50);
57
+ };
58
+ focusKey();
59
+ return () => {
60
+ cancelled = true;
61
+ if (timer !== undefined) window.clearTimeout(timer);
62
+ };
63
+ }, [open]);
64
+
28
65
  if (!open) return null;
29
66
  if (typeof document === 'undefined') return null;
30
67
 
@@ -55,7 +92,7 @@ export function LocalLlmSettingsModal({ open, onClose, onSaved }: LocalLlmSettin
55
92
  </button>
56
93
  </div>
57
94
  <div className="flex-1 overflow-y-auto p-3">
58
- <LlmSettingsPanel onSaved={onSaved} />
95
+ <LlmSettingsPanel ref={panelRef} onSaved={onSaved} />
59
96
  </div>
60
97
  <div className="flex h-9 flex-shrink-0 items-center justify-end border-t border-agent-border px-3 bg-agent-muted/10">
61
98
  <button
@@ -1,6 +1,16 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
2
  import { motion } from 'framer-motion';
3
- import { LuCheck, LuCopy, LuListChecks, LuLoaderCircle, LuRefreshCw, LuShare2 } from 'react-icons/lu';
3
+ import {
4
+ LuCheck,
5
+ LuChevronDown,
6
+ LuChevronUp,
7
+ LuCopy,
8
+ LuListChecks,
9
+ LuLoaderCircle,
10
+ LuRefreshCw,
11
+ LuShare2,
12
+ LuTriangleAlert,
13
+ } from 'react-icons/lu';
4
14
  import type { ChatMessage } from '@steerable/agent-protocol';
5
15
  import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
6
16
  import { t } from '@/i18n';
@@ -10,6 +20,9 @@ import type { ChildInfo } from './OrchestrationChildrenCard';
10
20
  import { collectTurnParticipants, extractMentionedAgentIds } from './executed-actions-model';
11
21
  import type { InspectTaskInput } from './executed-actions-model';
12
22
  import { StreamingStatus } from './StreamingStatus';
23
+ import { TurnFailureCard, type TurnFailureCardProps } from './TurnFailureCard';
24
+ import { readFailureDetail, type TurnFailureDetail } from './turn-failure';
25
+ import type { RetryState } from './RetryStatus';
13
26
  import { getFriendlyDate } from './timestamp';
14
27
  import { useCopy } from './useCopy';
15
28
  import type { TurnBlock } from './turn-timeline';
@@ -101,6 +114,12 @@ interface AssistantMessageProps {
101
114
  * `StreamingStatus` to render "Round 2 · 继续推理..." between LLM bursts.
102
115
  */
103
116
  currentRound?: number;
117
+ /**
118
+ * Live retry state of the in-flight request. While set, the status line
119
+ * says "reconnecting" instead of "thinking": a seconds-scale backoff and a
120
+ * stalled request look the same without it.
121
+ */
122
+ retryState?: RetryState | null;
104
123
  /** True iff the message was generated in Plan mode. */
105
124
  isPlanMode?: boolean;
106
125
  /**
@@ -110,6 +129,12 @@ interface AssistantMessageProps {
110
129
  * has fully streamed; the caller then re-hydrates the message list.
111
130
  */
112
131
  onRegenerate?: (messageId: string) => Promise<void>;
132
+ /**
133
+ * Resolves a failed turn's action into a handler. The message list decides
134
+ * what this build can do (Continue needs a backend route, opening settings
135
+ * needs the modal); the card only draws what it is given.
136
+ */
137
+ failureAction?: TurnFailureCardProps['action'];
113
138
  /** Epoch ms when this turn started — live ticker while streaming. */
114
139
  startedAtMs?: number;
115
140
  /** Frozen wall-clock of a finished turn. */
@@ -188,11 +213,71 @@ function bubbleClass(isPlanMode: boolean): string {
188
213
  // 两处汇合到同一个错误气泡,失败回合不再只显示"(空消息)"。
189
214
  const STREAM_ERROR_PREFIX = '[stream error] ';
190
215
 
191
- function readTurnFailure(
192
- message: ChatMessage,
193
- content: string,
194
- ): { reason: string; contentIsError: boolean } | null {
216
+ interface TurnFailure {
217
+ reason: string;
218
+ contentIsError: boolean;
219
+ /** Classified detail; undefined for live stream errors and old records. */
220
+ detail?: TurnFailureDetail;
221
+ traceId?: string;
222
+ }
223
+
224
+ /**
225
+ * Terminal state of a persisted turn, for the card it should show.
226
+ *
227
+ * `failed` and `cancelled` are different for the reader even though both can
228
+ * be continued: one is the network's fault and comes back as a failure card,
229
+ * the other is the user's own Stop and reads as a neutral notice. Absent
230
+ * metadata means the turn finished normally (older records carry no status).
231
+ */
232
+ /**
233
+ * Whether this failed/stopped turn was continued afterwards.
234
+ *
235
+ * The host stamps `continuedAt` when a resume starts from this turn. The card
236
+ * then collapses into one line: the failure is history the user already acted
237
+ * on, and re-showing the full card after a reload would read as "it failed
238
+ * again". Expanding it still yields the original diagnostics.
239
+ */
240
+ function readContinued(message: ChatMessage): boolean {
241
+ const raw = (message as { messageMetadata?: unknown }).messageMetadata;
242
+ if (typeof raw !== 'string' || !raw) return false;
243
+ try {
244
+ const at = (JSON.parse(raw) as { continuedAt?: unknown }).continuedAt;
245
+ return typeof at === 'string' && at.length > 0;
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+
251
+ /** True when the user's Stop landed while a retry was pending. */
252
+ function readStoppedDuringRetry(message: ChatMessage): boolean {
253
+ const raw = (message as { messageMetadata?: unknown }).messageMetadata;
254
+ if (typeof raw !== 'string' || !raw) return false;
255
+ try {
256
+ const reason = (JSON.parse(raw) as { completionReason?: unknown }).completionReason;
257
+ return reason === 'aborted_during_retry';
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
263
+ function readTurnStatus(message: ChatMessage): 'failed' | 'cancelled' | 'completed' {
264
+ const raw = (message as { messageMetadata?: unknown }).messageMetadata;
265
+ if (typeof raw !== 'string' || !raw) return 'completed';
266
+ try {
267
+ const status = (JSON.parse(raw) as { completionStatus?: unknown }).completionStatus;
268
+ if (status === 'failed') return 'failed';
269
+ if (status === 'cancelled') return 'cancelled';
270
+ return 'completed';
271
+ } catch {
272
+ // 元数据损坏按正常完成处理——落回原有的空消息展示。
273
+ return 'completed';
274
+ }
275
+ }
276
+
277
+ function readTurnFailure(message: ChatMessage, content: string): TurnFailure | null {
195
278
  if (content.startsWith(STREAM_ERROR_PREFIX)) {
279
+ // Live path: the framework stamped the reason into the content before the
280
+ // turn was persisted, so nothing structured is available yet.
196
281
  return { reason: content.slice(STREAM_ERROR_PREFIX.length), contentIsError: true };
197
282
  }
198
283
  const raw = (message as { messageMetadata?: unknown }).messageMetadata;
@@ -201,13 +286,20 @@ function readTurnFailure(
201
286
  const meta = JSON.parse(raw) as {
202
287
  completionStatus?: unknown;
203
288
  completionReason?: unknown;
289
+ failure?: unknown;
290
+ traceId?: unknown;
204
291
  };
205
292
  if (
206
293
  meta?.completionStatus === 'failed' &&
207
294
  typeof meta.completionReason === 'string' &&
208
295
  meta.completionReason
209
296
  ) {
210
- return { reason: meta.completionReason, contentIsError: false };
297
+ return {
298
+ reason: meta.completionReason,
299
+ contentIsError: false,
300
+ detail: readFailureDetail(meta.failure),
301
+ traceId: typeof meta.traceId === 'string' && meta.traceId ? meta.traceId : undefined,
302
+ };
211
303
  }
212
304
  } catch {
213
305
  // 元数据损坏按无失败处理——落回原有的空消息展示。
@@ -226,10 +318,102 @@ function readPersistedAgentId(message: ChatMessage): string | undefined {
226
318
  }
227
319
  }
228
320
 
229
- function TurnErrorBubble({ reason }: { reason: string }) {
321
+ function TurnErrorBubble({
322
+ failure,
323
+ completedSteps,
324
+ happenedAt,
325
+ onAction,
326
+ }: {
327
+ failure: TurnFailure;
328
+ completedSteps: number;
329
+ happenedAt?: string;
330
+ onAction?: TurnFailureCardProps['action'];
331
+ }) {
332
+ return (
333
+ <TurnFailureCard
334
+ detail={failure.detail}
335
+ reason={failure.reason}
336
+ completedSteps={completedSteps}
337
+ happenedAt={happenedAt}
338
+ traceId={failure.traceId}
339
+ action={onAction}
340
+ />
341
+ );
342
+ }
343
+
344
+ /**
345
+ * One collapsed line standing in for a failure the user already continued.
346
+ *
347
+ * It stays expandable rather than disappearing: the diagnostics are the only
348
+ * record of what went wrong, and someone filing a report a day later still
349
+ * needs them. The default is collapsed so a resumed conversation reads as
350
+ * continuous work rather than a stack of past failures.
351
+ */
352
+ function ContinuedHereLine({ children }: { children: React.ReactNode }) {
353
+ const [open, setOpen] = useState(false);
230
354
  return (
231
- <div className="rounded-agent-lg border border-agent-destructive/40 bg-agent-destructive/5 px-2.5 py-2 text-xs leading-relaxed text-agent-destructive shadow-sm">
232
- {t('Request failed: {reason}', { reason })}
355
+ <div data-testid="continued-here">
356
+ <button
357
+ type="button"
358
+ onClick={() => setOpen((value) => !value)}
359
+ aria-expanded={open}
360
+ className="inline-flex items-center gap-1 text-[11px] text-agent-muted-foreground hover:text-agent-foreground"
361
+ data-action="continued-here"
362
+ >
363
+ <LuTriangleAlert className="h-3 w-3" />
364
+ {t('Connection failed here. Continued.')}
365
+ {open ? <LuChevronUp className="h-3 w-3" /> : <LuChevronDown className="h-3 w-3" />}
366
+ </button>
367
+ {open ? <div className="mt-1.5">{children}</div> : null}
368
+ </div>
369
+ );
370
+ }
371
+
372
+ /**
373
+ * Neutral notice for a turn the user stopped.
374
+ *
375
+ * Deliberately not the failure card: nothing went wrong, the user chose to
376
+ * stop. It still offers Continue, because the usual reason to stop a turn is
377
+ * impatience with a retry, and the work done so far is worth keeping. The copy
378
+ * distinguishes a stop during reconnection — the case where "continue" is
379
+ * almost certainly wanted — from a stop while the model was writing.
380
+ */
381
+ function TurnStoppedCard({
382
+ duringRetry,
383
+ action,
384
+ }: {
385
+ duringRetry: boolean;
386
+ action?: TurnFailureCardProps['action'];
387
+ }) {
388
+ const handler = action?.({ kind: 'retry' }) ?? null;
389
+ return (
390
+ <div
391
+ className="rounded-agent-lg border border-agent-border bg-agent-muted/40 px-2.5 py-2 text-xs leading-relaxed"
392
+ data-testid="turn-stopped"
393
+ >
394
+ <div className="flex items-start gap-2 font-medium text-agent-foreground">
395
+ <span className="mt-[3px] h-2 w-2 flex-shrink-0 rounded-[2px] bg-agent-muted-foreground" />
396
+ <span>{t('Stopped')}</span>
397
+ </div>
398
+ <p className="mt-1 ml-5 text-agent-muted-foreground">
399
+ {duringRetry
400
+ ? t('You stopped this task while it was reconnecting.')
401
+ : t('You stopped this task.')}
402
+ </p>
403
+ {handler ? (
404
+ <div className="mt-2 ml-5">
405
+ <button
406
+ type="button"
407
+ disabled={handler.disabled}
408
+ title={handler.hint}
409
+ onClick={handler.onClick}
410
+ className="rounded-agent-md border border-agent-border bg-agent-card px-3 py-1 text-xs text-agent-foreground transition hover:bg-agent-muted disabled:opacity-50"
411
+ data-action="continue"
412
+ >
413
+ {t('Continue')}
414
+ </button>
415
+ </div>
416
+ ) : null}
233
417
  </div>
234
418
  );
235
419
  }
@@ -263,8 +447,10 @@ export function AssistantMessage({
263
447
  orchestrationChildren,
264
448
  previousUser,
265
449
  currentRound = 1,
450
+ retryState,
266
451
  isPlanMode = false,
267
452
  onRegenerate,
453
+ failureAction,
268
454
  startedAtMs,
269
455
  durationMs,
270
456
  llmSpeed,
@@ -274,6 +460,9 @@ export function AssistantMessage({
274
460
  }: AssistantMessageProps) {
275
461
  const content = message.content || '';
276
462
  const failure = readTurnFailure(message, content);
463
+ const stopped = !failure && readTurnStatus(message) === 'cancelled';
464
+ const stoppedDuringRetry = stopped && readStoppedDuringRetry(message);
465
+ const continued = readContinued(message);
277
466
  // live 错误文本("[stream error] ...")不当正文渲染;落库的失败回合可能
278
467
  // 留有部分正文,此时正文与错误气泡都展示。
279
468
  const displayContent = failure?.contentIsError ? '' : content;
@@ -308,6 +497,10 @@ export function AssistantMessage({
308
497
  llmRequestElapsedMs(llmSpeed, now),
309
498
  );
310
499
 
500
+ // A continued failure renders as one line; expanding it yields the card.
501
+ const wrapContinued = (card: React.ReactNode) =>
502
+ continued ? <ContinuedHereLine>{card}</ContinuedHereLine> : card;
503
+
311
504
  return (
312
505
  <motion.div
313
506
  className="group/message"
@@ -359,15 +552,25 @@ export function AssistantMessage({
359
552
  orchestrationChildren={orchestrationChildren}
360
553
  emptyFallback={
361
554
  failure ? (
362
- <TurnErrorBubble reason={failure.reason} />
555
+ wrapContinued(
556
+ <TurnErrorBubble
557
+ failure={failure}
558
+ completedSteps={toolActions.length}
559
+ happenedAt={message.createdAt}
560
+ onAction={failureAction}
561
+ />,
562
+ )
363
563
  ) : isStreaming ? (
364
564
  <div className={bubbleClass(isPlanMode)}>
365
565
  <StreamingStatus
366
566
  round={currentRound}
367
567
  actionCount={executedActions?.length ?? 0}
368
568
  hasContent={false}
569
+ retry={retryState ?? undefined}
369
570
  />
370
571
  </div>
572
+ ) : stopped ? (
573
+ <TurnStoppedCard duringRetry={stoppedDuringRetry} action={failureAction} />
371
574
  ) : (
372
575
  <div className={`${bubbleClass(isPlanMode)} text-xs italic text-agent-muted-foreground`}>
373
576
  {t('(empty message)')}
@@ -379,6 +582,7 @@ export function AssistantMessage({
379
582
  round={currentRound}
380
583
  actionCount={executedActions?.length ?? 0}
381
584
  hasContent={false}
585
+ retry={retryState ?? undefined}
382
586
  />
383
587
  }
384
588
  renderAnswer={(block, isLast) => (
@@ -394,7 +598,19 @@ export function AssistantMessage({
394
598
  (无块时走上面的 emptyFallback,不会重复)。 */}
395
599
  {failure && blocks.length > 0 ? (
396
600
  <div className="mt-1.5">
397
- <TurnErrorBubble reason={failure.reason} />
601
+ {wrapContinued(
602
+ <TurnErrorBubble
603
+ failure={failure}
604
+ completedSteps={toolActions.length}
605
+ happenedAt={message.createdAt}
606
+ onAction={failureAction}
607
+ />,
608
+ )}
609
+ </div>
610
+ ) : null}
611
+ {stopped && blocks.length > 0 ? (
612
+ <div className="mt-1.5">
613
+ <TurnStoppedCard duringRetry={stoppedDuringRetry} action={failureAction} />
398
614
  </div>
399
615
  ) : null}
400
616
  </>
@@ -417,15 +633,25 @@ export function AssistantMessage({
417
633
  {isStreaming && <StreamingCursor />}
418
634
  </div>
419
635
  ) : failure ? (
420
- <TurnErrorBubble reason={failure.reason} />
636
+ wrapContinued(
637
+ <TurnErrorBubble
638
+ failure={failure}
639
+ completedSteps={toolActions.length}
640
+ happenedAt={message.createdAt}
641
+ onAction={failureAction}
642
+ />,
643
+ )
421
644
  ) : isStreaming ? (
422
645
  <div className={bubbleClass(isPlanMode)}>
423
646
  <StreamingStatus
424
647
  round={currentRound}
425
648
  actionCount={executedActions?.length ?? 0}
426
649
  hasContent={false}
650
+ retry={retryState ?? undefined}
427
651
  />
428
652
  </div>
653
+ ) : stopped ? (
654
+ <TurnStoppedCard duringRetry={stoppedDuringRetry} action={failureAction} />
429
655
  ) : (
430
656
  <div className={`${bubbleClass(isPlanMode)} text-xs italic text-agent-muted-foreground`}>
431
657
  {t('(empty message)')}
@@ -433,7 +659,14 @@ export function AssistantMessage({
433
659
  )}
434
660
  {displayContent && failure ? (
435
661
  <div className="mt-1.5">
436
- <TurnErrorBubble reason={failure.reason} />
662
+ {wrapContinued(
663
+ <TurnErrorBubble
664
+ failure={failure}
665
+ completedSteps={toolActions.length}
666
+ happenedAt={message.createdAt}
667
+ onAction={failureAction}
668
+ />,
669
+ )}
437
670
  </div>
438
671
  ) : null}
439
672
  </>
@@ -474,7 +707,11 @@ export function AssistantMessage({
474
707
  )}
475
708
  </button>
476
709
  )}
477
- {content && !isStreaming && onRegenerate && (
710
+ {/* Regenerate is offered for a failed or stopped turn even when it
711
+ produced no text: the reply the user wanted is missing, and
712
+ regenerating from the turn's start is the alternative to
713
+ Continue when Continue is not the right answer. */}
714
+ {(content || failure || stopped) && !isStreaming && onRegenerate && (
478
715
  <button
479
716
  type="button"
480
717
  disabled={regenerating}
@@ -497,7 +734,7 @@ export function AssistantMessage({
497
734
  title={
498
735
  regenerating
499
736
  ? t('Regenerating...')
500
- : t('Regenerate (the old reply is kept as a branch)')
737
+ : t('Regenerate (start this turn over; the old reply is kept as a branch)')
501
738
  }
502
739
  aria-label={regenerating ? t('Regenerating') : t('Regenerate')}
503
740
  data-action="regenerate"
@@ -18,6 +18,8 @@ import type { InspectTaskInput } from './executed-actions-model';
18
18
  import type { ChildInfo } from './OrchestrationChildrenCard';
19
19
  import type { TurnBlock } from './turn-timeline';
20
20
  import type { TurnFile } from './turn-files';
21
+ import type { RetryState } from './RetryStatus';
22
+ import type { TurnFailureCardProps } from './TurnFailureCard';
21
23
  import type { LlmSpeedSnapshot } from './process-status';
22
24
  import { ComposerStatusStack } from './composer-status-stack';
23
25
  import { t } from '@/i18n';
@@ -120,6 +122,10 @@ export interface LocalChatPanelProps {
120
122
  orchestrationChildrenByMessageId?: Record<string, ChildInfo[]>;
121
123
  /** Current round number for the in-flight turn (1-based). */
122
124
  currentRound?: number;
125
+ /** Live "reconnecting" state of the in-flight turn (see RetryStatus). */
126
+ retryState?: RetryState | null;
127
+ /** Resolves a failed turn's action buttons (see TurnFailureCard). */
128
+ failureAction?: TurnFailureCardProps['action'];
123
129
  /**
124
130
  * Settings gear callback for the input toolbar. Receiving this enables
125
131
  * the gear button on `ChatInput`; AgentPage wires it to opening
@@ -196,6 +202,8 @@ export function LocalChatPanel({
196
202
  currentTurnChildren,
197
203
  orchestrationChildrenByMessageId,
198
204
  currentRound,
205
+ retryState,
206
+ failureAction,
199
207
  onOpenSettings,
200
208
  inputToolbarExtras,
201
209
  inputLeadingChrome,
@@ -509,6 +517,8 @@ export function LocalChatPanel({
509
517
  currentTurnChildren={currentTurnChildren}
510
518
  orchestrationChildrenByMessageId={orchestrationChildrenByMessageId}
511
519
  currentRound={currentRound}
520
+ retryState={retryState}
521
+ failureAction={failureAction}
512
522
  emptyState={emptyState ?? <EmptyChat onSelectPrompt={handleSelectPrompt} />}
513
523
  mode={mode}
514
524
  onRegenerate={onRegenerate}
@@ -25,6 +25,8 @@ import { followedPreviewPath, type TurnFile } from './turn-files';
25
25
  import { publishConversationPreview } from '@/layouts/conversation-preview';
26
26
  import { inferDurationMs, readPersistedDurationMs } from './elapsed';
27
27
  import type { LlmSpeedSnapshot } from './process-status';
28
+ import type { RetryState } from './RetryStatus';
29
+ import type { TurnFailureCardProps } from './TurnFailureCard';
28
30
 
29
31
  /**
30
32
  * MessageList — scrolling viewport that renders user/assistant message
@@ -154,6 +156,13 @@ interface MessageListProps {
154
156
  * AssistantMessage so its `StreamingStatus` can show "Round 2 · 继续推理...".
155
157
  */
156
158
  currentRound?: number;
159
+ /**
160
+ * Live "reconnecting" state of the in-flight turn (backend `turn_retry`).
161
+ * Forwarded to the tail message so the status line replaces "thinking".
162
+ */
163
+ retryState?: RetryState | null;
164
+ /** Resolves a failed turn's action buttons (see TurnFailureCard). */
165
+ failureAction?: TurnFailureCardProps['action'];
157
166
  /** Current chat mode (Agent / Plan). */
158
167
  mode?: ChatMode;
159
168
  /** W1.2.1: regenerate an assistant turn (fork-preserving). */
@@ -207,6 +216,8 @@ export function MessageList({
207
216
  currentTurnChildren,
208
217
  orchestrationChildrenByMessageId,
209
218
  currentRound,
219
+ retryState,
220
+ failureAction,
210
221
  mode,
211
222
  onRegenerate,
212
223
  interrupted = false,
@@ -530,6 +541,16 @@ export function MessageList({
530
541
  orchestrationChildren={childList}
531
542
  previousUser={previousUser}
532
543
  currentRound={isStreamingTail ? currentRound : undefined}
544
+ retryState={isStreamingTail ? retryState : null}
545
+ // 「继续」只属于当前最后一个回合:用户已经发了新消息
546
+ // 时,继续旧回合会把旧任务的输出接在新消息之后,前后
547
+ // 顺序就乱了。判据是「这条是不是会话里最后一条消息」,
548
+ // 不是「最后一条助手消息」——后者在用户已追问后仍成立。
549
+ failureAction={
550
+ message.id === lastAssistantId && isLast
551
+ ? failureAction
552
+ : undefined
553
+ }
533
554
  isPlanMode={isPlanMode}
534
555
  onRegenerate={onRegenerate}
535
556
  startedAtMs={isStreamingTail ? currentTurnStartedAtMs : undefined}
@@ -0,0 +1,77 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { t } from '@/i18n';
4
+
5
+ /**
6
+ * RetryStatus — "reconnecting" line for a model request that is being retried.
7
+ *
8
+ * The backend retries connection failures on a seconds-scale backoff, so a
9
+ * turn that is working looks identical to one that is stuck. This renders the
10
+ * three facts a waiting user needs: it is retrying, since when, and that Stop
11
+ * still works. The elapsed label appears only after
12
+ * `RETRY_ELAPSED_VISIBLE_MS`, because a sub-10s wait reads as progress while a
13
+ * ticking timer on a short wait reads as a problem.
14
+ */
15
+
16
+ /** Elapsed seconds are shown from here on: 10s is where a wait needs progress. */
17
+ export const RETRY_ELAPSED_VISIBLE_MS = 10_000;
18
+
19
+ export interface RetryState {
20
+ /** Epoch ms when the first retry of this request started. */
21
+ startedAt: number;
22
+ /** Delay the loop announced for this attempt, for the next tick. */
23
+ delayMs?: number;
24
+ }
25
+
26
+ interface RetryStatusProps {
27
+ /** Live retry state, or undefined once the request is no longer retrying. */
28
+ retry: RetryState;
29
+ /** Injectable clock for tests; real time by default. */
30
+ now?: () => number;
31
+ }
32
+
33
+ /** Seconds waited so far; exported for the label's unit tests. */
34
+ export function waitedSeconds(startedAt: number, now: number): number {
35
+ return Math.max(0, Math.floor((now - startedAt) / 1000));
36
+ }
37
+
38
+ /** The line's text: bare "reconnecting", then the elapsed count. */
39
+ export function retryLabel(startedAt: number, now: number): string {
40
+ const waited = waitedSeconds(startedAt, now);
41
+ if (now - startedAt < RETRY_ELAPSED_VISIBLE_MS) {
42
+ return t('Network unstable, reconnecting…');
43
+ }
44
+ return t('Network unstable, reconnecting… ({seconds}s)', { seconds: waited });
45
+ }
46
+
47
+ export function RetryStatus({ retry, now = Date.now }: RetryStatusProps) {
48
+ const [tick, setTick] = useState(() => now());
49
+ useEffect(() => {
50
+ const id = window.setInterval(() => setTick(now()), 1000);
51
+ return () => window.clearInterval(id);
52
+ }, [now, retry.startedAt]);
53
+
54
+ const current = Math.max(tick, retry.startedAt);
55
+
56
+ return (
57
+ <motion.div
58
+ key="retry"
59
+ initial={{ opacity: 0, y: 2 }}
60
+ animate={{ opacity: 1, y: 0 }}
61
+ transition={{ duration: 0.2 }}
62
+ // `polite` announces the line once when it appears. The elapsed counter
63
+ // updates every second but is not announced again: it is informational,
64
+ // and a per-second announcement would interrupt screen-reader reading.
65
+ role="status"
66
+ aria-live="polite"
67
+ aria-atomic="true"
68
+ className="my-2 inline-flex items-center gap-2 text-xs text-agent-warning"
69
+ data-testid="retry-status"
70
+ >
71
+ <span className="h-3 w-3 animate-spin rounded-full border-2 border-current border-r-transparent" />
72
+ <span>{retryLabel(retry.startedAt, current)}</span>
73
+ </motion.div>
74
+ );
75
+ }
76
+
77
+ export default RetryStatus;