@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
|
@@ -42,7 +42,7 @@ export function TaskOutcomeCards({ tasks, onInspect, onDismiss }: TaskOutcomeCar
|
|
|
42
42
|
))}
|
|
43
43
|
{overflow > 0 && (
|
|
44
44
|
<div className="px-1 text-[11px] text-agent-muted-foreground">
|
|
45
|
-
{t('{count} more background tasks finished. See
|
|
45
|
+
{t('{count} more background tasks finished. See Chat resources in the title bar.', {
|
|
46
46
|
count: overflow,
|
|
47
47
|
})}
|
|
48
48
|
</div>
|
|
@@ -103,7 +103,7 @@ function TaskOutcomeCard({
|
|
|
103
103
|
<span className="min-w-0 truncate font-mono text-[10px]">
|
|
104
104
|
{task.worktreeBranch}
|
|
105
105
|
</span>
|
|
106
|
-
<span>{t('Awaiting merge. Handle it from
|
|
106
|
+
<span>{t('Awaiting merge. Handle it from Chat resources in the title bar.')}</span>
|
|
107
107
|
</div>
|
|
108
108
|
)}
|
|
109
109
|
</div>
|
|
@@ -3,6 +3,7 @@ import { Outlet, useParams } from 'react-router-dom';
|
|
|
3
3
|
import { LuPanelLeftOpen } from 'react-icons/lu';
|
|
4
4
|
import { AgentSidebar } from '@/components/AgentSidebar';
|
|
5
5
|
import { TerminalPanel } from '@/components/TerminalPanel';
|
|
6
|
+
import { RightPanelTabBar } from '@/layouts/RightPanelTabBar';
|
|
6
7
|
import { TaskProcessPanel, type InspectedTask } from '@/components/chat/TaskProcessPanel';
|
|
7
8
|
import { AskUserPromptProvider } from '@/components/chat/AskUserPromptProvider';
|
|
8
9
|
import { ApprovalPromptProvider } from '@/components/chat/ApprovalPromptProvider';
|
|
@@ -18,6 +19,21 @@ import {
|
|
|
18
19
|
} from '@/hooks/useChatsAndAgents';
|
|
19
20
|
import { hostToolChrome, sanitizeRightPanelKind, settingsChrome } from '@/lib/host-tools';
|
|
20
21
|
import { t } from '@/i18n';
|
|
22
|
+
import {
|
|
23
|
+
closeRightPanelTab,
|
|
24
|
+
collapseRightPanel,
|
|
25
|
+
expandRightPanel,
|
|
26
|
+
openRightPanelTab,
|
|
27
|
+
parseRightPanelMap,
|
|
28
|
+
revealRightPanelTab,
|
|
29
|
+
toggleRightPanelEntry,
|
|
30
|
+
type RightPanelMap,
|
|
31
|
+
type RightPanelTabs,
|
|
32
|
+
} from '@/layouts/right-panel-tabs';
|
|
33
|
+
import { subscribeChatSlotRequests } from '@/layouts/request-chat-slot';
|
|
34
|
+
|
|
35
|
+
export { parseRightPanelMap };
|
|
36
|
+
export type { RightPanelMap, RightPanelTabs };
|
|
21
37
|
|
|
22
38
|
/**
|
|
23
39
|
* AgentLayout wraps `/agent`, `/agent/:chatId`, and `/` (default) with a
|
|
@@ -70,53 +86,9 @@ const DEFAULT_TERMINAL_WIDTH = 520;
|
|
|
70
86
|
const MIN_TERMINAL_WIDTH = 360;
|
|
71
87
|
const MAX_TERMINAL_WIDTH = 960;
|
|
72
88
|
|
|
73
|
-
/**
|
|
74
|
-
* 右侧栏位状态:null = 都不打开,'terminal' = 终端,其余字符串 = 包注册的
|
|
75
|
-
* 聊天页槽位(chatSlots,如文档包的预览栏位)。栏位同一时刻只开一个
|
|
76
|
-
* (互斥规则在本布局,不在包)。
|
|
77
|
-
*/
|
|
89
|
+
/** 当前看见的标签。null = 这一侧没有打开的标签。 */
|
|
78
90
|
export type RightPanelState = string | null;
|
|
79
91
|
|
|
80
|
-
/** 每个会话各自的右侧栏位状态:chatId → RightPanelState(null 不落盘)。 */
|
|
81
|
-
export type RightPanelMap = Record<string, string>;
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* 解析持久化的「会话 → 右侧栏位」映射。
|
|
85
|
-
*
|
|
86
|
-
* 兼容三种历史格式:
|
|
87
|
-
* - 新格式:{"<chatId>": "terminal" | "<slotId>"};
|
|
88
|
-
* - 旧格式:整个值就是单个字符串('' / 'terminal' / slotId)→ 迁到当前会话;
|
|
89
|
-
* - 更老:只记录终端是否打开过的布尔 key。
|
|
90
|
-
* 无效的会话 / 栏位值会被丢弃,避免脏数据把某个会话卡在打不开的面板上。
|
|
91
|
-
*/
|
|
92
|
-
export function parseRightPanelMap(options: {
|
|
93
|
-
raw: string | null;
|
|
94
|
-
legacyTerminalOpen: string | null;
|
|
95
|
-
chatId: string | null;
|
|
96
|
-
isValidValue: (value: string) => boolean;
|
|
97
|
-
}): RightPanelMap {
|
|
98
|
-
const { raw, legacyTerminalOpen, chatId, isValidValue } = options;
|
|
99
|
-
if (raw !== null) {
|
|
100
|
-
if (raw === '') return {};
|
|
101
|
-
try {
|
|
102
|
-
const parsed = JSON.parse(raw) as unknown;
|
|
103
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
104
|
-
const map: RightPanelMap = {};
|
|
105
|
-
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
106
|
-
if (typeof value === 'string' && isValidValue(value)) map[key] = value;
|
|
107
|
-
}
|
|
108
|
-
return map;
|
|
109
|
-
}
|
|
110
|
-
} catch {
|
|
111
|
-
// 旧格式:整个 key 就是单个值。
|
|
112
|
-
if (isValidValue(raw) && chatId) return { [chatId]: raw };
|
|
113
|
-
}
|
|
114
|
-
return {};
|
|
115
|
-
}
|
|
116
|
-
if (legacyTerminalOpen === '1' && chatId) return { [chatId]: 'terminal' };
|
|
117
|
-
return {};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
92
|
/** 从聊天页外把一段内容作为普通用户消息发进当前会话(包槽位的 fallback 通道)。 */
|
|
121
93
|
export type ChatMessageSender = (input: {
|
|
122
94
|
content: string;
|
|
@@ -131,10 +103,12 @@ export type AgentOutletContext = UseChatsAndAgentsResult & {
|
|
|
131
103
|
inspectTask: (task: InspectedTask) => void;
|
|
132
104
|
/** 包槽位(如文档预览):入口渲染在 chat 标题栏,状态按会话隔离。 */
|
|
133
105
|
chatSlots: readonly PackChatSlotContribution[];
|
|
134
|
-
/**
|
|
106
|
+
/** 当前会话正在看的右侧标签(null = 整栏关着或收起)。 */
|
|
135
107
|
rightPanel: RightPanelState;
|
|
136
|
-
/**
|
|
137
|
-
|
|
108
|
+
/** 当前会话已经打开且正在显示的标签,顺序与标签条一致。 */
|
|
109
|
+
openPanelIds: readonly string[];
|
|
110
|
+
/** 标题栏开关:整栏收着或空着时打开。 */
|
|
111
|
+
onOpenRightPanel: () => void;
|
|
138
112
|
};
|
|
139
113
|
|
|
140
114
|
function AgentLayoutContent() {
|
|
@@ -328,17 +302,13 @@ function AgentLayoutContent() {
|
|
|
328
302
|
}, []);
|
|
329
303
|
|
|
330
304
|
// ───── Right-side panel (embedded column beside the chat content) ─────
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
// 状态,首次读取时迁移。
|
|
305
|
+
// 终端与包槽位(PPT、Word、Markdown)共用一条标签。可以同时打开多个,
|
|
306
|
+
// 标签条切换当前看见的那一个。持久化按会话存标签列表;老的单值会迁成一个标签。
|
|
334
307
|
const packChatSlots = getPackChatSlots();
|
|
335
308
|
|
|
336
309
|
/**
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
* 以前是单个全局值(localStorage 里存一个字符串),会话 1 打开预览会连带
|
|
340
|
-
* 影响会话 2。现在存成映射,每个会话独立;老的单值 key 读取时自动迁移到
|
|
341
|
-
* 当前会话。
|
|
310
|
+
* 右侧标签按会话隔离。以前是单个全局值,会话 1 打开预览会连带影响会话 2。
|
|
311
|
+
* 现在存成映射,每个会话独立;老的单值 key 读取时自动迁移到当前会话。
|
|
342
312
|
*/
|
|
343
313
|
const readRightPanelMap = useCallback((): RightPanelMap => {
|
|
344
314
|
const isValidValue = (value: string): boolean =>
|
|
@@ -351,9 +321,17 @@ function AgentLayoutContent() {
|
|
|
351
321
|
isValidValue,
|
|
352
322
|
});
|
|
353
323
|
const sanitized: RightPanelMap = {};
|
|
354
|
-
for (const [id,
|
|
355
|
-
const
|
|
356
|
-
|
|
324
|
+
for (const [id, entry] of Object.entries(map)) {
|
|
325
|
+
const tabs: string[] = [];
|
|
326
|
+
for (const tab of entry.tabs) {
|
|
327
|
+
const next = sanitizeRightPanelKind(tab);
|
|
328
|
+
if (next && !tabs.includes(next)) tabs.push(next);
|
|
329
|
+
}
|
|
330
|
+
if (tabs.length === 0) continue;
|
|
331
|
+
const active = tabs.includes(entry.active) ? entry.active : tabs[0]!;
|
|
332
|
+
sanitized[id] = entry.collapsed
|
|
333
|
+
? { tabs, active, collapsed: true }
|
|
334
|
+
: { tabs, active };
|
|
357
335
|
}
|
|
358
336
|
return sanitized;
|
|
359
337
|
} catch {
|
|
@@ -370,7 +348,12 @@ function AgentLayoutContent() {
|
|
|
370
348
|
setRightPanelMap(readRightPanelMap());
|
|
371
349
|
}, [readRightPanelMap]);
|
|
372
350
|
|
|
373
|
-
const
|
|
351
|
+
const panelEntry: RightPanelTabs | null = chatId ? rightPanelMap[chatId] ?? null : null;
|
|
352
|
+
const panelOpen = panelEntry !== null && panelEntry.collapsed !== true;
|
|
353
|
+
const rightPanel: RightPanelState = panelOpen ? panelEntry.active : null;
|
|
354
|
+
const openPanelIds = panelOpen ? panelEntry.tabs : [];
|
|
355
|
+
// 自动展开 / 手动切换回调是挂载时注册的,直接读 state 会拿到过期闭包。
|
|
356
|
+
const panelEntryRef = useRef<RightPanelTabs | null>(panelEntry);
|
|
374
357
|
|
|
375
358
|
const persistRightPanelMap = useCallback((map: RightPanelMap) => {
|
|
376
359
|
try {
|
|
@@ -380,9 +363,10 @@ function AgentLayoutContent() {
|
|
|
380
363
|
}
|
|
381
364
|
}, []);
|
|
382
365
|
|
|
383
|
-
const
|
|
384
|
-
(next:
|
|
366
|
+
const applyPanelEntry = useCallback(
|
|
367
|
+
(next: RightPanelTabs | null) => {
|
|
385
368
|
if (!chatId) return;
|
|
369
|
+
panelEntryRef.current = next;
|
|
386
370
|
setRightPanelMap((prev) => {
|
|
387
371
|
const map = { ...prev };
|
|
388
372
|
if (next === null) delete map[chatId];
|
|
@@ -394,29 +378,74 @@ function AgentLayoutContent() {
|
|
|
394
378
|
[chatId, persistRightPanelMap],
|
|
395
379
|
);
|
|
396
380
|
|
|
397
|
-
// 自动展开 / 手动切换回调是挂载时注册的,直接读 state 会拿到过期闭包,
|
|
398
|
-
// 所以用 ref 跟踪当前栏位状态(见下方同步 effect)。
|
|
399
|
-
const rightPanelRef = useRef<RightPanelState>(rightPanel);
|
|
400
|
-
|
|
401
381
|
useEffect(() => {
|
|
402
|
-
|
|
403
|
-
}, [
|
|
382
|
+
panelEntryRef.current = panelEntry;
|
|
383
|
+
}, [panelEntry]);
|
|
404
384
|
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
385
|
+
const closePanelTab = useCallback(
|
|
386
|
+
(kind: string) => {
|
|
387
|
+
const current = panelEntryRef.current;
|
|
388
|
+
if (!current) return;
|
|
389
|
+
applyPanelEntry(closeRightPanelTab(current, kind));
|
|
390
|
+
},
|
|
391
|
+
[applyPanelEntry],
|
|
392
|
+
);
|
|
408
393
|
|
|
409
|
-
|
|
410
|
-
|
|
394
|
+
const activatePanelTab = useCallback(
|
|
395
|
+
(kind: string) => {
|
|
396
|
+
const current = panelEntryRef.current;
|
|
397
|
+
if (!current || !current.tabs.includes(kind)) return;
|
|
398
|
+
setInspectedTask(null);
|
|
399
|
+
if (current.active === kind) return;
|
|
400
|
+
applyPanelEntry({ tabs: current.tabs, active: kind });
|
|
401
|
+
},
|
|
402
|
+
[applyPanelEntry],
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const openPanelTab = useCallback(
|
|
406
|
+
(kind: string) => {
|
|
407
|
+
const id = sanitizeRightPanelKind(kind);
|
|
408
|
+
if (!id) return;
|
|
409
|
+
if (id !== 'terminal' && !packChatSlots.some((slot) => slot.slotId === id)) return;
|
|
410
|
+
setInspectedTask(null);
|
|
411
|
+
applyPanelEntry(openRightPanelTab(panelEntryRef.current, id));
|
|
412
|
+
},
|
|
413
|
+
[applyPanelEntry, packChatSlots],
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const expandRightSide = useCallback(() => {
|
|
417
|
+
const current = panelEntryRef.current;
|
|
418
|
+
if (current && current.tabs.length > 0) {
|
|
419
|
+
setInspectedTask(null);
|
|
420
|
+
applyPanelEntry(expandRightPanel(current));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const firstSlot = packChatSlots
|
|
424
|
+
.map((slot) => sanitizeRightPanelKind(slot.slotId))
|
|
425
|
+
.find((id): id is string => id !== null);
|
|
426
|
+
const first = firstSlot ?? sanitizeRightPanelKind('terminal');
|
|
427
|
+
if (!first) return;
|
|
428
|
+
setInspectedTask(null);
|
|
429
|
+
applyPanelEntry({ tabs: [first], active: first });
|
|
430
|
+
}, [applyPanelEntry, packChatSlots]);
|
|
431
|
+
|
|
432
|
+
const collapseRightSide = useCallback(() => {
|
|
433
|
+
const current = panelEntryRef.current;
|
|
434
|
+
if (!current || current.tabs.length === 0) return;
|
|
435
|
+
setInspectedTask(null);
|
|
436
|
+
applyPanelEntry(collapseRightPanel(current));
|
|
437
|
+
}, [applyPanelEntry]);
|
|
438
|
+
|
|
439
|
+
/** 快捷键:没开就追加并显示,已开但不是当前就切过去,当前这个就关掉。整栏收着时改为打开。 */
|
|
411
440
|
const toggleRightPanel = useCallback(
|
|
412
441
|
(kind: string) => {
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
442
|
+
const id = sanitizeRightPanelKind(kind);
|
|
443
|
+
if (!id) return;
|
|
444
|
+
const next = toggleRightPanelEntry(panelEntryRef.current, id);
|
|
416
445
|
if (next !== null) setInspectedTask(null);
|
|
417
|
-
|
|
446
|
+
applyPanelEntry(next);
|
|
418
447
|
},
|
|
419
|
-
[
|
|
448
|
+
[applyPanelEntry],
|
|
420
449
|
);
|
|
421
450
|
|
|
422
451
|
// Cmd+T from the app menu — wired in src/main.ts:createMenu (sends
|
|
@@ -480,20 +509,22 @@ function AgentLayoutContent() {
|
|
|
480
509
|
const showTerminalFromTask = useCallback(() => {
|
|
481
510
|
if (!hostToolChrome('terminal')) return;
|
|
482
511
|
setInspectedTask(null);
|
|
483
|
-
|
|
484
|
-
}, [
|
|
512
|
+
applyPanelEntry(openRightPanelTab(panelEntryRef.current, 'terminal'));
|
|
513
|
+
}, [applyPanelEntry]);
|
|
485
514
|
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
// 已打开任一面板时,自动展开请求被忽略。订阅逻辑由包自己实现。
|
|
515
|
+
// 包槽位的自动展开(如文档包:后端在本轮产出新稿时广播)。
|
|
516
|
+
// 栏位空着时打开并显示;已经有别的标签时只追加,不抢走当前标签。
|
|
489
517
|
useEffect(() => {
|
|
490
518
|
const cleanups: Array<() => void> = [];
|
|
491
519
|
for (const slot of packChatSlots) {
|
|
492
520
|
const cleanup = slot.setupAutoReveal?.({
|
|
493
521
|
reveal: () => {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
522
|
+
const id = sanitizeRightPanelKind(slot.slotId);
|
|
523
|
+
if (!id) return;
|
|
524
|
+
const next = revealRightPanelTab(panelEntryRef.current, id);
|
|
525
|
+
if (next === panelEntryRef.current) return;
|
|
526
|
+
if (panelEntryRef.current === null) setInspectedTask(null);
|
|
527
|
+
applyPanelEntry(next);
|
|
497
528
|
},
|
|
498
529
|
getCurrentChatId: () => chatId ?? null,
|
|
499
530
|
});
|
|
@@ -504,7 +535,10 @@ function AgentLayoutContent() {
|
|
|
504
535
|
};
|
|
505
536
|
// packChatSlots 在 bootstrap 后稳定;chatId 经 getCurrentChatId 闭包读取。
|
|
506
537
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
507
|
-
}, [chatId,
|
|
538
|
+
}, [chatId, applyPanelEntry]);
|
|
539
|
+
|
|
540
|
+
// 交付文件点击要打开对应栏位。菜单按钮不在文档里时也能切过去。
|
|
541
|
+
useEffect(() => subscribeChatSlotRequests(openPanelTab), [openPanelTab]);
|
|
508
542
|
|
|
509
543
|
// Same drag ergonomics as the sidebar handle, mirrored: the terminal's
|
|
510
544
|
// right edge is pinned to the window's right padding (p-1.5 = 6px), so the
|
|
@@ -610,13 +644,14 @@ function AgentLayoutContent() {
|
|
|
610
644
|
inspectTask,
|
|
611
645
|
chatSlots: packChatSlots,
|
|
612
646
|
rightPanel,
|
|
613
|
-
|
|
647
|
+
openPanelIds,
|
|
648
|
+
onOpenRightPanel: expandRightSide,
|
|
614
649
|
} satisfies AgentOutletContext}
|
|
615
650
|
/>
|
|
616
651
|
</div>
|
|
617
652
|
{settingsChrome('insights') && <InsightsConsentBanner />}
|
|
618
653
|
</div>
|
|
619
|
-
{(
|
|
654
|
+
{(panelOpen || inspectedTask !== null) && (
|
|
620
655
|
<>
|
|
621
656
|
<div
|
|
622
657
|
role="separator"
|
|
@@ -628,40 +663,61 @@ function AgentLayoutContent() {
|
|
|
628
663
|
title={t('Drag to resize the right panel (double-click to reset)')}
|
|
629
664
|
/>
|
|
630
665
|
<div
|
|
631
|
-
className=
|
|
632
|
-
rightPanel === 'terminal' && !inspectedTask ? 'bg-[#0b0b0c]' : 'bg-agent-canvas'
|
|
633
|
-
}`}
|
|
666
|
+
className="flex h-full flex-shrink-0 flex-col overflow-hidden rounded-agent-lg bg-agent-canvas shadow-sm"
|
|
634
667
|
style={{ width: terminalWidth }}
|
|
635
668
|
data-testid={inspectedTask ? 'task-process-dock' : 'terminal-panel'}
|
|
636
669
|
>
|
|
637
|
-
{
|
|
638
|
-
<
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
onClose={closeRightPanel}
|
|
648
|
-
onShowTaskProcess={recentTask ? showRecentTask : undefined}
|
|
649
|
-
taskProcessTitle={recentTask?.title}
|
|
670
|
+
{panelOpen && panelEntry && (
|
|
671
|
+
<RightPanelTabBar
|
|
672
|
+
tabs={panelEntry.tabs}
|
|
673
|
+
active={panelEntry.active}
|
|
674
|
+
slots={packChatSlots}
|
|
675
|
+
showTerminal={hostToolChrome('terminal')}
|
|
676
|
+
onActivate={activatePanelTab}
|
|
677
|
+
onClose={closePanelTab}
|
|
678
|
+
onOpen={openPanelTab}
|
|
679
|
+
onCollapse={collapseRightSide}
|
|
650
680
|
/>
|
|
651
|
-
) : (
|
|
652
|
-
(() => {
|
|
653
|
-
const slot = packChatSlots.find((s) => s.slotId === rightPanel);
|
|
654
|
-
if (!slot) return null;
|
|
655
|
-
const SlotPanel = slot.Component;
|
|
656
|
-
return (
|
|
657
|
-
<SlotPanel
|
|
658
|
-
chatId={chatId ?? ''}
|
|
659
|
-
onClose={closeRightPanel}
|
|
660
|
-
onSubmitToChat={sendChatMessage}
|
|
661
|
-
/>
|
|
662
|
-
);
|
|
663
|
-
})()
|
|
664
681
|
)}
|
|
682
|
+
<div className="relative min-h-0 flex-1">
|
|
683
|
+
{inspectedTask ? (
|
|
684
|
+
<TaskProcessPanel
|
|
685
|
+
inspected={inspectedTask}
|
|
686
|
+
onClose={closeTaskProcess}
|
|
687
|
+
onShowTerminal={
|
|
688
|
+
hostToolChrome('terminal') ? showTerminalFromTask : undefined
|
|
689
|
+
}
|
|
690
|
+
/>
|
|
691
|
+
) : (
|
|
692
|
+
panelEntry?.tabs.map((id) => {
|
|
693
|
+
const visible = id === panelEntry.active;
|
|
694
|
+
return (
|
|
695
|
+
<div key={id} className={visible ? 'h-full' : 'hidden'}>
|
|
696
|
+
{id === 'terminal' ? (
|
|
697
|
+
<TerminalPanel
|
|
698
|
+
onClose={() => closePanelTab('terminal')}
|
|
699
|
+
onShowTaskProcess={recentTask ? showRecentTask : undefined}
|
|
700
|
+
taskProcessTitle={recentTask?.title}
|
|
701
|
+
/>
|
|
702
|
+
) : (
|
|
703
|
+
(() => {
|
|
704
|
+
const slot = packChatSlots.find((s) => s.slotId === id);
|
|
705
|
+
if (!slot) return null;
|
|
706
|
+
const SlotPanel = slot.Component;
|
|
707
|
+
return (
|
|
708
|
+
<SlotPanel
|
|
709
|
+
chatId={chatId ?? ''}
|
|
710
|
+
onClose={() => closePanelTab(id)}
|
|
711
|
+
onSubmitToChat={sendChatMessage}
|
|
712
|
+
/>
|
|
713
|
+
);
|
|
714
|
+
})()
|
|
715
|
+
)}
|
|
716
|
+
</div>
|
|
717
|
+
);
|
|
718
|
+
})
|
|
719
|
+
)}
|
|
720
|
+
</div>
|
|
665
721
|
</div>
|
|
666
722
|
</>
|
|
667
723
|
)}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { LuCopy } from 'react-icons/lu';
|
|
2
|
+
import { t } from '@/i18n';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 右侧整栏的开关。栏关着时放在对话标题栏,栏开着时放到右侧标签条最右,点一下收起。
|
|
6
|
+
*/
|
|
7
|
+
export function PanelToggleButton({
|
|
8
|
+
pressed,
|
|
9
|
+
onClick,
|
|
10
|
+
}: {
|
|
11
|
+
pressed: boolean;
|
|
12
|
+
onClick: () => void;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<button
|
|
16
|
+
type="button"
|
|
17
|
+
onClick={onClick}
|
|
18
|
+
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground ${
|
|
19
|
+
pressed
|
|
20
|
+
? 'bg-agent-foreground/10 text-agent-foreground'
|
|
21
|
+
: 'text-agent-muted-foreground'
|
|
22
|
+
}`}
|
|
23
|
+
title={
|
|
24
|
+
pressed
|
|
25
|
+
? t('Close {name}', { name: t('Panels') })
|
|
26
|
+
: t('Open {name}', { name: t('Panels') })
|
|
27
|
+
}
|
|
28
|
+
aria-label={t('Panels')}
|
|
29
|
+
aria-pressed={pressed}
|
|
30
|
+
data-testid="header-chat-panels"
|
|
31
|
+
>
|
|
32
|
+
<LuCopy className="h-4 w-4" />
|
|
33
|
+
</button>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { LuPlus, LuTerminal, LuX } from 'react-icons/lu';
|
|
4
|
+
import type { PackChatSlotContribution } from '@/packs/registry';
|
|
5
|
+
import { t } from '@/i18n';
|
|
6
|
+
import { PanelToggleButton } from '@/layouts/PanelToggleButton';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 右侧栏位的标签条。打开的文档、终端都留在这一行,点标签切换,点 × 关掉那一个。
|
|
10
|
+
* 还没打开的栏位从行尾的加号加进去。最右边的按钮收起整栏。
|
|
11
|
+
*/
|
|
12
|
+
export function RightPanelTabBar({
|
|
13
|
+
tabs,
|
|
14
|
+
active,
|
|
15
|
+
slots,
|
|
16
|
+
showTerminal,
|
|
17
|
+
onActivate,
|
|
18
|
+
onClose,
|
|
19
|
+
onOpen,
|
|
20
|
+
onCollapse,
|
|
21
|
+
}: {
|
|
22
|
+
tabs: readonly string[];
|
|
23
|
+
active: string;
|
|
24
|
+
slots: readonly PackChatSlotContribution[];
|
|
25
|
+
showTerminal: boolean;
|
|
26
|
+
onActivate: (id: string) => void;
|
|
27
|
+
onClose: (id: string) => void;
|
|
28
|
+
onOpen: (id: string) => void;
|
|
29
|
+
onCollapse: () => void;
|
|
30
|
+
}) {
|
|
31
|
+
const addable = unopenedPanels(tabs, slots, showTerminal);
|
|
32
|
+
return (
|
|
33
|
+
<div className="flex h-10 shrink-0 items-center gap-1 border-b border-agent-border bg-agent-canvas px-2">
|
|
34
|
+
<div
|
|
35
|
+
role="tablist"
|
|
36
|
+
aria-label={t('Panels')}
|
|
37
|
+
data-testid="right-panel-tabs"
|
|
38
|
+
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
|
|
39
|
+
>
|
|
40
|
+
{tabs.map((id) => {
|
|
41
|
+
const slot = slots.find((item) => item.slotId === id);
|
|
42
|
+
const title = slot?.title ?? (id === 'terminal' ? t('Terminal') : id);
|
|
43
|
+
const Icon = slot?.Icon ?? (id === 'terminal' ? LuTerminal : null);
|
|
44
|
+
const selected = id === active;
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
key={id}
|
|
48
|
+
role="tab"
|
|
49
|
+
aria-selected={selected}
|
|
50
|
+
data-testid={`right-panel-tab-${id}`}
|
|
51
|
+
className={`flex h-7 max-w-[11rem] shrink-0 items-center rounded-md pl-1.5 text-xs ${
|
|
52
|
+
selected
|
|
53
|
+
? 'bg-agent-foreground/10 font-medium text-agent-foreground'
|
|
54
|
+
: 'text-agent-muted-foreground hover:bg-agent-foreground/5 hover:text-agent-foreground'
|
|
55
|
+
}`}
|
|
56
|
+
>
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
onClick={() => onActivate(id)}
|
|
60
|
+
className="flex min-w-0 items-center gap-1 px-0.5"
|
|
61
|
+
title={title}
|
|
62
|
+
>
|
|
63
|
+
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" /> : null}
|
|
64
|
+
<span className="truncate">{title}</span>
|
|
65
|
+
</button>
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
onClick={() => onClose(id)}
|
|
69
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-agent-muted-foreground hover:bg-agent-foreground/10 hover:text-agent-foreground"
|
|
70
|
+
aria-label={t('Close {name}', { name: title })}
|
|
71
|
+
title={t('Close {name}', { name: title })}
|
|
72
|
+
>
|
|
73
|
+
<LuX className="h-3 w-3" />
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
})}
|
|
78
|
+
{addable.length > 0 && <AddPanelButton entries={addable} onOpen={onOpen} />}
|
|
79
|
+
</div>
|
|
80
|
+
<PanelToggleButton pressed onClick={onCollapse} />
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function AddPanelButton({
|
|
86
|
+
entries,
|
|
87
|
+
onOpen,
|
|
88
|
+
}: {
|
|
89
|
+
entries: readonly AddPanelEntry[];
|
|
90
|
+
onOpen: (id: string) => void;
|
|
91
|
+
}) {
|
|
92
|
+
const [open, setOpen] = useState(false);
|
|
93
|
+
const [box, setBox] = useState<{ top: number; left: number } | null>(null);
|
|
94
|
+
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
|
95
|
+
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const button = buttonRef.current;
|
|
99
|
+
if (!open || !button) {
|
|
100
|
+
setBox(null);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const update = () => {
|
|
104
|
+
const rect = button.getBoundingClientRect();
|
|
105
|
+
const width = 168;
|
|
106
|
+
const left = Math.min(Math.max(8, rect.left), window.innerWidth - width - 8);
|
|
107
|
+
setBox({ top: rect.bottom + 4, left });
|
|
108
|
+
};
|
|
109
|
+
update();
|
|
110
|
+
window.addEventListener('resize', update);
|
|
111
|
+
window.addEventListener('scroll', update, true);
|
|
112
|
+
return () => {
|
|
113
|
+
window.removeEventListener('resize', update);
|
|
114
|
+
window.removeEventListener('scroll', update, true);
|
|
115
|
+
};
|
|
116
|
+
}, [open]);
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (!open) return;
|
|
120
|
+
const onPointerDown = (event: MouseEvent) => {
|
|
121
|
+
const target = event.target as Node;
|
|
122
|
+
if (buttonRef.current?.contains(target) || menuRef.current?.contains(target)) return;
|
|
123
|
+
setOpen(false);
|
|
124
|
+
};
|
|
125
|
+
document.addEventListener('mousedown', onPointerDown);
|
|
126
|
+
return () => document.removeEventListener('mousedown', onPointerDown);
|
|
127
|
+
}, [open]);
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<>
|
|
131
|
+
<button
|
|
132
|
+
ref={buttonRef}
|
|
133
|
+
type="button"
|
|
134
|
+
onClick={() => setOpen((current) => !current)}
|
|
135
|
+
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
136
|
+
title={t('Open {name}', { name: t('Panels') })}
|
|
137
|
+
aria-label={t('Open {name}', { name: t('Panels') })}
|
|
138
|
+
aria-expanded={open}
|
|
139
|
+
data-testid="right-panel-add"
|
|
140
|
+
>
|
|
141
|
+
<LuPlus className="h-3.5 w-3.5" />
|
|
142
|
+
</button>
|
|
143
|
+
{open &&
|
|
144
|
+
box &&
|
|
145
|
+
createPortal(
|
|
146
|
+
<div
|
|
147
|
+
ref={menuRef}
|
|
148
|
+
className="fixed z-[80] w-[168px] rounded-lg border border-agent-border bg-agent-canvas p-1 shadow-lg"
|
|
149
|
+
style={{ top: box.top, left: box.left }}
|
|
150
|
+
data-testid="right-panel-add-menu"
|
|
151
|
+
>
|
|
152
|
+
{entries.map((entry) => (
|
|
153
|
+
<button
|
|
154
|
+
key={entry.id}
|
|
155
|
+
type="button"
|
|
156
|
+
onClick={() => {
|
|
157
|
+
onOpen(entry.id);
|
|
158
|
+
setOpen(false);
|
|
159
|
+
}}
|
|
160
|
+
className="flex w-full items-center gap-2 rounded px-2.5 py-1.5 text-left text-xs text-agent-muted-foreground transition-colors hover:bg-agent-foreground/5 hover:text-agent-foreground"
|
|
161
|
+
title={entry.detail ?? t('Open {name}', { name: entry.title })}
|
|
162
|
+
aria-label={entry.id === 'terminal' ? t('Terminal') : entry.title}
|
|
163
|
+
data-testid={entry.testId}
|
|
164
|
+
>
|
|
165
|
+
{entry.icon}
|
|
166
|
+
<span className="min-w-0 flex-1 truncate">{entry.title}</span>
|
|
167
|
+
</button>
|
|
168
|
+
))}
|
|
169
|
+
</div>,
|
|
170
|
+
document.body,
|
|
171
|
+
)}
|
|
172
|
+
</>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface AddPanelEntry {
|
|
177
|
+
id: string;
|
|
178
|
+
title: string;
|
|
179
|
+
testId: string;
|
|
180
|
+
icon: ReactNode;
|
|
181
|
+
detail?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function unopenedPanels(
|
|
185
|
+
tabs: readonly string[],
|
|
186
|
+
slots: readonly PackChatSlotContribution[],
|
|
187
|
+
showTerminal: boolean,
|
|
188
|
+
): AddPanelEntry[] {
|
|
189
|
+
const entries: AddPanelEntry[] = [];
|
|
190
|
+
for (const slot of slots) {
|
|
191
|
+
if (tabs.includes(slot.slotId)) continue;
|
|
192
|
+
const Icon = slot.Icon;
|
|
193
|
+
entries.push({
|
|
194
|
+
id: slot.slotId,
|
|
195
|
+
title: slot.title,
|
|
196
|
+
testId: `header-slot-${slot.slotId}`,
|
|
197
|
+
icon: <Icon className="h-3.5 w-3.5 shrink-0" />,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (showTerminal && !tabs.includes('terminal')) {
|
|
201
|
+
const shortcut =
|
|
202
|
+
typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform)
|
|
203
|
+
? '⌘T'
|
|
204
|
+
: 'Ctrl+T';
|
|
205
|
+
entries.push({
|
|
206
|
+
id: 'terminal',
|
|
207
|
+
title: t('Terminal'),
|
|
208
|
+
testId: 'header-terminal',
|
|
209
|
+
icon: <LuTerminal className="h-3.5 w-3.5 shrink-0" />,
|
|
210
|
+
detail: t('Open terminal panel ({shortcut})', { shortcut }),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return entries;
|
|
214
|
+
}
|