@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.
@@ -1,51 +1,149 @@
1
1
  /**
2
2
  * 右侧栏位的多标签状态。
3
3
  *
4
- * 同一会话可以同时打开终端和若干包槽位。`active` 是当前看见的那一个。
4
+ * 同一会话可以同时打开终端和若干包槽位。`activeTabId` 是当前看见的那一个。
5
5
  * `collapsed` 表示整栏收起:标签还留着,右侧不显示,再打开时恢复。
6
- * 持久化兼容以前每个会话只存一个字符串的格式。
6
+ * 标签实例 id、内容身份和显示标题分开保存;持久化兼容旧字符串格式。
7
7
  */
8
8
 
9
+ export type RightPanelTabRecord = {
10
+ id: string;
11
+ kind: string;
12
+ contentId: string;
13
+ title: string;
14
+ };
15
+
9
16
  export type RightPanelTabs = {
10
- tabs: string[];
11
- active: string;
17
+ tabs: RightPanelTabRecord[];
18
+ activeTabId: string;
12
19
  collapsed?: boolean;
13
20
  };
14
21
 
15
22
  /** 每个会话各自的标签:chatId → 打开的标签(没有标签时不落盘)。 */
16
23
  export type RightPanelMap = Record<string, RightPanelTabs>;
17
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
+
18
89
  export function normalizeRightPanelEntry(
19
90
  value: unknown,
20
- isValidValue: (value: string) => boolean,
91
+ isValidKind: (kind: string) => boolean,
92
+ resolveLegacyTab: (value: string) => RightPanelTabTarget | null,
21
93
  ): RightPanelTabs | null {
22
94
  if (typeof value === 'string') {
23
- return isValidValue(value) ? { tabs: [value], active: value } : null;
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 };
24
99
  }
25
100
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
26
- const record = value as { tabs?: unknown; active?: unknown; collapsed?: unknown };
27
- const seen = new Set<string>();
28
- const tabs: string[] = [];
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[] = [];
29
110
  if (Array.isArray(record.tabs)) {
30
111
  for (const item of record.tabs) {
31
- if (typeof item !== 'string' || !isValidValue(item) || seen.has(item)) continue;
32
- seen.add(item);
33
- tabs.push(item);
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);
34
124
  }
35
125
  }
36
126
  if (tabs.length === 0) return null;
37
- const active =
38
- typeof record.active === 'string' && tabs.includes(record.active)
127
+ const requestedActive = typeof record.activeTabId === 'string'
128
+ ? record.activeTabId
129
+ : typeof record.active === 'string'
39
130
  ? record.active
40
- : tabs[0]!;
41
- return record.collapsed === true ? { tabs, active, collapsed: true } : { tabs, 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 };
42
139
  }
43
140
 
44
141
  /**
45
142
  * 解析持久化的「会话 → 右侧标签」映射。
46
143
  *
47
144
  * 兼容这些历史格式:
48
- * - 多标签:{"<chatId>": { "tabs": ["ppt", "terminal"], "active": "ppt" }};
145
+ * - v2:{"version":2,"sessions":{"<chatId>":{"tabs":[...],"activeTabId":"..."}}};
146
+ * - 旧多标签:{"<chatId>": { "tabs": ["ppt", "terminal"], "active": "ppt" }};
49
147
  * - 单值映射:{"<chatId>": "terminal" | "<slotId>"} → 一个标签;
50
148
  * - 整个值就是单个字符串 → 迁到当前会话;
51
149
  * - 更老:只记录终端是否打开过的布尔 key。
@@ -55,70 +153,94 @@ export function parseRightPanelMap(options: {
55
153
  raw: string | null;
56
154
  legacyTerminalOpen: string | null;
57
155
  chatId: string | null;
58
- isValidValue: (value: string) => boolean;
156
+ isValidKind: (kind: string) => boolean;
157
+ resolveLegacyTab: (value: string) => RightPanelTabTarget | null;
59
158
  }): RightPanelMap {
60
- const { raw, legacyTerminalOpen, chatId, isValidValue } = options;
159
+ const { raw, legacyTerminalOpen, chatId, isValidKind, resolveLegacyTab } = options;
61
160
  if (raw !== null) {
62
161
  if (raw === '') return {};
63
162
  try {
64
163
  const parsed = JSON.parse(raw) as unknown;
65
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>;
66
172
  const map: RightPanelMap = {};
67
- for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
68
- const entry = normalizeRightPanelEntry(value, isValidValue);
173
+ for (const [key, value] of Object.entries(source)) {
174
+ const entry = normalizeRightPanelEntry(value, isValidKind, resolveLegacyTab);
69
175
  if (entry) map[key] = entry;
70
176
  }
71
177
  return map;
72
178
  }
73
179
  } catch {
74
- const entry = normalizeRightPanelEntry(raw, isValidValue);
180
+ const entry = normalizeRightPanelEntry(raw, isValidKind, resolveLegacyTab);
75
181
  if (entry && chatId) return { [chatId]: entry };
76
182
  }
77
183
  return {};
78
184
  }
79
- if (legacyTerminalOpen === '1' && chatId && isValidValue('terminal')) {
80
- return { [chatId]: { tabs: ['terminal'], active: 'terminal' } };
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 } };
81
191
  }
82
192
  return {};
83
193
  }
84
194
 
195
+ /** 把运行时状态写成带版本的持久化载荷。 */
196
+ export function serializeRightPanelMap(map: RightPanelMap): string {
197
+ return JSON.stringify({ version: RIGHT_PANEL_PERSISTENCE_VERSION, sessions: map });
198
+ }
199
+
85
200
  /** 快捷键:整栏收着时打开并显示这一项;没开就追加并显示;已开但不是当前就切过去;当前这个就关掉。 */
