@steerable/agent-shell-web 0.6.64 → 0.6.66

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.
@@ -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,32 @@ 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
+ createRightPanelTab,
26
+ expandRightPanel,
27
+ followRightPanelTab,
28
+ openRightPanelTab,
29
+ parseRightPanelMap,
30
+ revealRightPanelTab,
31
+ serializeRightPanelMap,
32
+ shouldMountRightPanelTab,
33
+ toggleRightPanelEntry,
34
+ type RightPanelMap,
35
+ type RightPanelTabRecord,
36
+ type RightPanelTabTarget,
37
+ type RightPanelTabs,
38
+ } from '@/layouts/right-panel-tabs';
39
+ import {
40
+ parseLegacyChatSlotTabId,
41
+ subscribeChatSlotFollows,
42
+ subscribeChatSlotRequests,
43
+ type ChatSlotRequest,
44
+ } from '@/layouts/request-chat-slot';
45
+
46
+ export { parseRightPanelMap };
47
+ export type { RightPanelMap, RightPanelTabs };
21
48
 
22
49
  /**
23
50
  * AgentLayout wraps `/agent`, `/agent/:chatId`, and `/` (default) with a
@@ -64,57 +91,27 @@ const MIN_SIDEBAR_WIDTH = 180;
64
91
  const MAX_SIDEBAR_WIDTH = 420;
65
92
 
66
93
  const TERMINAL_OPEN_KEY = 'deeppath.agent.terminalOpen';
67
- 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';
68
96
  const TERMINAL_WIDTH_KEY = 'deeppath.agent.terminalWidth';
69
97
  const DEFAULT_TERMINAL_WIDTH = 520;
70
98
  const MIN_TERMINAL_WIDTH = 360;
71
99
  const MAX_TERMINAL_WIDTH = 960;
72
100
 
73
- /**
74
- * 右侧栏位状态:null = 都不打开,'terminal' = 终端,其余字符串 = 包注册的
75
- * 聊天页槽位(chatSlots,如文档包的预览栏位)。栏位同一时刻只开一个
76
- * (互斥规则在本布局,不在包)。
77
- */
101
+ /** 当前看见的标签。null = 这一侧没有打开的标签。 */
78
102
  export type RightPanelState = string | null;
79
103
 
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 {};
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;
115
114
  }
116
- if (legacyTerminalOpen === '1' && chatId) return { [chatId]: 'terminal' };
117
- return {};
118
115
  }
119
116
 
120
117
  /** 从聊天页外把一段内容作为普通用户消息发进当前会话(包槽位的 fallback 通道)。 */
@@ -131,10 +128,12 @@ export type AgentOutletContext = UseChatsAndAgentsResult & {
131
128
  inspectTask: (task: InspectedTask) => void;
132
129
  /** 包槽位(如文档预览):入口渲染在 chat 标题栏,状态按会话隔离。 */
133
130
  chatSlots: readonly PackChatSlotContribution[];
134
- /** 当前会话打开的右侧栏位(null = 都关着)。 */
131
+ /** 当前会话正在看的右侧标签(null = 整栏关着或收起)。 */
135
132
  rightPanel: RightPanelState;
136
- /** 顶栏入口:点已打开的关闭,点另一个直接切换。 */
137
- onToggleChatSlot: (slotId: string) => void;
133
+ /** 当前会话已经打开且正在显示的标签,顺序与标签条一致。 */
134
+ openPanelIds: readonly string[];
135
+ /** 标题栏开关:整栏收着或空着时打开。 */
136
+ onOpenRightPanel: () => void;
138
137
  };
139
138
 
140
139
  function AgentLayoutContent() {
@@ -328,38 +327,52 @@ function AgentLayoutContent() {
328
327
  }, []);
329
328
 
330
329
  // ───── Right-side panel (embedded column beside the chat content) ─────
331
- // 终端与包槽位面板(如 PPT 预览)合并为同一栏位的互斥切换:
332
- // null | 'terminal' | <slotId>。持久化到新 key;老版本只持久化终端打开
333
- // 状态,首次读取时迁移。
330
+ // 终端与包槽位(PPT、Word、Markdown)共用一条标签。可以同时打开多个,
331
+ // 标签条切换当前看见的那一个。持久化按会话存标签列表;老的单值会迁成一个标签。
334
332
  const packChatSlots = getPackChatSlots();
335
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
+
336
350
  /**
337
- * 右侧栏位状态按会话隔离:chatId → null | 'terminal' | <slotId>。
338
- *
339
- * 以前是单个全局值(localStorage 里存一个字符串),会话 1 打开预览会连带
340
- * 影响会话 2。现在存成映射,每个会话独立;老的单值 key 读取时自动迁移到
341
- * 当前会话。
351
+ * 右侧标签按会话隔离。以前是单个全局值,会话 1 打开预览会连带影响会话 2。
352
+ * 现在存成映射,每个会话独立;老的单值 key 读取时自动迁移到当前会话。
342
353
  */
