@steerable/agent-shell-web 0.6.64 → 0.6.65

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.
@@ -1,15 +1,43 @@
1
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
1
+ import {
2
+ useEffect,
3
+ useLayoutEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ButtonHTMLAttributes,
8
+ type ReactNode,
9
+ } from 'react';
2
10
  import { createPortal } from 'react-dom';
3
- import { LuCheck, LuDownload, LuGitBranch, LuListTodo, LuListTree, LuLoaderCircle, LuTerminal } from 'react-icons/lu';
4
- import type { LocalChat, LocalTask } from '@/lib/local-api';
11
+ import {
12
+ LuCheck,
13
+ LuChevronLeft,
14
+ LuDownload,
15
+ LuEllipsis,
16
+ LuFile,
17
+ LuFolder,
18
+ LuGitBranch,
19
+ LuGitMerge,
20
+ LuList,
21
+ LuListTodo,
22
+ LuListTree,
23
+ LuLoaderCircle,
24
+ LuPin,
25
+ LuPinOff,
26
+ LuShare2,
27
+ LuTrash2,
28
+ } from 'react-icons/lu';
29
+ import type { LocalChat, LocalProject, LocalTask } from '@/lib/local-api';
5
30
  import {
6
31
  activateChatBranch,
32
+ discardTaskWorktree,
7
33
  getChatBranches,
34
+ mergeTaskWorktree,
35
+ openLocalPath,
36
+ setChatPinned,
8
37
  type ChatBranchesResponse,
9
38
  } from '@/lib/local-api';
10
39
  import { SessionTreeModal } from './chat/SessionTreeModal';
11
- import { TaskPanelModal } from './chat/TaskPanelModal';
12
- import { actionableTasks, summarizeTasks, type ChatTaskSummary } from './chat/useChatTasks';
40
+ import { summarizeTasks, type ChatTaskSummary } from './chat/useChatTasks';
13
41
  import type { PackChatSlotContribution } from '@/packs/registry';