86
201
  export function toggleRightPanelEntry(
87
202
  current: RightPanelTabs | null,
88
- kind: string,
203
+ requested: RightPanelTabRecord,
89
204
  ): RightPanelTabs | null {
205
+ const existing = findRightPanelContentTab(current, requested);
206
+ const tab = existing ?? requested;
90
207
  if (current?.collapsed) {
91
- const tabs = current.tabs.includes(kind) ? current.tabs : [...current.tabs, kind];
92
- return { tabs, active: kind };
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
+ };
93
216
  }
94
- if (!current || !current.tabs.includes(kind)) {
95
- return { tabs: current ? [...current.tabs, kind] : [kind], active: kind };
217
+ if (current.activeTabId !== tab.id) {
218
+ return { tabs: current.tabs, activeTabId: tab.id };
96
219
  }
97
- if (current.active !== kind) return { tabs: current.tabs, active: kind };
98
- return closeRightPanelTab(current, kind);
220
+ return closeRightPanelTab(current, tab.id);
99
221
  }
100
222
 
101
223
  /** 收起整栏。标签保留,下次展开还是这些。 */
102
224
  export function collapseRightPanel(current: RightPanelTabs): RightPanelTabs {
103
- return { tabs: current.tabs, active: current.active, collapsed: true };
225
+ return { tabs: current.tabs, activeTabId: current.activeTabId, collapsed: true };
104
226
  }
105
227
 
106
228
  /** 展开整栏,回到收起前正在看的标签。 */
107
229
  export function expandRightPanel(current: RightPanelTabs): RightPanelTabs {
108
- return { tabs: current.tabs, active: current.active };
230
+ return { tabs: current.tabs, activeTabId: current.activeTabId };
109
231
  }
110
232
 
111
233
  /** 关掉一个标签。关掉的是当前标签时,改看相邻的那一个。 */
112
234
  export function closeRightPanelTab(
113
235
  current: RightPanelTabs,
114
- kind: string,
236
+ tabId: string,
115
237
  ): RightPanelTabs | null {
116
- const tabs = current.tabs.filter((id) => id !== kind);
238
+ const tabs = current.tabs.filter((tab) => tab.id !== tabId);
117
239
  if (tabs.length === 0) return null;
118
- if (current.active !== kind) return { tabs, active: current.active };
119
- const index = current.tabs.indexOf(kind);
240
+ if (current.activeTabId !== tabId) return { tabs, activeTabId: current.activeTabId };
241
+ const index = current.tabs.findIndex((tab) => tab.id === tabId);
120
242
  const active = tabs[Math.min(index, tabs.length - 1)] ?? tabs[0]!;
121
- return { tabs, active };
243
+ return { tabs, activeTabId: active.id };
122
244
  }
123
245
 