343
354
  const readRightPanelMap = useCallback((): RightPanelMap => {
344
- const isValidValue = (value: string): boolean =>
345
- value === 'terminal' || packChatSlots.some((s) => s.slotId === value);
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));
346
359
  try {
360
+ const current = window.localStorage.getItem(RIGHT_PANEL_KEY);
347
361
  const map = parseRightPanelMap({
348
- raw: window.localStorage.getItem(RIGHT_PANEL_KEY),
362
+ raw: current ?? window.localStorage.getItem(LEGACY_RIGHT_PANEL_KEY),
349
363
  legacyTerminalOpen: window.localStorage.getItem(TERMINAL_OPEN_KEY),
350
364
  chatId: chatId ?? null,
351
- isValidValue,
365
+ isValidKind,
366
+ resolveLegacyTab,
352
367
  });
353
- const sanitized: RightPanelMap = {};
354
- for (const [id, value] of Object.entries(map)) {
355
- const next = sanitizeRightPanelKind(value);
356
- if (next) sanitized[id] = next;
368
+ if (current === null && Object.keys(map).length > 0) {
369
+ window.localStorage.setItem(RIGHT_PANEL_KEY, serializeRightPanelMap(map));
357
370
  }
358
- return sanitized;
371
+ return map;
359
372
  } catch {
360
373
  return {};
361
374
  }
362
- }, [chatId, packChatSlots]);
375
+ }, [chatId, packChatSlots, resolveTabTarget]);
363
376
 
364
377
  const [rightPanelMap, setRightPanelMap] = useState<RightPanelMap>(() =>
365
378
  readRightPanelMap(),
@@ -370,19 +383,30 @@ function AgentLayoutContent() {
370
383
  setRightPanelMap(readRightPanelMap());
371
384
  }, [readRightPanelMap]);
372
385
 
373
- const rightPanel: RightPanelState = chatId ? rightPanelMap[chatId] ?? null : null;
386
+ const panelEntry: RightPanelTabs | null = chatId ? rightPanelMap[chatId] ?? null : null;
387
+ const panelOpen = panelEntry !== null && panelEntry.collapsed !== true;
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
+ : [];
395
+ // 自动展开 / 手动切换回调是挂载时注册的,直接读 state 会拿到过期闭包。
396
+ const panelEntryRef = useRef<RightPanelTabs | null>(panelEntry);
374
397
 
375
398
  const persistRightPanelMap = useCallback((map: RightPanelMap) => {
376
399
  try {
377
- window.localStorage.setItem(RIGHT_PANEL_KEY, JSON.stringify(map));
400
+ window.localStorage.setItem(RIGHT_PANEL_KEY, serializeRightPanelMap(map));
378
401
  } catch {
379
402
  /* ignore */
380
403
  }
381
404
  }, []);
382
405
 
