@steerable/agent-shell-web 0.6.63 → 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.
@@ -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 the tasks button in the title bar.', {
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 the tasks button in the title bar.')}</span>
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
- /** 当前会话打开的右侧栏位(null = 都关着)。 */
106
+ /** 当前会话正在看的右侧标签(null = 整栏关着或收起)。 */
135
107
  rightPanel: RightPanelState;
136
- /** 顶栏入口:点已打开的关闭,点另一个直接切换。 */
137
- onToggleChatSlot: (slotId: string) => void;
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
- // 终端与包槽位面板(如 PPT 预览)合并为同一栏位的互斥切换:
332
- // null | 'terminal' | <slotId>。持久化到新 key;老版本只持久化终端打开
333
- // 状态,首次读取时迁移。
305
+ // 终端与包槽位(PPT、Word、Markdown)共用一条标签。可以同时打开多个,
306
+ // 标签条切换当前看见的那一个。持久化按会话存标签列表;老的单值会迁成一个标签。
334
307
  const packChatSlots = getPackChatSlots();
335
308
 
336
309
  /**
337
- * 右侧栏位状态按会话隔离:chatId → null | 'terminal' | <slotId>。
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, value] of Object.entries(map)) {
355
- const next = sanitizeRightPanelKind(value);
356
- if (next) sanitized[id] = next;
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 rightPanel: RightPanelState = chatId ? rightPanelMap[chatId] ?? null : null;
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 setRightPanel = useCallback(
384
- (next: RightPanelState) => {
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
- rightPanelRef.current = rightPanel;
403
- }, [rightPanel]);
382
+ panelEntryRef.current = panelEntry;
383
+ }, [panelEntry]);
404
384
 
405
- const closeRightPanel = useCallback(() => {
406
- setRightPanel(null);
407
- }, [setRightPanel]);
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
- 打开任一面板即离开任务 dock(栏位同一时刻只呈现一个内容)。 */
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 raw: RightPanelState = rightPanelRef.current === kind ? null : kind;
414
- const next = sanitizeRightPanelKind(raw);
415
- rightPanelRef.current = next;
442
+ const id = sanitizeRightPanelKind(kind);
443
+ if (!id) return;
444
+ const next = toggleRightPanelEntry(panelEntryRef.current, id);
416
445
  if (next !== null) setInspectedTask(null);
417
- setRightPanel(next);
446
+ applyPanelEntry(next);
418
447
  },
419
- [setRightPanel],
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
- setRightPanel('terminal');
484
- }, [setRightPanel]);
512
+ applyPanelEntry(openRightPanelTab(panelEntryRef.current, 'terminal'));
513
+ }, [applyPanelEntry]);
485
514
 
486
- // 包槽位的自动展开(如文档包:后端在本轮产出新设计稿时广播包事件)。
487
- // 互斥规则:仅当栏位空闲(null)时 reveal 才生效;
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
- if (rightPanelRef.current !== null) return;
495
- rightPanelRef.current = slot.slotId;
496
- setRightPanel(slot.slotId);
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, setRightPanel]);
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
- onToggleChatSlot: toggleRightPanel,
647
+ openPanelIds,
648
+ onOpenRightPanel: expandRightSide,
614
649
  } satisfies AgentOutletContext}
615
650
  />
616
651
  </div>
617
652
  {settingsChrome('insights') && <InsightsConsentBanner />}
618
653
  </div>
619
- {(rightPanel !== null || inspectedTask !== null) && (
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={`h-full flex-shrink-0 overflow-hidden rounded-agent-lg shadow-sm ${
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
- {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}
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
+ }