@steerable/agent-shell-web 0.6.65 → 0.6.67
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/AgentSidebar.tsx +278 -18
- package/src/components/chat/ChatInput.tsx +28 -7
- package/src/components/chat/LocalChatPanel.tsx +4 -4
- package/src/components/chat/MessageList.tsx +58 -1
- package/src/components/chat/turn-files.ts +57 -0
- package/src/hooks/useProjects.ts +30 -1
- package/src/layouts/AgentLayout.tsx +160 -64
- package/src/layouts/RightPanelTabBar.tsx +22 -22
- package/src/layouts/conversation-preview.ts +87 -0
- package/src/layouts/request-chat-slot.ts +47 -4
- package/src/layouts/right-panel-tabs.ts +208 -48
- package/src/lib/host-paste.ts +23 -8
- package/src/lib/local-api.ts +11 -0
- package/src/lib/project-order.ts +48 -0
- package/src/packs/registry.ts +2 -0
package/src/hooks/useProjects.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { useCallback, useEffect, useSyncExternalStore } from 'react';
|
|
6
6
|
import { hasHostBridge } from '@/lib/host-bridge';
|
|
7
7
|
import { hostToolChrome } from '@/lib/host-tools';
|
|
8
|
-
import { listProjects, type LocalProject } from '@/lib/local-api';
|
|
8
|
+
import { listProjects, reorderProjects, type LocalProject } from '@/lib/local-api';
|
|
9
|
+
import { applyProjectIdOrder } from '@/lib/project-order';
|
|
9
10
|
|
|
10
11
|
type ProjectsSnapshot = {
|
|
11
12
|
projects: LocalProject[];
|
|
@@ -57,6 +58,34 @@ export async function refreshProjects(): Promise<void> {
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* 立刻按 orderedIds 重排共享列表,再写到服务端。
|
|
63
|
+
* 保存失败时把列表滚回拖拽前的顺序,并抛出原来的错误。
|
|
64
|
+
*/
|
|
65
|
+
export async function persistProjectOrder(orderedIds: string[]): Promise<void> {
|
|
66
|
+
const previous = snapshot;
|
|
67
|
+
const seq = ++requestSeq;
|
|
68
|
+
snapshot = {
|
|
69
|
+
projects: applyProjectIdOrder(previous.projects, orderedIds),
|
|
70
|
+
error: previous.error,
|
|
71
|
+
};
|
|
72
|
+
emit();
|
|
73
|
+
try {
|
|
74
|
+
const res = await reorderProjects(orderedIds);
|
|
75
|
+
if (seq !== requestSeq) return;
|
|
76
|
+
snapshot = {
|
|
77
|
+
projects: Array.isArray(res.projects) ? res.projects : snapshot.projects,
|
|
78
|
+
error: null,
|
|
79
|
+
};
|
|
80
|
+
emit();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (seq !== requestSeq) return;
|
|
83
|
+
snapshot = previous;
|
|
84
|
+
emit();
|
|
85
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
60
89
|
export function useProjects(): {
|
|
61
90
|
projects: LocalProject[];
|
|
62
91
|
error: string | null;
|
|
@@ -22,15 +22,26 @@ import { t } from '@/i18n';
|
|
|
22
22
|
import {
|
|
23
23
|
closeRightPanelTab,
|
|
24
24
|
collapseRightPanel,
|
|
25
|
+
createRightPanelTab,
|
|
25
26
|
expandRightPanel,
|
|
27
|
+
followRightPanelTab,
|
|
26
28
|
openRightPanelTab,
|
|
27
29
|
parseRightPanelMap,
|
|
28
30
|
revealRightPanelTab,
|
|
31
|
+
serializeRightPanelMap,
|
|
32
|
+
shouldMountRightPanelTab,
|
|
29
33
|
toggleRightPanelEntry,
|
|
30
34
|
type RightPanelMap,
|
|
35
|
+
type RightPanelTabRecord,
|
|
36
|
+
type RightPanelTabTarget,
|
|
31
37
|
type RightPanelTabs,
|
|
32
38
|
} from '@/layouts/right-panel-tabs';
|
|
33
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
parseLegacyChatSlotTabId,
|
|
41
|
+
subscribeChatSlotFollows,
|
|
42
|
+
subscribeChatSlotRequests,
|
|
43
|
+
type ChatSlotRequest,
|
|
44
|
+
} from '@/layouts/request-chat-slot';
|
|
34
45
|
|
|
35
46
|
export { parseRightPanelMap };
|
|
36
47
|
export type { RightPanelMap, RightPanelTabs };
|
|
@@ -80,7 +91,8 @@ const MIN_SIDEBAR_WIDTH = 180;
|
|
|
80
91
|
const MAX_SIDEBAR_WIDTH = 420;
|
|
81
92
|
|
|
82
93
|
const TERMINAL_OPEN_KEY = 'deeppath.agent.terminalOpen';
|
|
83
|
-
const RIGHT_PANEL_KEY = 'deeppath.agent.rightPanel';
|
|
94
|
+
const RIGHT_PANEL_KEY = 'deeppath.agent.rightPanel.v2';
|
|
95
|
+
const LEGACY_RIGHT_PANEL_KEY = 'deeppath.agent.rightPanel';
|
|
84
96
|
const TERMINAL_WIDTH_KEY = 'deeppath.agent.terminalWidth';
|
|
85
97
|
const DEFAULT_TERMINAL_WIDTH = 520;
|
|
86
98
|
const MIN_TERMINAL_WIDTH = 360;
|
|
@@ -89,6 +101,19 @@ const MAX_TERMINAL_WIDTH = 960;
|
|
|
89
101
|
/** 当前看见的标签。null = 这一侧没有打开的标签。 */
|
|
90
102
|
export type RightPanelState = string | null;
|
|
91
103
|
|
|
104
|
+
function contentTitle(contentId: string, fallback: string): string {
|
|
105
|
+
if (contentId.startsWith('/') || /^[A-Za-z]:[\\/]/.test(contentId)) {
|
|
106
|
+
const parts = contentId.split(/[/\\]/);
|
|
107
|
+
return parts.at(-1) || fallback;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const url = new URL(contentId);
|
|
111
|
+
return url.pathname.split('/').filter(Boolean).at(-1) || url.hostname || fallback;
|
|
112
|
+
} catch {
|
|
113
|
+
return fallback;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
92
117
|
/** 从聊天页外把一段内容作为普通用户消息发进当前会话(包槽位的 fallback 通道)。 */
|
|
93
118
|
export type ChatMessageSender = (input: {
|
|
94
119
|
content: string;
|
|
@@ -306,38 +331,48 @@ function AgentLayoutContent() {
|
|
|
306
331
|
// 标签条切换当前看见的那一个。持久化按会话存标签列表;老的单值会迁成一个标签。
|
|
307
332
|
const packChatSlots = getPackChatSlots();
|
|
308
333
|
|
|
334
|
+
const resolveTabTarget = useCallback((request: ChatSlotRequest): RightPanelTabTarget | null => {
|
|
335
|
+
const kind = sanitizeRightPanelKind(request.kind);
|
|
336
|
+
if (!kind) return null;
|
|
337
|
+
if (kind === 'terminal') {
|
|
338
|
+
return { kind, contentId: 'terminal', title: t('Terminal') };
|
|
339
|
+
}
|
|
340
|
+
const slot = packChatSlots.find((item) => item.slotId === kind);
|
|
341
|
+
if (!slot) return null;
|
|
342
|
+
const contentId = request.contentId || slot.defaultContentId || kind;
|
|
343
|
+
return {
|
|
344
|
+
kind,
|
|
345
|
+
contentId,
|
|
346
|
+
title: request.title || contentTitle(contentId, slot.title),
|
|
347
|
+
};
|
|
348
|
+
}, [packChatSlots]);
|
|
349
|
+
|
|
309
350
|
/**
|
|
310
351
|
* 右侧标签按会话隔离。以前是单个全局值,会话 1 打开预览会连带影响会话 2。
|
|
311
352
|
* 现在存成映射,每个会话独立;老的单值 key 读取时自动迁移到当前会话。
|
|
312
353
|
*/
|
|
313
354
|
const readRightPanelMap = useCallback((): RightPanelMap => {
|
|
314
|
-
const
|
|
315
|
-
|
|
355
|
+
const isValidKind = (kind: string): boolean =>
|
|
356
|
+
kind === 'terminal' || packChatSlots.some((slot) => slot.slotId === kind);
|
|
357
|
+
const resolveLegacyTab = (value: string): RightPanelTabTarget | null =>
|
|
358
|
+
resolveTabTarget(parseLegacyChatSlotTabId(value));
|
|
316
359
|
try {
|
|
360
|
+
const current = window.localStorage.getItem(RIGHT_PANEL_KEY);
|
|
317
361
|
const map = parseRightPanelMap({
|
|
318
|
-
raw: window.localStorage.getItem(
|
|
362
|
+
raw: current ?? window.localStorage.getItem(LEGACY_RIGHT_PANEL_KEY),
|
|
319
363
|
legacyTerminalOpen: window.localStorage.getItem(TERMINAL_OPEN_KEY),
|
|
320
364
|
chatId: chatId ?? null,
|
|
321
|
-
|
|
365
|
+
isValidKind,
|
|
366
|
+
resolveLegacyTab,
|
|
322
367
|
});
|
|
323
|
-
|
|
324
|
-
|
|
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 };
|
|
368
|
+
if (current === null && Object.keys(map).length > 0) {
|
|
369
|
+
window.localStorage.setItem(RIGHT_PANEL_KEY, serializeRightPanelMap(map));
|
|
335
370
|
}
|
|
336
|
-
return
|
|
371
|
+
return map;
|
|
337
372
|
} catch {
|
|
338
373
|
return {};
|
|
339
374
|
}
|
|
340
|
-
}, [chatId, packChatSlots]);
|
|
375
|
+
}, [chatId, packChatSlots, resolveTabTarget]);
|
|
341
376
|
|
|
342
377
|
const [rightPanelMap, setRightPanelMap] = useState<RightPanelMap>(() =>
|
|
343
378
|
readRightPanelMap(),
|
|
@@ -350,14 +385,19 @@ function AgentLayoutContent() {
|
|
|
350
385
|
|
|
351
386
|
const panelEntry: RightPanelTabs | null = chatId ? rightPanelMap[chatId] ?? null : null;
|
|
352
387
|
const panelOpen = panelEntry !== null && panelEntry.collapsed !== true;
|
|
353
|
-
const
|
|
354
|
-
|
|
388
|
+
const activePanelTab = panelOpen
|
|
389
|
+
? panelEntry.tabs.find((tab) => tab.id === panelEntry.activeTabId) ?? null
|
|
390
|
+
: null;
|
|
391
|
+
const rightPanel: RightPanelState = activePanelTab?.kind ?? null;
|
|
392
|
+
const openPanelIds = panelOpen
|
|
393
|
+
? [...new Set(panelEntry.tabs.map((tab) => tab.kind))]
|
|
394
|
+
: [];
|
|
355
395
|
// 自动展开 / 手动切换回调是挂载时注册的,直接读 state 会拿到过期闭包。
|
|
356
396
|
const panelEntryRef = useRef<RightPanelTabs | null>(panelEntry);
|
|
357
397
|
|
|
358
398
|
const persistRightPanelMap = useCallback((map: RightPanelMap) => {
|
|
359
399
|
try {
|
|
360
|
-
window.localStorage.setItem(RIGHT_PANEL_KEY,
|
|
400
|
+
window.localStorage.setItem(RIGHT_PANEL_KEY, serializeRightPanelMap(map));
|
|
361
401
|
} catch {
|
|
362
402
|
/* ignore */
|
|
363
403
|
}
|
|
@@ -383,36 +423,58 @@ function AgentLayoutContent() {
|
|
|
383
423
|
}, [panelEntry]);
|
|
384
424
|
|
|
385
425
|
const closePanelTab = useCallback(
|
|
386
|
-
(
|
|
426
|
+
(tabId: string) => {
|
|
387
427
|
const current = panelEntryRef.current;
|
|
388
428
|
if (!current) return;
|
|
389
|
-
|
|
429
|
+
const tab = current.tabs.find((item) => item.id === tabId);
|
|
430
|
+
if (!tab) return;
|
|
431
|
+
const slot = packChatSlots.find((item) => item.slotId === tab.kind);
|
|
432
|
+
if (chatId && slot?.onContentClosed) {
|
|
433
|
+
slot.onContentClosed({ chatId, contentId: tab.contentId });
|
|
434
|
+
}
|
|
435
|
+
applyPanelEntry(closeRightPanelTab(current, tabId));
|
|
390
436
|
},
|
|
391
|
-
[applyPanelEntry],
|
|
437
|
+
[applyPanelEntry, chatId, packChatSlots],
|
|
392
438
|
);
|
|
393
439
|
|
|
394
440
|
const activatePanelTab = useCallback(
|
|
395
|
-
(
|
|
441
|
+
(tabId: string) => {
|
|
396
442
|
const current = panelEntryRef.current;
|
|
397
|
-
if (!current || !current.tabs.
|
|
443
|
+
if (!current || !current.tabs.some((tab) => tab.id === tabId)) return;
|
|
398
444
|
setInspectedTask(null);
|
|
399
|
-
if (current.
|
|
400
|
-
applyPanelEntry({ tabs: current.tabs,
|
|
445
|
+
if (current.activeTabId === tabId) return;
|
|
446
|
+
applyPanelEntry({ tabs: current.tabs, activeTabId: tabId });
|
|
401
447
|
},
|
|
402
448
|
[applyPanelEntry],
|
|
403
449
|
);
|
|
404
450
|
|
|
451
|
+
const tabForRequest = useCallback((request: ChatSlotRequest): RightPanelTabRecord | null => {
|
|
452
|
+
const target = resolveTabTarget(request);
|
|
453
|
+
return target ? createRightPanelTab(target) : null;
|
|
454
|
+
}, [resolveTabTarget]);
|
|
455
|
+
|
|
405
456
|
const openPanelTab = useCallback(
|
|
406
|
-
(
|
|
407
|
-
const
|
|
408
|
-
if (!
|
|
409
|
-
if (id !== 'terminal' && !packChatSlots.some((slot) => slot.slotId === id)) return;
|
|
457
|
+
(request: ChatSlotRequest) => {
|
|
458
|
+
const tab = tabForRequest(request);
|
|
459
|
+
if (!tab) return;
|
|
410
460
|
setInspectedTask(null);
|
|
411
|
-
applyPanelEntry(openRightPanelTab(panelEntryRef.current,
|
|
461
|
+
applyPanelEntry(openRightPanelTab(panelEntryRef.current, tab));
|
|
412
462
|
},
|
|
413
|
-
[applyPanelEntry,
|
|
463
|
+
[applyPanelEntry, tabForRequest],
|
|
414
464
|
);
|
|
415
465
|
|
|
466
|
+
const openPanelKind = useCallback((kind: string) => {
|
|
467
|
+
if (kind === 'terminal') {
|
|
468
|
+
openPanelTab({ kind });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const slot = packChatSlots.find((item) => item.slotId === kind);
|
|
472
|
+
if (!slot) return;
|
|
473
|
+
const contentId = slot.multiple ? slot.createContentId?.() : undefined;
|
|
474
|
+
if (slot.multiple && !contentId) return;
|
|
475
|
+
openPanelTab({ kind, contentId });
|
|
476
|
+
}, [openPanelTab, packChatSlots]);
|
|
477
|
+
|
|
416
478
|
const expandRightSide = useCallback(() => {
|
|
417
479
|
const current = panelEntryRef.current;
|
|
418
480
|
if (current && current.tabs.length > 0) {
|
|
@@ -420,14 +482,11 @@ function AgentLayoutContent() {
|
|
|
420
482
|
applyPanelEntry(expandRightPanel(current));
|
|
421
483
|
return;
|
|
422
484
|
}
|
|
423
|
-
const
|
|
424
|
-
.map((slot) => sanitizeRightPanelKind(slot.slotId))
|
|
425
|
-
.find((id): id is string => id !== null);
|
|
426
|
-
const first = firstSlot ?? sanitizeRightPanelKind('terminal');
|
|
485
|
+
const first = packChatSlots[0]?.slotId ?? sanitizeRightPanelKind('terminal');
|
|
427
486
|
if (!first) return;
|
|
428
487
|
setInspectedTask(null);
|
|
429
|
-
|
|
430
|
-
}, [
|
|
488
|
+
openPanelKind(first);
|
|
489
|
+
}, [openPanelKind, packChatSlots]);
|
|
431
490
|
|
|
432
491
|
const collapseRightSide = useCallback(() => {
|
|
433
492
|
const current = panelEntryRef.current;
|
|
@@ -439,13 +498,25 @@ function AgentLayoutContent() {
|
|
|
439
498
|
/** 快捷键:没开就追加并显示,已开但不是当前就切过去,当前这个就关掉。整栏收着时改为打开。 */
|
|
440
499
|
const toggleRightPanel = useCallback(
|
|
441
500
|
(kind: string) => {
|
|
442
|
-
const
|
|
443
|
-
if (!
|
|
444
|
-
const
|
|
501
|
+
const sanitized = sanitizeRightPanelKind(kind);
|
|
502
|
+
if (!sanitized) return;
|
|
503
|
+
const current = panelEntryRef.current;
|
|
504
|
+
const active = current?.tabs.find((tab) => tab.id === current.activeTabId);
|
|
505
|
+
const matching = active?.kind === sanitized
|
|
506
|
+
? active
|
|
507
|
+
: [...(current?.tabs ?? [])].reverse().find((tab) => tab.kind === sanitized);
|
|
508
|
+
const requested = matching ?? tabForRequest({
|
|
509
|
+
kind: sanitized,
|
|
510
|
+
contentId: packChatSlots.find((slot) => slot.slotId === sanitized)?.multiple
|
|
511
|
+
? packChatSlots.find((slot) => slot.slotId === sanitized)?.createContentId?.()
|
|
512
|
+
: undefined,
|
|
513
|
+
});
|
|
514
|
+
if (!requested) return;
|
|
515
|
+
const next = toggleRightPanelEntry(current, requested);
|
|
445
516
|
if (next !== null) setInspectedTask(null);
|
|
446
517
|
applyPanelEntry(next);
|
|
447
518
|
},
|
|
448
|
-
[applyPanelEntry],
|
|
519
|
+
[applyPanelEntry, packChatSlots, tabForRequest],
|
|
449
520
|
);
|
|
450
521
|
|
|
451
522
|
// Cmd+T from the app menu — wired in src/main.ts:createMenu (sends
|
|
@@ -509,8 +580,9 @@ function AgentLayoutContent() {
|
|
|
509
580
|
const showTerminalFromTask = useCallback(() => {
|
|
510
581
|
if (!hostToolChrome('terminal')) return;
|
|
511
582
|
setInspectedTask(null);
|
|
512
|
-
|
|
513
|
-
|
|
583
|
+
const tab = tabForRequest({ kind: 'terminal' });
|
|
584
|
+
if (tab) applyPanelEntry(openRightPanelTab(panelEntryRef.current, tab));
|
|
585
|
+
}, [applyPanelEntry, tabForRequest]);
|
|
514
586
|
|
|
515
587
|
// 包槽位的自动展开(如文档包:后端在本轮产出新稿时广播)。
|
|
516
588
|
// 栏位空着时打开并显示;已经有别的标签时只追加,不抢走当前标签。
|
|
@@ -518,10 +590,10 @@ function AgentLayoutContent() {
|
|
|
518
590
|
const cleanups: Array<() => void> = [];
|
|
519
591
|
for (const slot of packChatSlots) {
|
|
520
592
|
const cleanup = slot.setupAutoReveal?.({
|
|
521
|
-
reveal: () => {
|
|
522
|
-
const
|
|
523
|
-
if (!
|
|
524
|
-
const next = revealRightPanelTab(panelEntryRef.current,
|
|
593
|
+
reveal: (target = {}) => {
|
|
594
|
+
const tab = tabForRequest({ kind: slot.slotId, ...target });
|
|
595
|
+
if (!tab) return;
|
|
596
|
+
const next = revealRightPanelTab(panelEntryRef.current, tab);
|
|
525
597
|
if (next === panelEntryRef.current) return;
|
|
526
598
|
if (panelEntryRef.current === null) setInspectedTask(null);
|
|
527
599
|
applyPanelEntry(next);
|
|
@@ -533,13 +605,20 @@ function AgentLayoutContent() {
|
|
|
533
605
|
return () => {
|
|
534
606
|
for (const cleanup of cleanups) cleanup();
|
|
535
607
|
};
|
|
536
|
-
|
|
537
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
538
|
-
}, [chatId, applyPanelEntry]);
|
|
608
|
+
}, [chatId, applyPanelEntry, tabForRequest]);
|
|
539
609
|
|
|
540
610
|
// 交付文件点击要打开对应栏位。菜单按钮不在文档里时也能切过去。
|
|
541
611
|
useEffect(() => subscribeChatSlotRequests(openPanelTab), [openPanelTab]);
|
|
542
612
|
|
|
613
|
+
// 对话滚动或新产物换右侧正在看的文件。关着的栏不弹出,终端不被抢走。
|
|
614
|
+
useEffect(() => subscribeChatSlotFollows((request) => {
|
|
615
|
+
const tab = tabForRequest(request);
|
|
616
|
+
if (!tab || tab.kind === 'terminal') return;
|
|
617
|
+
const next = followRightPanelTab(panelEntryRef.current, tab);
|
|
618
|
+
if (!next || next === panelEntryRef.current) return;
|
|
619
|
+
applyPanelEntry(next);
|
|
620
|
+
}), [applyPanelEntry, tabForRequest]);
|
|
621
|
+
|
|
543
622
|
// Same drag ergonomics as the sidebar handle, mirrored: the terminal's
|
|
544
623
|
// right edge is pinned to the window's right padding (p-1.5 = 6px), so the
|
|
545
624
|
// width is the distance from the cursor to that edge.
|
|
@@ -670,12 +749,12 @@ function AgentLayoutContent() {
|
|
|
670
749
|
{panelOpen && panelEntry && (
|
|
671
750
|
<RightPanelTabBar
|
|
672
751
|
tabs={panelEntry.tabs}
|
|
673
|
-
|
|
752
|
+
activeTabId={panelEntry.activeTabId}
|
|
674
753
|
slots={packChatSlots}
|
|
675
754
|
showTerminal={hostToolChrome('terminal')}
|
|
676
755
|
onActivate={activatePanelTab}
|
|
677
756
|
onClose={closePanelTab}
|
|
678
|
-
onOpen={
|
|
757
|
+
onOpen={openPanelKind}
|
|
679
758
|
onCollapse={collapseRightSide}
|
|
680
759
|
/>
|
|
681
760
|
)}
|
|
@@ -689,25 +768,42 @@ function AgentLayoutContent() {
|
|
|
689
768
|
}
|
|
690
769
|
/>
|
|
691
770
|
) : (
|
|
692
|
-
panelEntry?.tabs.map((
|
|
693
|
-
const visible = id === panelEntry.
|
|
771
|
+
panelEntry?.tabs.map((tab) => {
|
|
772
|
+
const visible = tab.id === panelEntry.activeTabId;
|
|
773
|
+
const slot = packChatSlots.find((item) => item.slotId === tab.kind);
|
|
774
|
+
const keepMounted = tab.kind === 'terminal' || slot?.keepMounted === true;
|
|
775
|
+
if (!shouldMountRightPanelTab(
|
|
776
|
+
tab.id,
|
|
777
|
+
panelEntry.activeTabId,
|
|
778
|
+
keepMounted,
|
|
779
|
+
)) return null;
|
|
694
780
|
return (
|
|
695
|
-
<div key={id} className={visible ? 'h-full' : 'hidden'}>
|
|
696
|
-
{
|
|
781
|
+
<div key={tab.id} className={visible ? 'h-full' : 'hidden'}>
|
|
782
|
+
{tab.kind === 'terminal' ? (
|
|
697
783
|
<TerminalPanel
|
|
698
|
-
onClose={() => closePanelTab(
|
|
784
|
+
onClose={() => closePanelTab(tab.id)}
|
|
699
785
|
onShowTaskProcess={recentTask ? showRecentTask : undefined}
|
|
700
786
|
taskProcessTitle={recentTask?.title}
|
|
701
787
|
/>
|
|
702
788
|
) : (
|
|
703
789
|
(() => {
|
|
704
|
-
const slot = packChatSlots.find((s) => s.slotId === id);
|
|
705
790
|
if (!slot) return null;
|
|
706
791
|
const SlotPanel = slot.Component;
|
|
707
792
|
return (
|
|
708
793
|
<SlotPanel
|
|
709
794
|
chatId={chatId ?? ''}
|
|
710
|
-
|
|
795
|
+
tabId={tab.id}
|
|
796
|
+
contentId={tab.contentId === tab.kind ? undefined : tab.contentId}
|
|
797
|
+
onTitleChange={(title) => {
|
|
798
|
+
const current = panelEntryRef.current;
|
|
799
|
+
if (!current || !title) return;
|
|
800
|
+
const existing = current.tabs.find((item) => item.id === tab.id);
|
|
801
|
+
if (!existing || existing.title === title) return;
|
|
802
|
+
const tabs = current.tabs.map((item) =>
|
|
803
|
+
item.id === tab.id ? { ...item, title } : item);
|
|
804
|
+
applyPanelEntry({ ...current, tabs });
|
|
805
|
+
}}
|
|
806
|
+
onClose={() => closePanelTab(tab.id)}
|
|
711
807
|
onSubmitToChat={sendChatMessage}
|
|
712
808
|
/>
|
|
713
809
|
);
|
|
@@ -4,6 +4,7 @@ import { LuPlus, LuTerminal, LuX } from 'react-icons/lu';
|
|
|
4
4
|
import type { PackChatSlotContribution } from '@/packs/registry';
|
|
5
5
|
import { t } from '@/i18n';
|
|
6
6
|
import { PanelToggleButton } from '@/layouts/PanelToggleButton';
|
|
7
|
+
import type { RightPanelTabRecord } from '@/layouts/right-panel-tabs';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* 右侧栏位的标签条。打开的文档、终端都留在这一行,点标签切换,点 × 关掉那一个。
|
|
@@ -11,7 +12,7 @@ import { PanelToggleButton } from '@/layouts/PanelToggleButton';
|
|
|
11
12
|
*/
|
|
12
13
|
export function RightPanelTabBar({
|
|
13
14
|
tabs,
|
|
14
|
-
|
|
15
|
+
activeTabId,
|
|
15
16
|
slots,
|
|
16
17
|
showTerminal,
|
|
17
18
|
onActivate,
|
|
@@ -19,13 +20,13 @@ export function RightPanelTabBar({
|
|
|
19
20
|
onOpen,
|
|
20
21
|
onCollapse,
|
|
21
22
|
}: {
|
|
22
|
-
tabs: readonly
|
|
23
|
-
|
|
23
|
+
tabs: readonly RightPanelTabRecord[];
|
|
24
|
+
activeTabId: string;
|
|
24
25
|
slots: readonly PackChatSlotContribution[];
|
|
25
26
|
showTerminal: boolean;
|
|
26
|
-
onActivate: (
|
|
27
|
-
onClose: (
|
|
28
|
-
onOpen: (
|
|
27
|
+
onActivate: (tabId: string) => void;
|
|
28
|
+
onClose: (tabId: string) => void;
|
|
29
|
+
onOpen: (kind: string) => void;
|
|
29
30
|
onCollapse: () => void;
|
|
30
31
|
}) {
|
|
31
32
|
const addable = unopenedPanels(tabs, slots, showTerminal);
|
|
@@ -37,17 +38,16 @@ export function RightPanelTabBar({
|
|
|
37
38
|
data-testid="right-panel-tabs"
|
|
38
39
|
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
|
|
39
40
|
>
|
|
40
|
-
{tabs.map((
|
|
41
|
-
const slot = slots.find((item) => item.slotId ===
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const selected = id === active;
|
|
41
|
+
{tabs.map((tab) => {
|
|
42
|
+
const slot = slots.find((item) => item.slotId === tab.kind);
|
|
43
|
+
const Icon = slot?.Icon ?? (tab.kind === 'terminal' ? LuTerminal : null);
|
|
44
|
+
const selected = tab.id === activeTabId;
|
|
45
45
|
return (
|
|
46
46
|
<div
|
|
47
|
-
key={id}
|
|
47
|
+
key={tab.id}
|
|
48
48
|
role="tab"
|
|
49
49
|
aria-selected={selected}
|
|
50
|
-
data-testid={`right-panel-tab-${id}`}
|
|
50
|
+
data-testid={`right-panel-tab-${tab.id}`}
|
|
51
51
|
className={`flex h-7 max-w-[11rem] shrink-0 items-center rounded-md pl-1.5 text-xs ${
|
|
52
52
|
selected
|
|
53
53
|
? 'bg-agent-foreground/10 font-medium text-agent-foreground'
|
|
@@ -56,19 +56,19 @@ export function RightPanelTabBar({
|
|
|
56
56
|
>
|
|
57
57
|
<button
|
|
58
58
|
type="button"
|
|
59
|
-
onClick={() => onActivate(id)}
|
|
59
|
+
onClick={() => onActivate(tab.id)}
|
|
60
60
|
className="flex min-w-0 items-center gap-1 px-0.5"
|
|
61
|
-
title={title}
|
|
61
|
+
title={tab.title}
|
|
62
62
|
>
|
|
63
63
|
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" /> : null}
|
|
64
|
-
<span className="truncate">{title}</span>
|
|
64
|
+
<span className="truncate">{tab.title}</span>
|
|
65
65
|
</button>
|
|
66
66
|
<button
|
|
67
67
|
type="button"
|
|
68
|
-
onClick={() => onClose(id)}
|
|
68
|
+
onClick={() => onClose(tab.id)}
|
|
69
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 })}
|
|
70
|
+
aria-label={t('Close {name}', { name: tab.title })}
|
|
71
|
+
title={t('Close {name}', { name: tab.title })}
|
|
72
72
|
>
|
|
73
73
|
<LuX className="h-3 w-3" />
|
|
74
74
|
</button>
|
|
@@ -182,13 +182,13 @@ interface AddPanelEntry {
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
function unopenedPanels(
|
|
185
|
-
tabs: readonly
|
|
185
|
+
tabs: readonly RightPanelTabRecord[],
|
|
186
186
|
slots: readonly PackChatSlotContribution[],
|
|
187
187
|
showTerminal: boolean,
|
|
188
188
|
): AddPanelEntry[] {
|
|
189
189
|
const entries: AddPanelEntry[] = [];
|
|
190
190
|
for (const slot of slots) {
|
|
191
|
-
if (tabs.
|
|
191
|
+
if (!slot.multiple && tabs.some((tab) => tab.kind === slot.slotId)) continue;
|
|
192
192
|
const Icon = slot.Icon;
|
|
193
193
|
entries.push({
|
|
194
194
|
id: slot.slotId,
|
|
@@ -197,7 +197,7 @@ function unopenedPanels(
|
|
|
197
197
|
icon: <Icon className="h-3.5 w-3.5 shrink-0" />,
|
|
198
198
|
});
|
|
199
199
|
}
|
|
200
|
-
if (showTerminal && !tabs.
|
|
200
|
+
if (showTerminal && !tabs.some((tab) => tab.kind === 'terminal')) {
|
|
201
201
|
const shortcut =
|
|
202
202
|
typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform)
|
|
203
203
|
? '⌘T'
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 右侧预览跟着当前对话走,按会话分开,不共用一条全局路径。
|
|
3
|
+
*
|
|
4
|
+
* 停在对话底部时显示最近一轮能预览的文件;往上翻时换成看着的那一轮。
|
|
5
|
+
* 点了某一份文件就先钉住它,直到翻到别的回合。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ConversationPreviewTarget {
|
|
9
|
+
chatId: string;
|
|
10
|
+
messageId: string;
|
|
11
|
+
path: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const targets = new Map<string, ConversationPreviewTarget>();
|
|
15
|
+
const pins = new Map<string, { messageId: string; path: string }>();
|
|
16
|
+
const listeners = new Set<(target: ConversationPreviewTarget) => void>();
|
|
17
|
+
|
|
18
|
+
function emit(target: ConversationPreviewTarget): void {
|
|
19
|
+
const previous = targets.get(target.chatId);
|
|
20
|
+
targets.set(target.chatId, target);
|
|
21
|
+
if (
|
|
22
|
+
previous
|
|
23
|
+
&& previous.messageId === target.messageId
|
|
24
|
+
&& previous.path === target.path
|
|
25
|
+
) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
for (const listener of listeners) listener(target);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 从 `#/agent/<chatId>` 取出当前会话。点文件时布局还没把 id 传进来。 */
|
|
32
|
+
export function chatIdFromHash(hash: string): string | null {
|
|
33
|
+
const match = /#\/agent\/([^/?#]+)/.exec(hash);
|
|
34
|
+
if (!match?.[1]) return null;
|
|
35
|
+
try {
|
|
36
|
+
return decodeURIComponent(match[1]);
|
|
37
|
+
} catch {
|
|
38
|
+
return match[1];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function peekConversationPreview(chatId: string): ConversationPreviewTarget | null {
|
|
43
|
+
return targets.get(chatId) ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function publishConversationPreview(target: ConversationPreviewTarget): void {
|
|
47
|
+
if (!target.chatId || !target.path) return;
|
|
48
|
+
const pin = pins.get(target.chatId);
|
|
49
|
+
if (!pin) {
|
|
50
|
+
emit(target);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (pin.messageId === '' || pin.messageId === target.messageId) {
|
|
54
|
+
if (pin.messageId === '') {
|
|
55
|
+
pins.set(target.chatId, { messageId: target.messageId, path: pin.path });
|
|
56
|
+
}
|
|
57
|
+
emit({ ...target, path: pin.path });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
pins.delete(target.chatId);
|
|
61
|
+
emit(target);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 点开的文件先于「这一轮的主文件」。翻到别的回合后钉自动松开。 */
|
|
65
|
+
export function pinConversationPreview(
|
|
66
|
+
chatId: string,
|
|
67
|
+
path: string,
|
|
68
|
+
messageId = '',
|
|
69
|
+
): void {
|
|
70
|
+
if (!chatId || !path) return;
|
|
71
|
+
pins.set(chatId, { messageId, path });
|
|
72
|
+
emit({ chatId, messageId, path });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function subscribeConversationPreview(
|
|
76
|
+
listener: (target: ConversationPreviewTarget) => void,
|
|
77
|
+
): () => void {
|
|
78
|
+
listeners.add(listener);
|
|
79
|
+
return () => {
|
|
80
|
+
listeners.delete(listener);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resetConversationPreviewForTests(): void {
|
|
85
|
+
targets.clear();
|
|
86
|
+
pins.clear();
|
|
87
|
+
}
|