@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.
- package/package.json +4 -4
- package/src/components/ChatHeader.tsx +666 -277
- package/src/components/chat/TaskOutcomeCards.tsx +2 -2
- package/src/layouts/AgentLayout.tsx +174 -118
- package/src/layouts/PanelToggleButton.tsx +35 -0
- package/src/layouts/RightPanelTabBar.tsx +214 -0
- package/src/layouts/request-chat-slot.ts +22 -0
- package/src/layouts/right-panel-tabs.ts +149 -0
- package/src/pages/AgentPage.tsx +13 -2
|
@@ -1,15 +1,43 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
4
|
-
|
|
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 {
|
|
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
|
|
64
|
+
* chat has branches), a branch picker appears in the chat menu.
|
|
30
65
|
*/
|
|
31
66
|
onBranchSwitched?: () => void;
|
|
32
67
|
/** 点击后台任务行:在右侧栏打开该任务的推理过程。 */
|
|
33
|
-
onInspectTask?: (task: {
|
|
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
|
-
/**
|
|
78
|
+
/** 当前会话正在看的右侧标签(null = 都关着)。 */
|
|
39
79
|
rightPanel?: string | null;
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
|
|
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.
|
|
48
|
-
*
|
|
49
|
-
* -
|
|
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
|
-
|
|
63
|
-
|
|
113
|
+
openPanelIds,
|
|
114
|
+
onOpenRightPanel,
|
|
115
|
+
showProject = false,
|
|
116
|
+
project = null,
|
|
117
|
+
outputs = [],
|
|
118
|
+
onShare,
|
|
119
|
+
onChatChanged,
|
|
64
120
|
}: ChatHeaderProps) {
|
|
65
|
-
const [
|
|
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
|
|
76
|
-
const
|
|
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
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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 (!
|
|
242
|
+
if (!popover || !anchor) {
|
|
133
243
|
setMenuBox(null);
|
|
134
244
|
return;
|
|
135
245
|
}
|
|
136
|
-
const
|
|
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
|
-
}, [
|
|
254
|
+
}, [popover, anchor, menuPage]);
|
|
147
255
|
|
|
148
256
|
useEffect(() => {
|
|
149
|
-
if (!
|
|
257
|
+
if (!popover) return;
|
|
150
258
|
const onPointerDown = (e: MouseEvent) => {
|
|
151
259
|
const target = e.target as Node;
|
|
152
|
-
if (
|
|
153
|
-
|
|
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
|
-
}, [
|
|
271
|
+
}, [popover]);
|
|
158
272
|
|
|
159
|
-
const
|
|
160
|
-
|
|
273
|
+
const popoverNode =
|
|
274
|
+
popover && menuBox
|
|
161
275
|
? createPortal(
|
|
162
276
|
<div
|
|
163
|
-
ref={
|
|
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-
|
|
280
|
+
data-header-popover={popover}
|
|
167
281
|
>
|
|
168
|
-
{
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 &&
|
|
341
|
+
{chat && (
|
|
226
342
|
<button
|
|
343
|
+
ref={menuButtonRef}
|
|
227
344
|
type="button"
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
271
|
-
|
|
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={
|
|
286
|
-
aria-label={
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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 &&
|
|
354
|
-
<
|
|
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
|
-
|
|
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
|
|
486
|
-
* `overflow-hidden` ancestors cannot clip the left side
|
|
487
|
-
function
|
|
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(
|
|
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));
|