@michengai/dsh-archive-manager 0.1.24 → 0.1.26

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/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@
4
4
 
5
5
  Published release notes are retained below; new versions are added without removing earlier entries.
6
6
 
7
+ ## 0.1.26 — 2026-09-03
8
+
9
+ - Fixed the Windows CI host-test fixture to use the same native realpath representation as DSH's asynchronous session-path index, preventing valid workspace sessions from being filtered out only in the test environment.
10
+ - Added an initialization regression assertion that verifies accounted workspace sessions remain visible after path membership validation.
11
+
12
+ ## 0.1.25 — 2026-09-03
13
+
14
+ - Added **Archive all chats** to each workspace action menu. The entry is hidden when no active chats remain, and the client refreshes its session list after a successful batch archive.
15
+ - Added a red outlined confirmation style for **Archive all** with hover and keyboard-focus states.
16
+ - Kept the path-safe `session_projcache_archive_manager_v2` domain as the only write target. Startup now imports missing entries from the short-lived old-name v2 domain, old-name v1 domain, and supported DSH cache formats without overwriting newer records.
17
+ - Added RC2-to-current isolated compatibility coverage and made it a release gate: CI and `prepublishOnly` now run `test:compat`; CI no longer assumes pnpm exists before Corepack enables it.
18
+
7
19
  ## 0.1.24 — 2026-09-02
8
20
 
9
21
  - Upgraded the development test stack to DSH `0.1.2-alpha.5` and `@deepseek-ai/cordis` `4.0.2`, while retaining the existing `>=0.1.0-rc.5 <0.2.0` DSH runtime peer declarations.
@@ -4,6 +4,18 @@
4
4
 
5
5
  以下发布说明会持续保留;新增版本时不再删除较早记录。
6
6
 
7
+ ## 0.1.26 — 2026-09-03
8
+
9
+ - 修复 Windows CI 宿主测试夹具:改用与 DSH 异步会话路径索引一致的原生 `realpath` 表示,避免有效工作区会话仅在测试环境被路径成员校验过滤。
10
+ - 新增初始化回归断言,验证已记账的工作区会话在路径成员校验后仍保持可见。
11
+
12
+ ## 0.1.25 — 2026-09-03
13
+
14
+ - 每个工作区操作菜单新增「归档全部聊天」;没有可归档聊天时不显示入口,批量归档成功后立即刷新客户端会话列表。
15
+ - 「全部归档」确认按钮使用红色描边,并提供悬停和键盘焦点状态。
16
+ - 路径安全的 `session_projcache_archive_manager_v2` 域继续作为唯一写入目标;启动时从短暂使用过的旧名 v2 域、旧名 v1 域及受支持的 DSH 缓存格式只读补缺导入,不覆盖更新的记录。
17
+ - 新增从 RC2 到当前版本的隔离兼容回归,并将其纳入发布门禁:CI 与 `prepublishOnly` 均执行 `test:compat`,CI 不再假定 Corepack 启用前系统已存在 pnpm。
18
+
7
19
  ## 0.1.24 — 2026-09-02
8
20
 
9
21
  - 将开发测试依赖升级至 DSH `0.1.2-alpha.5` 与 `@deepseek-ai/cordis` `4.0.2`,同时保留原有的 DSH 运行时 peer 声明 `>=0.1.0-rc.5 <0.2.0`。
package/README.md CHANGED
@@ -22,6 +22,7 @@
22
22
  ## Features
23
23
 
24
24
  - Archive a session from the sidebar session menu.
25
+ - Archive every active chat in a workspace from its sidebar action menu.
25
26
  - Search archived chats, sort them by update time, creation time, or title, and filter them by workspace in **Settings → Archived**.
26
27
  - Restore a session to its original workspace with **Unarchive**.
27
28
  - Restore or permanently delete every archived chat in a project group.
package/README.zh-CN.md CHANGED
@@ -22,6 +22,7 @@
22
22
  ## 功能概览
23
23
 
24
24
  - 在侧栏会话菜单中选择「归档会话」。
25
+ - 在工作区侧栏操作菜单中一键归档该工作区全部未归档聊天。
25
26
  - 在「设置 → 已归档」按工作区查看归档会话,并支持搜索、按更新时间/创建时间/标题排序和按项目筛选。
26
27
  - 安全取消归档,将会话恢复到原工作区位置。
27
28
  - 在项目分组中批量恢复或永久删除该项目的全部已归档聊天。
package/lib/client.js CHANGED
@@ -30,6 +30,12 @@ window.__ModuleLoader__.load({
30
30
  return value;
31
31
  }
32
32
  };