383
- const setRightPanel = useCallback(
384
- (next: RightPanelState) => {
406
+ const applyPanelEntry = useCallback(
407
+ (next: RightPanelTabs | null) => {
385
408
  if (!chatId) return;
409
+ panelEntryRef.current = next;
386
410
  setRightPanelMap((prev) => {
387
411
  const map = { ...prev };
388
412
  if (next === null) delete map[chatId];
@@ -394,29 +418,105 @@ function AgentLayoutContent() {
394
418
  [chatId, persistRightPanelMap],
395
419
  );
396
420
 
397
- // 自动展开 / 手动切换回调是挂载时注册的,直接读 state 会拿到过期闭包,
398
- // 所以用 ref 跟踪当前栏位状态(见下方同步 effect)。
399
- const rightPanelRef = useRef<RightPanelState>(rightPanel);
400
-
401
421
  useEffect(() => {
402
- rightPanelRef.current = rightPanel;
403
- }, [rightPanel]);
422
+ panelEntryRef.current = panelEntry;
423
+ }, [panelEntry]);
424
+
425
+ const closePanelTab = useCallback(
426
+ (tabId: string) => {
427
+ const current = panelEntryRef.current;
428
+ if (!current) return;
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));
436
+ },
437
+ [applyPanelEntry, chatId, packChatSlots],
438
+ );
439
+
440
+ const activatePanelTab = useCallback(
441
+ (tabId: string) => {
442
+ const current = panelEntryRef.current;
443
+ if (!current || !current.tabs.some((tab) => tab.id === tabId)) return;
444
+ setInspectedTask(null);
445
+ if (current.activeTabId === tabId) return;
446
+ applyPanelEntry({ tabs: current.tabs, activeTabId: tabId });
447
+ },
448
+ [applyPanelEntry],
449
+ );
404
450
 
405
- const closeRightPanel = useCallback(() => {
406
- setRightPanel(null);
407
- }, [setRightPanel]);
451
+ const tabForRequest = useCallback((request: ChatSlotRequest): RightPanelTabRecord | null => {
452
+ const target = resolveTabTarget(request);
453
+ return target ? createRightPanelTab(target) : null;
454
+ }, [resolveTabTarget]);
455
+
456
+ const openPanelTab = useCallback(
457
+ (request: ChatSlotRequest) => {
458
+ const tab = tabForRequest(request);
459
+ if (!tab) return;
460
+ setInspectedTask(null);
461
+ applyPanelEntry(openRightPanelTab(panelEntryRef.current, tab));
462
+ },
463
+ [applyPanelEntry, tabForRequest],
464
+ );
408
465
 
409
- /** 顶栏 / 快捷键入口:点已打开的按钮关闭;点另一个按钮直接切换。
410
- 打开任一面板即离开任务 dock(栏位同一时刻只呈现一个内容)。 */
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
+
478
+ const expandRightSide = useCallback(() => {
479
+ const current = panelEntryRef.current;
480
+ if (current && current.tabs.length > 0) {
481
+ setInspectedTask(null);
482
+ applyPanelEntry(expandRightPanel(current));
483
+ return;
484
+ }
485
+ const first = packChatSlots[0]?.slotId ?? sanitizeRightPanelKind('terminal');
486
+ if (!first) return;
487
+ setInspectedTask(null);
488
+ openPanelKind(first);
489
+ }, [openPanelKind, packChatSlots]);
490
+
491
+ const collapseRightSide = useCallback(() => {
492
+ const current = panelEntryRef.current;
493
+ if (!current || current.tabs.length === 0) return;
494
+ setInspectedTask(null);
495
+ applyPanelEntry(collapseRightPanel(current));
496
+ }, [applyPanelEntry]);
497
+
498
+ /** 快捷键:没开就追加并显示,已开但不是当前就切过去,当前这个就关掉。整栏收着时改为打开。 */
411
499
  const toggleRightPanel = useCallback(
412
500
  (kind: string) => {
413
- const raw: RightPanelState = rightPanelRef.current === kind ? null : kind;
414
- const next = sanitizeRightPanelKind(raw);
415
- rightPanelRef.current = next;
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);
416
516
  if (next !== null) setInspectedTask(null);
417
- setRightPanel(next);
517
+ applyPanelEntry(next);
418
518
  },
419
- [setRightPanel],
519
+ [applyPanelEntry, packChatSlots, tabForRequest],
420
520
  );
421
521
 
422
522
  // Cmd+T from the app menu — wired in src/main.ts:createMenu (sends
@@ -480,20 +580,23 @@ function AgentLayoutContent() {
480
580
  const showTerminalFromTask = useCallback(() => {
481
581
  if (!hostToolChrome('terminal')) return;
482
582
  setInspectedTask(null);
483
- setRightPanel('terminal');
484
- }, [setRightPanel]);
583
+ const tab = tabForRequest({ kind: 'terminal' });
584
+ if (tab) applyPanelEntry(openRightPanelTab(panelEntryRef.current, tab));
585
+ }, [applyPanelEntry, tabForRequest]);
485
586
 
486
- // 包槽位的自动展开(如文档包:后端在本轮产出新设计稿时广播包事件)。
487
- // 互斥规则:仅当栏位空闲(null)时 reveal 才生效;
488
- // 已打开任一面板时,自动展开请求被忽略。订阅逻辑由包自己实现。
587
+ // 包槽位的自动展开(如文档包:后端在本轮产出新稿时广播)。
588
+ // 栏位空着时打开并显示;已经有别的标签时只追加,不抢走当前标签。
489
589
  useEffect(() => {
490
590
  const cleanups: Array<() => void> = [];
491
591
  for (const slot of packChatSlots) {
492
592
  const cleanup = slot.setupAutoReveal?.({
493
- reveal: () => {
494
- if (rightPanelRef.current !== null) return;
495
- rightPanelRef.current = slot.slotId;
496
- setRightPanel(slot.slotId);
593
+ reveal: (target = {}) => {
594
+ const tab = tabForRequest({ kind: slot.slotId, ...target });
595
+ if (!tab) return;
596
+ const next = revealRightPanelTab(panelEntryRef.current, tab);
597
+ if (next === panelEntryRef.current) return;
598
+ if (panelEntryRef.current === null) setInspectedTask(null);
599
+ applyPanelEntry(next);
497
600
  },
498
601
  getCurrentChatId: () => chatId ?? null,
499
602
  });
@@ -502,9 +605,19 @@ function AgentLayoutContent() {
502
605
  return () => {
503
606
  for (const cleanup of cleanups) cleanup();
504
607
  };
505
- // packChatSlots 在 bootstrap 后稳定;chatId 经 getCurrentChatId 闭包读取。
506
- // eslint-disable-next-line react-hooks/exhaustive-deps
507
- }, [chatId, setRightPanel]);
608
+ }, [chatId, applyPanelEntry, tabForRequest]);
609
+
610
+ // 交付文件点击要打开对应栏位。菜单按钮不在文档里时也能切过去。
611
+ useEffect(() => subscribeChatSlotRequests(openPanelTab), [openPanelTab]);
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]);
508
621
 