124
246
  /**
@@ -127,23 +249,61 @@ export function closeRightPanelTab(
127
249
  */
128
250
  export function revealRightPanelTab(
129
251
  current: RightPanelTabs | null,
130
- kind: string,
252
+ requested: RightPanelTabRecord,
131
253
  ): RightPanelTabs {
132
- if (!current) return { tabs: [kind], active: kind };
254
+ if (!current) return { tabs: [requested], activeTabId: requested.id };
255
+ const existing = findRightPanelContentTab(current, requested);
256
+ const tab = existing ?? requested;
133
257
  if (current.collapsed) {
134
- const tabs = current.tabs.includes(kind) ? current.tabs : [...current.tabs, kind];
135
- return { tabs, active: kind };
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;
136
296
  }
137
- if (current.tabs.includes(kind)) return current;
138
- return { tabs: [...current.tabs, kind], active: current.active };
297
+ return next;
139
298
  }
140
299
 
141
300
  /** 打开并显示。已经开着时只切换当前标签。 */
142
301
  export function openRightPanelTab(
143
302
  current: RightPanelTabs | null,
144
- kind: string,
303
+ requested: RightPanelTabRecord,
145
304
  ): RightPanelTabs {
146
- if (!current) return { tabs: [kind], active: kind };
147
- if (current.tabs.includes(kind)) return { tabs: current.tabs, active: kind };
148
- return { tabs: [...current.tabs, kind], active: kind };
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 };
149
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',
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 侧栏项目拖拽排序。显示顺序以列表下标为准;服务端再把同样的 id
3
+ * 顺序写成 sortOrder。
4
+ */
5
+
6
+ export type ProjectDropPlace = 'before' | 'after';
7
+
8
+ /**
9
+ * 把 fromId 插到 targetId 前面或后面。顺序没有变化时返回 null,
10
+ * 调用方就不必再请求保存。
11
+ */
12
+ export function nextProjectOrder(
13
+ ids: readonly string[],
14
+ fromId: string,
15
+ targetId: string,
16
+ place: ProjectDropPlace,
17
+ ): string[] | null {
18
+ if (!fromId || fromId === targetId) return null;
19
+ const without = ids.filter((id) => id !== fromId);
20
+ const targetIndex = without.indexOf(targetId);
21
+ if (targetIndex < 0) return null;
22
+ const insertAt = place === 'before' ? targetIndex : targetIndex + 1;
23
+ const next = without.slice();
24
+ next.splice(insertAt, 0, fromId);
25
+ if (next.length === ids.length && next.every((id, index) => id === ids[index])) return null;
26
+ return next;
27
+ }
28
+
29
+ /** 按 orderedIds 重排。名单里没有的项目保持原先的相对顺序,接在后面。 */
30
+ export function applyProjectIdOrder<T extends { id: string }>(
31
+ projects: readonly T[],
32
+ orderedIds: readonly string[],
33
+ ): Array<T & { sortOrder: number }> {
34
+ const byId = new Map(projects.map((project) => [project.id, project]));
35
+ const next: Array<T & { sortOrder: number }> = [];
36
+ const seen = new Set<string>();
37
+ for (const id of orderedIds) {
38
+ const project = byId.get(id);
39
+ if (!project || seen.has(id)) continue;
40
+ seen.add(id);
41
+ next.push({ ...project, sortOrder: next.length });
42
+ }
43
+ for (const project of projects) {
44
+ if (seen.has(project.id)) continue;
45
+ next.push({ ...project, sortOrder: next.length });
46
+ }
47
+ return next;
48
+ }
@@ -13,6 +13,7 @@ import type {
13
13
  PackChatSlotAutoRevealApi,
14
14
  PackChatSlotContribution,
15
15
  PackChatSlotProps,
16
+ PackChatSlotTarget,
16
17
  PackRendererContributions,
17
18
  PackRouteContribution,
18
19
  PackSettingsPanelContribution,
@@ -23,6 +24,7 @@ export type {
23
24
  PackChatSlotAutoRevealApi,
24
25
  PackChatSlotContribution,
25
26
  PackChatSlotProps,
27
+ PackChatSlotTarget,
26
28
  PackRendererContributions,
27
29
  PackRouteContribution,
28
30
  PackSettingsPanelContribution,