@steerable/agent-shell-web 0.6.61 → 0.6.63

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.6.61",
3
+ "version": "0.6.63",
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.6.61",
49
- "@steerable/agent-ui": "0.6.61"
48
+ "@steerable/agent-protocol": "0.6.63",
49
+ "@steerable/agent-ui": "0.6.63"
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.6.61"
62
+ "@steerable/pack-sdk": "0.6.63"
63
63
  },
64
64
  "scripts": {
65
65
  "gen": "echo agent-shell-web",
@@ -1326,6 +1326,7 @@ export function AgentSidebar({
1326
1326
  })
1327
1327
  : ""
1328
1328
  }
1329
+ error={pendingDeleteChat ? error : null}
1329
1330
  pending={
1330
1331
  pendingDeleteChat !== null && deletingChatId === pendingDeleteChat.id
1331
1332
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 破坏性操作的二次确认弹窗。点遮罩或按 Esc 取消;确认进行中不关闭。
3
3
  */
4
- import { useEffect, useId } from 'react';
4
+ import { useEffect, useId, useRef } from 'react';
5
5
  import { createPortal } from 'react-dom';
6
6
  import { t } from '@/i18n';
7
7
 
@@ -9,6 +9,7 @@ export interface ConfirmDialogProps {
9
9
  open: boolean;
10
10
  title: string;
11
11
  description: string;
12
+ error?: string | null;
12
13
  confirmLabel?: string;
13
14
  pending?: boolean;
14
15
  onCancel: () => void;
@@ -20,6 +21,7 @@ export function ConfirmDialog({
20
21
  open,
21
22
  title,
22
23
  description,
24
+ error,
23
25
  confirmLabel = t('Delete'),
24
26
  pending = false,
25
27
  onCancel,
@@ -28,21 +30,49 @@ export function ConfirmDialog({
28
30
  }: ConfirmDialogProps) {
29
31
  const titleId = useId();
30
32
  const descriptionId = useId();
33
+ const errorId = useId();
34
+ const dialogRef = useRef<HTMLDivElement>(null);
35
+ const cancelRef = useRef<HTMLButtonElement>(null);
31
36
 
32
37
  useEffect(() => {
33
38
  if (!open) return;
39
+ const previouslyFocused =
40
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
41
+ cancelRef.current?.focus();
34
42
  const onKey = (event: KeyboardEvent) => {
35
- if (event.key !== 'Escape' || pending) return;
36
- onCancel();
43
+ if (event.key === 'Escape' && !pending) {
44
+ onCancel();
45
+ return;
46
+ }
47
+ if (event.key !== 'Tab') return;
48
+ const focusable = Array.from(
49
+ dialogRef.current?.querySelectorAll<HTMLElement>(
50
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
51
+ ) ?? [],
52
+ );
53
+ if (focusable.length === 0) return;
54
+ const first = focusable[0];
55
+ const last = focusable[focusable.length - 1];
56
+ if (event.shiftKey && document.activeElement === first) {
57
+ event.preventDefault();
58
+ last.focus();
59
+ } else if (!event.shiftKey && document.activeElement === last) {
60
+ event.preventDefault();
61
+ first.focus();
62
+ }
37
63
  };
38
64
  window.addEventListener('keydown', onKey);
39
- return () => window.removeEventListener('keydown', onKey);
65
+ return () => {
66
+ window.removeEventListener('keydown', onKey);
67
+ if (previouslyFocused?.isConnected) previouslyFocused.focus();
68
+ };
40
69
  }, [open, pending, onCancel]);
41
70
 
42
71
  if (!open || typeof document === 'undefined') return null;
43
72
 
44
73
  return createPortal(
45
74
  <div
75
+ ref={dialogRef}
46
76
  className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
47
77
  onMouseDown={(event) => {
48
78
  if (event.target === event.currentTarget && !pending) onCancel();
@@ -50,7 +80,7 @@ export function ConfirmDialog({
50
80
  role="alertdialog"
51
81
  aria-modal="true"
52
82
  aria-labelledby={titleId}
53
- aria-describedby={descriptionId}
83
+ aria-describedby={error ? `${descriptionId} ${errorId}` : descriptionId}
54
84
  data-testid={testId}
55
85
  >
56
86
  <div className="w-[380px] max-w-[92vw] rounded-2xl border border-agent-border bg-agent-canvas p-5 shadow-2xl">
@@ -63,12 +93,17 @@ export function ConfirmDialog({
63
93
  >
64
94
  {description}
65
95
  </p>
96
+ {error && (
97
+ <p id={errorId} role="alert" className="mt-2 text-sm text-agent-destructive">
98
+ {error}
99
+ </p>
100
+ )}
66
101
  <div className="mt-5 flex justify-end gap-2">
67
102
  <button
103
+ ref={cancelRef}
68
104
  type="button"
69
105
  onClick={onCancel}
70
106
  disabled={pending}
71
- autoFocus
72
107
  className="h-9 rounded-full px-4 text-sm text-agent-muted-foreground transition-colors hover:bg-agent-muted hover:text-agent-foreground disabled:opacity-40"
73
108
  data-testid={`${testId}-cancel`}
74
109
  >
@@ -139,8 +139,11 @@ export function TerminalView({
139
139
  containerRef.current.addEventListener('contextmenu', onContextMenu);
140
140
 
141
141
  let cancelled = false;
142
+ const pendingChunks: Array<{ sessionId: string; chunk: string }> = [];
142
143
  const offData = terminal.onData(({ sessionId, chunk }) => {
143
- if (sessionIdRef.current && sessionId === sessionIdRef.current) {
144
+ if (sessionIdRef.current === null) {
145
+ pendingChunks.push({ sessionId, chunk });
146
+ } else if (sessionId === sessionIdRef.current) {
144
147
  term.write(chunk);
145
148
  }
146
149
  });
@@ -159,6 +162,12 @@ export function TerminalView({
159
162
  });
160
163
  if (cancelled) return;
161
164
  sessionIdRef.current = session.id;
165
+ for (const pending of pendingChunks) {
166
+ if (pending.sessionId === session.id) {
167
+ term.write(pending.chunk);
168
+ }
169
+ }
170
+ pendingChunks.length = 0;
162
171
  setStatus('ready');
163
172
  term.writeln(
164
173
  `\x1b[90m[agent-shell] ${session.shell} pid=${session.pid} cwd=${session.cwd}\x1b[0m`,
@@ -1254,11 +1254,13 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1254
1254
  setIsDragging(false);
1255
1255
  };
1256
1256
 
1257
- // 把新文件并入列表:按 path(有路径时)或 name(浏览器模式无路径)去重。
1257
+ // 桌面壳的真实 path 可去重;浏览器 File 没有 path,同名文件可能来自
1258
+ // 不同目录,必须全部保留,提交落盘时由宿主生成唯一文件名。
1258
1259
  const addFiles = (incoming: AttachmentFile[]) => {
1259
- const keyOf = (f: AttachmentFile) => f.path || f.name;
1260
- const existingKeys = new Set(actualFiles.map(keyOf));
1261
- const filteredNewFiles = incoming.filter((f) => !existingKeys.has(keyOf(f)));
1260
+ const existingPaths = new Set(actualFiles.map((file) => file.path).filter(Boolean));
1261
+ const filteredNewFiles = incoming.filter(
1262
+ (file) => !file.path || !existingPaths.has(file.path),
1263
+ );
1262
1264
  if (filteredNewFiles.length > 0) {
1263
1265
  actualOnFilesChange([...actualFiles, ...filteredNewFiles]);
1264
1266
  }
@@ -1349,9 +1351,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1349
1351
  if (value[removeEnd] === ' ') removeEnd += 1;
1350
1352
  replaceRange(start, removeEnd, '');
1351
1353
  if (ref) {
1352
- setMentionReferences((prev) =>
1353
- prev.filter((item) => !(item.type === ref.type && item.id === ref.id)),
1354
+ const nextReferences = mentionReferences.filter(
1355
+ (item) => !(item.type === ref.type && item.id === ref.id),
1354
1356
  );
1357
+ setMentionReferences(nextReferences);
1358
+ onMentionReferencesChange?.(nextReferences);
1355
1359
  }
1356
1360
  };
1357
1361
 
@@ -1686,12 +1690,13 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1686
1690
  event.key === 'Enter' && !event.shiftKey;
1687
1691
  if (isSendCombo) {
1688
1692
  event.preventDefault();
1693
+ const submittedText = getEditableText(event.currentTarget).trim();
1689
1694
  // streaming 期间:Enter = follow-up 排队(本轮结束后自动作为下一轮
1690
1695
  // 发出);⌘/Ctrl+Enter = 轮中插队(注入运行中的回合;注入不了时
1691
1696
  // hook 兜底为 W6-2 排队或新回合直发,消息不会丢)。
1692
1697
  if (isStreaming) {
1693
- if ((event.metaKey || event.ctrlKey) && onSteer && trimmed) {
1694
- void onSteer(trimmed).then((outcome) => {
1698
+ if ((event.metaKey || event.ctrlKey) && onSteer && submittedText) {
1699
+ void onSteer(submittedText).then((outcome) => {
1695
1700
  // steered / queued / sent 三种结果消息都已落地(注入当前回合 /
1696
1701
  // 进入待发队列 / 作为新回合发出),草稿都可以清;queued 额外
1697
1702
  // 提示用户"不是追加进当前回合"。
@@ -1706,8 +1711,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
1706
1711
  });
1707
1712
  return;
1708
1713
  }
1709
- if (onFollowUp && trimmed) {
1710
- onFollowUp(trimmed);
1714
+ if (onFollowUp && submittedText) {
1715
+ onFollowUp(submittedText);
1711
1716
  onChange('');
1712
1717
  }
1713
1718
  return;
@@ -0,0 +1,122 @@
1
+ import { useState } from 'react';
2
+ import { LuCirclePause, LuPlay, LuRepeat2, LuSquare, LuTarget, LuTrash2 } from 'react-icons/lu';
3
+
4
+ import { t } from '@/i18n';
5
+ import {
6
+ stopChatLoop,
7
+ updateChatGoal,
8
+ type LocalGoal,
9
+ type LocalMonitoredLoop,
10
+ } from '@/lib/local-api';
11
+
12
+ export interface GoalLoopStatusBarProps {
13
+ chatId: string;
14
+ goal: LocalGoal | null;
15
+ loops: LocalMonitoredLoop[];
16
+ onChanged: () => void;
17
+ }
18
+
19
+ /** Compact native status for the goal and monitored PTY loops in this chat. */
20
+ export function GoalLoopStatusBar({
21
+ chatId,
22
+ goal,
23
+ loops = [],
24
+ onChanged,
25
+ }: GoalLoopStatusBarProps) {
26
+ const [busy, setBusy] = useState(false);
27
+ const visibleGoal = goal?.phase === 'complete' ? null : goal;
28
+ if (!visibleGoal && loops.length === 0) return null;
29
+
30
+ const goalAction = async (action: 'pause' | 'resume' | 'clear') => {
31
+ setBusy(true);
32
+ try {
33
+ await updateChatGoal(chatId, { action });
34
+ onChanged();
35
+ } finally {
36
+ setBusy(false);
37
+ }
38
+ };
39
+
40
+ const stopLoop = async (id: string) => {
41
+ setBusy(true);
42
+ try {
43
+ await stopChatLoop(chatId, id);
44
+ onChanged();
45
+ } finally {
46
+ setBusy(false);
47
+ }
48
+ };
49
+
50
+ return (
51
+ <div className="mx-2.5 mb-1 space-y-1 rounded-agent-md border border-agent-border bg-agent-muted/30 px-2.5 py-1.5 text-xs">
52
+ {visibleGoal ? (
53
+ <div className="flex items-center gap-2">
54
+ <LuTarget className="h-3.5 w-3.5 shrink-0" />
55
+ <span className="min-w-0 flex-1 truncate">
56
+ {t('Goal')} · {visibleGoal.objective} · {goalPhase(visibleGoal)}
57
+ </span>
58
+ {visibleGoal.phase === 'active' ? (
59
+ <button
60
+ type="button"
61
+ disabled={busy}
62
+ onClick={() => void goalAction('pause')}
63
+ title={t('Pause goal')}
64
+ aria-label={t('Pause goal')}
65
+ >
66
+ <LuCirclePause className="h-3.5 w-3.5" />
67
+ </button>
68
+ ) : visibleGoal.phase === 'paused' || visibleGoal.phase === 'blocked' ? (
69
+ <button
70
+ type="button"
71
+ disabled={busy}
72
+ onClick={() => void goalAction('resume')}
73
+ title={t('Resume goal')}
74
+ aria-label={t('Resume goal')}
75
+ >
76
+ <LuPlay className="h-3.5 w-3.5" />
77
+ </button>
78
+ ) : null}
79
+ <button
80
+ type="button"
81
+ disabled={busy}
82
+ onClick={() => void goalAction('clear')}
83
+ title={t('Clear goal')}
84
+ aria-label={t('Clear goal')}
85
+ >
86
+ <LuTrash2 className="h-3.5 w-3.5" />
87
+ </button>
88
+ </div>
89
+ ) : null}
90
+ {loops.map((loop) => (
91
+ <div key={loop.id} className="flex items-center gap-2">
92
+ <LuRepeat2 className="h-3.5 w-3.5 shrink-0" />
93
+ <span className="min-w-0 flex-1 truncate">
94
+ {t('Loop')} · {loop.prompt} · {t('every {seconds}s', { seconds: loop.intervalSeconds })}
95
+ </span>
96
+ <button
97
+ type="button"
98
+ disabled={busy}
99
+ onClick={() => void stopLoop(loop.id)}
100
+ title={t('Stop loop')}
101
+ aria-label={t('Stop loop')}
102
+ className="flex shrink-0 items-center gap-1 rounded-agent-sm border border-agent-border bg-agent-canva px-1.5 py-0.5 font-medium hover:bg-agent-muted disabled:opacity-50"
103
+ >
104
+ <LuSquare className="h-2.5 w-2.5 fill-current" />
105
+ <span>{t('Stop')}</span>
106
+ </button>
107
+ </div>
108
+ ))}
109
+ </div>
110
+ );
111
+ }
112
+
113
+ function goalPhase(goal: LocalGoal): string {
114
+ if (goal.phase === 'active') return t('Turn {turn}', { turn: goal.turns });
115
+ if (goal.phase === 'paused') return t('Paused');
116
+ if (goal.phase === 'blocked') {
117
+ return goal.blockedReason
118
+ ? t('Blocked: {reason}', { reason: goal.blockedReason })
119
+ : t('Blocked');
120
+ }
121
+ return t('Complete');
122
+ }
@@ -225,6 +225,10 @@ export function LocalChatPanel({
225
225
  const [mentionReferences, setMentionReferences] = useState<MentionReference[]>([]);
226
226
  const inputRef = useRef<ChatInputHandle>(null);
227
227
 
228
+ useEffect(() => {
229
+ inputRef.current?.focusAtEnd();
230
+ }, [chatId]);
231
+
228
232
  const handleSelectPrompt = useCallback((prompt: string) => {
229
233
  setInputValue(prompt);
230
234
  // Wait for the textarea to receive the new value before pulling focus,
@@ -54,6 +54,22 @@ import type { LlmSpeedSnapshot } from './process-status';
54
54
 
55
55
  const NEAR_BOTTOM_THRESHOLD_PX = 100;
56
56
 
57
+ function internalTrigger(message: ChatMessage): 'goal' | 'loop' | null {
58
+ if (!message.messageMetadata) return null;
59
+ try {
60
+ const metadata = JSON.parse(message.messageMetadata) as {
61
+ internal?: unknown;
62
+ trigger?: unknown;
63
+ };
64
+ if (metadata.internal !== true) return null;
65
+ return metadata.trigger === 'goal' || metadata.trigger === 'loop'
66
+ ? metadata.trigger
67
+ : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
57
73
  interface MessageListProps {
58
74
  messages: ChatMessage[];
59
75
  isStreaming: boolean;
@@ -330,15 +346,20 @@ export function MessageList({
330
346
  className="conversation-turn relative space-y-4"
331
347
  data-conversation-turn={turn.key}
332
348
  >
333
- {turn.userGroup && (
349
+ {turn.userGroup &&
350
+ !turn.userGroup.messages.every((message) => internalTrigger(message) !== null) ? (
334
351
  <div className="sticky top-0 z-10 -mx-3 px-3 py-1 bg-agent-canvas/95 backdrop-blur-xs">
335
352
  {turn.userGroup.messages.length === 1 ? (
336
- <UserMessage
337
- key={turn.userGroup.messages[0].id}
338
- message={turn.userGroup.messages[0]}
339
- agents={agents}
340
- chats={chats}
341
- />
353
+ internalTrigger(turn.userGroup.messages[0]) ? (
354
+ null
355
+ ) : (
356
+ <UserMessage
357
+ key={turn.userGroup.messages[0].id}
358
+ message={turn.userGroup.messages[0]}
359
+ agents={agents}
360
+ chats={chats}
361
+ />
362
+ )
342
363
  ) : (
343
364
  <div
344
365
  key={turn.userGroup.key}
@@ -348,21 +369,25 @@ export function MessageList({
348
369
  <div className="flex justify-start">
349
370
  <div className="mx-auto w-full max-w-[var(--chat-input-box-width)] overflow-hidden rounded-agent-lg border border-agent-border/80 bg-agent-muted/70 shadow-sm">
350
371
  {turn.userGroup.messages.map((msg, idx) => (
351
- <UserMessage
352
- key={msg.id}
353
- message={msg}
354
- agents={agents}
355
- chats={chats}
356
- isGrouped
357
- isAppended={idx > 0}
358
- />
372
+ internalTrigger(msg) ? (
373
+ null
374
+ ) : (
375
+ <UserMessage
376
+ key={msg.id}
377
+ message={msg}
378
+ agents={agents}
379
+ chats={chats}
380
+ isGrouped
381
+ isAppended={idx > 0}
382
+ />
383
+ )
359
384
  ))}
360
385
  </div>
361
386
  </div>
362
387
  </div>
363
388
  )}
364
389
  </div>
365
- )}
390
+ ) : null}
366
391
 
367
392
  {turn.assistants.map((item) => {
368
393
  const message = item.message;
@@ -46,12 +46,12 @@ export function todoProgressCopy(todos: SessionTodo[]): string {
46
46
  const summary = summarizeTodos(todos);
47
47
  if (summary.total === 0) return '';
48
48
  if (summary.completed === summary.total) {
49
- return t('{completed}/{total} done', { completed: summary.completed, total: summary.total });
49
+ return `${summary.completed}/${summary.total}`;
50
50
  }
51
51
  const currentIndex = todos.findIndex((todo) => todo.status === 'in_progress');
52
52
  const step =
53
53
  currentIndex >= 0 ? currentIndex + 1 : Math.min(summary.completed + 1, summary.total);
54
- return t('On step {step}/{total}', { step, total: summary.total });
54
+ return `${step}/${summary.total}`;
55
55
  }
56
56
 
57
57
  export function TodoItems({
@@ -211,17 +211,6 @@ export function SessionTodoList({ todos }: { todos: SessionTodo[] }) {
211
211
  <span>{t('Task list')}</span>
212
212
  </div>
213
213
  <div className="flex items-center gap-1.5">
214
- {progressLabel && (
215
- <span
216
- className={`rounded-full px-1.5 py-0.2 text-[10px] font-medium ${
217
- isAllCompleted
218
- ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
219
- : 'bg-blue-500/10 text-blue-600 dark:text-blue-400'
220
- }`}
221
- >
222
- {progressLabel}
223
- </span>
224
- )}
225
214
  <button
226
215
  type="button"
227
216
  onClick={() => setUserToggled(false)}
@@ -17,8 +17,12 @@ export interface TurnFile {
17
17
  additions?: number;
18
18
  /** 删除行数(展示用,如 -0)。 */
19
19
  deletions?: number;
20
- /** 类别:deliverable = present_files 声明的最终交付文件;intermediate = 其余本轮写过的文件。 */
20
+ /** 类别:deliverable = 本轮交付卡片;intermediate = 其余本轮写过的文件。 */
21
21
  category?: 'deliverable' | 'intermediate';
22
+ /** 后端已经按分层证据完成选择;前端必须原样采用 category。 */
23
+ selection?: 'resolved';
24
+ /** 交付选择的来源,供调试和后续展示使用。 */
25
+ deliverySource?: 'generation' | 'final-reference' | 'present-files' | 'inference';
22
26
  /** present_files 给出的一行说明(交付卡片副标题)。 */
23
27
  description?: string;
24
28
  }
@@ -41,6 +45,13 @@ export function parseTurnFiles(raw: unknown): TurnFile[] | null {
41
45
  ...(rec.category === 'deliverable' || rec.category === 'intermediate'
42
46
  ? { category: rec.category }
43
47
  : {}),
48
+ ...(rec.selection === 'resolved' ? { selection: rec.selection } : {}),
49
+ ...(rec.deliverySource === 'generation' ||
50
+ rec.deliverySource === 'final-reference' ||
51
+ rec.deliverySource === 'present-files' ||
52
+ rec.deliverySource === 'inference'
53
+ ? { deliverySource: rec.deliverySource }
54
+ : {}),
44
55
  ...(typeof rec.description === 'string' && rec.description
45
56
  ? { description: rec.description }
46
57
  : {}),
@@ -103,8 +114,8 @@ export const DELIVERABLE_EXTENSIONS = new Set([
103
114
  ]);
104
115
 
105
116
  /**
106
- * 后端按 present_files 声明给出 category;没有 category 的是声明机制上线前
107
- * 落库的消息,退回扩展名规则。
117
+ * 单文件扩展名猜测。回合里有多份文件时,`groupTurnFiles` 会再拿掉同名预览。
118
+ * 没有 category 的是声明机制上线前落库的消息。
108
119
  */
109
120
  export function getTurnFileCategory(file: TurnFile): 'deliverable' | 'intermediate' {
110
121
  if (file.category) return file.category;
@@ -247,6 +258,54 @@ function parseDiffStats(diffText: string): { additions: number; deletions: numbe
247
258
  return { additions, deletions };
248
259
  }
249
260
 
261
+ const SOURCE_EXTENSIONS = new Set([
262
+ '.pptx', '.ppt', '.key', // shell-neutral:allow — Office 幻灯片扩展名,不是产品品牌
263
+ '.docx', '.doc', '.pages',
264
+ '.xlsx', '.xls', '.xlsm', '.numbers',
265
+ ]);
266
+
267
+ const PREVIEW_NAME = /预览|截图|图像版|preview|screenshot|thumbnail/i;
268
+
269
+ /**
270
+ * 旧消息没有 selection=resolved,只纠正最明确的一种历史误判:同目录的
271
+ * 幻灯片与预览 PDF / 截图都被标成交付物。新消息完全采用后端结论。
272
+ */
273
+ function selectLegacyDeliverablePaths(files: readonly TurnFile[]): Set<string> {
274
+ const chosen = new Set(
275
+ files.filter((file) => getTurnFileCategory(file) === 'deliverable').map((file) => file.path),
276
+ );
277
+ const sources = files
278
+ .filter((file) => SOURCE_EXTENSIONS.has(getFileExtension(file.path)))
279
+ .map((file) => file.path);
280
+ for (const candidate of [...chosen]) {
281
+ const source = sources.find((item) => isLegacyPreviewOf(candidate, item));
282
+ if (!source) continue;
283
+ chosen.delete(candidate);
284
+ chosen.add(source);
285
+ }
286
+ return new Set(chosen);
287
+ }
288
+
289
+ function isLegacyPreviewOf(filePath: string, sourcePath: string): boolean {
290
+ if (filePath === sourcePath || getFileExtension(sourcePath) !== '.pptx') return false; // shell-neutral:allow — Office extension
291
+ const ext = getFileExtension(filePath);
292
+ if (ext !== '.pdf' && !['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) return false;
293
+ const file = splitTurnFilePath(filePath);
294
+ const source = splitTurnFilePath(sourcePath);
295
+ const normalized = (name: string) =>
296
+ name
297
+ .replace(/\.[^.]+$/, '')
298
+ .replace(PREVIEW_NAME, '')
299
+ .replace(/[\s._\-—–()()【】[\]]+/g, '')
300
+ .toLowerCase()
301
+ .normalize('NFC');
302
+ if (normalized(file.name) === normalized(source.name)) return true;
303
+ return (
304
+ PREVIEW_NAME.test(file.name) &&
305
+ file.dir.normalize('NFC') === source.dir.normalize('NFC')
306
+ );
307
+ }
308
+
250
309
  /** 把本轮文件按最终交付物(最终文件)与中间修改文件分类,并合并行数统计。 */
251
310
  export function groupTurnFiles(
252
311
  files: TurnFile[],
@@ -287,8 +346,16 @@ export function groupTurnFiles(
287
346
  let totalAdditions = 0;
288
347
  let totalDeletions = 0;
289
348
 
349
+ const backendResolved =
350
+ validFiles.length > 0 && validFiles.every((file) => file.selection === 'resolved');
351
+ const deliverablePaths = backendResolved
352
+ ? new Set(
353
+ validFiles.filter((file) => file.category === 'deliverable').map((file) => file.path),
354
+ )
355
+ : selectLegacyDeliverablePaths(validFiles);
356
+
290
357
  for (const file of validFiles) {
291
- const cat = getTurnFileCategory(file);
358
+ const cat = deliverablePaths.has(file.path) ? 'deliverable' : 'intermediate';
292
359
  // 匹配统计:尝试直接匹配或 basename 匹配
293
360
  let stats = statsByPath.get(file.path);
294
361
  if (!stats) {
@@ -0,0 +1,71 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+
3
+ import {
4
+ getChatGoal,
5
+ listChatLoops,
6
+ type LocalGoal,
7
+ type LocalMonitoredLoop,
8
+ } from '@/lib/local-api';
9
+ import { getHostBridge, hasHostBridge } from '@/lib/host-bridge';
10
+
11
+ export interface GoalAndLoopsState {
12
+ goal: LocalGoal | null;
13
+ loops: LocalMonitoredLoop[];
14
+ refreshGoal: () => Promise<void>;
15
+ refreshLoops: () => Promise<void>;
16
+ }
17
+
18
+ /** Native goal state and Cursor-style monitored-loop process state for one chat. */
19
+ export function useGoalAndLoops(chatId: string | null): GoalAndLoopsState {
20
+ const [goal, setGoal] = useState<LocalGoal | null>(null);
21
+ const [loops, setLoops] = useState<LocalMonitoredLoop[]>([]);
22
+
23
+ const refreshGoal = useCallback(async () => {
24
+ if (!chatId || !hasHostBridge()) {
25
+ setGoal(null);
26
+ return;
27
+ }
28
+ try {
29
+ setGoal((await getChatGoal(chatId)).goal);
30
+ } catch {
31
+ setGoal(null);
32
+ }
33
+ }, [chatId]);
34
+
35
+ const refreshLoops = useCallback(async () => {
36
+ if (!chatId || !hasHostBridge()) {
37
+ setLoops([]);
38
+ return;
39
+ }
40
+ try {
41
+ const response = await listChatLoops(chatId);
42
+ setLoops(Array.isArray(response.loops) ? response.loops : []);
43
+ } catch {
44
+ setLoops([]);
45
+ }
46
+ }, [chatId]);
47
+
48
+ useEffect(() => {
49
+ void refreshGoal();
50
+ void refreshLoops();
51
+ }, [refreshGoal, refreshLoops]);
52
+
53
+ useEffect(() => {
54
+ if (!chatId) return;
55
+ const bridge = getHostBridge();
56
+ const offGoal = bridge?.onPackEvent?.('goal-changed', (value) => {
57
+ const payload = value as { chatId?: string; goal?: LocalGoal | null };
58
+ if (payload.chatId === chatId) setGoal(payload.goal ?? null);
59
+ });
60
+ const offLoop = bridge?.onPackEvent?.('loop-changed', (value) => {
61
+ const payload = value as { chatId?: string; loops?: LocalMonitoredLoop[] };
62
+ if (payload.chatId === chatId) setLoops(payload.loops ?? []);
63
+ });
64
+ return () => {
65
+ offGoal?.();
66
+ offLoop?.();
67
+ };
68
+ }, [chatId]);
69
+
70
+ return { goal, loops, refreshGoal, refreshLoops };
71
+ }
@@ -119,10 +119,18 @@ function subscribeChannel<T>(channel: string, callback: (payload: T) => void): (
119
119
  }
120
120
 
121
121
  // ---------------------------------------------------------------------------
122
- // 流式聊天:fetch + ReadableStream。取消 = AbortController(server 端
123
- // req close → abort agent 循环,对齐 Electron cancelStream 语义)。
122
+ // 流式聊天:fetch + ReadableStream。聊天流通过显式 cancel 端点停止,原
123
+ // response 在服务端完成取消和落库后自然结束;其他流回退 AbortController。
124
124
  // ---------------------------------------------------------------------------
125
- const activeStreams = new Map<string, AbortController>();
125
+ const activeStreams = new Map<
126
+ string,
127
+ { controller: AbortController; cancelPath: string | null; cancelling: boolean }
128
+ >();
129
+
130
+ function chatCancelPath(path: string): string | null {
131
+ const match = path.match(/^(\/api\/v2\/chats\/[^/]+)\/(?:send|run|agent)$/);
132
+ return match ? `${match[1]}/cancel` : null;
133
+ }
126
134
 
127
135
  async function startStream(
128
136
  input: LocalBackendRequestInput,
@@ -146,7 +154,11 @@ async function startStream(
146
154
  onEvent({ type: 'error', error: `start stream failed (${res.status})` });
147
155
  return null;
148
156
  }
149
- activeStreams.set(streamId, controller);
157
+ activeStreams.set(streamId, {
158
+ controller,
159
+ cancelPath: chatCancelPath(input.path),
160
+ cancelling: false,
161
+ });
150
162
  void (async () => {
151
163
  const reader = res.body!.getReader();
152
164
  const decoder = new TextDecoder();
@@ -172,8 +184,16 @@ async function startStream(
172
184
  }
173
185
 
174
186
  function cancelStream(streamId: string): void {
175
- activeStreams.get(streamId)?.abort();
176
- activeStreams.delete(streamId);
187
+ const active = activeStreams.get(streamId);
188
+ if (!active || active.cancelling) return;
189
+ active.cancelling = true;
190
+ if (!active.cancelPath) {
191
+ active.controller.abort();
192
+ return;
193
+ }
194
+ void http('POST', active.cancelPath, {}).catch(() => {
195
+ active.controller.abort();
196
+ });
177
197
  }
178
198
 
179
199
  // ---------------------------------------------------------------------------
@@ -341,6 +341,60 @@ export async function getChatLiveStream(chatId: string) {
341
341
  });
342
342
  }
343
343
 
344
+ export interface LocalGoal {
345
+ id: string;
346
+ chatId: string;
347
+ revision: number;
348
+ objective: string;
349
+ phase: 'active' | 'paused' | 'blocked' | 'complete';
350
+ blockedReason?: string;
351
+ turns: number;
352
+ createdAt: number;
353
+ updatedAt: number;
354
+ }
355
+
356
+ export async function getChatGoal(chatId: string) {
357
+ return bridge().localBackend.request<{ goal: LocalGoal | null }>({
358
+ method: 'GET',
359
+ path: `/api/v2/chats/${encodeURIComponent(chatId)}/goal`,
360
+ });
361
+ }
362
+
363
+ export async function updateChatGoal(
364
+ chatId: string,
365
+ body:
366
+ | { action: 'edit'; objective: string }
367
+ | { action: 'pause' | 'resume' | 'complete' | 'clear' },
368
+ ) {
369
+ return bridge().localBackend.request<{ goal: LocalGoal | null }>({
370
+ method: 'POST',
371
+ path: `/api/v2/chats/${encodeURIComponent(chatId)}/goal`,
372
+ body,
373
+ });
374
+ }
375
+
376
+ export interface LocalMonitoredLoop {
377
+ id: string;
378
+ chatId: string;
379
+ terminalSessionId: string;
380
+ prompt: string;
381
+ intervalSeconds: number;
382
+ }
383
+
384
+ export async function listChatLoops(chatId: string) {
385
+ return bridge().localBackend.request<{ loops: LocalMonitoredLoop[] }>({
386
+ method: 'GET',
387
+ path: `/api/v2/chats/${encodeURIComponent(chatId)}/loops`,
388
+ });
389
+ }
390
+
391
+ export async function stopChatLoop(chatId: string, loopId: string) {
392
+ return bridge().localBackend.request<{ success: boolean }>({
393
+ method: 'DELETE',
394
+ path: `/api/v2/chats/${encodeURIComponent(chatId)}/loops/${encodeURIComponent(loopId)}`,
395
+ });
396
+ }
397
+
344
398
  /** 按 chatId 取消运行中的回合(切回后新挂载的视图手里没有原 streamId)。 */
345
399
  export async function cancelChatTurn(chatId: string) {
346
400
  return bridge().localBackend.request<{ success: boolean; reason?: string }>({
@@ -43,6 +43,8 @@ import {
43
43
  type SuggestedRepliesState,
44
44
  } from "@/components/chat/suggested-replies-model";
45
45
  import { useChatTasks } from "@/components/chat/useChatTasks";
46
+ import { useGoalAndLoops } from "@/components/chat/useGoalAndLoops";
47
+ import { GoalLoopStatusBar } from "@/components/chat/GoalLoopStatusBar";
46
48
  import { LuListChecks, LuArrowRight } from "react-icons/lu";
47
49
  import { LocalLlmSettingsModal } from "@/components/LocalLlmSettingsModal";
48
50
  import {
@@ -398,6 +400,12 @@ function AgentChatView({
398
400
  finished: finishedTasks,
399
401
  dismissFinished,
400
402
  } = useChatTasks(chatId);
403
+ const {
404
+ goal,
405
+ loops,
406
+ refreshGoal,
407
+ refreshLoops,
408
+ } = useGoalAndLoops(chatId);
401
409
  const handleInspectTask = useCallback(
402
410
  (task: InspectTaskInput) => {
403
411
  ctx.inspectTask({
@@ -416,6 +424,7 @@ function AgentChatView({
416
424
  // 把「正在运行 + 部分产出」恢复出来。initialLiveStream 是挂载时同步拿到的
417
425
  // 首帧,之后由轮询持续刷新。
418
426
  const [liveStream, setLiveStream] = useState<ChatLiveStream>(initialLiveStream);
427
+ const liveStreamEpochRef = useRef(0);
419
428
 
420
429
  // ── executed_actions plumbing ────────────────────────────────────────
421
430
  // Local-backend emits `{type: 'executed_actions', actions: [...]}` AFTER each
@@ -683,7 +692,6 @@ function AgentChatView({
683
692
  followUpUserMessage,
684
693
  pendingFollowUps,
685
694
  removeFollowUp,
686
- cancel,
687
695
  setMessages,
688
696
  appendMessage,
689
697
  } = useChatStream({
@@ -692,6 +700,42 @@ function AgentChatView({
692
700
  onUnknownEvent: handleUnknownEvent,
693
701
  onStreamError: handleStreamError,
694
702
  });
703
+ const awaitingRemoteActiveSnapshotRef = useRef(false);
704
+ useEffect(() => {
705
+ awaitingRemoteActiveSnapshotRef.current = false;
706
+ }, [chatId]);
707
+
708
+ useEffect(() => {
709
+ const bridge = getHostBridge();
710
+ if (!bridge?.onPackEvent) return;
711
+ const offStarted = bridge.onPackEvent('chat-turn-started', (value) => {
712
+ const payload = value as { chatId?: string };
713
+ if (payload.chatId === chatId) {
714
+ awaitingRemoteActiveSnapshotRef.current = true;
715
+ liveStreamEpochRef.current += 1;
716
+ setLiveStream((current) => ({ ...current, active: true }));
717
+ }
718
+ });
719
+ const offFinished = bridge.onPackEvent('chat-turn-finished', (value) => {
720
+ const payload = value as { chatId?: string };
721
+ if (payload.chatId !== chatId) return;
722
+ awaitingRemoteActiveSnapshotRef.current = false;
723
+ liveStreamEpochRef.current += 1;
724
+ setLiveStream({ active: false });
725
+ void bridge.localBackend.request<{
726
+ messages?: LocalChatMessage[];
727
+ }>({
728
+ method: "GET",
729
+ path: `/api/v2/chats/${encodeURIComponent(chatId)}/messages?limit=200`,
730
+ }).then((response) => {
731
+ if (response.messages) setMessages(chronological(response.messages));
732
+ });
733
+ });
734
+ return () => {
735
+ offStarted();
736
+ offFinished();
737
+ };
738
+ }, [chatId, setMessages]);
695
739
 
696
740
  // ── 切回恢复:远端回合仍在跑,但本 mount 不是发起者 ──────────────────
697
741
  // useChatStream.isStreaming 只在本 mount 自己发起的流时为 true;切回后的
@@ -735,10 +779,18 @@ function AgentChatView({
735
779
  useEffect(() => {
736
780
  if (!remoteStreaming) return;
737
781
  let cancelled = false;
782
+ const epoch = liveStreamEpochRef.current;
738
783
  const poll = async () => {
739
784
  try {
740
785
  const next = await getChatLiveStream(chatId);
741
- if (!cancelled) setLiveStream(next ?? { active: false });
786
+ if (!cancelled && liveStreamEpochRef.current === epoch) {
787
+ if (next?.active) {
788
+ awaitingRemoteActiveSnapshotRef.current = false;
789
+ } else if (awaitingRemoteActiveSnapshotRef.current) {
790
+ return;
791
+ }
792
+ setLiveStream(next ?? { active: false });
793
+ }
742
794
  } catch {
743
795
  /* 保持上一帧快照 */
744
796
  }
@@ -807,14 +859,12 @@ function AgentChatView({
807
859
  wasStreamingRef.current = isStreaming;
808
860
  }, [isStreaming, messages]);
809
861
 
810
- // 真正能中断后端 agent 循环的取消:框架 hook 的 cancel 只重置前端状态
811
- // (cancel 句柄在流结束后才被保存,是已知的上游缺陷),这里补上
812
- // transport.cancelActive() 让主进程 abort LLM / 工具循环。
862
+ // 真正中断后端 agent 循环;流保持运行态直到后端发回 cancelled done,
863
+ // 避免界面提前恢复发送后,新回合被仍在清理的旧回合以 409 拒绝。
813
864
  const handleCancel = useCallback(() => {
814
865
  transport.cancelActive();
815
- cancel();
816
866
  updateRemoteFollowUps(() => []);
817
- }, [transport, cancel, updateRemoteFollowUps]);
867
+ }, [transport, updateRemoteFollowUps]);
818
868
 
819
869
  // Reset the in-flight queue on every new user submit. We do this in a
820
870
  // wrapper rather than directly in `handleUnknownEvent`, because there's no
@@ -1258,6 +1308,15 @@ function AgentChatView({
1258
1308
  }
1259
1309
  inputBanner={
1260
1310
  <>
1311
+ <GoalLoopStatusBar
1312
+ chatId={chatId}
1313
+ goal={goal}
1314
+ loops={loops}
1315
+ onChanged={() => {
1316
+ void refreshGoal();
1317
+ void refreshLoops();
1318
+ }}
1319
+ />
1261
1320
  {/* W6-5 项目信任门控:项目含规则文件但未信任时提示授权。 */}
1262
1321
  {showProjectsChrome ? (
1263
1322
  <ProjectTrustBanner