@steerable/agent-shell-web 0.6.65 → 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.
@@ -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 { subscribeChatSlotRequests } from '@/layouts/request-chat-slot';
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 isValidValue = (value: string): boolean =>
315
- 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));
316
359
  try {
360
+ const current = window.localStorage.getItem(RIGHT_PANEL_KEY);
317
361
  const map = parseRightPanelMap({
318
- raw: window.localStorage.getItem(RIGHT_PANEL_KEY),
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
- isValidValue,
365
+ isValidKind,
366
+ resolveLegacyTab,
322
367
  });
323
- const sanitized: RightPanelMap = {};
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 };
368
+ if (current === null && Object.keys(map).length > 0) {
369
+ window.localStorage.setItem(RIGHT_PANEL_KEY, serializeRightPanelMap(map));
335
370
  }
336
- return sanitized;
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 rightPanel: RightPanelState = panelOpen ? panelEntry.active : null;
354
- const openPanelIds = panelOpen ? panelEntry.tabs : [];
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, JSON.stringify(map));
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
- (kind: string) => {
426
+ (tabId: string) => {
387
427
  const current = panelEntryRef.current;
388
428
  if (!current) return;
389
- applyPanelEntry(closeRightPanelTab(current, kind));
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
- (kind: string) => {
441
+ (tabId: string) => {
396
442
  const current = panelEntryRef.current;
397
- if (!current || !current.tabs.includes(kind)) return;
443
+ if (!current || !current.tabs.some((tab) => tab.id === tabId)) return;
398
444
  setInspectedTask(null);
399
- if (current.active === kind) return;
400
- applyPanelEntry({ tabs: current.tabs, active: kind });
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
- (kind: string) => {
407
- const id = sanitizeRightPanelKind(kind);
408
- if (!id) return;
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, id));
461
+ applyPanelEntry(openRightPanelTab(panelEntryRef.current, tab));
412
462
  },
413
- [applyPanelEntry, packChatSlots],
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 firstSlot = packChatSlots
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
- applyPanelEntry({ tabs: [first], active: first });
430
- }, [applyPanelEntry, packChatSlots]);
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 id = sanitizeRightPanelKind(kind);
443
- if (!id) return;
444
- const next = toggleRightPanelEntry(panelEntryRef.current, id);
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
- applyPanelEntry(openRightPanelTab(panelEntryRef.current, 'terminal'));
513
- }, [applyPanelEntry]);
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 id = sanitizeRightPanelKind(slot.slotId);
523
- if (!id) return;
524
- const next = revealRightPanelTab(panelEntryRef.current, id);
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
- // packChatSlots 在 bootstrap 后稳定;chatId 经 getCurrentChatId 闭包读取。
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
- active={panelEntry.active}
752
+ activeTabId={panelEntry.activeTabId}
674
753
  slots={packChatSlots}
675
754
  showTerminal={hostToolChrome('terminal')}
676
755
  onActivate={activatePanelTab}
677
756
  onClose={closePanelTab}
678
- onOpen={openPanelTab}
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((id) => {
693
- const visible = id === panelEntry.active;
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
- {id === 'terminal' ? (
781
+ <div key={tab.id} className={visible ? 'h-full' : 'hidden'}>
782
+ {tab.kind === 'terminal' ? (
697
783
  <TerminalPanel
698
- onClose={() => closePanelTab('terminal')}
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
- onClose={() => closePanelTab(id)}
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
- active,
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 string[];
23
- active: string;
23
+ tabs: readonly RightPanelTabRecord[];
24
+ activeTabId: string;
24
25
  slots: readonly PackChatSlotContribution[];
25
26
  showTerminal: boolean;
26
- onActivate: (id: string) => void;
27
- onClose: (id: string) => void;
28
- onOpen: (id: string) => void;
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((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;
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 string[],
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.includes(slot.slotId)) continue;
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.includes('terminal')) {
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
+ }
@@ -4,19 +4,62 @@
4
4
  * 直接通知布局:没有这个标签就打开,已经打开就切过去。整栏收着时一并展开。
5
5
  */
6
6
 
7
- const listeners = new Set<(slotId: string) => void>();
7
+ const LEGACY_RESOURCE_SEPARATOR = ':resource:';
8
+
9
+ export interface ChatSlotRequest {
10
+ kind: string;
11
+ contentId?: string;
12
+ title?: string;
13
+ }
14
+
15
+ const listeners = new Set<(request: ChatSlotRequest) => void>();
16
+ const followListeners = new Set<(request: ChatSlotRequest) => void>();
17
+
18
+ /** 只用于读取 v1 持久化的 `slotId:resource:<encoded path>`。 */
19
+ export function parseLegacyChatSlotTabId(value: string): ChatSlotRequest {
20
+ const separator = value.indexOf(LEGACY_RESOURCE_SEPARATOR);
21
+ if (separator < 0) return { kind: value };
22
+ const kind = value.slice(0, separator);
23
+ const encoded = value.slice(separator + LEGACY_RESOURCE_SEPARATOR.length);
24
+ if (!kind || !encoded) return { kind: value };
25
+ try {
26
+ return { kind, contentId: decodeURIComponent(encoded) };
27
+ } catch {
28
+ return { kind: value };
29
+ }
30
+ }
8
31
 
9
32
  /** 打开并聚焦这个栏位。 */
10
- export function requestChatSlot(slotId: string): void {
11
- for (const listener of listeners) listener(slotId);
33
+ export function requestChatSlot(kind: string, options: Omit<ChatSlotRequest, 'kind'> = {}): void {
34
+ const request = { kind, ...options };
35
+ for (const listener of listeners) listener(request);
12
36
  }
13
37
 
14
38
  /** 布局挂上后接收请求。返回取消订阅。 */
15
39
  export function subscribeChatSlotRequests(
16
- listener: (slotId: string) => void,
40
+ listener: (request: ChatSlotRequest) => void,
17
41
  ): () => void {
18
42
  listeners.add(listener);
19
43
  return () => {
20
44
  listeners.delete(listener);
21
45
  };
22
46
  }
47
+
48
+ /**
49
+ * 对话滚动或新产物把右侧内容换到这个栏位。
50
+ * 和点击不同:栏位关着时不弹出,正在看终端时也不抢走。
51
+ */
52
+ export function followChatSlot(kind: string, options: Omit<ChatSlotRequest, 'kind'> = {}): void {
53
+ const request = { kind, ...options };
54
+ for (const listener of followListeners) listener(request);
55
+ }
56
+
57
+ /** 布局接收「跟着对话换栏位」的请求。返回取消订阅。 */
58
+ export function subscribeChatSlotFollows(
59
+ listener: (request: ChatSlotRequest) => void,
60
+ ): () => void {
61
+ followListeners.add(listener);
62
+ return () => {
63
+ followListeners.delete(listener);
64
+ };
65
+ }