33
+ const workspaceIdSchema = {
34
+ parse(value) {
35
+ if (typeof value !== "string" || value.length === 0) throw new TypeError(`workspaceId must be a non-empty string, got ${String(value)}`);
36
+ return value;
37
+ }
38
+ };
33
39
  const archivedSetSchema = {
34
40
  parse(value) {
35
41
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("result must be an object");
@@ -61,6 +67,14 @@ window.__ModuleLoader__.load({
61
67
  return value;
62
68
  }
63
69
  };
70
+ const archivedWorkspaceBatchSchema = {
71
+ parse(value) {
72
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("result must be an object");
73
+ if (!Array.isArray(value.archivedSessionIds) || value.archivedSessionIds.some((id) => typeof id !== "string")) throw new TypeError("archivedSessionIds must be a string array");
74
+ if (!Array.isArray(value.archivedSessionIdsAdded) || value.archivedSessionIdsAdded.some((id) => typeof id !== "string")) throw new TypeError("archivedSessionIdsAdded must be a string array");
75
+ return value;
76
+ }
77
+ };
64
78
  const deletedBatchSchema = {
65
79
  parse(value) {
66
80
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("result must be an object");
@@ -143,6 +157,25 @@ window.__ModuleLoader__.load({
143
157
  },
144
158
  sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
145
159
  },
160
+ {
161
+ id: "@michengai/dsh-archive-manager#workspaceRegistry/archiveWorkspaceSessions",
162
+ service: "workspaceRegistry",
163
+ namespace: "workspaceRegistry",
164
+ method: "archiveWorkspaceSessions",
165
+ invocation: { kind: "direct" },
166
+ parameters: [{
167
+ name: "workspaceId",
168
+ wire: "workspaceId",
169
+ source: "json",
170
+ codec: { mode: "strict", typeSymbol: "@deepseek-ai/dsh-workspace/types#WorkspaceId", schema: workspaceIdSchema }
171
+ }],
172
+ result: {
173
+ mode: "strict",
174
+ typeSymbol: "@michengai/dsh-archive-manager/types#ArchivedWorkspaceBatch",
175
+ schema: archivedWorkspaceBatchSchema
176
+ },
177
+ sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
178
+ },
146
179
  {
147
180
  id: "@michengai/dsh-archive-manager#workspaceRegistry/deleteArchivedSessions",
148
181
  service: "workspaceRegistry",
@@ -607,6 +640,22 @@ window.__ModuleLoader__.load({
607
640
  if (stray.length > 0) groups.push(buildGroup("", void 0, void 0, void 0, UNGROUPED_LABEL, ungroupedOrder === void 0 ? stray : orderedUngrouped(stray, ungroupedOrder), ungroupedOrder === void 0 ? "recency" : "account"));
608
641
  return groups;
609
642
  }
643
+ /** 返回工作区中尚未归档的唯一会话数量。 */
644
+ function archiveableWorkspaceSessionCount(workspace, archivedSessionIds) {
645
+ const archived = archivedSessionIds instanceof Set ? archivedSessionIds : new Set(archivedSessionIds);
646
+ return [...new Set(workspace.sessionIds)].filter((sessionId) => !archived.has(sessionId)).length;
647
+ }
648
+ /** 仅在工作区仍有活跃会话时创建批量归档确认目标。 */
649
+ function archiveWorkspaceDialogTarget(workspaces, workspaceId, title, archivedSessionIds) {
650
+ const workspace = workspaces.find((item) => item.workspaceId === workspaceId);
651
+ if (workspace === void 0) return null;
652
+ const count = archiveableWorkspaceSessionCount(workspace, archivedSessionIds);
653
+ return count === 0 ? null : { workspaceId, title, count };
654
+ }
655
+ /** 请求失败时保留确认目标,方便用户查看错误后重试。 */
656
+ function archiveWorkspaceDialogFailureState(target, reason, t) {
657
+ return { target, archiving: false, error: formatArchiveError(reason, t) };
658
+ }
610
659
  /** 只把侧栏已支持的待处理交互类型交给行渲染,避免未知类型触发断言。 */
611
660
  function visiblePendingKind(kind) {
612
661
  switch (kind) {
@@ -942,7 +991,11 @@ window.__ModuleLoader__.load({
942
991
  id: "rename",
943
992
  label: t("rename"),
944
993
  icon: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconEditOutline16, {})
945
- }, {
994
+ }, ...(actions?.canArchive === true ? [{
995
+ id: "archive-workspace",
996
+ label: t("menu.archiveWorkspace"),
997
+ icon: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconArchiveOutline20, { size: 16 })
998
+ }] : []), {
946
999
  id: "delete",
947
1000
  label: t("delete.workspace"),
948
1001
  icon: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconTrashOutline16, {}),
@@ -986,9 +1039,10 @@ window.__ModuleLoader__.load({
986
1039
  items: workspaceMenuItems,
987
1040
  onSelect: (id) => {
988
1041
  setMenuOpen(false);
989
- /* v8 ignore next -- workspaceMenuItems carries exactly these two rows today. */
990
- if (id !== "rename" && id !== "delete") return;
1042
+ /* v8 ignore next -- workspaceMenuItems carries exactly these three rows today. */
1043
+ if (id !== "rename" && id !== "archive-workspace" && id !== "delete") return;
991
1044
  if (id === "rename") actions.rename();
1045
+ else if (id === "archive-workspace") actions.archive();
992
1046
  else actions.delete();
993
1047
  },
994
1048
  portal: true,
@@ -1741,7 +1795,7 @@ window.__ModuleLoader__.load({
1741
1795
  return e.clientY < rect.top + rect.height / 2 ? "before" : "after";
1742
1796
  }
1743
1797
  /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */
1744
- function SessionTree({ useSessions, useSessionPendingInteraction, startSession, open, forkSession, workspaces, archivedSessionIds, showArchived, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, onSessionUnarchive, onSessionDelete, insertWorkspaceBefore, insertSessionBefore, orderBy, groupExpansion, setGroupExpanded, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t }) {
1798
+ function SessionTree({ useSessions, useSessionPendingInteraction, startSession, open, forkSession, workspaces, archivedSessionIds, showArchived, onRenameRequest, onArchiveRequest, onDeleteRequest, onSessionRename, onSessionArchive, onSessionUnarchive, onSessionDelete, insertWorkspaceBefore, insertSessionBefore, orderBy, groupExpansion, setGroupExpanded, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t }) {
1745
1799
  const list = useSessions((s) => s);
1746
1800
  const pendingInteractions = useSessionPendingInteraction((s) => s);
1747
1801
  const current = list.current;
@@ -1937,16 +1991,24 @@ window.__ModuleLoader__.load({
1937
1991
  }
1938
1992
  },
1939
1993
  drag: workspaceDragProps,
1940
- actions: group.workspaceId === void 0 ? void 0 : {
1941
- rename: () => {
1942
- /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
1943
- if (group.workspaceId !== void 0) onRenameRequest(group.workspaceId, group.label);
1944
- },
1945
- delete: () => {
1946
- /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
1947
- if (group.workspaceId !== void 0) onDeleteRequest(group.workspaceId, group.label);
1994
+ actions: group.workspaceId === void 0 ? void 0 : {
1995
+ canArchive: (() => {
1996
+ const workspace = workspaces.find((item) => item.workspaceId === group.workspaceId);
1997
+ return workspace !== void 0 && archiveableWorkspaceSessionCount(workspace, archivedSessionIds) > 0;
1998
+ })(),
1999
+ rename: () => {
2000
+ /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
2001
+ if (group.workspaceId !== void 0) onRenameRequest(group.workspaceId, group.label);
2002
+ },
2003
+ archive: () => {
2004
+ /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
2005
+ if (group.workspaceId !== void 0) onArchiveRequest(group.workspaceId, group.label);
2006
+ },
2007
+ delete: () => {
2008
+ /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
2009
+ if (group.workspaceId !== void 0) onDeleteRequest(group.workspaceId, group.label);
2010
+ }
1948
2011
  }
1949
- }
1950
2012
  }),
1951
2013
  (expandedSessionGroups.includes(group.key) ? group.sessions : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT)).map((node) => {
1952
2014
  const sameGroupDrag = drag !== null && drag.accountKey === group.key;
@@ -2216,7 +2278,7 @@ window.__ModuleLoader__.load({
2216
2278
  * @param props - composed slot props (shell owner share + store + injected actions).
2217
2279
  * @returns the region element tree.
2218
2280
  */
2219
- function WorkspaceBrowser({ wide, expandSidebar, useSessions, useSessionPendingInteraction, useWorkspaces, useStore, actions, startSession, open, renameSession, forkSession, renameWorkspace, deleteWorkspace, insertWorkspaceBefore, archiveSession, unarchiveSession, deleteSession, insertSessionBefore, createWorkspace, searchSessions, searchResultLimit, useDirectoryFlow, renderSlot, t }) {
2281
+ function WorkspaceBrowser({ wide, expandSidebar, useSessions, useSessionPendingInteraction, useWorkspaces, useStore, actions, startSession, open, renameSession, forkSession, renameWorkspace, deleteWorkspace, insertWorkspaceBefore, archiveSession, archiveWorkspaceSessions, unarchiveSession, deleteSession, insertSessionBefore, createWorkspace, searchSessions, searchResultLimit, useDirectoryFlow, renderSlot, t }) {
2220
2282
  const useEffectiveSessionPendingInteraction = useSessionPendingInteraction ?? useEmptySessionPendingInteraction;
2221
2283
  const workspaces = useWorkspaces((state) => state.items);
2222
2284
  const workspacePhase = useWorkspaces((state) => state.phase);
@@ -2411,6 +2473,34 @@ window.__ModuleLoader__.load({
2411
2473
  showArchivedToast(formatArchiveError(reason, t));
2412
2474
  });
2413
2475
  };
2476
+ const [archiveWorkspaceTarget, setArchiveWorkspaceTarget] = (0, react.useState)(null);
2477
+ const [archivingWorkspace, setArchivingWorkspace] = (0, react.useState)(false);
2478
+ const [archiveWorkspaceError, setArchiveWorkspaceError] = (0, react.useState)(null);
2479
+ const closeArchiveWorkspace = () => {
2480
+ if (archivingWorkspace) return;
2481
+ setArchiveWorkspaceTarget(null);
2482
+ setArchiveWorkspaceError(null);
2483
+ };
2484
+ const onArchiveWorkspaceRequest = (workspaceId, title) => {
2485
+ const target = archiveWorkspaceDialogTarget(workspaces, workspaceId, title, archivedSet);
2486
+ if (target === null) return;
2487
+ setArchiveWorkspaceTarget(target);
2488
+ setArchiveWorkspaceError(null);
2489
+ };
2490
+ const confirmArchiveWorkspace = () => {
2491
+ /* v8 ignore next -- the Modal is absent without a target and its button is disabled while archiving. */
2492
+ if (archivingWorkspace || archiveWorkspaceTarget === null) return;
2493
+ setArchivingWorkspace(true);
2494
+ setArchiveWorkspaceError(null);
2495
+ archiveWorkspaceSessions(archiveWorkspaceTarget.workspaceId).then(() => {
2496
+ setArchivingWorkspace(false);
2497
+ setArchiveWorkspaceTarget(null);
2498
+ }).catch((reason) => {
2499
+ const state = archiveWorkspaceDialogFailureState(archiveWorkspaceTarget, reason, t);
2500
+ setArchivingWorkspace(state.archiving);
2501
+ setArchiveWorkspaceError(state.error);
2502
+ });
2503
+ };
2414
2504
  const [deleteTarget, setDeleteTarget] = (0, react.useState)(null);
2415
2505
  const [deleting, setDeleting] = (0, react.useState)(false);
2416
2506
  const [deleteCommittedId, setDeleteCommittedId] = (0, react.useState)(null);
@@ -2675,6 +2765,7 @@ window.__ModuleLoader__.load({
2675
2765
  setRenameDraft(currentTitle);
2676
2766
  setRenameError(null);
2677
2767
  },
2768
+ onArchiveRequest: onArchiveWorkspaceRequest,
2678
2769
  onDeleteRequest: (workspaceId, title) => {
2679
2770
  setDeleteTarget({
2680
2771
  workspaceId,
@@ -2814,6 +2905,34 @@ window.__ModuleLoader__.load({
2814
2905
  children: deleteError
2815
2906
  })]
2816
2907
  }),
2908
+ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
2909
+ open: archiveWorkspaceTarget !== null,
2910
+ onClose: closeArchiveWorkspace,
2911
+ closeLabel: t("close"),
2912
+ title: archiveWorkspaceTarget === null ? "" : t("archiveWorkspace.title", { n: archiveWorkspaceTarget.count }),
2913
+ ...archiveWorkspaceTarget === null ? {} : { description: t("archiveWorkspace.desc", { name: archiveWorkspaceTarget.title }) },
2914
+ footer: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2915
+ variant: "outline",
2916
+ disabled: archivingWorkspace,
2917
+ onClick: closeArchiveWorkspace,
2918
+ children: t("cancel")
2919
+ }), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2920
+ variant: "outline",
2921
+ className: "dsham_archiveWorkspaceConfirm",
2922
+ disabled: archivingWorkspace,
2923
+ onClick: confirmArchiveWorkspace,
2924
+ children: t("archiveWorkspace.confirm")
2925
+ })] }),
2926
+ children: [archivingWorkspace && (0, react_jsx_runtime.jsx)("div", {
2927
+ className: WorkspaceBrowser_module_css_default.deleteStatus,
2928
+ role: "status",
2929
+ children: t("archiveWorkspace.pending")
2930
+ }), archiveWorkspaceError !== null && (0, react_jsx_runtime.jsx)("div", {
2931
+ className: WorkspaceBrowser_module_css_default.renameError,
2932
+ role: "alert",
2933
+ children: archiveWorkspaceError
2934
+ })]
2935
+ }),
2817
2936
  (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
2818
2937
  open: deleteSessionTarget !== null,
2819
2938
  onClose: closeDeleteSession,
@@ -2858,13 +2977,14 @@ window.__ModuleLoader__.load({
2858
2977
  const ARCHIVE_SETTINGS_BATCH_CSS = ".dsham_settingsHeaderActions,.dsham_settingsGroupMeta{display:flex;align-items:center;gap:8px;flex:none}.dsham_settingsRestoreAll{display:inline-flex;align-items:center;min-height:32px;padding:0 12px;color:var(--dsw-alias-label-primary);background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;cursor:pointer;font:inherit;font-size:13px;font-weight:500}.dsham_settingsRestoreAll:hover{background:var(--dsw-alias-interactive-bg-hover)}.dsham_settingsRestoreAll:disabled,.dsham_settingsDanger:disabled,.dsham_settingsGroupMenu:disabled{cursor:not-allowed;opacity:.5}.dsham_settingsGroupMenu{display:flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;color:var(--dsw-alias-label-tertiary);background:transparent;border:0;border-radius:8px;cursor:pointer}.dsham_settingsGroupMenu:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.dsham_settingsStatus{margin-top:10px;color:var(--dsw-alias-label-secondary);font-size:12px}@media(max-width:720px){.dsham_settingsHeader{flex-direction:column}.dsham_settingsHeaderActions{align-self:flex-end}}";
2859
2978
  const ARCHIVE_SETTINGS_EXTERNAL_LINK_CSS = ".dsham_settingsTitleRow{display:flex;align-items:center;gap:8px;min-width:0}.dsham_settingsLinks{display:flex;align-items:center;gap:4px}.dsham_settingsExternalLink{display:inline-flex;align-items:center;gap:5px;min-height:28px;padding:0 8px;color:var(--dsw-alias-label-secondary);background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;font-size:12px;font-weight:500;line-height:18px;text-decoration:none;white-space:nowrap}.dsham_settingsExternalLink:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.dsham_settingsExternalLink:focus-visible{outline:2px solid var(--dsw-alias-state-success-primary);outline-offset:2px}.dsham_settingsExternalLink svg{flex:none}@media(max-width:720px){.dsham_settingsTitleRow{flex-wrap:wrap}}";
2860
2979
  const ARCHIVE_SETTINGS_DELETE_CONFIRM_CSS = ".dsham_settingsDeleteConfirm{color:var(--dsw-alias-state-error-primary)!important;background:transparent!important;border-color:var(--dsw-alias-state-error-primary)!important}.dsham_settingsDeleteConfirm:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 20%,transparent)!important}.dsham_settingsDeleteConfirm:focus-visible{outline:2px solid var(--dsw-alias-state-error-secondary);outline-offset:2px}.dsham_settingsDeleteConfirm:disabled{cursor:not-allowed;opacity:.5}";
2980
+ const ARCHIVE_WORKSPACE_CONFIRM_CSS = ".dsham_archiveWorkspaceConfirm{color:var(--dsw-alias-state-error-primary)!important;background:transparent!important;border-color:var(--dsw-alias-state-error-primary)!important}.dsham_archiveWorkspaceConfirm:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 20%,transparent)!important}.dsham_archiveWorkspaceConfirm:focus-visible{outline:2px solid var(--dsw-alias-state-error-secondary);outline-offset:2px}.dsham_archiveWorkspaceConfirm:disabled{cursor:not-allowed;opacity:.5}";
2861
2981
  const ARCHIVE_SETTINGS_SELECTION_CSS = ".dsham_settingsSelection{display:flex;align-items:center;gap:10px;min-height:40px;margin:-8px 0 16px;padding:4px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-button-elevated-fill))}.dsham_settingsSelectionToggle{display:inline-flex;align-items:center;gap:8px;min-width:0;color:var(--dsw-alias-label-primary);font-size:13px;cursor:pointer}.dsham_settingsCheckbox{width:16px;height:16px;margin:0;accent-color:#3b82f6}.dsham_settingsSelectionCount{margin-right:auto;color:var(--dsw-alias-label-tertiary);font-size:12px}.dsham_settingsSelectionAction{min-height:28px;padding:0 10px;color:var(--dsw-alias-label-primary);background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;cursor:pointer;font:inherit;font-size:12px;font-weight:500}.dsham_settingsSelectionAction:hover{background:var(--dsw-alias-interactive-bg-hover)}.dsham_settingsSelectionDelete{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.dsham_settingsSelectionAction:disabled{cursor:not-allowed;opacity:.5}@media(max-width:720px){.dsham_settingsSelection{flex-wrap:wrap}.dsham_settingsSelectionCount{margin-right:0}.dsham_settingsSelectionAction{margin-left:auto}.dsham_settingsSelectionAction+.dsham_settingsSelectionAction{margin-left:-2px}}";
2862
2982
  const ARCHIVE_SETTINGS_LAYOUT_OVERRIDE = ".dsham_settings{margin:0 auto!important}@media(max-width:720px){.dsham_settings{margin:0 auto!important}}";
2863
2983
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify("dsh-archive-manager/ArchiveSettings.layout.css") + "]") === null) {
2864
2984
  const tag = document.createElement("style");
2865
2985
  tag.dataset.plugin = "@michengai/dsh-archive-manager";
2866
2986
  tag.dataset.pluginCss = "dsh-archive-manager/ArchiveSettings.layout.css";
2867
- tag.textContent = ARCHIVE_SETTINGS_LAYOUT_OVERRIDE + ARCHIVE_SETTINGS_DELETE_CONFIRM_CSS;
2987
+ tag.textContent = ARCHIVE_SETTINGS_LAYOUT_OVERRIDE + ARCHIVE_SETTINGS_DELETE_CONFIRM_CSS + ARCHIVE_WORKSPACE_CONFIRM_CSS;
2868
2988
  document.head.appendChild(tag);
2869
2989
  }
2870
2990
  /** 设置页筛选/排序:自定义菜单,避免原生 select 弹出系统浅色下拉。 */
@@ -3436,6 +3556,11 @@ window.__ModuleLoader__.load({
3436
3556
  "delete.pending": "正在删除工作区…",
3437
3557
  "menu.fork": "分叉会话",
3438
3558
  "menu.archiveSession": "归档会话",
3559
+ "menu.archiveWorkspace": "归档全部聊天",
3560
+ "archiveWorkspace.title": "归档 {n} 个聊天?",
3561
+ "archiveWorkspace.desc": "这会将“{name}”中的聊天归档。之后你可以在已归档的聊天中找到它们。",
3562
+ "archiveWorkspace.confirm": "全部归档",
3563
+ "archiveWorkspace.pending": "正在归档聊天…",
3439
3564
  "sessions.count.one": "{n} 个会话",
3440
3565
  "sessions.count.other": "{n} 个会话",
3441
3566
  "actions.workspace.aria": "工作区“{name}”的操作",
@@ -3562,6 +3687,11 @@ window.__ModuleLoader__.load({
3562
3687
  "delete.pending": "Deleting workspace…",
3563
3688
  "menu.fork": "Fork session",
3564
3689
  "menu.archiveSession": "Archive session",
3690
+ "menu.archiveWorkspace": "Archive all chats",
3691
+ "archiveWorkspace.title": "Archive {n} chats?",
3692
+ "archiveWorkspace.desc": "This archives the chats in “{name}”. You can find them later in Archived chats.",
3693
+ "archiveWorkspace.confirm": "Archive all",
3694
+ "archiveWorkspace.pending": "Archiving chats…",
3565
3695
  "sessions.count.one": "{n} session",
3566
3696
  "sessions.count.other": "{n} sessions",
3567
3697
  "actions.workspace.aria": "Workspace actions for {name}",
@@ -3767,6 +3897,12 @@ window.__ModuleLoader__.load({
3767
3897
  * effect-scoped inside `applyWorkspaceBrowser`).
3768
3898
  * @param ctx - client root context.
3769
3899
  */
3900
+ async function archiveWorkspaceSessionsAndRefresh(registry, workspaceId, refresh) {
3901
+ const result = await registry.archiveWorkspaceSessions(workspaceId);
3902
+ if (!result.ok) throw new Error(result.error.message);
3903
+ await refresh();
3904
+ return result.value;
3905
+ }
3770
3906
  async function apply(ctx) {
3771
3907
  const remote = ctx.get("remote");
3772
3908
  let disposeRemote = () => {};
@@ -3819,6 +3955,11 @@ window.__ModuleLoader__.load({
3819
3955
  if (!result.ok) throw new Error(result.error.message);
3820
3956
  await refreshSessionList();
3821
3957
  };
3958
+ const archiveWorkspaceSessions = async (workspaceId) => {
3959
+ const registry = ctx.get("remote.workspaceRegistry");
3960
+ if (registry === void 0) throw new Error("archive-manager remote service is unavailable");
3961
+ return archiveWorkspaceSessionsAndRefresh(registry, workspaceId, refreshSessionList);
3962
+ };
3822
3963
  const deleteSession = async (sessionId) => {
3823
3964
  const registry = ctx.get("remote.workspaceRegistry");
3824
3965
  if (registry === void 0) throw new Error("archive-manager remote service is unavailable");
@@ -3889,6 +4030,7 @@ window.__ModuleLoader__.load({
3889
4030
  archiveSession: async (sessionId) => {
3890
4031
  await ctx.workspaces.archiveSession(sessionId);
3891
4032
  },
4033
+ archiveWorkspaceSessions,
3892
4034
  unarchiveSession,
3893
4035
  deleteSession,
3894
4036
  insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
@@ -3956,6 +4098,10 @@ window.__ModuleLoader__.load({
3956
4098
  pruneArchivedSelection,
3957
4099
  toggleArchivedSelection,
3958
4100
  archivedDeleteFeedback,
4101
+ archiveableWorkspaceSessionCount,
4102
+ archiveWorkspaceDialogTarget,
4103
+ archiveWorkspaceDialogFailureState,
4104
+ archiveWorkspaceSessionsAndRefresh,
3959
4105
  createWorkspaceViewStore,
3960
4106
  bindObservable,
3961
4107
  hasSplitClientStore,
package/lib/projcache.js CHANGED
@@ -10,21 +10,17 @@ import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
10
10
  import { trackTombstone } from "./tombstone.js";
11
11
 
12
12
  /**
13
- * Archive Manager owns a separate cache domain so DSH alpha.2 never asks the
14
- * JSON backend to turn a logical IM id such as `im:qq:dm:...` into a Windows
15
- * filename. The public Session id remains unchanged; only the storage key is
16
- * a fixed-length SHA-256 token and the original id is carried in the value.
17
- *
18
- * On rc.2 the runtime has no per-record layout, so the same domain remains a
19
- * single JSON file. After that user upgrades to alpha.2, every already-safe
20
- * hashed key can be migrated by the official backend without hitting the
21
- * legacy colon bug.
13
+ * 当前投影缓存写入独立的 v2 域,避免 DSH alpha.2 将包含冒号的逻辑会话 ID
14
+ * 直接映射为 Windows 文件名。旧名 v1 与旧名 v2 域只在启动时读取并补缺迁入,
15
+ * 迁移不删除原始缓存,任意中断都可在后续启动继续。
22
16
  */
23
17
  const storedCheckpointRecord = checkpointRecord.extend({
24
18
  sessionId: checkpointIdentity.shape.cwd.unwrap()
25
19
  });
26
20
  const safeDomainInput = {
27
- name: "session_projcache_archive_manager",
21
+ // 旧版 DSH 不支持同名域的版本迁移。v2 改用新域名,确保升级时不会在
22
+ // 打开目标域之前因旧 v1 元数据而终止启动。
23
+ name: "session_projcache_archive_manager_v2",
28
24
  version: 2,
29
25
  tables: { sessions: domainTable(storedCheckpointRecord) },
30
26
  ...(projectionCacheDomainSpec.layout === "per-record" ? { layout: "per-record" } : {})
@@ -32,11 +28,31 @@ const safeDomainInput = {
32
28
  const safeProjectionCacheDomainSpec = defineDomain(safeDomainInput);
33
29
 
34
30
  /** The last official whole-file cache format used by DSH rc.2. */
31
+ const legacyOptionalIdentityFields = {};
32
+ for (const field of ["isSeeded", "inheritedEventCount"]) {
33
+ // rc.2 的身份 schema 尚未定义这些字段;向 Zod 传入不存在的字段会在
34
+ // 首次解析历史记录时抛错,因此只能按当前运行时实际的 schema 构造掩码。
35
+ if (Object.hasOwn(checkpointIdentity.shape, field)) legacyOptionalIdentityFields[field] = true;
36
+ }
35
37
  const legacyCheckpointRecord = checkpointRecord.extend({
36
- identity: checkpointIdentity.partial({
37
- isSeeded: true,
38
- inheritedEventCount: true
39
- })
38
+ identity: checkpointIdentity.partial(legacyOptionalIdentityFields)
39
+ });
40
+ const legacyStoredCheckpointRecord = legacyCheckpointRecord.extend({
41
+ sessionId: checkpointIdentity.shape.cwd.unwrap()
42
+ });
43
+ /** v0.1.24 短暂使用过的旧名 v2 安全缓存域。 */
44
+ const legacySafeV2ProjectionCacheDomainSpec = defineDomain({
45
+ name: "session_projcache_archive_manager",
46
+ version: 2,
47
+ tables: { sessions: domainTable(storedCheckpointRecord) },
48
+ ...(projectionCacheDomainSpec.layout === "per-record" ? { layout: "per-record" } : {})
49
+ });
50
+ /** v0.1.23 及以前归档管理器自身使用的安全缓存域。 */
51
+ const legacySafeProjectionCacheDomainSpec = defineDomain({
52
+ name: "session_projcache_archive_manager",
53
+ version: 1,
54
+ tables: { sessions: domainTable(legacyStoredCheckpointRecord) },
55
+ ...(projectionCacheDomainSpec.layout === "per-record" ? { layout: "per-record" } : {})
40
56
  });
41
57
  const legacyProjectionCacheDomainSpec = defineDomain({
42
58
  name: "session_projcache",
@@ -113,6 +129,23 @@ async function readSourceRecords(ctx, spec, label) {
113
129
  }
114
130
  }
115
131
 
132
+ async function readLegacySafeRecords(ctx, spec, label) {
133
+ const source = await readSourceRecords(ctx, spec, label);
134
+ const records = [];
135
+ for (const [physicalKey, stored] of source.records) {
136
+ if (stored === null || typeof stored !== "object" || typeof stored.sessionId !== "string") {
137
+ ctx.logger.warn(`archive-manager projcache: ${label} row "${physicalKey}" import skipped because its session id is invalid`);
138
+ continue;
139
+ }
140
+ if (projectionCacheStorageKey(stored.sessionId) !== physicalKey) {
141
+ ctx.logger.warn(`archive-manager projcache: ${label} row "${physicalKey}" import skipped because its storage key does not match the session id`);
142
+ continue;
143
+ }
144
+ records.push([stored.sessionId, unwrapStoredCheckpoint(stored)]);
145
+ }
146
+ return { ...source, records };
147
+ }
148
+
116
149
  /**
117
150
  * 旧版记录没有完整身份时,只能安全补齐未播种会话的零继承切点。
118
151
  * 播种会话无法从头部反推出精确切点,宁可跳过并按需重建。
@@ -158,14 +191,18 @@ async function importMissingRecords(ctx, target, records, label) {
158
191
  }
159
192
 
160
193
  /**
161
- * 合并官方新版逐会话缓存与旧版整文件缓存。归档管理器自身记录最高优先,
162
- * 新版记录优先于旧版;任一来源失败时仍继续尝试另一个来源。
194
+ * 合并归档管理器旧名 v2、旧名 v1、官方新版逐会话与旧版整文件缓存。目标域记录
195
+ * 最高优先,随后按新旧顺序只补缺失记录;任一来源失败时仍继续尝试另一个来源。
163
196
  */
164
197
  async function importPreviousProjectionCache(ctx, target) {
198
+ const legacySafeV2 = await readLegacySafeRecords(ctx, legacySafeV2ProjectionCacheDomainSpec, "archive-manager v2");
199
+ const legacySafe = await readLegacySafeRecords(ctx, legacySafeProjectionCacheDomainSpec, "archive-manager v1");
165
200
  const legacy = await readSourceRecords(ctx, legacyProjectionCacheDomainSpec, "legacy v3");
166
201
  const sameSpec = projectionCacheDomainSpec.version === legacyProjectionCacheDomainSpec.version
167
202
  && projectionCacheDomainSpec.layout === legacyProjectionCacheDomainSpec.layout;
168
203
  let imported = 0;
204
+ if (legacySafeV2.opened) imported += await importMissingRecords(ctx, target, legacySafeV2.records, "archive-manager v2");
205
+ if (legacySafe.opened) imported += await importMissingRecords(ctx, target, legacySafe.records, "archive-manager v1");
169
206
  if (!sameSpec) {
170
207
  const current = await readSourceRecords(ctx, projectionCacheDomainSpec, "current");
171
208
  if (current.opened) imported += await importMissingRecords(ctx, target, current.records, "current");
@@ -178,8 +215,9 @@ async function importPreviousProjectionCache(ctx, target) {
178
215
  * dsh-archive-manager projcache 半边。
179
216
  *
180
217
  * `ArchiveProjectionCache` 继承上游 `SessionProjectionCache`,保留服务名
181
- * `sessionProjectionCache` 与 fail-soft 写路径,但使用独立、路径安全的
182
- * `session_projcache_archive_manager` 兼容域,为归档管理的
218
+ * `sessionProjectionCache` 与 fail-soft 写路径,但写入独立、路径安全的
219
+ * `session_projcache_archive_manager_v2` 域。旧名 v1 与 v2 域只在启动时
220
+ * 读取并补缺迁入;归档管理的
183
221
  * `deleteSession` 增加三处守护:
184
222
  *
185
223
  * - `delete(id)` - 永久移除兼容域中该会话经 SHA-256 映射的缓存投影行,
@@ -289,6 +327,8 @@ export {
289
327
  SafeSessionTable,
290
328
  importPreviousProjectionCache,
291
329
  legacyProjectionCacheDomainSpec,
330
+ legacySafeProjectionCacheDomainSpec,
331
+ legacySafeV2ProjectionCacheDomainSpec,
292
332
  projectionCacheStorageKey,
293
333
  safeProjectionCacheDomainSpec
294
334
  };
package/lib/workspace.js CHANGED
@@ -65,6 +65,12 @@ const sessionIdSchema = {
65
65
  return value;
66
66
  }
67
67
  };
68
+ const workspaceIdSchema = {
69
+ parse(value) {
70
+ if (typeof value !== "string" || value.length === 0) throw new TypeError(`workspaceId must be a non-empty string, got ${String(value)}`);
71
+ return value;
72
+ }
73
+ };
68
74
  const archivedSetSchema = {
69
75
  parse(value) {
70
76
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("result must be an object");
@@ -96,6 +102,14 @@ const unarchivedBatchSchema = {
96
102
  return value;
97
103
  }
98
104
  };
105
+ const archivedWorkspaceBatchSchema = {
106
+ parse(value) {
107
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("result must be an object");
108
+ if (!Array.isArray(value.archivedSessionIds) || value.archivedSessionIds.some((id) => typeof id !== "string")) throw new TypeError("archivedSessionIds must be a string array");
109
+ if (!Array.isArray(value.archivedSessionIdsAdded) || value.archivedSessionIdsAdded.some((id) => typeof id !== "string")) throw new TypeError("archivedSessionIdsAdded must be a string array");
110
+ return value;
111
+ }
112
+ };
99
113
  const deletedBatchSchema = {
100
114
  parse(value) {
101
115
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("result must be an object");
@@ -176,6 +190,25 @@ const ARCHIVE_MANAGER_INVOCATIONS = [
176
190
  },
177
191
  sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
178
192
  },
193
+ {
194
+ id: "@michengai/dsh-archive-manager#workspaceRegistry/archiveWorkspaceSessions",
195
+ service: "workspaceRegistry",
196
+ namespace: "workspaceRegistry",
197
+ method: "archiveWorkspaceSessions",
198
+ invocation: { kind: "direct" },
199
+ parameters: [{
200
+ name: "workspaceId",
201
+ wire: "workspaceId",
202
+ source: "json",
203
+ codec: { mode: "strict", typeSymbol: "@deepseek-ai/dsh-workspace/types#WorkspaceId", schema: workspaceIdSchema }
204
+ }],
205
+ result: {
206
+ mode: "strict",
207
+ typeSymbol: "@michengai/dsh-archive-manager/types#ArchivedWorkspaceBatch",
208
+ schema: archivedWorkspaceBatchSchema
209
+ },
210
+ sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
211
+ },
179
212
  {
180
213
  id: "@michengai/dsh-archive-manager#workspaceRegistry/deleteArchivedSessions",
181
214
  service: "workspaceRegistry",
@@ -248,6 +281,7 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
248
281
  markRemoteMethod(this, "unarchiveSession");
249
282
  markRemoteMethod(this, "deleteSession");
250
283
  markRemoteMethod(this, "unarchiveSessions");
284
+ markRemoteMethod(this, "archiveWorkspaceSessions");
251
285
  markRemoteMethod(this, "deleteArchivedSessions");
252
286
  markRemoteMethod(this, "archivedSessionMetadata");
253
287
  registerHostRemote(this.ctx);
@@ -328,6 +362,36 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
328
362
  });
329
363
  }
330
364
  /**
365
+ * 将一个工作区内所有会话加入归档集合。先确认所有待归档会话仍存在,
366
+ * 再执行单次状态写入,因此未知会话不会导致项目只归档一部分。
367
+ */
368
+ async archiveWorkspaceSessions(workspaceId) {
369
+ return this.enqueueOperation(async () => {
370
+ workspaceId = workspaceIdSchema.parse(workspaceId);
371
+ const workspace = this.requireTable().get(workspaceId);
372
+ if (workspace === void 0) throw new Error(`unknown workspace "${workspaceId}"`);
373
+ const state = this.requireState();
374
+ const archived = new Set(state.archivedSessionIds);
375
+ const archivedSessionIdsAdded = [...new Set(workspace.sessionIds)].filter((sessionId) => !archived.has(sessionId));
376
+ for (const sessionId of archivedSessionIdsAdded) {
377
+ if (!await this.sessionKnown(sessionId)) throw new ArchiveUnknownSessionError(sessionId);
378
+ }
379
+ if (archivedSessionIdsAdded.length === 0) return {
380
+ archivedSessionIds: [...state.archivedSessionIds],
381
+ archivedSessionIdsAdded
382
+ };
383
+ const next = {
384
+ ...state,
385
+ archivedSessionIds: [...state.archivedSessionIds, ...archivedSessionIdsAdded]
386
+ };
387
+ await this.setState(next);
388
+ return {
389
+ archivedSessionIds: [...next.archivedSessionIds],
390
+ archivedSessionIdsAdded
391
+ };
392
+ });
393
+ }
394
+ /**
331
395
  * 按宿主权威归档集合一次恢复全部、一个工作区或未分组的归档会话。
332
396
  * 目标全部来自已归档集合,因此即使日志已被外部移除,也会清掉陈旧归档标记。
333
397
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michengai/dsh-archive-manager",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "NPM-installable DSH Web plugin for managing archived sessions.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.21.0",
@@ -28,8 +28,9 @@
28
28
  "scripts": {
29
29
  "build": "node scripts/check-package.mjs",
30
30
  "test": "node --test test/*.test.mjs",
31
+ "test:compat": "node scripts/test-legacy-compatibility.mjs",
31
32
  "pack:check": "pnpm pack --dry-run",
32
- "prepublishOnly": "pnpm build && pnpm test"
33
+ "prepublishOnly": "pnpm build && pnpm test && pnpm test:compat"
33
34
  },
34
35
  "peerDependencies": {
35
36
  "@deepseek-ai/cordis": "^4.0.1",