14
42
  import {
15
43
  fetchChatDocument,
@@ -19,38 +47,61 @@ import {
19
47
  saveJsonFile,
20
48
  } from '@/lib/portable';
21
49
  import { hostToolChrome } from '@/lib/host-tools';
50
+ import { PanelToggleButton } from '@/layouts/PanelToggleButton';
22
51
  import { t } from '@/i18n';
52
+ import {
53
+ getTurnFileCategory,
54
+ isIgnoredTurnFile,
55
+ splitTurnFilePath,
56
+ type TurnFile,
57
+ } from './chat/turn-files';
23
58
 
24
59
  interface ChatHeaderProps {
25
60
  chat: LocalChat | null;
26
61
  /**
27
62
  * W1.2.1: called after the active branch switches — the page re-hydrates
28
63
  * the message list from the re-projected store. When provided (and the
29
- * chat has branches), a branch picker appears in the header.
64
+ * chat has branches), a branch picker appears in the chat menu.
30
65
  */
31
66
  onBranchSwitched?: () => void;
32
67
  /** 点击后台任务行:在右侧栏打开该任务的推理过程。 */
33
- onInspectTask?: (task: { id: string; chatId: string; title: string }) => void;
68
+ onInspectTask?: (task: {
69
+ id: string;
70
+ chatId: string;
71
+ title: string;
72
+ live?: boolean;
73
+ }) => void;
34
74
  /** 本对话的后台任务(AgentPage 的 `useChatTasks` 单一订阅源)。 */
35
75
  tasks?: LocalTask[];
36
- /** 包注册的聊天页槽位(如「文档预览」):入口放在本标题栏,按会话独立开关。 */
76
+ /** 包注册的聊天页槽位(如文档预览)。有槽位或终端时,标题栏才放右侧开关。 */
37
77
  chatSlots?: readonly PackChatSlotContribution[];
38
- /** 当前会话打开的右侧栏位(null = 都关着)。 */
78
+ /** 当前会话正在看的右侧标签(null = 都关着)。 */
39
79
  rightPanel?: string | null;
40
- /** 点击槽位入口:点已打开的关闭,点另一个直接切换。 */
41
- onToggleChatSlot?: (slotId: string) => void;
42
- /** 切换右侧面板(终端、包槽位等)。 */
43
- onToggleRightPanel?: (kind: string) => void;
80
+ /** 已经打开且正在显示的标签。缺省时只把 rightPanel 当作唯一打开项。 */
81
+ openPanelIds?: readonly string[];
82
+ /** 右侧整栏关着时,点标题栏开关把它打开。 */
83
+ onOpenRightPanel?: () => void;
84
+ /** 产品打开项目入口时,summary 展示当前对话关联的目录。 */
85
+ showProject?: boolean;
86
+ project?: LocalProject | null;
87
+ /** 本对话各回合写过的文件。summary 按路径去重后列出。 */
88
+ outputs?: readonly TurnFile[];
89
+ /** 把对话截图写入剪贴板。缺省时菜单不显示分享。 */
90
+ onShare?: () => Promise<boolean> | void;
91
+ /** 置顶写完后刷新会话列表。 */
92
+ onChatChanged?: () => void | Promise<void>;
44
93
  }
45
94
 
95
+ type HeaderPopover = 'menu' | 'summary';
96
+
46
97
  /**
47
- * ChatHeader — slim title bar above ChatPanel. Shows:
48
- * - chat title (完整显示,长标题换行不截断)
49
- * - shortened chat id (for support / debugging)
50
- * - 导出 / 分支 / 后台任务 / 包槽位 / 终端 入口
98
+ * ChatHeader — slim title bar above ChatPanel.
99
+ * 右侧入口:
100
+ * - 菜单:操作当前对话(置顶、导出、分享、分支)
101
+ * - summary:当前对话的资源(关联目录、产出文件;有后台任务时也列在这里,点开后在右侧显示过程)
102
+ * - 面板开关:右侧整栏关着时出现在这里,点一下打开。栏开着时按钮挪到右侧标签条,点一下收起。
51
103
  *
52
- * 刷新按钮已下线(消息流有 SSE 自动同步)。分享在最后一条
53
- * 助手消息的时间戳行,不占标题栏。
104
+ * 刷新按钮已下线(消息流有 SSE 自动同步)。
54
105
  */
55
106
  export function ChatHeader({
56
107
  chat,
@@ -59,56 +110,58 @@ export function ChatHeader({
59
110
  tasks = [],
60
111
  chatSlots = [],
61
112
  rightPanel = null,
62
- onToggleChatSlot,
63
- onToggleRightPanel,
113
+ openPanelIds,
114
+ onOpenRightPanel,
115
+ showProject = false,
116
+ project = null,
117
+ outputs = [],
118
+ onShare,
119
+ onChatChanged,
64
120
  }: ChatHeaderProps) {
65
- const [branchMenuOpen, setBranchMenuOpen] = useState(false);
121
+ const [popover, setPopover] = useState<HeaderPopover | null>(null);
122
+ const [menuPage, setMenuPage] = useState<'root' | 'branches'>('root');
66
123
  const [treeModalOpen, setTreeModalOpen] = useState(false);
67
- const [taskPanelOpen, setTaskPanelOpen] = useState(false);
68
124
  const [branches, setBranches] = useState<ChatBranchesResponse | null>(null);
69
125
  const [switching, setSwitching] = useState(false);
70
126
  const [exporting, setExporting] = useState(false);
71
127
  const [exportError, setExportError] = useState<string | null>(null);
128
+ const [sharing, setSharing] = useState(false);
129
+ const [pinning, setPinning] = useState(false);
130
+ const [openingPath, setOpeningPath] = useState<string | null>(null);
72
131
  const [menuBox, setMenuBox] = useState<{ top: number; left: number; width: number } | null>(
73
132
  null,
74
133
  );
75
- const buttonRef = useRef<HTMLButtonElement | null>(null);
76
- const menuRef = useRef<HTMLDivElement | null>(null);
77
-
78
- const [isMac, setIsMac] = useState(() => {
79
- if (typeof navigator !== 'undefined') {
80
- return /Mac|iPod|iPhone|iPad/.test(navigator.platform);
81
- }
82
- return false;
83
- });
84
-
85
- useEffect(() => {
86
- if (typeof navigator !== 'undefined') {
87
- setIsMac(/Mac|iPod|iPhone|iPad/.test(navigator.platform));
88
- }
89
- }, []);
134
+ const menuButtonRef = useRef<HTMLButtonElement | null>(null);
135
+ const summaryButtonRef = useRef<HTMLButtonElement | null>(null);
136
+ const popoverRef = useRef<HTMLDivElement | null>(null);
90
137
 
91
138
  const showTerminalChrome = hostToolChrome('terminal');
92
- const togglePanel = onToggleRightPanel ?? onToggleChatSlot;
139
+ const allowOpenPath = hostToolChrome('local-fs');
140
+ const openIds = openPanelIds ?? (rightPanel ? [rightPanel] : []);
141
+ const canTogglePanel = chatSlots.length > 0 || showTerminalChrome;
93
142
 
94
143
  const branchCount = branches ? branches.lineage.length + branches.children.length : 0;
144
+ const files = useMemo(() => uniqueOutputs(outputs), [outputs]);
95
145
 
96
146
  const taskSummary = useMemo(() => summarizeTasks(tasks), [tasks]);
97
147
  const taskBadge = describeTaskBadge(taskSummary);
98
- const taskShortcut = useMemo(
99
- () => describeTaskShortcut(tasks, taskSummary),
100
- [tasks, taskSummary],
101
- );
102
148
 
103
- const openBranchMenu = async () => {
104
- if (!chat || branchMenuOpen) {
105
- setBranchMenuOpen(false);
106
- return;
107
- }
149
+ const closePopover = () => {
150
+ setPopover(null);
151
+ setMenuPage('root');
152
+ };
153
+
154
+ const togglePopover = (next: HeaderPopover) => {
155
+ setMenuPage('root');
156
+ setPopover((current) => (current === next ? null : next));
157
+ };
158
+
159
+ const openBranchPage = async () => {
160
+ if (!chat) return;
161
+ setMenuPage('branches');
108
162
  try {
109
163
  const data = await getChatBranches(chat.id);
110
164
  setBranches(data);
111
- setBranchMenuOpen(true);
112
165
  } catch {
113
166
  setBranches(null);
114
167
  }
@@ -119,7 +172,7 @@ export function ChatHeader({
119
172
  setSwitching(true);
120
173
  try {
121
174
  await activateChatBranch(chat.id, recordId);
122
- setBranchMenuOpen(false);
175
+ closePopover();
123
176
  onBranchSwitched?.();
124
177
  } catch {
125
178
  // 切换失败(分支族外记录 / sidecar 离线)——菜单保持打开,用户可重试。
@@ -128,14 +181,69 @@ export function ChatHeader({
128
181
  }
129
182
  };
130
183
 
184
+ const exportChat = () => {
185
+ if (!chat || exporting) return;
186
+ setExporting(true);
187
+ setExportError(null);
188
+ void fetchChatDocument(chat.id)
189
+ .then((doc) => saveJsonFile(safeDownloadName(chat.title, t('Chat')), doc))
190
+ .then((saved) => {
191
+ if (saved) closePopover();
192
+ })
193
+ .catch((err: unknown) => setExportError(portableErrorMessage(err)))
194
+ .finally(() => setExporting(false));
195
+ };
196
+
197
+ const shareChat = () => {
198
+ if (!onShare || sharing) return;
199
+ setSharing(true);
200
+ void Promise.resolve(onShare())
201
+ .then((ok) => {
202
+ if (ok !== false) closePopover();
203
+ })
204
+ .finally(() => setSharing(false));
205
+ };
206
+
207
+ const togglePin = () => {
208
+ if (!chat || pinning) return;
209
+ setPinning(true);
210
+ void setChatPinned(chat.id, !chat.isPinned)
211
+ .then(() => onChatChanged?.())
212
+ .catch(() => {
213
+ // 宿主不在或写入失败时保持菜单,方便再点一次。
214
+ })
215
+ .finally(() => setPinning(false));
216
+ };
217
+
218
+ const openListedTask = (task: LocalTask) => {
219
+ onInspectTask?.({
220
+ id: task.id,
221
+ chatId: task.chatId,
222
+ title: task.task,
223
+ ...(task.status === 'running' || task.status === 'blocked' ? { live: true } : {}),
224
+ });
225
+ closePopover();
226
+ };
227
+
228
+ const openOutput = (filePath: string) => {
229
+ if (!allowOpenPath || openingPath) return;
230
+ setOpeningPath(filePath);
231
+ void openLocalPath(filePath).finally(() => setOpeningPath(null));
232
+ };
233
+
234
+ const anchor =
235
+ popover === 'menu'
236
+ ? menuButtonRef.current
237
+ : popover === 'summary'
238
+ ? summaryButtonRef.current
239
+ : null;
240
+
131
241
  useLayoutEffect(() => {
132
- if (!branchMenuOpen) {
242
+ if (!popover || !anchor) {
133
243
  setMenuBox(null);
134
244
  return;
135
245
  }
136
- const button = buttonRef.current;
137
- if (!button) return;
138
- const update = () => setMenuBox(placeBranchMenu(button));
246
+ const update = () => setMenuBox(placeHeaderPopover(anchor, sheetWidth(popover, menuPage)));
139
247
  update();
140
248
  window.addEventListener('resize', update);
141
249
  window.addEventListener('scroll', update, true);
@@ -143,75 +251,83 @@ export function ChatHeader({
143
251
  window.removeEventListener('resize', update);
144
252
  window.removeEventListener('scroll', update, true);
145
253
  };
146
- }, [branchMenuOpen]);
254
+ }, [popover, anchor, menuPage]);
147
255
 
148
256
  useEffect(() => {
149
- if (!branchMenuOpen) return;
257
+ if (!popover) return;
150
258
  const onPointerDown = (e: MouseEvent) => {
151
259
  const target = e.target as Node;
152
- if (buttonRef.current?.contains(target) || menuRef.current?.contains(target)) return;
153
- setBranchMenuOpen(false);
260
+ if (
261
+ menuButtonRef.current?.contains(target) ||
262
+ summaryButtonRef.current?.contains(target) ||
263
+ popoverRef.current?.contains(target)
264
+ ) {
265
+ return;
266
+ }
267
+ closePopover();
154
268
  };
155
269
  document.addEventListener('mousedown', onPointerDown);
156
270
  return () => document.removeEventListener('mousedown', onPointerDown);
157
- }, [branchMenuOpen]);
271
+ }, [popover]);
158
272
 
159
- const branchMenu =
160
- branchMenuOpen && menuBox
273
+ const popoverNode =
274
+ popover && menuBox
161
275
  ? createPortal(
162
276
  <div
163
- ref={menuRef}
164
- className="fixed z-[80] rounded-lg border border-agent-border bg-agent-canvas p-1 shadow-lg"
277
+ ref={popoverRef}
278
+ className="fixed z-[80] max-h-[min(24rem,70vh)] overflow-y-auto rounded-lg border border-agent-border bg-agent-canvas p-1 shadow-lg"
165
279
  style={{ top: menuBox.top, left: menuBox.left, width: menuBox.width }}
166
- data-branch-menu
280
+ data-header-popover={popover}
167
281
  >
168
- {!branches || branchCount === 0 ? (
169
- <div className="px-2.5 py-1.5 text-xs text-agent-muted-foreground">
170
- {t('No branches yet. After you regenerate a reply, the old version stays here.')}
171
- </div>
172
- ) : (
173
- <>
174
- {branches.lineage.map((point) => (
175
- <BranchRow
176
- key={point.recordId}
177
- label={point.label}
178
- active={point.recordId === branches.activeRecordId}
179
- disabled={switching}
180
- onSelect={() => void switchBranch(point.recordId)}
181
- />
182
- ))}
183
- {branches.children.map((point) => (
184
- <BranchRow
185
- key={point.recordId}
186
- label={point.label}
187
- active={point.recordId === branches.activeRecordId}
188
- disabled={switching}
189
- onSelect={() => void switchBranch(point.recordId)}
190
- />
191
- ))}
192
- {/* 全树入口:下拉只有 lineage + 直接子节点,堂兄弟等更远的
193
- 家族成员在树视图里切换。 */}
194
- <div className="mt-1 border-t border-agent-border pt-1">
195
- <button
196
- type="button"
197
- onClick={() => {
198
- setBranchMenuOpen(false);
199
- setTreeModalOpen(true);
200
- }}
201
- className="flex w-full items-center gap-2 rounded px-2.5 py-1 text-left text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
202
- data-action="branch-tree"
203
- >
204
- <LuListTree className="h-3 w-3 shrink-0" />
205
- {t('View full branch tree')}
206
- </button>
207
- </div>
208
- </>
282
+ {popover === 'menu' &&
283
+ (menuPage === 'branches' ? (
284
+ <BranchPage
285
+ branches={branches}
286
+ branchCount={branchCount}
287
+ switching={switching}
288
+ onBack={() => setMenuPage('root')}
289
+ onSelect={(recordId) => void switchBranch(recordId)}
290
+ onOpenTree={() => {
291
+ closePopover();
292
+ setTreeModalOpen(true);
293
+ }}
294
+ />
295
+ ) : (
296
+ <ChatActionsMenu
297
+ pinned={Boolean(chat?.isPinned)}
298
+ pinning={pinning}
299
+ onTogglePin={togglePin}
300
+ showExport={Boolean(chat && isPortableEnabled())}
301
+ exporting={exporting}
302
+ exportError={exportError}
303
+ onExport={exportChat}
304
+ showShare={Boolean(onShare)}
305
+ sharing={sharing}
306
+ onShare={shareChat}
307
+ showBranches={Boolean(onBranchSwitched)}
308
+ onOpenBranches={() => void openBranchPage()}
309
+ />
310
+ ))}
311
+ {popover === 'summary' && (
312
+ <ChatSummary
313
+ showProject={showProject}
314
+ project={project}
315
+ files={files}
316
+ allowOpenPath={allowOpenPath}
317
+ openingPath={openingPath}
318
+ onOpen={openOutput}
319
+ tasks={tasks}
320
+ onOpenTask={openListedTask}
321
+ />
209
322
  )}
210
323
  </div>,
211
324
  document.body,
212
325
  )
213
326
  : null;
214
327
 
328
+ const iconButton =
329
+ 'flex h-7 shrink-0 items-center justify-center gap-1 rounded-full text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground';
330
+
215
331
  return (
216
332
  <header className="flex w-full min-w-0 items-center gap-2 bg-agent-canvas px-2.5 py-1.5">
217
333
  <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-0.5">
@@ -222,155 +338,48 @@ export function ChatHeader({
222
338
  {chat?.title ?? t('No chat selected')}
223
339
  </span>
224
340
  </div>
225
- {chat && isPortableEnabled() && (
341
+ {chat && (
226
342
  <button
343
+ ref={menuButtonRef}
227
344
  type="button"
228
- disabled={exporting}
229
- onClick={() => {
230
- setExporting(true);
231
- setExportError(null);
232
- void fetchChatDocument(chat.id)
233
- .then((doc) => saveJsonFile(safeDownloadName(chat.title, t('Chat')), doc))
234
- .then((saved) => {
235
- if (!saved) return;
236
- })
237
- .catch((err: unknown) => setExportError(portableErrorMessage(err)))
238
- .finally(() => setExporting(false));
239
- }}
240
- className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground disabled:opacity-60"
241
- title={exportError ?? t('Export this chat')}
242
- aria-label={t('Export this chat')}
243
- data-testid="header-export-chat"
345
+ onClick={() => togglePopover('menu')}
346
+ className={`${iconButton} w-7 ${
347
+ popover === 'menu' ? 'bg-agent-foreground/10 text-agent-foreground' : ''
348
+ }`}
349
+ title={t('Chat menu')}
350
+ aria-label={t('Chat menu')}
351
+ aria-expanded={popover === 'menu'}
352
+ data-testid="header-chat-menu"
244
353
  >
245
- <LuDownload className="h-3.5 w-3.5" />
246
- {exportError ? t('Export failed') : exporting ? t('Exporting') : t('Export')}
354
+ <LuEllipsis className="h-4 w-4" />
247
355
  </button>
248
356
  )}
249
- {chat && onBranchSwitched && (
250
- <div className="shrink-0">
251
- <button
252
- ref={buttonRef}
253
- type="button"
254
- onClick={() => void openBranchMenu()}
255
- className="flex h-7 items-center gap-1 rounded-full px-2 text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
256
- title={t('Chat branches (forks created by regenerating)')}
257
- aria-label={t('Chat branches')}
258
- data-action="branches"
259
- >
260
- <LuGitBranch className="h-3.5 w-3.5" />
261
- {branchCount > 0 && <span>{branchCount}</span>}
262
- </button>
263
- {branchMenu}
264
- </div>
265
- )}
266
357
  {chat && (
267
358
  <button
359
+ ref={summaryButtonRef}
268
360
  type="button"
269
- onClick={() => {
270
- if (taskShortcut?.kind === 'process' && onInspectTask) {
271
- onInspectTask({
272
- id: taskShortcut.task.id,
273
- chatId: taskShortcut.task.chatId,
274
- title: taskShortcut.task.task,
275
- });
276
- return;
277
- }
278
- setTaskPanelOpen(true);
279
- }}
280
- className={`flex h-7 shrink-0 items-center gap-1 rounded-full transition-colors ${
281
- taskBadge
282
- ? `px-2 ${taskBadge.className}`
283
- : 'w-7 justify-center text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground'
361
+ onClick={() => togglePopover('summary')}
362
+ className={`${iconButton} ${taskBadge ? 'px-2' : 'w-7'} ${
363
+ popover === 'summary' ? 'bg-agent-foreground/10 text-agent-foreground' : ''
284
364
  }`}
285
- title={taskButtonTitle(taskBadge, taskShortcut)}
286
- aria-label={
287
- taskBadge
288
- ? t('Background tasks: {title}', { title: taskBadge.title })
289
- : t('Background tasks')
290
- }
291
- data-action="tasks"
365
+ title={taskBadge?.title ?? t('Chat resources')}
366
+ aria-label={t('Chat resources')}
367
+ aria-expanded={popover === 'summary'}
368
+ data-testid="header-chat-summary"
292
369
  data-task-state={taskBadge?.state}
293
- data-task-shortcut={taskShortcut?.kind}
294
370
  >
295
- {taskSummary.running > 0 ? (
296
- <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
297
- ) : (
298
- <LuListTodo className="h-3.5 w-3.5" />
371
+ <LuList className="h-4 w-4" />
372
+ {taskBadge && (
373
+ <span className={`rounded-full px-1 text-xs ${taskBadge.className}`}>
374
+ {taskBadge.count}
375
+ </span>
299
376
  )}
300
- {taskBadge && <span className="text-xs">{taskBadge.count}</span>}
301
- </button>
302
- )}
303
- {chat &&
304
- chatSlots.map((slot) => {
305
- const open = rightPanel === slot.slotId;
306
- return (
307
- <button
308
- key={slot.slotId}
309
- type="button"
310
- onClick={() => onToggleChatSlot?.(slot.slotId)}
311
- className={`flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs font-medium transition-colors ${
312
- open
313
- ? 'bg-agent-foreground/10 text-agent-foreground'
314
- : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground'
315
- }`}
316
- title={
317
- open
318
- ? t('Close {name}', { name: slot.title })
319
- : t('Open {name}', { name: slot.title })
320
- }
321
- aria-label={slot.title}
322
- aria-pressed={open}
323
- data-action={`chat-slot-${slot.slotId}`}
324
- data-testid={`header-slot-${slot.slotId}`}
325
- >
326
- <slot.Icon className="h-3.5 w-3.5" />
327
- {slot.title}
328
- </button>
329
- );
330
- })}
331
- {chat && showTerminalChrome && (
332
- <button
333
- type="button"
334
- onClick={() => togglePanel?.('terminal')}
335
- className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs transition-colors ${
336
- rightPanel === 'terminal'
337
- ? 'bg-agent-foreground/10 text-agent-foreground'
338
- : 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground'
339
- }`}
340
- title={
341
- rightPanel === 'terminal'
342
- ? t('Close terminal panel ({shortcut})', { shortcut: isMac ? '⌘T' : 'Ctrl+T' })
343
- : t('Open terminal panel ({shortcut})', { shortcut: isMac ? '⌘T' : 'Ctrl+T' })
344
- }
345
- aria-label={t('Terminal')}
346
- aria-pressed={rightPanel === 'terminal'}
347
- data-action="terminal"
348
- data-testid="header-terminal"
349
- >
350
- <LuTerminal className="h-3.5 w-3.5" />
351
377
  </button>
352
378
  )}
353
- {chat && taskPanelOpen && (
354
- <TaskPanelModal
355
- chatId={chat.id}
356
- onClose={() => setTaskPanelOpen(false)}
357
- initialExpandedId={
358
- taskShortcut?.kind === 'expand' ? taskShortcut.taskId : undefined
359
- }
360
- onInspect={
361
- onInspectTask
362
- ? (task) => {
363
- onInspectTask({
364
- id: task.id,
365
- chatId: task.chatId,
366
- title: task.task,
367
- });
368
- setTaskPanelOpen(false);
369
- }
370
- : undefined
371
- }
372
- />
379
+ {chat && canTogglePanel && openIds.length === 0 && (
380
+ <PanelToggleButton pressed={false} onClick={() => onOpenRightPanel?.()} />
373
381
  )}
382
+ {popoverNode}
374
383
  {chat && treeModalOpen && (
375
384
  <SessionTreeModal
376
385
  chatId={chat.id}
@@ -382,6 +391,412 @@ export function ChatHeader({
382
391
  );
383
392
  }
384
393
 
394
+ function ChatActionsMenu({
395
+ pinned,
396
+ pinning,
397
+ onTogglePin,
398
+ showExport,
399
+ exporting,
400
+ exportError,
401
+ onExport,
402
+ showShare,
403
+ sharing,
404
+ onShare,
405
+ showBranches,
406
+ onOpenBranches,
407
+ }: {
408
+ pinned: boolean;
409
+ pinning: boolean;
410
+ onTogglePin: () => void;
411
+ showExport: boolean;
412
+ exporting: boolean;
413
+ exportError: string | null;
414
+ onExport: () => void;
415
+ showShare: boolean;
416
+ sharing: boolean;
417
+ onShare: () => void;
418
+ showBranches: boolean;
419
+ onOpenBranches: () => void;
420
+ }) {
421
+ return (
422
+ <>
423
+ <MenuRow
424
+ icon={
425
+ pinning ? (
426
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
427
+ ) : pinned ? (
428
+ <LuPinOff className="h-3.5 w-3.5" />
429
+ ) : (
430
+ <LuPin className="h-3.5 w-3.5" />
431
+ )
432
+ }
433
+ label={pinned ? t('Unpin') : t('Pin chat')}
434
+ disabled={pinning}
435
+ onClick={onTogglePin}
436
+ data-action="pin"
437
+ />
438
+ {showExport && (
439
+ <MenuRow
440
+ icon={
441
+ exporting ? (
442
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
443
+ ) : (
444
+ <LuDownload className="h-3.5 w-3.5" />
445
+ )
446
+ }
447
+ label={exportError ? t('Export failed') : exporting ? t('Exporting') : t('Export')}
448
+ disabled={exporting}
449
+ title={exportError ?? t('Export this chat')}
450
+ onClick={onExport}
451
+ data-testid="header-export-chat"
452
+ />
453
+ )}
454
+ {showShare && (
455
+ <MenuRow
456
+ icon={
457
+ sharing ? (
458
+ <LuLoaderCircle className="h-3.5 w-3.5 animate-spin" />
459
+ ) : (
460
+ <LuShare2 className="h-3.5 w-3.5" />
461
+ )
462
+ }
463
+ label={t('Share chat screenshot')}
464
+ disabled={sharing}
465
+ onClick={onShare}
466
+ data-action="share"
467
+ />
468
+ )}
469
+ {showBranches && (
470
+ <MenuRow
471
+ icon={<LuGitBranch className="h-3.5 w-3.5" />}
472
+ label={t('Chat branches')}
473
+ title={t('Chat branches (forks created by regenerating)')}
474
+ onClick={onOpenBranches}
475
+ data-action="branches"
476
+ />
477
+ )}
478
+ </>
479
+ );
480
+ }
481
+
482
+ function BranchPage({
483
+ branches,
484
+ branchCount,
485
+ switching,
486
+ onBack,
487
+ onSelect,
488
+ onOpenTree,
489
+ }: {
490
+ branches: ChatBranchesResponse | null;
491
+ branchCount: number;
492
+ switching: boolean;
493
+ onBack: () => void;
494
+ onSelect: (recordId: string) => void;
495
+ onOpenTree: () => void;
496
+ }) {
497
+ return (
498
+ <>
499
+ <MenuRow
500
+ icon={<LuChevronLeft className="h-3.5 w-3.5" />}
501
+ label={t('Chat branches')}
502
+ onClick={onBack}
503
+ data-action="branches-back"
504
+ />
505
+ <div className="mx-1 my-1 border-t border-agent-border" />
506
+ {!branches || branchCount === 0 ? (
507
+ <div className="px-2.5 py-1.5 text-xs text-agent-muted-foreground">
508
+ {t('No branches yet. After you regenerate a reply, the old version stays here.')}
509
+ </div>
510
+ ) : (
511
+ <>
512
+ {branches.lineage.map((point) => (
513
+ <BranchRow
514
+ key={point.recordId}
515
+ label={point.label}
516
+ active={point.recordId === branches.activeRecordId}
517
+ disabled={switching}
518
+ onSelect={() => onSelect(point.recordId)}
519
+ />
520
+ ))}
521
+ {branches.children.map((point) => (
522
+ <BranchRow
523
+ key={point.recordId}
524
+ label={point.label}
525
+ active={point.recordId === branches.activeRecordId}
526
+ disabled={switching}
527
+ onSelect={() => onSelect(point.recordId)}
528
+ />
529
+ ))}
530
+ <div className="mt-1 border-t border-agent-border pt-1">
531
+ <button
532
+ type="button"
533
+ onClick={onOpenTree}
534
+ className="flex w-full items-center gap-2 rounded px-2.5 py-1 text-left text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
535
+ data-action="branch-tree"
536
+ >
537
+ <LuListTree className="h-3 w-3 shrink-0" />
538
+ {t('View full branch tree')}
539
+ </button>
540
+ </div>
541
+ </>
542
+ )}
543
+ </>
544
+ );
545
+ }
546
+
547
+ function ChatSummary({
548
+ showProject,
549
+ project,
550
+ files,
551
+ allowOpenPath,
552
+ openingPath,
553
+ onOpen,
554
+ tasks,
555
+ onOpenTask,
556
+ }: {
557
+ showProject: boolean;
558
+ project: LocalProject | null;
559
+ files: TurnFile[];
560
+ allowOpenPath: boolean;
561
+ openingPath: string | null;
562
+ onOpen: (path: string) => void;
563
+ tasks: readonly LocalTask[];
564
+ onOpenTask: (task: LocalTask) => void;
565
+ }) {
566
+ const [actingId, setActingId] = useState<string | null>(null);
567
+ const [actionError, setActionError] = useState<Record<string, string>>({});
568
+ const [showAllOutputs, setShowAllOutputs] = useState(false);
569
+
570
+ const runWorktreeAction = async (task: LocalTask, action: 'merge' | 'discard') => {
571
+ if (actingId) return;
572
+ setActingId(task.id);
573
+ setActionError((prev) => {
574
+ const next = { ...prev };
575
+ delete next[task.id];
576
+ return next;
577
+ });
578
+ try {
579
+ if (action === 'merge') {
580
+ await mergeTaskWorktree(task.id);
581
+ } else {
582
+ await discardTaskWorktree(task.id);
583
+ }
584
+ } catch (err) {
585
+ const message = err instanceof Error ? err.message : String(err);
586
+ setActionError((prev) => ({ ...prev, [task.id]: message }));
587
+ } finally {
588
+ setActingId(null);
589
+ }
590
+ };
591
+
592
+ const directories = linkedDirectories(project);
593
+ const visibleFiles = showAllOutputs ? files : files.slice(0, OUTPUT_PREVIEW_LIMIT);
594
+ const hiddenOutputCount = files.length - visibleFiles.length;
595
+
596
+ return (
597
+ <div className="px-1 py-0.5">
598
+ {showProject && (
599
+ <section className="mb-1">
600
+ <SectionLabel>{t('Linked directory')}</SectionLabel>
601
+ {directories.length === 0 ? (
602
+ <div className="px-2 py-1.5 text-xs text-agent-muted-foreground">{t('No directory linked')}</div>
603
+ ) : (
604
+ directories.map((dir) => (
605
+ <button
606
+ key={dir}
607
+ type="button"
608
+ disabled={!allowOpenPath || openingPath === dir}
609
+ onClick={() => onOpen(dir)}
610
+ title={dir}
611
+ className="flex w-full items-start gap-2 rounded px-2 py-1 text-left text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-default disabled:hover:bg-transparent"
612
+ data-linked-directory={dir}
613
+ >
614
+ {openingPath === dir ? (
615
+ <LuLoaderCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />
616
+ ) : (
617
+ <LuFolder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
618
+ )}
619
+ <span className="min-w-0 flex-1 break-all font-mono text-[10px] leading-4">{dir}</span>
620
+ </button>
621
+ ))
622
+ )}
623
+ </section>
624
+ )}
625
+ {tasks.length > 0 && (
626
+ <section className="mb-1">
627
+ <SectionLabel>{t('Background tasks')}</SectionLabel>
628
+ {tasks.map((task) => {
629
+ const pending = task.worktreeState === 'pending' && task.status === 'completed';
630
+ const acting = actingId === task.id;
631
+ return (
632
+ <div key={task.id} className="rounded" data-task-id={task.id}>
633
+ <div className="flex items-center gap-1">
634
+ <button
635
+ type="button"
636
+ onClick={() => onOpenTask(task)}
637
+ title={task.task}
638
+ className="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1 text-left text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5"
639
+ data-action="tasks"
640
+ >
641
+ {task.status === 'running' || task.status === 'blocked' ? (
642
+ <LuLoaderCircle className="h-3.5 w-3.5 shrink-0 animate-spin" />
643
+ ) : (
644
+ <LuListTodo className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
645
+ )}
646
+ <span className="min-w-0 flex-1 truncate">{task.task}</span>
647
+ </button>
648
+ {pending && (
649
+ <>
650
+ <button
651
+ type="button"
652
+ disabled={actingId !== null}
653
+ onClick={() => void runWorktreeAction(task, 'merge')}
654
+ className="flex h-6 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] text-amber-700 transition-colors hover:bg-amber-500/10 disabled:opacity-50 dark:text-amber-400"
655
+ title={t('Merge into main repo')}
656
+ aria-label={t('Merge into main repo')}
657
+ data-task-merge
658
+ >
659
+ {acting ? (
660
+ <LuLoaderCircle className="h-3 w-3 animate-spin" />
661
+ ) : (
662
+ <LuGitMerge className="h-3 w-3" />
663
+ )}
664
+ </button>
665
+ <button
666
+ type="button"
667
+ disabled={actingId !== null}
668
+ onClick={() => void runWorktreeAction(task, 'discard')}
669
+ className="flex h-6 shrink-0 items-center rounded px-1.5 text-agent-muted-foreground transition-colors hover:bg-red-500/10 hover:text-red-600 disabled:opacity-50 dark:hover:text-red-400"
670
+ title={t('Discard')}
671
+ aria-label={t('Discard')}
672
+ data-task-discard
673
+ >
674
+ <LuTrash2 className="h-3 w-3" />
675
+ </button>
676
+ </>
677
+ )}
678
+ </div>
679
+ {actionError[task.id] && (
680
+ <div className="px-2 pb-1 text-[10px] text-red-600 dark:text-red-400">
681
+ {actionError[task.id]}
682
+ </div>
683
+ )}
684
+ </div>
685
+ );
686
+ })}
687
+ </section>
688
+ )}
689
+ <section>
690
+ <SectionLabel>{t('Outputs')}</SectionLabel>
691
+ {files.length === 0 ? (
692
+ <div className="px-2 py-1.5 text-xs text-agent-muted-foreground">
693
+ {t('No files from this chat yet')}
694
+ </div>
695
+ ) : (
696
+ visibleFiles.map((file) => {
697
+ const { name } = splitTurnFilePath(file.path);
698
+ const deliverable = getTurnFileCategory(file) === 'deliverable';
699
+ return (
700
+ <button
701
+ key={file.path}
702
+ type="button"
703
+ disabled={!allowOpenPath || openingPath === file.path}
704
+ onClick={() => onOpen(file.path)}
705
+ title={file.path}
706
+ className="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:cursor-default disabled:hover:bg-transparent"
707
+ data-output-path={file.path}
708
+ >
709
+ {openingPath === file.path ? (
710
+ <LuLoaderCircle className="h-3.5 w-3.5 shrink-0 animate-spin" />
711
+ ) : (
712
+ <LuFile className="h-3.5 w-3.5 shrink-0 text-agent-muted-foreground" />
713
+ )}
714
+ <span className="min-w-0 flex-1 truncate">{name}</span>
715
+ {deliverable && (
716
+ <span className="shrink-0 text-[10px] text-agent-muted-foreground">{t('Output')}</span>
717
+ )}
718
+ </button>
719
+ );
720
+ })
721
+ )}
722
+ {hiddenOutputCount > 0 && (
723
+ <button
724
+ type="button"
725
+ onClick={() => setShowAllOutputs(true)}
726
+ className="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
727
+ data-output-view-all
728
+ >
729
+ {t('View all')}
730
+ </button>
731
+ )}
732
+ </section>
733
+ </div>
734
+ );
735
+ }
736
+
737
+ function SectionLabel({ children }: { children: ReactNode }) {
738
+ return (
739
+ <div className="px-2 pb-0.5 pt-1 text-[10px] font-medium uppercase tracking-wide text-agent-muted-foreground/70">
740
+ {children}
741
+ </div>
742
+ );
743
+ }
744
+
745
+ function MenuRow({
746
+ icon,
747
+ label,
748
+ detail,
749
+ title,
750
+ disabled,
751
+ onClick,
752
+ ...rest
753
+ }: {
754
+ icon: ReactNode;
755
+ label: string;
756
+ detail?: string;
757
+ title?: string;
758
+ disabled?: boolean;
759
+ onClick: () => void;
760
+ } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'disabled' | 'title'>) {
761
+ return (
762
+ <button
763
+ type="button"
764
+ onClick={onClick}
765
+ disabled={disabled}
766
+ title={title}
767
+ className="flex w-full items-center gap-2 rounded px-2.5 py-1.5 text-left text-xs text-agent-foreground transition-colors hover:bg-agent-foreground/5 disabled:opacity-60"
768
+ {...rest}
769
+ >
770
+ <span className="text-agent-muted-foreground">{icon}</span>
771
+ <span className="min-w-0 flex-1 truncate">{label}</span>
772
+ {detail && <span className="shrink-0 text-agent-muted-foreground">{detail}</span>}
773
+ </button>
774
+ );
775
+ }
776
+
777
+ /** 资源浮层里先露出的产出条数。其余用「查看全部」展开,不把回合文件一次摊开。 */
778
+ const OUTPUT_PREVIEW_LIMIT = 3;
779
+
780
+ function linkedDirectories(project: LocalProject | null): string[] {
781
+ if (!project) return [];
782
+ const dirs = [project.folderPath, ...(project.sourceFolders ?? [])];
783
+ return [...new Set(dirs.filter((dir) => dir.length > 0))];
784
+ }
785
+
786
+ function uniqueOutputs(files: readonly TurnFile[]): TurnFile[] {
787
+ const byPath = new Map<string, TurnFile>();
788
+ for (const file of files) {
789
+ if (isIgnoredTurnFile(file.path)) continue;
790
+ byPath.set(file.path, file);
791
+ }
792
+ return [...byPath.values()].sort((a, b) => {
793
+ const rank = (file: TurnFile) => (getTurnFileCategory(file) === 'deliverable' ? 0 : 1);
794
+ const byKind = rank(a) - rank(b);
795
+ if (byKind !== 0) return byKind;
796
+ return splitTurnFilePath(a.path).name.localeCompare(splitTurnFilePath(b.path).name);
797
+ });
798
+ }
799
+
385
800
  interface TaskBadge {
386
801
  /** 驱动角标配色的那一类任务,也写进 `data-task-state` 供测试断言。 */
387
802
  state: 'running' | 'review' | 'failed' | 'idle';
@@ -391,7 +806,7 @@ interface TaskBadge {
391
806
  }
392
807
 
393
808
  /**
394
- * 任务按钮的角标。只有一个状态能上色,按「用户该不该现在看一眼」排序:
809
+ * 资源按钮上的任务角标。只有一个状态能上色,按「用户该不该现在看一眼」排序:
395
810
  * 运行中(正在发生)> 待合并(等用户动手)> 失败(需要知道)> 全部跑完。
396
811
  * 计数跟着状态走——显示 3 个任务里那 1 个待合并的,比显示总数 3 更有用。
397
812
  */
@@ -447,49 +862,23 @@ function describeTaskBadge(summary: ChatTaskSummary): TaskBadge | null {
447
862
  };
448
863
  }
449
864
 
450
- /**
451
- * 角标的直达目标——只有一件事等着处理时跳过任务列表这一层。失败任务直接
452
- * 开推理过程(要看的是为什么挂的),待合并的 worktree 任务直接展开它在弹层
453
- * 里的行(合并/丢弃按钮在那儿)。有多件事等着、或还有任务在跑时仍然先给
454
- * 列表:那时候用户得先挑一个。
455
- */
456
- type TaskShortcut =
457
- | { kind: 'process'; task: LocalTask }
458
- | { kind: 'expand'; taskId: string };
459
-
460
- function describeTaskShortcut(
461
- tasks: LocalTask[],
462
- summary: ChatTaskSummary,
463
- ): TaskShortcut | null {
464
- if (summary.running + summary.blocked > 0) return null;
465
- const pending = actionableTasks(tasks);
466
- if (pending.length !== 1) return null;
467
- const [task] = pending;
468
- return task.worktreeState === 'pending'
469
- ? { kind: 'expand', taskId: task.id }
470
- : { kind: 'process', task };
471
- }
472
-
473
- function taskButtonTitle(
474
- badge: TaskBadge | null,
475
- shortcut: TaskShortcut | null,
476
- ): string {
477
- const base =
478
- badge?.title ?? t('Background tasks (run across turns; view results and merge worktrees)');
479
- if (!shortcut) return base;
480
- return shortcut.kind === 'process'
481
- ? t('{base} · Click to view the reasoning', { base })
482
- : t('{base} · Click to handle the worktree', { base });
865
+ /** 浮层按内容给宽度,右缘对齐触发按钮,再夹进对话面板里。 */
866
+ function sheetWidth(kind: HeaderPopover, page: 'root' | 'branches'): number {
867
+ if (kind === 'summary' || page === 'branches') return 304;
868
+ return 176;
483
869
  }
484
870
 
485
- /** Pin the branch menu to the trigger, clamped inside the chat panel so
486
- * `overflow-hidden` ancestors cannot clip the left side of a `w-72` sheet. */
487
- function placeBranchMenu(button: HTMLElement): { top: number; left: number; width: number } {
871
+ /** Pin the popover to the trigger, clamped inside the chat panel so
872
+ * `overflow-hidden` ancestors cannot clip the left side. */
873
+ function placeHeaderPopover(
874
+ button: HTMLElement,
875
+ preferredWidth: number,
876
+ ): { top: number; left: number; width: number } {
488
877
  const pad = 8;
489
878
  const panel = button.closest('.chat-panel-container');
490
879
  const bounds = (panel ?? document.documentElement).getBoundingClientRect();
491
880
  const btn = button.getBoundingClientRect();
492
- const width = Math.min(288, Math.max(160, bounds.width - pad * 2));
881
+ const width = Math.min(preferredWidth, Math.max(160, bounds.width - pad * 2));
493
882
  const minLeft = bounds.left + pad;
494
883
  const maxLeft = bounds.right - width - pad;
495
884
  const left = Math.min(Math.max(btn.right - width, minLeft), Math.max(minLeft, maxLeft));