509
622
  // Same drag ergonomics as the sidebar handle, mirrored: the terminal's
510
623
  // right edge is pinned to the window's right padding (p-1.5 = 6px), so the
@@ -610,13 +723,14 @@ function AgentLayoutContent() {
610
723
  inspectTask,
611
724
  chatSlots: packChatSlots,
612
725
  rightPanel,
613
- onToggleChatSlot: toggleRightPanel,
726
+ openPanelIds,
727
+ onOpenRightPanel: expandRightSide,
614
728
  } satisfies AgentOutletContext}
615
729
  />
616
730
  </div>
617
731
  {settingsChrome('insights') && <InsightsConsentBanner />}
618
732
  </div>
619
- {(rightPanel !== null || inspectedTask !== null) && (
733
+ {(panelOpen || inspectedTask !== null) && (
620
734
  <>
621
735
  <div
622
736
  role="separator"
@@ -628,40 +742,78 @@ function AgentLayoutContent() {
628
742
  title={t('Drag to resize the right panel (double-click to reset)')}
629
743
  />
630
744
  <div
631
- className={`h-full flex-shrink-0 overflow-hidden rounded-agent-lg shadow-sm ${
632
- rightPanel === 'terminal' && !inspectedTask ? 'bg-[#0b0b0c]' : 'bg-agent-canvas'
633
- }`}
745
+ className="flex h-full flex-shrink-0 flex-col overflow-hidden rounded-agent-lg bg-agent-canvas shadow-sm"
634
746
  style={{ width: terminalWidth }}
635
747
  data-testid={inspectedTask ? 'task-process-dock' : 'terminal-panel'}
636
748
  >
637
- {inspectedTask ? (
638
- <TaskProcessPanel
639
- inspected={inspectedTask}
640
- onClose={closeTaskProcess}
641
- onShowTerminal={
642
- hostToolChrome('terminal') ? showTerminalFromTask : undefined
643
- }
644
- />
645
- ) : rightPanel === 'terminal' ? (
646
- <TerminalPanel
647
- onClose={closeRightPanel}
648
- onShowTaskProcess={recentTask ? showRecentTask : undefined}
649
- taskProcessTitle={recentTask?.title}
749
+ {panelOpen && panelEntry && (
750
+ <RightPanelTabBar
751
+ tabs={panelEntry.tabs}
752
+ activeTabId={panelEntry.activeTabId}
753
+ slots={packChatSlots}
754
+ showTerminal={hostToolChrome('terminal')}
755
+ onActivate={activatePanelTab}
756
+ onClose={closePanelTab}
757
+ onOpen={openPanelKind}
758
+ onCollapse={collapseRightSide}
650
759
  />
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
760
  )}
761
+ <div className="relative min-h-0 flex-1">
762
+ {inspectedTask ? (
763
+ <TaskProcessPanel
764
+ inspected={inspectedTask}
765
+ onClose={closeTaskProcess}
766
+ onShowTerminal={
767
+ hostToolChrome('terminal') ? showTerminalFromTask : undefined
768
+ }
769
+ />
770
+ ) : (
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;
780
+ return (
781
+ <div key={tab.id} className={visible ? 'h-full' : 'hidden'}>
782
+ {tab.kind === 'terminal' ? (
783
+ <TerminalPanel
784
+ onClose={() => closePanelTab(tab.id)}
785
+ onShowTaskProcess={recentTask ? showRecentTask : undefined}
786
+ taskProcessTitle={recentTask?.title}
787
+ />
788
+ ) : (
789
+ (() => {
790
+ if (!slot) return null;
791
+ const SlotPanel = slot.Component;
792
+ return (
793
+ <SlotPanel
794
+ chatId={chatId ?? ''}
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)}
807
+ onSubmitToChat={sendChatMessage}
808
+ />
809
+ );
810
+ })()
811
+ )}
812
+ </div>
813
+ );
814
+ })
815
+ )}
816
+ </div>
665
817
  </div>
666
818
  </>
667
819
  )}
@@ -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
+ }