@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.
@@ -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
+ import type { RightPanelTabRecord } from '@/layouts/right-panel-tabs';
8
+
9
+ /**
10
+ * 右侧栏位的标签条。打开的文档、终端都留在这一行,点标签切换,点 × 关掉那一个。
11
+ * 还没打开的栏位从行尾的加号加进去。最右边的按钮收起整栏。
12
+ */
13
+ export function RightPanelTabBar({
14
+ tabs,
15
+ activeTabId,
16
+ slots,
17
+ showTerminal,
18
+ onActivate,
19
+ onClose,
20
+ onOpen,
21
+ onCollapse,
22
+ }: {
23
+ tabs: readonly RightPanelTabRecord[];
24
+ activeTabId: string;
25
+ slots: readonly PackChatSlotContribution[];
26
+ showTerminal: boolean;
27
+ onActivate: (tabId: string) => void;
28
+ onClose: (tabId: string) => void;
29
+ onOpen: (kind: string) => void;
30
+ onCollapse: () => void;
31
+ }) {
32
+ const addable = unopenedPanels(tabs, slots, showTerminal);
33
+ return (
34
+ <div className="flex h-10 shrink-0 items-center gap-1 border-b border-agent-border bg-agent-canvas px-2">
35
+ <div
36
+ role="tablist"
37
+ aria-label={t('Panels')}
38
+ data-testid="right-panel-tabs"
39
+ className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
40
+ >
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
+ return (
46
+ <div
47
+ key={tab.id}
48
+ role="tab"
49
+ aria-selected={selected}
50
+ data-testid={`right-panel-tab-${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(tab.id)}
60
+ className="flex min-w-0 items-center gap-1 px-0.5"
61
+ title={tab.title}
62
+ >
63
+ {Icon ? <Icon className="h-3.5 w-3.5 shrink-0" /> : null}
64
+ <span className="truncate">{tab.title}</span>
65
+ </button>
66
+ <button
67
+ type="button"
68
+ onClick={() => onClose(tab.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: tab.title })}
71
+ title={t('Close {name}', { name: tab.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 RightPanelTabRecord[],
186
+ slots: readonly PackChatSlotContribution[],
187
+ showTerminal: boolean,
188
+ ): AddPanelEntry[] {
189
+ const entries: AddPanelEntry[] = [];
190
+ for (const slot of slots) {
191
+ if (!slot.multiple && tabs.some((tab) => tab.kind === 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.some((tab) => tab.kind === '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
+ }
@@ -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
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 让对话里的文件点击打开右侧栏位。
3
+ *
4
+ * 直接通知布局:没有这个标签就打开,已经打开就切过去。整栏收着时一并展开。
5
+ */
6
+
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
+ }
31
+
32
+ /** 打开并聚焦这个栏位。 */
33
+ export function requestChatSlot(kind: string, options: Omit<ChatSlotRequest, 'kind'> = {}): void {
34
+ const request = { kind, ...options };
35
+ for (const listener of listeners) listener(request);
36
+ }
37
+
38
+ /** 布局挂上后接收请求。返回取消订阅。 */
39
+ export function subscribeChatSlotRequests(
40
+ listener: (request: ChatSlotRequest) => void,
41
+ ): () => void {
42
+ listeners.add(listener);
43
+ return () => {
44
+ listeners.delete(listener);
45
+ };
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
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * 右侧栏位的多标签状态。
3
+ *
4
+ * 同一会话可以同时打开终端和若干包槽位。`activeTabId` 是当前看见的那一个。
5
+ * `collapsed` 表示整栏收起:标签还留着,右侧不显示,再打开时恢复。
6
+ * 标签实例 id、内容身份和显示标题分开保存;持久化兼容旧字符串格式。
7
+ */
8
+
9
+ export type RightPanelTabRecord = {
10
+ id: string;
11
+ kind: string;
12
+ contentId: string;
13
+ title: string;
14
+ };
15
+
16
+ export type RightPanelTabs = {
17
+ tabs: RightPanelTabRecord[];
18
+ activeTabId: string;
19
+ collapsed?: boolean;
20
+ };
21
+
22
+ /** 每个会话各自的标签:chatId → 打开的标签(没有标签时不落盘)。 */
23
+ export type RightPanelMap = Record<string, RightPanelTabs>;
24
+
25
+ export type RightPanelTabTarget = Omit<RightPanelTabRecord, 'id'>;
26
+
27
+ export const RIGHT_PANEL_PERSISTENCE_VERSION = 2;
28
+
29
+ let fallbackTabSequence = 0;
30
+
31
+ /** 新建一次标签实例。内容相同的标签是否复用由打开策略决定。 */
32
+ export function createRightPanelTab(
33
+ target: RightPanelTabTarget,
34
+ id?: string,
35
+ ): RightPanelTabRecord {
36
+ const generated = globalThis.crypto?.randomUUID?.()
37
+ ?? `tab-${Date.now().toString(36)}-${(++fallbackTabSequence).toString(36)}`;
38
+ return { id: id ?? generated, ...target };
39
+ }
40
+
41
+ function contentKey(tab: Pick<RightPanelTabRecord, 'kind' | 'contentId'>): string {
42
+ return `${tab.kind}\0${tab.contentId}`;
43
+ }
44
+
45
+ /** 查找已打开的同一内容。 */
46
+ export function findRightPanelContentTab(
47
+ current: RightPanelTabs | null,
48
+ target: Pick<RightPanelTabRecord, 'kind' | 'contentId'>,
49
+ ): RightPanelTabRecord | null {
50
+ return current?.tabs.find((tab) =>
51
+ tab.kind === target.kind && tab.contentId === target.contentId) ?? null;
52
+ }
53
+
54
+ /** 非当前标签只在类型声明需要保活时继续挂载。 */
55
+ export function shouldMountRightPanelTab(
56
+ tabId: string,
57
+ activeTabId: string,
58
+ keepMounted: boolean,
59
+ ): boolean {
60
+ return tabId === activeTabId || keepMounted;
61
+ }
62
+
63
+ function normalizeTabRecord(
64
+ value: unknown,
65
+ isValidKind: (kind: string) => boolean,
66
+ ): RightPanelTabRecord | null {
67
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
68
+ const record = value as Partial<Record<keyof RightPanelTabRecord, unknown>>;
69
+ if (
70
+ typeof record.id !== 'string'
71
+ || !record.id
72
+ || typeof record.kind !== 'string'
73
+ || !isValidKind(record.kind)
74
+ || typeof record.contentId !== 'string'
75
+ || !record.contentId
76
+ || typeof record.title !== 'string'
77
+ || !record.title
78
+ ) {
79
+ return null;
80
+ }
81
+ return {
82
+ id: record.id,
83
+ kind: record.kind,
84
+ contentId: record.contentId,
85
+ title: record.title,
86
+ };
87
+ }
88
+
89
+ export function normalizeRightPanelEntry(
90
+ value: unknown,
91
+ isValidKind: (kind: string) => boolean,
92
+ resolveLegacyTab: (value: string) => RightPanelTabTarget | null,
93
+ ): RightPanelTabs | null {
94
+ if (typeof value === 'string') {
95
+ const target = resolveLegacyTab(value);
96
+ if (!target || !isValidKind(target.kind)) return null;
97
+ const tab = createRightPanelTab(target, `legacy:${encodeURIComponent(value)}`);
98
+ return { tabs: [tab], activeTabId: tab.id };
99
+ }
100
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
101
+ const record = value as {
102
+ tabs?: unknown;
103
+ active?: unknown;
104
+ activeTabId?: unknown;
105
+ collapsed?: unknown;
106
+ };
107
+ const seenIds = new Set<string>();
108
+ const seenContent = new Set<string>();
109
+ const tabs: RightPanelTabRecord[] = [];
110
+ if (Array.isArray(record.tabs)) {
111
+ for (const item of record.tabs) {
112
+ const tab = typeof item === 'string'
113
+ ? (() => {
114
+ const target = resolveLegacyTab(item);
115
+ return target && isValidKind(target.kind)
116
+ ? createRightPanelTab(target, `legacy:${encodeURIComponent(item)}`)
117
+ : null;
118
+ })()
119
+ : normalizeTabRecord(item, isValidKind);
120
+ if (!tab || seenIds.has(tab.id) || seenContent.has(contentKey(tab))) continue;
121
+ seenIds.add(tab.id);
122
+ seenContent.add(contentKey(tab));
123
+ tabs.push(tab);
124
+ }
125
+ }
126
+ if (tabs.length === 0) return null;
127
+ const requestedActive = typeof record.activeTabId === 'string'
128
+ ? record.activeTabId
129
+ : typeof record.active === 'string'
130
+ ? record.active
131
+ : '';
132
+ const activeTabId = tabs.some((tab) => tab.id === requestedActive)
133
+ ? requestedActive
134
+ : tabs.find((tab) => tab.id === `legacy:${encodeURIComponent(requestedActive)}`)?.id
135
+ ?? tabs[0]!.id;
136
+ return record.collapsed === true
137
+ ? { tabs, activeTabId, collapsed: true }
138
+ : { tabs, activeTabId };
139
+ }
140
+
141
+ /**
142
+ * 解析持久化的「会话 → 右侧标签」映射。
143
+ *
144
+ * 兼容这些历史格式:
145
+ * - v2:{"version":2,"sessions":{"<chatId>":{"tabs":[...],"activeTabId":"..."}}};
146
+ * - 旧多标签:{"<chatId>": { "tabs": ["ppt", "terminal"], "active": "ppt" }};
147
+ * - 单值映射:{"<chatId>": "terminal" | "<slotId>"} → 一个标签;
148
+ * - 整个值就是单个字符串 → 迁到当前会话;
149
+ * - 更老:只记录终端是否打开过的布尔 key。
150
+ * 无效的会话 / 栏位值会被丢弃。
151
+ */
152
+ export function parseRightPanelMap(options: {
153
+ raw: string | null;
154
+ legacyTerminalOpen: string | null;
155
+ chatId: string | null;
156
+ isValidKind: (kind: string) => boolean;
157
+ resolveLegacyTab: (value: string) => RightPanelTabTarget | null;
158
+ }): RightPanelMap {
159
+ const { raw, legacyTerminalOpen, chatId, isValidKind, resolveLegacyTab } = options;
160
+ if (raw !== null) {
161
+ if (raw === '') return {};
162
+ try {
163
+ const parsed = JSON.parse(raw) as unknown;
164
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
165
+ const root = parsed as { version?: unknown; sessions?: unknown };
166
+ const source = root.version === RIGHT_PANEL_PERSISTENCE_VERSION
167
+ && root.sessions
168
+ && typeof root.sessions === 'object'
169
+ && !Array.isArray(root.sessions)
170
+ ? root.sessions as Record<string, unknown>
171
+ : parsed as Record<string, unknown>;
172
+ const map: RightPanelMap = {};
173
+ for (const [key, value] of Object.entries(source)) {
174
+ const entry = normalizeRightPanelEntry(value, isValidKind, resolveLegacyTab);
175
+ if (entry) map[key] = entry;
176
+ }
177
+ return map;
178
+ }
179
+ } catch {
180
+ const entry = normalizeRightPanelEntry(raw, isValidKind, resolveLegacyTab);
181
+ if (entry && chatId) return { [chatId]: entry };
182
+ }
183
+ return {};
184
+ }
185
+ if (legacyTerminalOpen === '1' && chatId && isValidKind('terminal')) {
186
+ const tab = createRightPanelTab(
187
+ { kind: 'terminal', contentId: 'terminal', title: 'Terminal' },
188
+ 'legacy:terminal',
189
+ );
190
+ return { [chatId]: { tabs: [tab], activeTabId: tab.id } };
191
+ }
192
+ return {};
193
+ }
194
+
195
+ /** 把运行时状态写成带版本的持久化载荷。 */
196
+ export function serializeRightPanelMap(map: RightPanelMap): string {
197
+ return JSON.stringify({ version: RIGHT_PANEL_PERSISTENCE_VERSION, sessions: map });
198
+ }
199
+
200
+ /** 快捷键:整栏收着时打开并显示这一项;没开就追加并显示;已开但不是当前就切过去;当前这个就关掉。 */
201
+ export function toggleRightPanelEntry(
202
+ current: RightPanelTabs | null,
203
+ requested: RightPanelTabRecord,
204
+ ): RightPanelTabs | null {
205
+ const existing = findRightPanelContentTab(current, requested);
206
+ const tab = existing ?? requested;
207
+ if (current?.collapsed) {
208
+ const tabs = existing ? current.tabs : [...current.tabs, tab];
209
+ return { tabs, activeTabId: tab.id };
210
+ }
211
+ if (!current || !existing) {
212
+ return {
213
+ tabs: current ? [...current.tabs, tab] : [tab],
214
+ activeTabId: tab.id,
215
+ };
216
+ }
217
+ if (current.activeTabId !== tab.id) {
218
+ return { tabs: current.tabs, activeTabId: tab.id };
219
+ }
220
+ return closeRightPanelTab(current, tab.id);
221
+ }
222
+
223
+ /** 收起整栏。标签保留,下次展开还是这些。 */
224
+ export function collapseRightPanel(current: RightPanelTabs): RightPanelTabs {
225
+ return { tabs: current.tabs, activeTabId: current.activeTabId, collapsed: true };
226
+ }
227
+
228
+ /** 展开整栏,回到收起前正在看的标签。 */
229
+ export function expandRightPanel(current: RightPanelTabs): RightPanelTabs {
230
+ return { tabs: current.tabs, activeTabId: current.activeTabId };
231
+ }
232
+
233
+ /** 关掉一个标签。关掉的是当前标签时,改看相邻的那一个。 */
234
+ export function closeRightPanelTab(
235
+ current: RightPanelTabs,
236
+ tabId: string,
237
+ ): RightPanelTabs | null {
238
+ const tabs = current.tabs.filter((tab) => tab.id !== tabId);
239
+ if (tabs.length === 0) return null;
240
+ if (current.activeTabId !== tabId) return { tabs, activeTabId: current.activeTabId };
241
+ const index = current.tabs.findIndex((tab) => tab.id === tabId);
242
+ const active = tabs[Math.min(index, tabs.length - 1)] ?? tabs[0]!;
243
+ return { tabs, activeTabId: active.id };
244
+ }
245
+
246
+ /**
247
+ * 自动展开:栏位空着时打开并显示;已经有别的标签时只把这个加进去,不抢走当前标签。
248
+ * 这个标签已经开着则保持原样。
249
+ */
250
+ export function revealRightPanelTab(
251
+ current: RightPanelTabs | null,
252
+ requested: RightPanelTabRecord,
253
+ ): RightPanelTabs {
254
+ if (!current) return { tabs: [requested], activeTabId: requested.id };
255
+ const existing = findRightPanelContentTab(current, requested);
256
+ const tab = existing ?? requested;
257
+ if (current.collapsed) {
258
+ const tabs = existing ? current.tabs : [...current.tabs, tab];
259
+ return { tabs, activeTabId: tab.id };
260
+ }
261
+ if (existing) return current;
262
+ return { tabs: [...current.tabs, tab], activeTabId: current.activeTabId };
263
+ }
264
+
265
+ function sameRightPanelTabs(
266
+ tabs: readonly RightPanelTabRecord[],
267
+ next: readonly RightPanelTabRecord[],
268
+ ): boolean {
269
+ return tabs.length === next.length && tabs.every((tab, index) => tab.id === next[index]?.id);
270
+ }
271
+
272
+ /**
273
+ * 对话里的预览跟着当前回合换文件。
274
+ * 栏位关着时不重新打开。正在看终端时只把文档标签留在后面,不抢走终端。
275
+ * 已经开着文档时,切到这一轮对应的标签。
276
+ */
277
+ export function followRightPanelTab(
278
+ current: RightPanelTabs | null,
279
+ requested: RightPanelTabRecord,
280
+ ): RightPanelTabs | null {
281
+ if (!current) return null;
282
+ const existing = findRightPanelContentTab(current, requested);
283
+ const tab = existing ?? requested;
284
+ const tabs = existing ? current.tabs : [...current.tabs, tab];
285
+ const activeTab = current.tabs.find((item) => item.id === current.activeTabId);
286
+ const activeTabId = activeTab?.kind === 'terminal' ? activeTab.id : tab.id;
287
+ const next: RightPanelTabs = current.collapsed
288
+ ? { tabs, activeTabId, collapsed: true }
289
+ : { tabs, activeTabId };
290
+ if (
291
+ current.activeTabId === next.activeTabId
292
+ && current.collapsed === next.collapsed
293
+ && sameRightPanelTabs(current.tabs, next.tabs)
294
+ ) {
295
+ return current;
296
+ }
297
+ return next;
298
+ }
299
+
300
+ /** 打开并显示。已经开着时只切换当前标签。 */
301
+ export function openRightPanelTab(
302
+ current: RightPanelTabs | null,
303
+ requested: RightPanelTabRecord,
304
+ ): RightPanelTabs {
305
+ if (!current) return { tabs: [requested], activeTabId: requested.id };
306
+ const existing = findRightPanelContentTab(current, requested);
307
+ if (existing) return { tabs: current.tabs, activeTabId: existing.id };
308
+ return { tabs: [...current.tabs, requested], activeTabId: requested.id };
309
+ }
@@ -445,6 +445,8 @@ export interface LocalProject {
445
445
  sourceFolders?: string[];
446
446
  /** W6-5: 信任后该项目目录里的规则文件才会注入模型上下文。缺省 false。 */
447
447
  trusted?: boolean;
448
+ /** 侧边栏顺序,越小越靠前。缺省时列表顺序由服务端决定。 */
449
+ sortOrder?: number;
448
450
  createdAt: string;
449
451
  updatedAt: string;
450
452
  }
@@ -570,6 +572,15 @@ export async function updateProject(
570
572
  });
571
573
  }
572
574
 
575
+ /** 按 id 顺序保存侧栏项目排序。服务端忽略未知 id,没提到的项目接在后面。 */
576
+ export async function reorderProjects(orderedIds: string[]) {
577
+ return bridge().localBackend.request<{ success: boolean; projects: LocalProject[] }>({
578
+ method: 'PUT',
579
+ path: '/api/v2/projects/order',
580
+ body: { orderedIds },
581
+ });
582
+ }
583
+
573
584
  export async function deleteProject(projectId: string) {
574
585
  return bridge().localBackend.request<{ success: boolean; detachedChats: number }>({
575
586
  method: 'DELETE',