dsh-workspace-api-key 0.2.2 → 0.2.3

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.
package/README.md CHANGED
@@ -72,7 +72,7 @@ Then add `"dsh-workspace-api-key"` to `dsh.profile.bundles` in that profile's `p
72
72
 
73
73
  ```sh
74
74
  node test/host.test.mjs # 125 assertions
75
- node test/client.test.mjs # 121 assertions
75
+ node test/client.test.mjs # 124 assertions
76
76
  ```
77
77
 
78
78
  Neither suite needs a running DSH process. The host suite drives `apply()` against a mock context (fake `credentials`, `workspaceRegistry`, `agents`, `sessionController`, `sessions`, `webServer`, stubbed `fetch`) and covers ref derivation, the session → workspace → default cascade, workspace matching, redirecting a second adapter's `apiKeyEnv`, clearing back to the default, 401/402/429/5xx classification, the loopback and origin checks, state persistence, and the session probe including the `{items}` envelope and the live-session fallback. The client suite loads `lib/client.js` through a minimal `window.__ModuleLoader__` plus a small React runtime, renders the panel and the overlay, and asserts slot registration, the first render, session expand/collapse, per-session saving and testing, sessions recovered from the browser-side store when the host has none (with `displayTitle` winning and subagent sessions hidden), the ungrouped-directory card, the stale and mismatch prompts, the "go configure" hand-off, graceful degradation without a session list, and back-navigation when `ctx.get('layout')` only becomes available after the plugin was applied.
package/README.zh.md CHANGED
@@ -72,7 +72,7 @@ pnpm add "dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key"
72
72
 
73
73
  ```sh
74
74
  node test/host.test.mjs # 125 条断言
75
- node test/client.test.mjs # 121 条断言
75
+ node test/client.test.mjs # 124 条断言
76
76
  ```
77
77
 
78
78
  两个套件都不需要启动 DSH。宿主套件用一个假 ctx(假的 `credentials`、`workspaceRegistry`、`agents`、`sessionController`、`sessions`、`webServer`,以及打桩的 `fetch`)驱动 `apply()`,覆盖 ref 派生、会话→工作区→默认的级联、工作区匹配、把第二个适配器的 `apiKeyEnv` 一起重定向、清回默认、401/402/429/5xx 的判定、回环与来源校验、状态落盘,以及会话探针(含 `{items}` 信封与 live 兜底)。浏览器套件通过一个极小的 `window.__ModuleLoader__` 与自制 React 运行时加载 `lib/client.js`,渲染出面板与覆盖层,断言槽注册、首屏渲染、会话折叠/展开、会话级保存与测试、宿主列不出会话时从浏览器端存储恢复会话(标题取 `displayTitle`、隐藏子代理会话)、未分组目录卡片、失效与不匹配提醒、「去配置」的交接、没有会话列表时的降级,以及 `ctx.get('layout')` 在插件 apply 之后才可用时返回键仍然有效。
package/lib/client.js CHANGED
@@ -212,6 +212,11 @@ window.__ModuleLoader__.load({
212
212
  return parts[parts.length - 1] ?? p
213
213
  }
214
214
 
215
+ /** 会话按最近使用排序。 */
216
+ function sortByUpdated(rows) {
217
+ return rows.slice().sort((a, b) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))
218
+ }
219
+
215
220
  /** 浏览器端 sessions 服务快照 → 顶层会话条目(子代理会话不算)。 */
