@steerable/agent-shell-web 0.7.2 → 0.8.0

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.2",
3
+ "version": "0.8.0",
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.2",
49
- "@steerable/agent-ui": "0.7.2"
48
+ "@steerable/agent-protocol": "0.8.0",
49
+ "@steerable/agent-ui": "0.8.0"
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.2"
62
+ "@steerable/pack-sdk": "0.8.0"
63
63
  },
64
64
  "scripts": {
65
65
  "gen": "echo agent-shell-web",
@@ -1932,6 +1932,7 @@ export function AgentSidebar({
1932
1932
  void forkThisChat(target);
1933
1933
  }}
1934
1934
  onDelete={() => {
1935
+ chatMenu.anchor.focus();
1935
1936
  setPendingDeleteChat({ id: chatMenu.id, title: chatMenu.title });
1936
1937
  setChatMenu(null);
1937
1938
  }}
@@ -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
@@ -167,6 +167,16 @@ export function ApprovalPromptMenu({
167
167
  </span>
168
168
  ) : null}
169
169
  </>
170
+ ) : isBrowser && current.toolName === 'browser_navigate' ? (
171
+ <>
172
+ {t('Agent requests to open a page')}
173
+ <span className="mx-1 font-mono">{current.toolName}</span>
174
+ {current.categoryLabel ? (
175
+ <span className="text-agent-muted-foreground">
176
+ {t('({category})', { category: categoryName(current.categoryLabel) })}
177
+ </span>
178
+ ) : null}
179
+ </>
170
180
  ) : (
171
181
  <>
172
182
  {t('Agent requests to run')}
@@ -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"
@@ -21,6 +21,9 @@ import {
21
21
  isTaskFamilyTool,
22
22
  inspectableTaskFromAction,
23
23
  shortTaskId,
24
+ askReplay,
25
+ askRowSummary,
26
+ egressRowSummary,
24
27
  type InspectTaskInput,
25
28
  } from './executed-actions-model';
26
29
  import {
@@ -60,8 +63,8 @@ export interface ExecutedAction {
60
63
  * port-only egress); none = no backend on this platform.
61
64
  */
62
65
  sandbox?: { backend?: string; enforcement: string };
63
- /** Set when a session allow covered this call, so the row can say so. */
64
- approval?: { kind?: string; category?: string; cached?: boolean; waitMs?: number };
66
+ /** Set when a session allow or an unattended allow covered this call, so the row can say so. */
67
+ approval?: { kind?: string; category?: string; cached?: boolean; waitMs?: number; auto?: boolean };
65
68
  /**
66
69
  * 工具注册时声明的卡片。`declared` 为真时行标题用 `title`,
67
70
  * 不再按工具名猜测。旧事件没有这个字段,仍走原来的摘要。
@@ -108,7 +111,7 @@ function summarizeArguments(args: unknown): string | null {
108
111
  if (typeof args === 'string') return args.length > 60 ? args.slice(0, 57) + '…' : args;
109
112
  if (typeof args !== 'object') return String(args);
110
113
  const obj = args as Record<string, unknown>;
111
- for (const key of ['command', 'cmd', 'query', 'path', 'file', 'target', 'message']) {
114
+ for (const key of ['command', 'cmd', 'url', 'query', 'path', 'file', 'target', 'message']) {
112
115
  const v = obj[key];
113
116
  if (typeof v === 'string' && v.length > 0) {
114
117
  return v.length > 60 ? v.slice(0, 57) + '…' : v;
@@ -129,10 +132,16 @@ function sandboxBadge(sandbox: ExecutedAction['sandbox']): string | null {
129
132
  }
130
133
  }
131
134
 
132
- function sessionAllowNote(action: ExecutedAction): string | undefined {
135
+ function toolProgressNote(action: ExecutedAction): string | undefined {
133
136
  if (action.approval?.kind === 'allow_for_session' && action.approval.cached === true) {
134
137
  return t('Allowed for this chat');
135
138
  }
139
+ if (action.tool === 'ask_user' && askReplay(action.result)?.unattended && deriveStatus(action) !== 'failed') {
140
+ return t('Answered automatically');
141
+ }
142
+ if (action.approval?.auto === true && deriveStatus(action) !== 'failed') {
143
+ return t('Auto');
144
+ }
136
145
  return undefined;
137
146
  }
138
147
 
@@ -145,8 +154,13 @@ function actionToTool(action: ExecutedAction, idx: number): ToolExecutionPayload
145
154
  ? humanizeDelegateError(env.error)
146
155
  : env.error
147
156
  : null;
157
+ // 追问行用第一条问题。出网行用主机名。已保存的 view.title 往往只是开场白或工具名。
158
+ const asked = askRowSummary(action);
159
+ const egress = egressRowSummary(action);
148
160
  // 声明过的工具用 view.title。没有 view 的旧事件仍按工具名做摘要。
149
- const summary = action.view?.declared
161
+ const summary = asked
162
+ || egress
163
+ || (action.view?.declared
150
164
  ? action.view.title
151
165
  : ((action.tool === 'delegate_subagent'
152
166
  ? summarizeDelegateSubagent(action.arguments)
@@ -156,7 +170,7 @@ function actionToTool(action: ExecutedAction, idx: number): ToolExecutionPayload
156
170
  : null) ??
157
171
  summarizeRunCodeAction(action.tool, action.arguments, action.result) ??
158
172
  summarizeWebAction(action.tool, action.arguments, action.result) ??
159
- summarizeArguments(action.arguments));
173
+ summarizeArguments(action.arguments)));
160
174
  const badge = sandboxBadge(action.sandbox);
161
175
  return {
162
176
  id: action.id ?? `${action.tool}-${idx}`,
@@ -177,6 +191,20 @@ function actionToTool(action: ExecutedAction, idx: number): ToolExecutionPayload
177
191
  * unified `diff` (local_edit_file), render it as a coloured diff; otherwise
178
192
  * fall back to the default JSON/text view.
179
193
  */
194
+ function renderAskOutput(output: unknown): ReactNode {
195
+ const replay = askReplay(output);
196
+ if (!replay) return renderActionOutput(output);
197
+ return (
198
+ <div className="space-y-1 text-[11px] text-agent-foreground">
199
+ {replay.lines.map((line, index) => (
200
+ <p key={`${index}-${line}`} className="whitespace-pre-wrap break-words">
201
+ {line}
202
+ </p>
203
+ ))}
204
+ </div>
205
+ );
206
+ }
207
+
180
208
  function renderActionOutput(output: unknown): ReactNode {
181
209
  if (output && typeof output === 'object') {
182
210
  const obj = output as Record<string, unknown>;
@@ -444,6 +472,9 @@ function toolCardExtras(
444
472
  renderOutput: renderDelegateOutput,
445
473
  };
446
474
  }
475
+ if (action.tool === 'ask_user') {
476
+ return { renderOutput: renderAskOutput };
477
+ }
447
478
  if (isTaskFamilyTool(action.tool)) {
448
479
  return {
449
480
  label: taskToolLabel(action.tool, action.arguments) ?? undefined,
@@ -543,7 +574,7 @@ export function ToolsFlow({
543
574
  renderOutput={extras.renderOutput ?? renderActionOutput}
544
575
  onActivate={inspect.onActivate}
545
576
  activateTitle={inspect.activateTitle}
546
- note={sessionAllowNote(action)}
577
+ note={toolProgressNote(action)}
547
578
  className="rounded-none border-0 border-t border-agent-border first:border-t-0"
548
579
  />
549
580
  );
@@ -606,7 +637,7 @@ export function ExecutedActionsCard({
606
637
  renderOutput={extras.renderOutput ?? renderActionOutput}
607
638
  onActivate={inspect.onActivate}
608
639
  activateTitle={inspect.activateTitle}
609
- note={sessionAllowNote(action)}
640
+ note={toolProgressNote(action)}
610
641
  className="rounded-none border-0 border-t border-agent-border first:border-t-0"
611
642
  />
612
643
  );