216
221
  function clientSessionEntries(sessions) {
217
222
  const snap = sessions?.list?.getSnapshot?.()
@@ -237,9 +242,10 @@ window.__ModuleLoader__.load({
237
242
  }
238
243
 
239
244
  /**
240
- * 用浏览器端的会话列表重建「工作区/目录 → 会话」的树。
241
- * 宿主行(含 key 层级)按 sessionId 合并进来;宿主没给出的会话用
242
- * /state 里的覆盖记录补出层级,保证按钮不会因为宿主列表为空而消失。
245
+ * 用浏览器端的会话列表补全「工作区/目录 → 会话」的树。
246
+ * 宿主行始终保留(不会被浏览器端列表挤掉),浏览器端额外只做两件事:
247
+ * 给同名会话补标题/cwd,以及把宿主漏掉的会话补进来(层级从 /state 的
248
+ * 覆盖记录推出)。这样宿主列表为空时面板照样有会话可列。
243
249
  */
244
250
  function mergeSessions(state, sessions) {
245
251
  const entries = clientSessionEntries(sessions)
@@ -261,18 +267,27 @@ window.__ModuleLoader__.load({
261
267
  if (key !== undefined) workspaceByPath.set(key, ws)
262
268
  }
263
269
 
264
- const buckets = new Map()
265
- const extras = new Map()
270
+ // 第一遍:宿主行能补什么就补什么,宿主没有的会话合成一行。
271
+ const enriched = new Map()
272
+ const additions = []
266
273
  for (const entry of entries) {
267
274
  const host = hostRows.get(entry.sessionId)
268
- const cwd = typeof host?.cwd === 'string' && host.cwd !== '' ? host.cwd : entry.cwd
275
+ if (host !== undefined) {
276
+ const patch = {}
277
+ if ((host.title === undefined || host.title === '') && entry.title !== undefined) patch.title = entry.title
278
+ if ((host.cwd === undefined || host.cwd === '') && entry.cwd !== undefined) patch.cwd = entry.cwd
279
+ if (Object.keys(patch).length > 0) enriched.set(entry.sessionId, { ...host, ...patch })
280
+ continue
281
+ }
282
+ const cwd = entry.cwd
269
283
  const pathKey = normKey(cwd)
270
284
  const ws = pathKey !== undefined ? workspaceByPath.get(pathKey) : undefined
271
- let row = host
272
- if (row === undefined) {
273
- const own = overrideBySession.get(entry.sessionId)
274
- const level = own !== undefined ? 'session' : ws?.configured === true ? 'workspace' : 'default'
275
- row = {
285
+ const own = overrideBySession.get(entry.sessionId)
286
+ const level = own !== undefined ? 'session' : ws?.configured === true ? 'workspace' : 'default'
287
+ additions.push({
288
+ pathKey,
289
+ workspace: ws,
290
+ row: {
276
291
  sessionId: entry.sessionId,
277
292
  title: entry.title,
278
293
  cwd,
@@ -297,45 +312,50 @@ window.__ModuleLoader__.load({
297
312
  effectiveTitle: undefined,
298
313
  invalid: (level === 'session' ? own?.invalid : level === 'workspace' ? ws?.invalid : null) ?? null,
299
314
  mismatch: null,
300
- }
301
- } else {
302
- const patch = {}
303
- if ((row.title === undefined || row.title === '') && entry.title !== undefined) patch.title = entry.title
304
- if ((row.cwd === undefined || row.cwd === '') && cwd !== undefined) patch.cwd = cwd
305
- if (Object.keys(patch).length > 0) row = { ...row, ...patch }
306
- }
307
- const bucketKey = pathKey ?? ''
308
- const target = ws !== undefined ? buckets : extras
309
- const list = target.get(ws !== undefined ? ws.path : bucketKey) ?? []
310
- list.push(row)
311
- target.set(ws !== undefined ? ws.path : bucketKey, list)
315
+ },
316
+ })
312
317
  }
313
318
 
314
- const sorted = (rows) => rows.slice().sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
315
- const workspaces = (state.workspaces ?? []).map((ws) => (
316
- { ...ws, sessions: sorted(buckets.get(ws.path) ?? []) }
317
- ))
319
+ const fill = (rows) => (Array.isArray(rows) ? rows : [])
320
+ .map((row) => enriched.get(row?.sessionId) ?? row)
321
+
322
+ // 第二遍:宿主结构照旧,浏览器端多出来的会话按 cwd 归档。
323
+ const workspaces = (state.workspaces ?? []).map((ws) => {
324
+ const rows = fill(ws.sessions)
325
+ for (const item of additions) {
326
+ if (item.workspace?.path === ws.path) rows.push(item.row)
327
+ }
328
+ return { ...ws, sessions: sortByUpdated(rows) }
329
+ })
330
+
318
331
  const ungrouped = []
332
+ const extras = new Map()
333
+ for (const item of additions) {
334
+ if (item.workspace !== undefined) continue
335
+ const key = item.pathKey ?? ''
336
+ const list = extras.get(key) ?? []
337
+ list.push(item.row)
338
+ extras.set(key, list)
339
+ }
340
+ for (const group of (state.ungrouped ?? [])) {
341
+ const key = normKey(group?.path) ?? String(group?.path ?? '')
342
+ const rows = fill(group.sessions)
343
+ for (const row of (extras.get(key) ?? [])) rows.push(row)
344
+ extras.delete(key)
345
+ ungrouped.push({ ...group, sessions: sortByUpdated(rows) })
346
+ }
319
347
  for (const [key, rows] of extras) {
320
- const known = (state.ungrouped ?? []).find((group) => normKey(group?.path) === key)
321
- const path = known?.path ?? rows[0]?.cwd ?? undefined
348
+ const path = key !== '' ? key : undefined
322
349
  ungrouped.push({
323
- ...(known ?? {
324
- id: undefined,
325
- path,
326
- title: path !== undefined && path !== '' ? dirName(path) : '(没有目录)',
327
- kind: 'directory',
328
- configured: false,
329
- exists: true,
330
- }),
331
- sessions: sorted(rows),
350
+ id: undefined,
351
+ path: rows[0]?.cwd ?? path,
352
+ title: path !== undefined && path !== '' ? dirName(path) : '(没有目录)',
353
+ kind: 'directory',
354
+ configured: false,
355
+ exists: true,
356
+ sessions: sortByUpdated(rows),
332
357
  })
333
358
  }
334
- for (const group of (state.ungrouped ?? [])) {
335
- if (!ungrouped.some((item) => item.path === group.path)) {
336
- ungrouped.push({ ...group, sessions: sorted(buckets.get(group.path) ?? []) })
337
- }
338
- }
339
359
  return { ...state, workspaces, ungrouped, sessionSource: 'client' }
340
360
  }
341
361
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workspace-api-key",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "A DeepSeek Harness plugin that binds a separate DeepSeek API key to each workspace, or to a single session, so the spend of one key stays with the sessions you choose.",
5
5
  "type": "module",
6
6
  "license": "MIT",