@michengai/dsh-archive-manager 0.1.13 → 0.1.15

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
@@ -2,7 +2,23 @@
2
2
 
3
3
  [简体中文](CHANGELOG.zh-CN.md)
4
4
 
5
- The five most recent published versions are listed below.
5
+ Published release notes are retained below; new versions are added without removing earlier entries.
6
+
7
+ ## 0.1.15 — 2026-08-26
8
+
9
+ - Hardened permanent deletion so missing transcripts clear stale archive, workspace, spill, and projection-cache data while failed physical deletion remains retryable.
10
+ - Cleared workspace and projection-cache tombstones when a session ID is reused, preventing valid replacement sessions from being blocked.
11
+ - Corrected batch deletion feedback and duplicate restore submission handling, and aligned client batch counts with the host's authoritative archive set.
12
+
13
+ Published package: [`@michengai/dsh-archive-manager@0.1.15`](https://www.npmjs.com/package/@michengai/dsh-archive-manager/v/0.1.15).
14
+
15
+ ## 0.1.14 — 2026-08-24
16
+
17
+ - Added project-scoped batch restore and permanent deletion, plus a page-level **Restore all** action.
18
+ - Added archived-chat sorting by last update, creation time, or title, backed by authoritative host creation metadata.
19
+ - Added confirmation, success, and partial-failure feedback for batch archive operations.
20
+
21
+ Published package: [`@michengai/dsh-archive-manager@0.1.14`](https://www.npmjs.com/package/@michengai/dsh-archive-manager/v/0.1.14).
6
22
 
7
23
  ## 0.1.13 — 2026-08-23
8
24
 
@@ -2,7 +2,23 @@
2
2
 
3
3
  [English](CHANGELOG.md)
4
4
 
5
- 以下记录最近发布的五个版本。
5
+ 以下发布说明会持续保留;新增版本时不再删除较早记录。
6
+
7
+ ## 0.1.15 — 2026-08-26
8
+
9
+ - 加固永久删除流程:转录已缺失时清理陈旧归档标记、工作区记账、spill 和投影缓存,物理删除失败时仍可重试。
10
+ - 会话 ID 被复用时同步撤销工作区与投影缓存墓碑,避免合法的新会话生命周期被阻断。
11
+ - 修正批量删除反馈和单条恢复重复提交,并使客户端批量计数与宿主权威归档集合保持一致。
12
+
13
+ 发布包:[`@michengai/dsh-archive-manager@0.1.15`](https://www.npmjs.com/package/@michengai/dsh-archive-manager/v/0.1.15)。
14
+
15
+ ## 0.1.14 — 2026-08-24
16
+
17
+ - 新增项目级批量恢复和永久删除,并在页面顶部增加「全部恢复」。
18
+ - 新增按更新时间、创建时间或标题排序归档聊天;创建时间来自宿主权威元数据。
19
+ - 为批量归档操作新增确认、成功和部分失败反馈。
20
+
21
+ 发布包:[`@michengai/dsh-archive-manager@0.1.14`](https://www.npmjs.com/package/@michengai/dsh-archive-manager/v/0.1.14)。
6
22
 
7
23
  ## 0.1.13 — 2026-08-23
8
24
 
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  **Safely manage archived sessions in DeepSeek Harness**
10
10
 
11
- [简体中文](README.zh-CN.md) · [Apache-2.0](LICENSE)
11
+ [简体中文](README.zh-CN.md) · [Changelog](CHANGELOG.md) · [Apache-2.0](LICENSE)
12
12
 
13
13
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](LICENSE)
14
14
  [![npm package](https://img.shields.io/npm/v/%40michengai%2Fdsh-archive-manager.svg?label=npm%20package)](https://www.npmjs.com/package/@michengai/dsh-archive-manager)
@@ -22,9 +22,11 @@
22
22
  ## Features
23
23
 
24
24
  - Archive a session from the sidebar session menu.
25
- - Search archived chats and filter them by workspace in **Settings → Archived**.
26
- - Restore a session to its original workspace with **Unarchive**.
27
- - Permanently delete a confirmed session, its workspace association, archive marker, and projection cache.
25
+ - Search archived chats, sort them by update time, creation time, or title, and filter them by workspace in **Settings → Archived**.
26
+ - Restore a session to its original workspace with **Unarchive**.
27
+ - Restore or permanently delete every archived chat in a project group.
28
+ - Restore all archived chats from the page header.
29
+ - Permanently delete a confirmed session, its workspace association, archive marker, and projection cache.
28
30
  - Delete all archived chats after confirmation, including child agents.
29
31
  - Remove unloaded deleted sessions from connected sidebars immediately.
30
32
  - Paste one sentence into DSH, Codex, or WorkBuddy and let that agent install the plugin locally.
@@ -35,7 +37,7 @@ Open the sidebar session menu and choose **Archive session**:
35
37
 
36
38
  ![Archive a session from the session menu](assets/screenshots/archive-session-menu.png)
37
39
 
38
- Search, filter by project, unarchive, or permanently delete chats in **Settings → Archived**:
40
+ Search, sort, filter by project, unarchive, or permanently delete chats in **Settings → Archived**:
39
41
 
40
42
  ![Archived chats settings page](assets/screenshots/archived-sessions.png)
41
43
 
@@ -103,7 +105,7 @@ dsh plugin --profile web add @michengai/dsh-archive-manager@latest --registry=ht
103
105
  dsh --profile web --dump-config
104
106
  ```
105
107
 
106
- To pin a release, replace `@latest` with a version such as `@0.1.8`.
108
+ To pin a release, replace `@latest` with a specific version such as `@x.y.z`.
107
109
 
108
110
  The configuration output should contain `workspace-archive-manager` and `ui-workspace-archive-manager`. Restart DSH Web and hard-refresh the browser. Do not copy client files manually: the Settings page and archive menu need the mounted plugin.
109
111
 
@@ -129,9 +131,10 @@ Restart DSH Web and hard-refresh the browser. `dsh plugin ... add .` reads the p
129
131
 
130
132
  1. Open the sidebar session menu and choose **Archive session**.
131
133
  2. Open **Settings → Archived** to inspect sessions by workspace.
132
- 3. Use the search box or project filter to narrow the list.
133
- 4. Select **Unarchive** to restore a session, or use the delete icon to remove it permanently.
134
- 5. Confirm deletion. **It cannot be undone.**
134
+ 3. Search by title, sort by update time, creation time, or title, or filter the list by project.
135
+ 4. Select **Unarchive** to restore one session, or select **Restore all** in the page header.
136
+ 5. Open a project heading's action menu to restore or delete all of that project's archived chats.
137
+ 6. Use the delete icon to remove one session permanently, then confirm the deletion. **It cannot be undone.**
135
138
 
136
139
  If the entry is missing after installation or upgrade, restart DSH Web and hard-refresh the browser. It is located directly after **Connectors** in Settings.
137
140
 
@@ -183,4 +186,4 @@ Licensed under [Apache License 2.0](LICENSE).
183
186
 
184
187
  ## Changelog
185
188
 
186
- See [CHANGELOG.md](CHANGELOG.md) for the five most recent releases.
189
+ See [CHANGELOG.md](CHANGELOG.md) for retained release notes.
package/README.zh-CN.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  **在 DeepSeek Harness 中安全管理已归档会话**
10
10
 
11
- [English](README.md) · [Apache-2.0](LICENSE)
11
+ [English](README.md) · [更新日志](CHANGELOG.zh-CN.md) · [Apache-2.0](LICENSE)
12
12
 
13
13
  [![许可证:Apache-2.0](https://img.shields.io/badge/许可证-Apache--2.0-blue.svg)](LICENSE)
14
14
  [![npm package](https://img.shields.io/npm/v/%40michengai%2Fdsh-archive-manager.svg?label=npm%20package)](https://www.npmjs.com/package/@michengai/dsh-archive-manager)
@@ -22,9 +22,11 @@
22
22
  ## 功能概览
23
23
 
24
24
  - 在侧栏会话菜单中选择「归档会话」。
25
- - 在「设置 → 已归档」按工作区查看归档会话,并支持搜索和按项目筛选。
26
- - 安全取消归档,将会话恢复到原工作区位置。
27
- - 经确认后永久删除会话、工作区归属、归档标记和投影缓存。
25
+ - 在「设置 → 已归档」按工作区查看归档会话,并支持搜索、按更新时间/创建时间/标题排序和按项目筛选。
26
+ - 安全取消归档,将会话恢复到原工作区位置。
27
+ - 在项目分组中批量恢复或永久删除该项目的全部已归档聊天。
28
+ - 在页面顶部一键恢复全部已归档聊天。
29
+ - 经确认后永久删除会话、工作区归属、归档标记和投影缓存。
28
30
  - 经确认后删除全部已归档聊天,包含子代理。
29
31
  - 已删除的未加载归档会话会立即从已连接客户端的侧栏移除。
30
32
  - 可把一句话复制到 DSH、Codex 或 WorkBuddy,让对方代装到本机 DSH。
@@ -35,7 +37,7 @@
35
37
 
36
38
  ![从会话菜单归档会话](assets/screenshots/archive-session-menu.png)
37
39
 
38
- 在「设置 → 已归档」中搜索、按项目筛选、取消归档或永久删除:
40
+ 在「设置 → 已归档」中搜索、排序、按项目筛选、取消归档或永久删除:
39
41
 
40
42
  ![已归档聊天设置页面](assets/screenshots/archived-sessions.png)
41
43
 
@@ -103,7 +105,7 @@ dsh plugin --profile web add @michengai/dsh-archive-manager@latest --registry=ht
103
105
  dsh --profile web --dump-config
104
106
  ```
105
107
 
106
- 需要钉死某一版时,把 `@latest` 换成具体版本,例如 `@0.1.8`。
108
+ 需要钉死某一版时,把 `@latest` 换成具体版本,例如 `@x.y.z`。
107
109
 
108
110
  配置输出中应包含 `workspace-archive-manager` 与 `ui-workspace-archive-manager`。安装后重启 DSH Web 并在浏览器硬刷新;请勿手工复制客户端文件,否则设置页和归档菜单不会被挂载。
109
111
 
@@ -129,9 +131,10 @@ dsh --profile web --dump-config
129
131
 
130
132
  1. 在侧栏右键或打开会话菜单,选择「归档会话」。
131
133
  2. 打开「设置 → 已归档」,按工作区查看归档会话。
132
- 3. 用搜索框或项目筛选收窄列表。
133
- 4. 点击「取消归档」恢复会话;点击删除图标永久移除会话。
134
- 5. 删除前确认提示。**删除无法撤销。**
134
+ 3. 按标题搜索,按更新时间、创建时间或标题排序,或按项目筛选列表。
135
+ 4. 点击「取消归档」恢复单个会话,或在顶部点击「全部恢复」。
136
+ 5. 打开项目标题右侧菜单,可恢复或删除该项目的全部已归档聊天。
137
+ 6. 点击删除图标永久移除单个会话;删除前确认提示。**删除无法撤销。**
135
138
 
136
139
  安装或升级后找不到入口时,重启 DSH Web 并硬刷新浏览器;入口位于「设置」中,连接器之后。
137
140
 
@@ -183,4 +186,4 @@ pnpm pack:check
183
186
 
184
187
  ## 更新日志
185
188
 
186
- 最近五个发布版本见 [CHANGELOG.zh-CN.md](CHANGELOG.zh-CN.md)。
189
+ 完整保留的发布说明见 [CHANGELOG.zh-CN.md](CHANGELOG.zh-CN.md)。
package/lib/client.js CHANGED
@@ -29,12 +29,45 @@ window.__ModuleLoader__.load({
29
29
  return value;
30
30
  }
31
31
  };
32
- const deletedSchema = {
33
- parse(value) {
34
- if (typeof value !== "object" || value === null || Array.isArray(value) || value.deleted !== true) throw new TypeError("deleted must be true");
35
- return value;
36
- }
37
- };
32
+ const deletedSchema = {
33
+ parse(value) {
34
+ if (typeof value !== "object" || value === null || Array.isArray(value) || value.deleted !== true) throw new TypeError("deleted must be true");
35
+ return value;
36
+ }
37
+ };
38
+ const archivedBatchTargetSchema = {
39
+ parse(value) {
40
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("target must be an object");
41
+ if (value.scope === "all" || value.scope === "ungrouped") return value;
42
+ if (value.scope === "workspace" && typeof value.workspaceId === "string" && value.workspaceId.length > 0) return value;
43
+ throw new TypeError("target.scope must be all, ungrouped, or workspace with a non-empty workspaceId");
44
+ }
45
+ };
46
+ const unarchivedBatchSchema = {
47
+ parse(value) {
48
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("result must be an object");
49
+ if (!Array.isArray(value.archivedSessionIds) || value.archivedSessionIds.some((id) => typeof id !== "string")) throw new TypeError("archivedSessionIds must be a string array");
50
+ if (!Array.isArray(value.unarchivedSessionIds) || value.unarchivedSessionIds.some((id) => typeof id !== "string")) throw new TypeError("unarchivedSessionIds must be a string array");
51
+ return value;
52
+ }
53
+ };
54
+ const deletedBatchSchema = {
55
+ parse(value) {
56
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("result must be an object");
57
+ for (const key of ["requestedSessionIds", "deletedSessionIds", "skippedSessionIds"]) {
58
+ if (!Array.isArray(value[key]) || value[key].some((id) => typeof id !== "string")) throw new TypeError(`${key} must be a string array`);
59
+ }
60
+ if (!Array.isArray(value.failures) || value.failures.some((failure) => typeof failure !== "object" || failure === null || typeof failure.sessionId !== "string" || typeof failure.message !== "string")) throw new TypeError("failures must contain sessionId/message objects");
61
+ return value;
62
+ }
63
+ };
64
+ const archivedSessionMetadataSchema = {
65
+ parse(value) {
66
+ if (typeof value !== "object" || value === null || Array.isArray(value) || !Array.isArray(value.items)) throw new TypeError("result.items must be an array");
67
+ if (value.items.some((item) => typeof item !== "object" || item === null || typeof item.sessionId !== "string" || typeof item.createdAt !== "number" || !Number.isFinite(item.createdAt))) throw new TypeError("items must contain sessionId/createdAt objects");
68
+ return value;
69
+ }
70
+ };
38
71
  /**
39
72
  * 客户端通过 `ctx.remote.$mount` 注册 workspaceRegistry 的远程方法。
40
73
  * 调用走 typert gateway SRC 路径,不影响既有 `/api/workspace.*` 网关。
@@ -61,8 +94,8 @@ window.__ModuleLoader__.load({
61
94
  },
62
95
  sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
63
96
  },
64
- {
65
- id: "@michengai/dsh-archive-manager#workspaceRegistry/deleteSession",
97
+ {
98
+ id: "@michengai/dsh-archive-manager#workspaceRegistry/deleteSession",
66
99
  service: "workspaceRegistry",
67
100
  namespace: "workspaceRegistry",
68
101
  method: "deleteSession",
@@ -78,8 +111,60 @@ window.__ModuleLoader__.load({
78
111
  typeSymbol: "@michengai/dsh-archive-manager/types#Deleted",
79
112
  schema: deletedSchema
80
113
  },
81
- sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
82
- }
114
+ sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
115
+ },
116
+ {
117
+ id: "@michengai/dsh-archive-manager#workspaceRegistry/unarchiveSessions",
118
+ service: "workspaceRegistry",
119
+ namespace: "workspaceRegistry",
120
+ method: "unarchiveSessions",
121
+ invocation: { kind: "direct" },
122
+ parameters: [{
123
+ name: "target",
124
+ wire: "target",
125
+ source: "json",
126
+ codec: { mode: "strict", typeSymbol: "@michengai/dsh-archive-manager/types#ArchivedBatchTarget", schema: archivedBatchTargetSchema }
127
+ }],
128
+ result: {
129
+ mode: "strict",
130
+ typeSymbol: "@michengai/dsh-archive-manager/types#UnarchivedBatch",
131
+ schema: unarchivedBatchSchema
132
+ },
133
+ sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
134
+ },
135
+ {
136
+ id: "@michengai/dsh-archive-manager#workspaceRegistry/deleteArchivedSessions",
137
+ service: "workspaceRegistry",
138
+ namespace: "workspaceRegistry",
139
+ method: "deleteArchivedSessions",
140
+ invocation: { kind: "direct" },
141
+ parameters: [{
142
+ name: "target",
143
+ wire: "target",
144
+ source: "json",
145
+ codec: { mode: "strict", typeSymbol: "@michengai/dsh-archive-manager/types#ArchivedBatchTarget", schema: archivedBatchTargetSchema }
146
+ }],
147
+ result: {
148
+ mode: "strict",
149
+ typeSymbol: "@michengai/dsh-archive-manager/types#DeletedBatch",
150
+ schema: deletedBatchSchema
151
+ },
152
+ sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
153
+ },
154
+ {
155
+ id: "@michengai/dsh-archive-manager#workspaceRegistry/archivedSessionMetadata",
156
+ service: "workspaceRegistry",
157
+ namespace: "workspaceRegistry",
158
+ method: "archivedSessionMetadata",
159
+ invocation: { kind: "direct" },
160
+ parameters: [],
161
+ result: {
162
+ mode: "strict",
163
+ typeSymbol: "@michengai/dsh-archive-manager/types#ArchivedSessionMetadata",
164
+ schema: archivedSessionMetadataSchema
165
+ },
166
+ sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
167
+ }
83
168
  ]
84
169
  };
85
170
  /** Archived-session row presentation classes (theme-token driven). */
@@ -2107,9 +2192,7 @@ window.__ModuleLoader__.load({
2107
2192
  setDeletingSession(true);
2108
2193
  setDeleteSessionError(null);
2109
2194
  const rootId = deleteSessionTarget.sessionId;
2110
- (async () => {
2111
- await deleteSession(rootId);
2112
- })().then(() => {
2195
+ deleteSession(rootId).then(() => {
2113
2196
  setDeletingSession(false);
2114
2197
  setDeleteSessionTarget(null);
2115
2198
  setDeleteSessionCommittedId(rootId);
@@ -2495,7 +2578,8 @@ window.__ModuleLoader__.load({
2495
2578
  }
2496
2579
  //#endregion
2497
2580
  //#region dsh-archive-manager: settings section
2498
- const ARCHIVE_SETTINGS_CSS = ".dsham_settings{box-sizing:border-box;width:min(100%,760px);margin:0 auto;padding:0 0 32px;color:var(--dsw-alias-label-primary)}.dsham_settingsHeader{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:12px}.dsham_settings h2{margin:0;font-size:20px;font-weight:650;letter-spacing:-.2px;line-height:28px}.dsham_settingsIntro{margin:4px 0 0;max-width:42em;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.dsham_settingsDanger{display:inline-flex;align-items:center;gap:6px;min-height:32px;padding:0 12px;color:var(--dsw-alias-state-error-primary);background:transparent;border:1px solid var(--dsw-alias-state-error-primary);border-radius:8px;cursor:pointer;font:inherit;font-size:13px;font-weight:500}.dsham_settingsDanger:hover{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 20%,transparent)}.dsham_settingsToolbar{display:flex;gap:8px;margin-bottom:16px}.dsham_settingsSearch{display:flex;align-items:center;gap:8px;min-width:0;flex:1;height:32px;padding:0 12px;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-button-elevated-fill));border:1px solid var(--dsw-alias-border-l2);border-radius:8px}.dsham_settingsSearch:focus-within{border-color:var(--dsw-alias-label-tertiary)}.dsham_settingsSearch input{width:100%;min-width:0;padding:0;color:var(--dsw-alias-label-primary);background:transparent;border:0;outline:0;font:inherit;font-size:12px}.dsham_settingsSearch input::placeholder{color:var(--dsw-alias-label-tertiary)}.dsham_settingsFilter{position:relative;min-width:168px;flex:none}.dsham_selectTrigger{box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;min-height:32px;padding:0 10px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-button-elevated-fill));border:1px solid var(--dsw-alias-border-l2);border-radius:8px;cursor:pointer;font:inherit;font-size:13px;line-height:20px;text-align:left}.dsham_selectTrigger:hover{background:var(--dsw-alias-interactive-bg-hover)}.dsham_selectTrigger:focus-visible{outline:2px solid var(--dsw-alias-state-success-primary);outline-offset:2px}.dsham_selectTrigger[aria-expanded='true']{border-color:var(--dsw-alias-state-success-primary)}.dsham_selectValue{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dsham_selectCaret{flex:none;width:12px;height:12px;color:var(--dsw-alias-label-tertiary)}.dsham_selectMenu{position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:30;box-sizing:border-box;min-width:100%;max-height:280px;overflow:auto;padding:4px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-2));box-shadow:var(--dsw-shadow-lv3)}.dsham_selectOption{box-sizing:border-box;display:flex;align-items:center;width:100%;min-height:32px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;text-align:left;cursor:pointer}.dsham_selectOption:hover,.dsham_selectOption[data-active='true']{background:var(--dsw-alias-interactive-bg-hover)}.dsham_selectOption[aria-selected='true']{color:var(--dsw-alias-state-success-primary)}.dsham_settingsGroup{margin:0 0 20px}.dsham_settingsGroupHeading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 14px}.dsham_settingsGroupTitle{display:flex;align-items:center;gap:8px;min-width:0;margin:0;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600}.dsham_settingsGroupTitle svg{flex:none;color:var(--dsw-alias-label-secondary)}.dsham_settingsCount{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px}.dsham_settingsList{overflow:hidden;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-button-elevated-fill))}.dsham_settingsRow{display:flex;align-items:center;gap:12px;min-height:60px;padding:10px 16px;border-bottom:1px solid var(--dsw-alias-border-l2)}.dsham_settingsRow:last-child{border-bottom:0}.dsham_settingsContent{min-width:0;flex:1}.dsham_settingsTitle{overflow:hidden;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:18px;text-overflow:ellipsis;white-space:nowrap}.dsham_settingsMeta{margin-top:2px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}.dsham_settingsActions{display:flex;align-items:center;gap:8px}.dsham_settingsAction{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_settingsAction:hover{filter:brightness(1.12)}.dsham_settingsDelete{display:flex;align-items:center;justify-content:center;width:28px;height:28px;color:var(--dsw-alias-label-tertiary);background:transparent;border:0;border-radius:8px;cursor:pointer}.dsham_settingsDelete:hover{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover)}.dsham_settingsEmpty{padding:28px 8px;color:var(--dsw-alias-label-secondary);text-align:center}.dsham_settingsError{margin-top:10px;color:var(--dsw-alias-state-error-primary);font-size:12px}@media(max-width:720px){.dsham_settings{width:100%;margin:28px auto 48px;padding:0 16px}.dsham_settingsHeader{margin-bottom:28px}.dsham_settingsToolbar{flex-wrap:wrap;margin-bottom:28px}.dsham_settingsSearch{flex-basis:100%}.dsham_settingsFilter{flex:1;min-width:0}.dsham_settingsGroup{margin-bottom:32px}.dsham_settingsRow{padding:10px 12px}.dsham_settingsActions{gap:4px}}";
2581
+ const ARCHIVE_SETTINGS_CSS = ".dsham_settings{box-sizing:border-box;width:min(100%,760px);margin:0 auto;padding:0 0 32px;color:var(--dsw-alias-label-primary)}.dsham_settingsHeader{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:12px}.dsham_settings h2{margin:0;font-size:20px;font-weight:650;letter-spacing:-.2px;line-height:28px}.dsham_settingsIntro{margin:4px 0 0;max-width:42em;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}.dsham_settingsDanger{display:inline-flex;align-items:center;gap:6px;min-height:32px;padding:0 12px;color:var(--dsw-alias-state-error-primary);background:transparent;border:1px solid var(--dsw-alias-state-error-primary);border-radius:8px;cursor:pointer;font:inherit;font-size:13px;font-weight:500}.dsham_settingsDanger:hover{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 20%,transparent)}.dsham_settingsToolbar{display:flex;gap:8px;margin-bottom:16px}.dsham_settingsSearch{display:flex;align-items:center;gap:8px;min-width:0;flex:1;height:32px;padding:0 12px;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-button-elevated-fill));border:1px solid var(--dsw-alias-border-l2);border-radius:8px}.dsham_settingsSearch:focus-within{border-color:var(--dsw-alias-label-tertiary)}.dsham_settingsSearch input{width:100%;min-width:0;padding:0;color:var(--dsw-alias-label-primary);background:transparent;border:0;outline:0;font:inherit;font-size:12px}.dsham_settingsSearch input::placeholder{color:var(--dsw-alias-label-tertiary)}.dsham_settingsFilter{position:relative;min-width:168px;flex:none}.dsham_selectTrigger{box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;min-height:32px;padding:0 10px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-button-elevated-fill));border:1px solid var(--dsw-alias-border-l2);border-radius:8px;cursor:pointer;font:inherit;font-size:13px;line-height:20px;text-align:left}.dsham_selectTrigger:hover{background:var(--dsw-alias-interactive-bg-hover)}.dsham_selectTrigger:focus-visible{outline:2px solid var(--dsw-alias-state-success-primary);outline-offset:2px}.dsham_selectTrigger[aria-expanded='true']{border-color:var(--dsw-alias-state-success-primary)}.dsham_selectValue{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dsham_selectCaret{flex:none;width:12px;height:12px;color:var(--dsw-alias-label-tertiary)}.dsham_selectMenu{position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:30;box-sizing:border-box;min-width:100%;max-height:280px;overflow:auto;padding:4px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-2));box-shadow:var(--dsw-shadow-lv3)}.dsham_selectOption{box-sizing:border-box;display:flex;align-items:center;width:100%;min-height:32px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;text-align:left;cursor:pointer}.dsham_selectOption:hover,.dsham_selectOption[data-active='true']{background:var(--dsw-alias-interactive-bg-hover)}.dsham_selectOption[aria-selected='true']{color:var(--dsw-alias-state-success-primary)}.dsham_settingsGroup{margin:0 0 20px}.dsham_settingsGroupHeading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 14px}.dsham_settingsGroupTitle{display:flex;align-items:center;gap:8px;min-width:0;margin:0;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600}.dsham_settingsGroupTitle svg{flex:none;color:var(--dsw-alias-label-secondary)}.dsham_settingsCount{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px}.dsham_settingsList{overflow:hidden;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-button-elevated-fill))}.dsham_settingsRow{display:flex;align-items:center;gap:12px;min-height:60px;padding:10px 16px;border-bottom:1px solid var(--dsw-alias-border-l2)}.dsham_settingsRow:last-child{border-bottom:0}.dsham_settingsContent{min-width:0;flex:1}.dsham_settingsTitle{overflow:hidden;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:18px;text-overflow:ellipsis;white-space:nowrap}.dsham_settingsMeta{margin-top:2px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}.dsham_settingsActions{display:flex;align-items:center;gap:8px}.dsham_settingsAction{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_settingsAction:hover{filter:brightness(1.12)}.dsham_settingsDelete{display:flex;align-items:center;justify-content:center;width:28px;height:28px;color:var(--dsw-alias-label-tertiary);background:transparent;border:0;border-radius:8px;cursor:pointer}.dsham_settingsDelete:hover{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover)}.dsham_settingsEmpty{padding:28px 8px;color:var(--dsw-alias-label-secondary);text-align:center}.dsham_settingsError{margin-top:10px;color:var(--dsw-alias-state-error-primary);font-size:12px}@media(max-width:720px){.dsham_settings{width:100%;margin:28px auto 48px;padding:0 16px}.dsham_settingsHeader{margin-bottom:28px}.dsham_settingsToolbar{flex-wrap:wrap;margin-bottom:28px}.dsham_settingsSearch{flex-basis:100%}.dsham_settingsFilter{flex:1;min-width:0}.dsham_settingsGroup{margin-bottom:32px}.dsham_settingsRow{padding:10px 12px}.dsham_settingsActions{gap:4px}}";
2582
+ 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}}";
2499
2583
  const ARCHIVE_SETTINGS_LAYOUT_OVERRIDE = ".dsham_settings{margin:0 auto!important}@media(max-width:720px){.dsham_settings{margin:0 auto!important}}";
2500
2584
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify("dsh-archive-manager/ArchiveSettings.layout.css") + "]") === null) {
2501
2585
  const tag = document.createElement("style");
@@ -2504,8 +2588,8 @@ window.__ModuleLoader__.load({
2504
2588
  tag.textContent = ARCHIVE_SETTINGS_LAYOUT_OVERRIDE;
2505
2589
  document.head.appendChild(tag);
2506
2590
  }
2507
- /** 设置页项目筛选:自定义菜单,避免原生 select 弹出系统浅色下拉。 */
2508
- function ArchiveProjectSelect({ id, value, options, onChange, "aria-label": ariaLabel }) {
2591
+ /** 设置页筛选/排序:自定义菜单,避免原生 select 弹出系统浅色下拉。 */
2592
+ function ArchiveProjectSelect({ id, value, options, onChange, "aria-label": ariaLabel }) {
2509
2593
  const [open, setOpen] = (0, react.useState)(false);
2510
2594
  const selectedIndex = Math.max(0, options.findIndex((option) => option.value === value));
2511
2595
  const [active, setActive] = (0, react.useState)(selectedIndex);
@@ -2634,10 +2718,75 @@ window.__ModuleLoader__.load({
2634
2718
  children: option.label
2635
2719
  }, option.value === "all" ? "all" : option.value))
2636
2720
  }) : null]
2637
- });
2638
- }
2639
- /** 设置页“未分组”桶的稳定 key(workspaceId 均为非空 uuid,不会冲突)。 */
2640
- const ARCHIVE_UNGROUPED_KEY = "__ungrouped__";
2721
+ });
2722
+ }
2723
+ /** 项目标题右侧的批量恢复/删除菜单,复用宿主菜单组件的键盘和焦点行为。 */
2724
+ function ArchivedGroupActions({ group, busy, onRestore, onDelete, t }) {
2725
+ const [open, setOpen] = (0, react.useState)(false);
2726
+ const ungrouped = group.key === ARCHIVE_UNGROUPED_KEY;
2727
+ const items = [{
2728
+ id: "restore",
2729
+ label: t(ungrouped ? "archives.restoreUngrouped" : "archives.restoreProject")
2730
+ }, {
2731
+ id: "delete",
2732
+ label: t(ungrouped ? "archives.deleteUngrouped" : "archives.deleteProject"),
2733
+ icon: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconTrashOutline16, {}),
2734
+ danger: true
2735
+ }];
2736
+ return (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Menu, {
2737
+ open,
2738
+ onClose: () => setOpen(false),
2739
+ items,
2740
+ onSelect: (id) => {
2741
+ setOpen(false);
2742
+ if (id === "restore") onRestore();
2743
+ else if (id === "delete") onDelete();
2744
+ },
2745
+ portal: true,
2746
+ anchor: (0, react_jsx_runtime.jsx)("button", {
2747
+ type: "button",
2748
+ className: "dsham_settingsGroupMenu",
2749
+ disabled: busy,
2750
+ "aria-label": t(ungrouped ? "archives.ungroupedActions" : "archives.projectActions", { name: group.title }),
2751
+ onClick: () => setOpen((current) => !current),
2752
+ children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconEllipsisOutline16, {})
2753
+ })
2754
+ });
2755
+ }
2756
+ /** 设置页“未分组”桶的稳定 key(workspaceId 均为非空 uuid,不会冲突)。 */
2757
+ const ARCHIVE_UNGROUPED_KEY = "__ungrouped__";
2758
+ function archivedBatchTargetForGroup(groupKey) {
2759
+ return groupKey === ARCHIVE_UNGROUPED_KEY ? { scope: "ungrouped" } : { scope: "workspace", workspaceId: groupKey };
2760
+ }
2761
+ /** 客户端只用此派生显示权威批次计数;宿主会用自己的持久状态重新解析。 */
2762
+ function deriveArchivedBatchIds(archivedSessionIds, items, target) {
2763
+ const ids = [...new Set(archivedSessionIds ?? [])];
2764
+ if (target.scope === "all") return ids;
2765
+ if (target.scope === "workspace") {
2766
+ const accounted = new Set(items.find((workspace) => workspace.workspaceId === target.workspaceId)?.sessionIds ?? []);
2767
+ return ids.filter((id) => accounted.has(id));
2768
+ }
2769
+ const accounted = new Set(items.flatMap((workspace) => workspace.sessionIds));
2770
+ return ids.filter((id) => !accounted.has(id));
2771
+ }
2772
+ /** 把批量删除结果转换为准确区分“已删除”与“陈旧标记已清理”的反馈。 */
2773
+ function archivedDeleteFeedback(result, t) {
2774
+ const deleted = result.deletedSessionIds.length;
2775
+ const skipped = result.skippedSessionIds.length;
2776
+ if (result.failures.length > 0) return {
2777
+ kind: "error",
2778
+ message: t("archives.deletePartial", { deleted, skipped, failed: result.failures.length, detail: result.failures[0].message })
2779
+ };
2780
+ if (skipped > 0 && deleted > 0) return {
2781
+ kind: "notice",
2782
+ message: t("archives.deleteSuccessWithSkipped", { deleted, skipped })
2783
+ };
2784
+ if (skipped > 0) return {
2785
+ kind: "notice",
2786
+ message: t("archives.deleteSkipped", { n: skipped })
2787
+ };
2788
+ return { kind: "notice", message: t("archives.deleteSuccess", { n: deleted }) };
2789
+ }
2641
2790
  /**
2642
2791
  * 设置页归档分组派生:按工作区分组,组 key 用 workspaceId(上游允许
2643
2792
  * 不同路径的工作区同名,title 不能作 React key / 筛选 value),未归入
@@ -2648,7 +2797,7 @@ window.__ModuleLoader__.load({
2648
2797
  * @param ungroupedLabel - “未分组”显示文案。
2649
2798
  * @returns 分组数组(仅含有会话的组),每项含 key / title / sessions。
2650
2799
  */
2651
- function deriveArchivedGroups(byId, items, archivedSessionIds, ungroupedLabel) {
2800
+ function deriveArchivedGroups(byId, items, archivedSessionIds, ungroupedLabel) {
2652
2801
  const byWorkspace = items.map((workspace) => ({
2653
2802
  key: workspace.workspaceId,
2654
2803
  title: workspace.title,
@@ -2664,37 +2813,62 @@ window.__ModuleLoader__.load({
2664
2813
  }
2665
2814
  const result = byWorkspace.filter((group) => group.sessions.length > 0);
2666
2815
  if (ungrouped.length > 0) result.push({ key: ARCHIVE_UNGROUPED_KEY, title: ungroupedLabel, sessions: ungrouped });
2667
- return result;
2668
- }
2669
- /**
2670
- * “全部删除”的提交集合:直接取归档全集,仅排除摘要已知为 subagent 的
2671
- * id(其删除由服务端级联)。不依赖摘要是否已加载,投影未就绪的会话
2672
- * 不会被静默漏删。
2673
- * @param archivedSessionIds - 注册表全局归档集合。
2674
- * @param byId - 会话摘要表(可能不完整)。
2675
- * @returns 去重后的待删除 id 列表。
2676
- */
2677
- function deriveArchivedDeleteAllIds(archivedSessionIds, byId) {
2678
- const seen = /* @__PURE__ */ new Set();
2679
- const ids = [];
2680
- for (const id of archivedSessionIds ?? []) {
2681
- if (typeof id !== "string" || id.length === 0 || seen.has(id)) continue;
2682
- seen.add(id);
2683
- if (byId?.[id]?.origin === "subagent") continue;
2684
- ids.push(id);
2685
- }
2686
- return ids;
2687
- }
2688
- /** 管理设置页中的归档会话,数据直接订阅 DSH 的会话与工作区投影。 */
2689
- function ArchivedSessionsSection({ sessionStore, workspaceStore, unarchiveSession, deleteSession, t }) {
2690
- const sessions = (0, react.useSyncExternalStore)(sessionStore.subscribe, sessionStore.getSnapshot);
2691
- const workspaceState = (0, react.useSyncExternalStore)(workspaceStore.subscribe, workspaceStore.getSnapshot);
2692
- const [deleteTarget, setDeleteTarget] = (0, react.useState)(null);
2693
- const [busy, setBusy] = (0, react.useState)(false);
2694
- const [error, setError] = (0, react.useState)(null);
2695
- const [query, setQuery] = (0, react.useState)("");
2696
- const [project, setProject] = (0, react.useState)("all");
2697
- const groups = (0, react.useMemo)(() => deriveArchivedGroups(sessions.byId, workspaceState.items, workspaceState.archivedSessionIds, t("group.ungrouped")), [sessions.byId, workspaceState, t]);
2816
+ return result;
2817
+ }
2818
+ /**
2819
+ * 归档设置页排序:时间排序同时按每组首条会话排列项目,字母排序则
2820
+ * 同时排列项目名和组内标题;所有输入均复制后排序,不改写 store 快照。
2821
+ */
2822
+ function sortArchivedGroups(groups, sortBy, createdAtById, t) {
2823
+ const compareText = (left, right) => String(left).localeCompare(String(right), void 0, { numeric: true, sensitivity: "base" });
2824
+ const timestampOf = (session) => {
2825
+ const value = sortBy === "created" ? createdAtById[session.id] : session.updatedAt;
2826
+ return typeof value === "number" && Number.isFinite(value) ? value : Number.NEGATIVE_INFINITY;
2827
+ };
2828
+ const compareSessions = (left, right) => {
2829
+ if (sortBy !== "alphabetical") {
2830
+ const byTime = timestampOf(right) - timestampOf(left);
2831
+ if (Number.isFinite(byTime) && byTime !== 0) return byTime;
2832
+ }
2833
+ return compareText(displayTitle(left, t), displayTitle(right, t)) || compareText(left.id, right.id);
2834
+ };
2835
+ const result = groups.map((group) => ({ ...group, sessions: [...group.sessions].sort(compareSessions) }));
2836
+ return result.sort((left, right) => {
2837
+ if (sortBy !== "alphabetical") {
2838
+ const byTime = timestampOf(right.sessions[0]) - timestampOf(left.sessions[0]);
2839
+ if (Number.isFinite(byTime) && byTime !== 0) return byTime;
2840
+ }
2841
+ return compareText(left.title, right.title) || compareText(left.key, right.key);
2842
+ });
2843
+ }
2844
+ /** 管理设置页中的归档会话,数据直接订阅 DSH 的会话与工作区投影。 */
2845
+ function ArchivedSessionsSection({ sessionStore, workspaceStore, unarchiveSession, deleteSession, unarchiveSessions, deleteArchivedSessions, archivedSessionMetadata, t }) {
2846
+ const sessions = (0, react.useSyncExternalStore)(sessionStore.subscribe, sessionStore.getSnapshot);
2847
+ const workspaceState = (0, react.useSyncExternalStore)(workspaceStore.subscribe, workspaceStore.getSnapshot);
2848
+ const [deleteTarget, setDeleteTarget] = (0, react.useState)(null);
2849
+ const [busy, setBusy] = (0, react.useState)(false);
2850
+ const [error, setError] = (0, react.useState)(null);
2851
+ const [notice, setNotice] = (0, react.useState)(null);
2852
+ const [query, setQuery] = (0, react.useState)("");
2853
+ const [project, setProject] = (0, react.useState)("all");
2854
+ const [sortBy, setSortBy] = (0, react.useState)("updated");
2855
+ const [createdAtById, setCreatedAtById] = (0, react.useState)({});
2856
+ const [unarchivingSessionIds, setUnarchivingSessionIds] = (0, react.useState)(() => /* @__PURE__ */ new Set());
2857
+ const unarchivingSessionIdsRef = (0, react.useRef)(/* @__PURE__ */ new Set());
2858
+ const groups = (0, react.useMemo)(() => deriveArchivedGroups(sessions.byId, workspaceState.items, workspaceState.archivedSessionIds, t("group.ungrouped")), [sessions.byId, workspaceState, t]);
2859
+ const sortedGroups = (0, react.useMemo)(() => sortArchivedGroups(groups, sortBy, createdAtById, t), [groups, sortBy, createdAtById, t]);
2860
+ (0, react.useEffect)(() => {
2861
+ let cancelled = false;
2862
+ archivedSessionMetadata().then((result) => {
2863
+ if (cancelled) return;
2864
+ setCreatedAtById(Object.fromEntries(result.items.map((item) => [item.sessionId, item.createdAt])));
2865
+ }).catch(() => {
2866
+ if (!cancelled) setCreatedAtById({});
2867
+ });
2868
+ return () => {
2869
+ cancelled = true;
2870
+ };
2871
+ }, [archivedSessionMetadata, workspaceState.archivedSessionIds]);
2698
2872
  (0, react.useEffect)(() => {
2699
2873
  // 选中的分组消失(如最后一个归档会话被取消归档)时回退到
2700
2874
  // “所有项目”,避免筛选值停留在失效 key 上把列表过滤为空。
@@ -2704,20 +2878,42 @@ window.__ModuleLoader__.load({
2704
2878
  }, [groups, project]);
2705
2879
  const filteredGroups = (0, react.useMemo)(() => {
2706
2880
  const normalizedQuery = query.trim().toLocaleLowerCase();
2707
- return groups.filter((group) => project === "all" || project === group.key).map((group) => ({
2881
+ return sortedGroups.filter((group) => project === "all" || project === group.key).map((group) => ({
2708
2882
  ...group,
2709
2883
  sessions: group.sessions.filter((session) => normalizedQuery === "" || displayTitle(session, t).toLocaleLowerCase().includes(normalizedQuery))
2710
2884
  })).filter((group) => group.sessions.length > 0);
2711
- }, [groups, project, query, t]);
2712
- const deleteAllSessionIds = (0, react.useMemo)(() => deriveArchivedDeleteAllIds(workspaceState.archivedSessionIds, sessions.byId), [workspaceState.archivedSessionIds, sessions.byId]);
2713
- const onUnarchive = (sessionId) => {
2714
- setError(null);
2715
- unarchiveSession(sessionId).catch((reason) => {
2716
- setError(formatUnarchiveError(reason, t));
2717
- });
2718
- };
2719
- const closeDelete = () => {
2720
- if (!busy) setDeleteTarget(null);
2885
+ }, [sortedGroups, project, query, t]);
2886
+ const allBatchTarget = { scope: "all" };
2887
+ const allBatchSessionIds = (0, react.useMemo)(() => deriveArchivedBatchIds(workspaceState.archivedSessionIds, workspaceState.items, allBatchTarget), [workspaceState.archivedSessionIds, workspaceState.items]);
2888
+ const onUnarchive = (sessionId) => {
2889
+ if (busy || unarchivingSessionIdsRef.current.has(sessionId)) return;
2890
+ unarchivingSessionIdsRef.current.add(sessionId);
2891
+ setUnarchivingSessionIds(new Set(unarchivingSessionIdsRef.current));
2892
+ setError(null);
2893
+ setNotice(null);
2894
+ unarchiveSession(sessionId).catch((reason) => {
2895
+ setError(formatUnarchiveError(reason, t));
2896
+ }).finally(() => {
2897
+ unarchivingSessionIdsRef.current.delete(sessionId);
2898
+ setUnarchivingSessionIds(new Set(unarchivingSessionIdsRef.current));
2899
+ });
2900
+ };
2901
+ const onBatchUnarchive = async (target) => {
2902
+ if (busy) return;
2903
+ setBusy(true);
2904
+ setError(null);
2905
+ setNotice(null);
2906
+ try {
2907
+ const result = await unarchiveSessions(target);
2908
+ setNotice(t("archives.restoreSuccess", { n: result.unarchivedSessionIds.length }));
2909
+ } catch (reason) {
2910
+ setError(t("archives.restoreBatchFailed", { detail: reason instanceof Error ? reason.message : String(reason) }));
2911
+ } finally {
2912
+ setBusy(false);
2913
+ }
2914
+ };
2915
+ const closeDelete = () => {
2916
+ if (!busy) setDeleteTarget(null);
2721
2917
  };
2722
2918
  (0, react.useEffect)(() => {
2723
2919
  if (deleteTarget === null) return;
@@ -2732,49 +2928,58 @@ window.__ModuleLoader__.load({
2732
2928
  return () => window.removeEventListener("keydown", onKeyDown, true);
2733
2929
  }, [deleteTarget, busy]);
2734
2930
  const confirmDelete = async () => {
2735
- if (busy || deleteTarget === null) return;
2736
- setBusy(true);
2737
- setError(null);
2738
- try {
2739
- for (const id of deleteTarget.all ? deleteAllSessionIds : [deleteTarget.id]) {
2740
- try {
2741
- await deleteSession(id);
2742
- } catch (reason) {
2743
- if (deleteTarget.all && isUnknownSessionError(reason)) continue;
2744
- throw reason;
2745
- }
2746
- }
2747
- setDeleteTarget(null);
2931
+ if (busy || deleteTarget === null) return;
2932
+ setBusy(true);
2933
+ setError(null);
2934
+ setNotice(null);
2935
+ try {
2936
+ if (deleteTarget.kind === "batch") {
2937
+ const result = await deleteArchivedSessions(deleteTarget.target);
2938
+ const feedback = archivedDeleteFeedback(result, t);
2939
+ if (feedback.kind === "error") setError(feedback.message);
2940
+ else setNotice(feedback.message);
2941
+ } else {
2942
+ await deleteSession(deleteTarget.session.id);
2943
+ }
2944
+ setDeleteTarget(null);
2748
2945
  } catch (reason) {
2749
2946
  setError(formatDeleteError(reason, t));
2750
- } finally {
2751
- setBusy(false);
2752
- }
2753
- };
2754
- return (0, react_jsx_runtime.jsxs)("section", {
2755
- className: "dsham_settings",
2756
- "aria-label": t("archives.title"),
2757
- children: [(0, react_jsx_runtime.jsx)("style", { children: ARCHIVE_SETTINGS_CSS }), (0, react_jsx_runtime.jsxs)("header", { className: "dsham_settingsHeader", children: [(0, react_jsx_runtime.jsxs)("div", { children: [(0, react_jsx_runtime.jsx)("h2", { children: t("archives.title") }), (0, react_jsx_runtime.jsx)("p", { className: "dsham_settingsIntro", children: t("archives.description") })] }), (0, react_jsx_runtime.jsxs)("button", { type: "button", className: "dsham_settingsDanger", disabled: busy || deleteAllSessionIds.length === 0, onClick: () => setDeleteTarget({ all: true }), children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconTrashOutline16, {}), t("archives.deleteAll")] })] }), (0, react_jsx_runtime.jsxs)("div", { className: "dsham_settingsToolbar", children: [(0, react_jsx_runtime.jsxs)("label", { className: "dsham_settingsSearch", children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconSearchOutline16, {}), (0, react_jsx_runtime.jsx)("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: t("archives.searchPlaceholder"), "aria-label": t("archives.searchPlaceholder") })] }), (0, react_jsx_runtime.jsx)(ArchiveProjectSelect, { id: "dsham-project-filter", value: project, options: [{ value: "all", label: t("archives.allProjects") }, ...groups.map((group) => ({ value: group.key, label: group.title }))], onChange: setProject, "aria-label": t("archives.projectFilter") })] }), groups.length === 0 ? (0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsEmpty", children: t("archives.empty") }) : filteredGroups.length === 0 ? (0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsEmpty", children: t("archives.emptyFiltered") }) : filteredGroups.map((group) => (0, react_jsx_runtime.jsxs)("section", {
2758
- className: "dsham_settingsGroup",
2759
- children: [(0, react_jsx_runtime.jsxs)("div", { className: "dsham_settingsGroupHeading", children: [(0, react_jsx_runtime.jsxs)("h3", { className: "dsham_settingsGroupTitle", children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconFolderOpenOutline16, {}), group.title] }), (0, react_jsx_runtime.jsx)("span", { className: "dsham_settingsCount", children: t("archives.sessionCount", { n: group.sessions.length }) })] }), (0, react_jsx_runtime.jsx)("div", {
2760
- className: "dsham_settingsList",
2947
+ } finally {
2948
+ setBusy(false);
2949
+ }
2950
+ };
2951
+ const batchScope = deleteTarget?.kind === "batch" ? deleteTarget.target.scope : null;
2952
+ const deleteDialogTitle = batchScope === "all" ? t("archives.deleteAllTitle") : batchScope === "ungrouped" ? t("archives.deleteUngroupedTitle") : batchScope === "workspace" ? t("archives.deleteProjectTitle", { name: deleteTarget.title }) : t("deleteSession.title");
2953
+ const deleteDialogDescription = deleteTarget === null ? void 0 : batchScope === "all" ? t("archives.deleteAllDesc", { n: deleteTarget.count }) : batchScope === "ungrouped" ? t("archives.deleteUngroupedDesc", { n: deleteTarget.count }) : batchScope === "workspace" ? t("archives.deleteProjectDesc", { name: deleteTarget.title, n: deleteTarget.count }) : t("deleteSession.desc", { name: displayTitle(deleteTarget.session, t) });
2954
+ const deleteConfirmLabel = batchScope === "all" ? t("archives.deleteAll") : batchScope === "ungrouped" ? t("archives.deleteUngroupedConfirm") : batchScope === "workspace" ? t("archives.deleteProjectConfirm") : t("deleteSession.title");
2955
+ return (0, react_jsx_runtime.jsxs)("section", {
2956
+ className: "dsham_settings",
2957
+ "aria-label": t("archives.title"),
2958
+ children: [(0, react_jsx_runtime.jsx)("style", { children: ARCHIVE_SETTINGS_CSS + ARCHIVE_SETTINGS_BATCH_CSS }), (0, react_jsx_runtime.jsxs)("header", { className: "dsham_settingsHeader", children: [(0, react_jsx_runtime.jsxs)("div", { children: [(0, react_jsx_runtime.jsx)("h2", { children: t("archives.title") }), (0, react_jsx_runtime.jsx)("p", { className: "dsham_settingsIntro", children: t("archives.description") })] }), (0, react_jsx_runtime.jsxs)("div", { className: "dsham_settingsHeaderActions", children: [(0, react_jsx_runtime.jsx)("button", { type: "button", className: "dsham_settingsRestoreAll", disabled: busy || allBatchSessionIds.length === 0, onClick: () => onBatchUnarchive(allBatchTarget), children: t("archives.restoreAll") }), (0, react_jsx_runtime.jsxs)("button", { type: "button", className: "dsham_settingsDanger", disabled: busy || allBatchSessionIds.length === 0, onClick: () => setDeleteTarget({ kind: "batch", target: allBatchTarget, title: t("archives.allProjects"), count: allBatchSessionIds.length }), children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconTrashOutline16, {}), t("archives.deleteAll")] })] })] }), (0, react_jsx_runtime.jsxs)("div", { className: "dsham_settingsToolbar", children: [(0, react_jsx_runtime.jsxs)("label", { className: "dsham_settingsSearch", children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconSearchOutline16, {}), (0, react_jsx_runtime.jsx)("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: t("archives.searchPlaceholder"), "aria-label": t("archives.searchPlaceholder") })] }), (0, react_jsx_runtime.jsx)(ArchiveProjectSelect, { id: "dsham-sort-filter", value: sortBy, options: [{ value: "updated", label: t("archives.sortUpdated") }, { value: "created", label: t("archives.sortCreated") }, { value: "alphabetical", label: t("archives.sortAlphabetical") }], onChange: setSortBy, "aria-label": t("archives.sortBy") }), (0, react_jsx_runtime.jsx)(ArchiveProjectSelect, { id: "dsham-project-filter", value: project, options: [{ value: "all", label: t("archives.allProjects") }, ...sortedGroups.map((group) => ({ value: group.key, label: group.title }))], onChange: setProject, "aria-label": t("archives.projectFilter") })] }), groups.length === 0 ? (0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsEmpty", children: t("archives.empty") }) : filteredGroups.length === 0 ? (0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsEmpty", children: t("archives.emptyFiltered") }) : filteredGroups.map((group) => {
2959
+ const target = archivedBatchTargetForGroup(group.key);
2960
+ const count = deriveArchivedBatchIds(workspaceState.archivedSessionIds, workspaceState.items, target).length;
2961
+ return (0, react_jsx_runtime.jsxs)("section", {
2962
+ className: "dsham_settingsGroup",
2963
+ children: [(0, react_jsx_runtime.jsxs)("div", { className: "dsham_settingsGroupHeading", children: [(0, react_jsx_runtime.jsxs)("h3", { className: "dsham_settingsGroupTitle", children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconFolderOpenOutline16, {}), group.title] }), (0, react_jsx_runtime.jsxs)("div", { className: "dsham_settingsGroupMeta", children: [(0, react_jsx_runtime.jsx)("span", { className: "dsham_settingsCount", children: t("archives.sessionCount", { n: count }) }), (0, react_jsx_runtime.jsx)(ArchivedGroupActions, { group, busy, onRestore: () => onBatchUnarchive(target), onDelete: () => setDeleteTarget({ kind: "batch", target, title: group.title, count }), t })] })] }), (0, react_jsx_runtime.jsx)("div", {
2964
+ className: "dsham_settingsList",
2761
2965
  children: group.sessions.map((session) => (0, react_jsx_runtime.jsxs)("article", {
2762
2966
  className: "dsham_settingsRow",
2763
2967
  children: [(0, react_jsx_runtime.jsxs)("div", { className: "dsham_settingsContent", children: [(0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsTitle", children: displayTitle(session, t) }), (0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsMeta", children: archiveTimeLabel(session.updatedAt, t) })] }), (0, react_jsx_runtime.jsxs)("div", {
2764
2968
  className: "dsham_settingsActions",
2765
- children: [(0, react_jsx_runtime.jsx)("button", { type: "button", className: "dsham_settingsAction", disabled: busy, onClick: () => onUnarchive(session.id), children: t("menu.unarchive") }), (0, react_jsx_runtime.jsx)("button", { type: "button", className: "dsham_settingsDelete", disabled: busy, "aria-label": t("menu.deleteSession"), onClick: () => setDeleteTarget(session), children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconTrashOutline16, {}) })]
2766
- })]
2767
- }, session.id))
2768
- })]
2769
- }, group.key)), error !== null && (0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsError", role: "alert", children: error }), (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
2770
- open: deleteTarget !== null,
2771
- onClose: closeDelete,
2772
- closeLabel: t("close"),
2773
- title: deleteTarget?.all ? t("archives.deleteAllTitle") : t("deleteSession.title"),
2774
- ...deleteTarget === null ? {} : { description: deleteTarget.all ? t("archives.deleteAllDesc", { n: deleteAllSessionIds.length }) : t("deleteSession.desc", { name: displayTitle(deleteTarget, t) }) },
2775
- footer: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, { variant: "outline", disabled: busy, onClick: closeDelete, children: t("cancel") }), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, { variant: "outline", disabled: busy, onClick: confirmDelete, children: deleteTarget?.all ? t("archives.deleteAll") : t("deleteSession.title") })] }),
2776
- children: busy && (0, react_jsx_runtime.jsx)("div", { role: "status", children: deleteTarget?.all ? t("archives.deleteAllPending") : t("deleteSession.pending") })
2777
- })]
2969
+ children: [(0, react_jsx_runtime.jsx)("button", { type: "button", className: "dsham_settingsAction", disabled: busy || unarchivingSessionIds.has(session.id), onClick: () => onUnarchive(session.id), children: t("menu.unarchive") }), (0, react_jsx_runtime.jsx)("button", { type: "button", className: "dsham_settingsDelete", disabled: busy || unarchivingSessionIds.has(session.id), "aria-label": t("menu.deleteSession"), onClick: () => setDeleteTarget({ kind: "session", session }), children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconTrashOutline16, {}) })]
2970
+ })]
2971
+ }, session.id))
2972
+ })]
2973
+ }, group.key);
2974
+ }), error !== null && (0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsError", role: "alert", children: error }), notice !== null && (0, react_jsx_runtime.jsx)("div", { className: "dsham_settingsStatus", role: "status", children: notice }), (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
2975
+ open: deleteTarget !== null,
2976
+ onClose: closeDelete,
2977
+ closeLabel: t("close"),
2978
+ title: deleteDialogTitle,
2979
+ ...deleteDialogDescription === void 0 ? {} : { description: deleteDialogDescription },
2980
+ footer: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, { variant: "outline", disabled: busy, onClick: closeDelete, children: t("cancel") }), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, { variant: "outline", disabled: busy, onClick: confirmDelete, children: deleteConfirmLabel })] }),
2981
+ children: busy && (0, react_jsx_runtime.jsx)("div", { role: "status", children: deleteTarget?.kind === "batch" ? t("archives.deleteBatchPending") : t("deleteSession.pending") })
2982
+ })]
2778
2983
  });
2779
2984
  }
2780
2985
  //#endregion
@@ -2819,15 +3024,39 @@ window.__ModuleLoader__.load({
2819
3024
  "archives.description": "管理已归档的会话。",
2820
3025
  "archives.empty": "暂无已归档会话",
2821
3026
  "archives.emptyFiltered": "没有匹配的已归档聊天",
2822
- "archives.searchPlaceholder": "搜索已归档聊天",
2823
- "archives.projectFilter": "按项目筛选",
3027
+ "archives.searchPlaceholder": "搜索已归档聊天",
3028
+ "archives.sortBy": "排序方式",
3029
+ "archives.sortUpdated": "更新时间",
3030
+ "archives.sortCreated": "创建时间",
3031
+ "archives.sortAlphabetical": "按字母顺序",
3032
+ "archives.projectFilter": "按项目筛选",
2824
3033
  "archives.allProjects": "所有项目",
2825
- "archives.sessionCount": "{n} 个聊天",
2826
- "archives.timestamp": "{date},{time}",
2827
- "archives.deleteAll": "全部删除",
2828
- "archives.deleteAllTitle": "删除全部已归档聊天",
2829
- "archives.deleteAllDesc": "将永久删除全部 {n} 个已归档聊天及其子代理(含正在运行的)和记录,此操作不可恢复。",
2830
- "archives.deleteAllPending": "正在删除已归档聊天…",
3034
+ "archives.sessionCount": "{n} 个聊天",
3035
+ "archives.timestamp": "{date},{time}",
3036
+ "archives.restoreAll": "全部恢复",
3037
+ "archives.restoreProject": "恢复该项目的全部聊天",
3038
+ "archives.restoreUngrouped": "恢复未分组的全部聊天",
3039
+ "archives.deleteProject": "删除该项目的全部聊天",
3040
+ "archives.deleteUngrouped": "删除未分组的全部聊天",
3041
+ "archives.projectActions": "项目“{name}”的归档操作",
3042
+ "archives.ungroupedActions": "未分组聊天的归档操作",
3043
+ "archives.restoreSuccess": "已恢复 {n} 个已归档聊天。",
3044
+ "archives.restoreBatchFailed": "批量恢复失败:{detail}",
3045
+ "archives.deleteAll": "全部删除",
3046
+ "archives.deleteAllTitle": "删除全部已归档聊天",
3047
+ "archives.deleteAllDesc": "将永久删除全部 {n} 个已归档聊天及其子代理(含正在运行的)和记录,此操作不可恢复。",
3048
+ "archives.deleteAllPending": "正在删除已归档聊天…",
3049
+ "archives.deleteProjectTitle": "删除“{name}”中的已归档聊天",
3050
+ "archives.deleteProjectDesc": "将永久删除“{name}”中的 {n} 个已归档聊天及其子代理和记录。项目目录和未归档聊天不会受影响,此操作不可恢复。",
3051
+ "archives.deleteProjectConfirm": "删除该项目的全部聊天",
3052
+ "archives.deleteUngroupedTitle": "删除未分组的已归档聊天",
3053
+ "archives.deleteUngroupedDesc": "将永久删除未分组中的 {n} 个已归档聊天及其子代理和记录。其他项目和未归档聊天不会受影响,此操作不可恢复。",
3054
+ "archives.deleteUngroupedConfirm": "删除未分组的全部聊天",
3055
+ "archives.deleteBatchPending": "正在删除已归档聊天…",
3056
+ "archives.deleteSuccess": "已删除 {n} 个已归档聊天。",
3057
+ "archives.deleteSuccessWithSkipped": "已删除 {deleted} 个已归档聊天;另清理 {skipped} 个已不存在会话的归档记录。",
3058
+ "archives.deleteSkipped": "已清理 {n} 个已不存在会话的归档记录。",
3059
+ "archives.deletePartial": "已删除 {deleted} 个聊天,清理 {skipped} 个已不存在会话的归档记录;{failed} 个删除失败:{detail}",
2831
3060
  "archives.unarchiveUnknown": "会话已不存在,无法取消归档。",
2832
3061
  "archives.unarchiveFailed": "取消归档失败:{detail}",
2833
3062
  "archives.archiveUnknown": "会话已不存在,无法归档。",
@@ -2911,15 +3140,39 @@ window.__ModuleLoader__.load({
2911
3140
  "archives.description": "Manage archived sessions.",
2912
3141
  "archives.empty": "No archived sessions.",
2913
3142
  "archives.emptyFiltered": "No archived chats match your filters.",
2914
- "archives.searchPlaceholder": "Search archived chats",
2915
- "archives.projectFilter": "Filter by project",
3143
+ "archives.searchPlaceholder": "Search archived chats",
3144
+ "archives.sortBy": "Sort archived chats",
3145
+ "archives.sortUpdated": "Last updated",
3146
+ "archives.sortCreated": "Created",
3147
+ "archives.sortAlphabetical": "Alphabetical",
3148
+ "archives.projectFilter": "Filter by project",
2916
3149
  "archives.allProjects": "All projects",
2917
- "archives.sessionCount": "{n} chats",
2918
- "archives.timestamp": "{date}, {time}",
2919
- "archives.deleteAll": "Delete all",
2920
- "archives.deleteAllTitle": "Delete all archived chats",
2921
- "archives.deleteAllDesc": "This permanently deletes all {n} archived chats, their child agents (including any that are still running), and their records. This cannot be undone.",
2922
- "archives.deleteAllPending": "Deleting archived chats…",
3150
+ "archives.sessionCount": "{n} chats",
3151
+ "archives.timestamp": "{date}, {time}",
3152
+ "archives.restoreAll": "Restore all",
3153
+ "archives.restoreProject": "Restore all chats in this project",
3154
+ "archives.restoreUngrouped": "Restore all ungrouped chats",
3155
+ "archives.deleteProject": "Delete all chats in this project",
3156
+ "archives.deleteUngrouped": "Delete all ungrouped chats",
3157
+ "archives.projectActions": "Archive actions for project {name}",
3158
+ "archives.ungroupedActions": "Archive actions for ungrouped chats",
3159
+ "archives.restoreSuccess": "Restored {n} archived chats.",
3160
+ "archives.restoreBatchFailed": "Could not restore the archived chats: {detail}",
3161
+ "archives.deleteAll": "Delete all",
3162
+ "archives.deleteAllTitle": "Delete all archived chats",
3163
+ "archives.deleteAllDesc": "This permanently deletes all {n} archived chats, their child agents (including any that are still running), and their records. This cannot be undone.",
3164
+ "archives.deleteAllPending": "Deleting archived chats…",
3165
+ "archives.deleteProjectTitle": "Delete archived chats in {name}",
3166
+ "archives.deleteProjectDesc": "This permanently deletes the {n} archived chats in {name}, their child agents, and their records. The project directory and unarchived chats are not affected. This cannot be undone.",
3167
+ "archives.deleteProjectConfirm": "Delete all project chats",
3168
+ "archives.deleteUngroupedTitle": "Delete ungrouped archived chats",
3169
+ "archives.deleteUngroupedDesc": "This permanently deletes the {n} ungrouped archived chats, their child agents, and their records. Other projects and unarchived chats are not affected. This cannot be undone.",
3170
+ "archives.deleteUngroupedConfirm": "Delete all ungrouped chats",
3171
+ "archives.deleteBatchPending": "Deleting archived chats…",
3172
+ "archives.deleteSuccess": "Deleted {n} archived chats.",
3173
+ "archives.deleteSuccessWithSkipped": "Deleted {deleted} archived chats and cleared {skipped} stale archive entries for sessions that no longer exist.",
3174
+ "archives.deleteSkipped": "Cleared {n} stale archive entries for sessions that no longer exist.",
3175
+ "archives.deletePartial": "Deleted {deleted} chats and cleared {skipped} stale archive entries; {failed} could not be deleted: {detail}",
2923
3176
  "archives.unarchiveUnknown": "This session no longer exists, so it cannot be unarchived.",
2924
3177
  "archives.unarchiveFailed": "Could not unarchive the session: {detail}",
2925
3178
  "archives.archiveUnknown": "This session no longer exists, so it cannot be archived.",
@@ -3034,12 +3287,33 @@ window.__ModuleLoader__.load({
3034
3287
  const result = await registry.unarchiveSession(sessionId);
3035
3288
  if (!result.ok) throw new Error(result.error.message);
3036
3289
  };
3037
- const deleteSession = async (sessionId) => {
3038
- const registry = ctx.get("remote.workspaceRegistry");
3039
- if (registry === void 0) throw new Error("archive-manager remote service is unavailable");
3040
- const result = await registry.deleteSession(sessionId);
3041
- if (!result.ok) throw new Error(result.error.message);
3042
- };
3290
+ const deleteSession = async (sessionId) => {
3291
+ const registry = ctx.get("remote.workspaceRegistry");
3292
+ if (registry === void 0) throw new Error("archive-manager remote service is unavailable");
3293
+ const result = await registry.deleteSession(sessionId);
3294
+ if (!result.ok) throw new Error(result.error.message);
3295
+ };
3296
+ const unarchiveSessions = async (target) => {
3297
+ const registry = ctx.get("remote.workspaceRegistry");
3298
+ if (registry === void 0) throw new Error("archive-manager remote service is unavailable");
3299
+ const result = await registry.unarchiveSessions(target);
3300
+ if (!result.ok) throw new Error(result.error.message);
3301
+ return result.value;
3302
+ };
3303
+ const deleteArchivedSessions = async (target) => {
3304
+ const registry = ctx.get("remote.workspaceRegistry");
3305
+ if (registry === void 0) throw new Error("archive-manager remote service is unavailable");
3306
+ const result = await registry.deleteArchivedSessions(target);
3307
+ if (!result.ok) throw new Error(result.error.message);
3308
+ return result.value;
3309
+ };
3310
+ const archivedSessionMetadata = async () => {
3311
+ const registry = ctx.get("remote.workspaceRegistry");
3312
+ if (registry === void 0) throw new Error("archive-manager remote service is unavailable");
3313
+ const result = await registry.archivedSessionMetadata();
3314
+ if (!result.ok) throw new Error(result.error.message);
3315
+ return result.value;
3316
+ };
3043
3317
  const browserInjected = () => ({
3044
3318
  startSession: (workspaceId) => {
3045
3319
  ctx.workspaces.startSession(workspaceId);
@@ -3113,25 +3387,30 @@ window.__ModuleLoader__.load({
3113
3387
  locale: NS,
3114
3388
  inject: () => ({
3115
3389
  sessionStore: ctx.sessions.list,
3116
- workspaceStore: ctx.workspaces.list,
3117
- unarchiveSession,
3118
- deleteSession,
3119
- t: ctx.locale.bind(NS)
3390
+ workspaceStore: ctx.workspaces.list,
3391
+ unarchiveSession,
3392
+ deleteSession,
3393
+ unarchiveSessions,
3394
+ deleteArchivedSessions,
3395
+ archivedSessionMetadata,
3396
+ t: ctx.locale.bind(NS)
3120
3397
  })
3121
3398
  }, ArchivedSessionsSection));
3122
3399
  }
3123
3400
  //#endregion
3124
3401
  /** Pure derivation surface for dsh-archive-manager self-tests (no-op for the runtime). */
3125
- exports.__test = {
3402
+ exports.__test = {
3126
3403
  displayTitle,
3127
3404
  sessionVisible,
3128
3405
  isUnknownSessionError,
3129
3406
  deriveGroups,
3130
3407
  deriveFlat,
3131
- deriveSearchResults,
3132
- deriveArchivedGroups,
3133
- deriveArchivedDeleteAllIds,
3134
- createWorkspaceViewStore,
3408
+ deriveSearchResults,
3409
+ deriveArchivedGroups,
3410
+ sortArchivedGroups,
3411
+ deriveArchivedBatchIds,
3412
+ archivedDeleteFeedback,
3413
+ createWorkspaceViewStore,
3135
3414
  groupByWorkspace,
3136
3415
  byRecency,
3137
3416
  ARCHIVE_MANAGER_REMOTE
package/lib/workspace.js CHANGED
@@ -15,8 +15,9 @@ import { trackTombstone } from "./tombstone.js";
15
15
  * 删除投影缓存,级联删除 SUBAGENT 子会话并清理 spill;最后才删除转录
16
16
  * 目录。fork 分支虽有 `parentSession`,但属于独立用户会话,不参与级联。
17
17
  *
18
- * 所有可失败的持久化清理都先于不可逆的物理删除。此前任一步失败时,
19
- * 转录仍保留,可重试删除;物理删除失败时记录残留路径供人工排查。
18
+ * 父会话的可失败记账清理先于其转录目录删除;父目录删除失败时保留头部
19
+ * 索引,允许再次调用同一入口完成删除。SUBAGENT 级联跨多个转录目录,无法
20
+ * 组成事务:子会话可能已先删除,重试会跳过它们并继续处理仍保留的父会话。
20
21
  * 物理删除成功后必须遗忘父类内存索引(headers / sessionPaths /
21
22
  * invalidSessionPaths)并打上删除墓碑:父类 sessionKnown 以 headers.has
22
23
  * 短路,indexHeaders 只增不减,stale list() 否则会把已删 id 救活,
@@ -24,7 +25,8 @@ import { trackTombstone } from "./tombstone.js";
24
25
  * 墓碑同时记录被删生命周期的日志身份(createdAt/cwd):其他进程以同 id
25
26
  * 重建并落盘新会话时(冷复用),sessionKnown 的探针以身份差异区分新旧
26
27
  * 生命周期并撤掉墓碑,避免已重建的会话在本进程永久 UNKNOWN_SESSION。
27
- * `unarchiveSession` 与 `deleteSession` 通过 Typert Remote 暴露给浏览器,并注册到 typert.local,避免生产环境只靠 SRC 扫描时 404。
28
+ * 单会话与批量恢复/删除方法均通过 Typert Remote 暴露给浏览器,并注册到
29
+ * typert.local,避免生产环境只靠 SRC 扫描时 404。
28
30
  */
29
31
  function markRemoteMethod(instance, method) {
30
32
  // 模拟 TS 装饰器管线 `@Remote(method)`:`Remote` 返回标准方法装饰器,
@@ -71,12 +73,45 @@ const archivedSetSchema = {
71
73
  return value;
72
74
  }
73
75
  };
74
- const deletedSchema = {
75
- parse(value) {
76
- if (typeof value !== "object" || value === null || Array.isArray(value) || value.deleted !== true) throw new TypeError("deleted must be true");
77
- return value;
78
- }
79
- };
76
+ const deletedSchema = {
77
+ parse(value) {
78
+ if (typeof value !== "object" || value === null || Array.isArray(value) || value.deleted !== true) throw new TypeError("deleted must be true");
79
+ return value;
80
+ }
81
+ };
82
+ const archivedBatchTargetSchema = {
83
+ parse(value) {
84
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("target must be an object");
85
+ if (value.scope === "all" || value.scope === "ungrouped") return value;
86
+ if (value.scope === "workspace" && typeof value.workspaceId === "string" && value.workspaceId.length > 0) return value;
87
+ throw new TypeError("target.scope must be all, ungrouped, or workspace with a non-empty workspaceId");
88
+ }
89
+ };
90
+ const unarchivedBatchSchema = {
91
+ parse(value) {
92
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("result must be an object");
93
+ if (!Array.isArray(value.archivedSessionIds) || value.archivedSessionIds.some((id) => typeof id !== "string")) throw new TypeError("archivedSessionIds must be a string array");
94
+ if (!Array.isArray(value.unarchivedSessionIds) || value.unarchivedSessionIds.some((id) => typeof id !== "string")) throw new TypeError("unarchivedSessionIds must be a string array");
95
+ return value;
96
+ }
97
+ };
98
+ const deletedBatchSchema = {
99
+ parse(value) {
100
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("result must be an object");
101
+ for (const key of ["requestedSessionIds", "deletedSessionIds", "skippedSessionIds"]) {
102
+ if (!Array.isArray(value[key]) || value[key].some((id) => typeof id !== "string")) throw new TypeError(`${key} must be a string array`);
103
+ }
104
+ if (!Array.isArray(value.failures) || value.failures.some((failure) => typeof failure !== "object" || failure === null || typeof failure.sessionId !== "string" || typeof failure.message !== "string")) throw new TypeError("failures must contain sessionId/message objects");
105
+ return value;
106
+ }
107
+ };
108
+ const archivedSessionMetadataSchema = {
109
+ parse(value) {
110
+ if (typeof value !== "object" || value === null || Array.isArray(value) || !Array.isArray(value.items)) throw new TypeError("result.items must be an array");
111
+ if (value.items.some((item) => typeof item !== "object" || item === null || typeof item.sessionId !== "string" || typeof item.createdAt !== "number" || !Number.isFinite(item.createdAt))) throw new TypeError("items must contain sessionId/createdAt objects");
112
+ return value;
113
+ }
114
+ };
80
115
  /**
81
116
  * Host 严格描述符。网关优先读 typert.local,避免 SRC 扫描缓存
82
117
  * 或协议包双份导致 /api/workspaceRegistry/deleteSession 在生产环境 404。
@@ -101,8 +136,8 @@ const ARCHIVE_MANAGER_INVOCATIONS = [
101
136
  },
102
137
  sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
103
138
  },
104
- {
105
- id: "@michengai/dsh-archive-manager#workspaceRegistry/deleteSession",
139
+ {
140
+ id: "@michengai/dsh-archive-manager#workspaceRegistry/deleteSession",
106
141
  service: "workspaceRegistry",
107
142
  namespace: "workspaceRegistry",
108
143
  method: "deleteSession",
@@ -117,10 +152,62 @@ const ARCHIVE_MANAGER_INVOCATIONS = [
117
152
  mode: "strict",
118
153
  typeSymbol: "@michengai/dsh-archive-manager/types#Deleted",
119
154
  schema: deletedSchema
120
- },
121
- sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
122
- }
123
- ];
155
+ },
156
+ sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
157
+ },
158
+ {
159
+ id: "@michengai/dsh-archive-manager#workspaceRegistry/unarchiveSessions",
160
+ service: "workspaceRegistry",
161
+ namespace: "workspaceRegistry",
162
+ method: "unarchiveSessions",
163
+ invocation: { kind: "direct" },
164
+ parameters: [{
165
+ name: "target",
166
+ wire: "target",
167
+ source: "json",
168
+ codec: { mode: "strict", typeSymbol: "@michengai/dsh-archive-manager/types#ArchivedBatchTarget", schema: archivedBatchTargetSchema }
169
+ }],
170
+ result: {
171
+ mode: "strict",
172
+ typeSymbol: "@michengai/dsh-archive-manager/types#UnarchivedBatch",
173
+ schema: unarchivedBatchSchema
174
+ },
175
+ sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
176
+ },
177
+ {
178
+ id: "@michengai/dsh-archive-manager#workspaceRegistry/deleteArchivedSessions",
179
+ service: "workspaceRegistry",
180
+ namespace: "workspaceRegistry",
181
+ method: "deleteArchivedSessions",
182
+ invocation: { kind: "direct" },
183
+ parameters: [{
184
+ name: "target",
185
+ wire: "target",
186
+ source: "json",
187
+ codec: { mode: "strict", typeSymbol: "@michengai/dsh-archive-manager/types#ArchivedBatchTarget", schema: archivedBatchTargetSchema }
188
+ }],
189
+ result: {
190
+ mode: "strict",
191
+ typeSymbol: "@michengai/dsh-archive-manager/types#DeletedBatch",
192
+ schema: deletedBatchSchema
193
+ },
194
+ sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
195
+ },
196
+ {
197
+ id: "@michengai/dsh-archive-manager#workspaceRegistry/archivedSessionMetadata",
198
+ service: "workspaceRegistry",
199
+ namespace: "workspaceRegistry",
200
+ method: "archivedSessionMetadata",
201
+ invocation: { kind: "direct" },
202
+ parameters: [],
203
+ result: {
204
+ mode: "strict",
205
+ typeSymbol: "@michengai/dsh-archive-manager/types#ArchivedSessionMetadata",
206
+ schema: archivedSessionMetadataSchema
207
+ },
208
+ sourceLocation: { file: "@michengai/dsh-archive-manager/lib/workspace.js", line: 1, column: 1 }
209
+ }
210
+ ];
124
211
  const ARCHIVE_MANAGER_TYPERT = {
125
212
  package: "@michengai/dsh-archive-manager",
126
213
  face: "host",
@@ -155,11 +242,27 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
155
242
  deletedIdentities = /* @__PURE__ */ new Map();
156
243
  constructor(ctx) {
157
244
  super(ctx);
158
- this.typertRemote = bindTypertRemote(this, this.name);
159
- markRemoteMethod(this, "unarchiveSession");
160
- markRemoteMethod(this, "deleteSession");
161
- registerHostRemote(this.ctx);
162
- }
245
+ this.typertRemote = bindTypertRemote(this, this.name);
246
+ markRemoteMethod(this, "unarchiveSession");
247
+ markRemoteMethod(this, "deleteSession");
248
+ markRemoteMethod(this, "unarchiveSessions");
249
+ markRemoteMethod(this, "deleteArchivedSessions");
250
+ markRemoteMethod(this, "archivedSessionMetadata");
251
+ registerHostRemote(this.ctx);
252
+ }
253
+ /** 归档设置页创建时间排序所需的最小元数据;列表摘要本身只暴露 updatedAt。 */
254
+ async archivedSessionMetadata() {
255
+ const items = [];
256
+ for (const sessionId of [...new Set(this.requireState().archivedSessionIds)]) {
257
+ try {
258
+ const header = await this.readSessionHeader(sessionId);
259
+ if (typeof header.createdAt === "number" && Number.isFinite(header.createdAt)) items.push({ sessionId, createdAt: header.createdAt });
260
+ } catch (error) {
261
+ this.ctx.logger.warn(`archive-manager: could not read creation time for archived session "${sessionId}": ${String(error)}`);
262
+ }
263
+ }
264
+ return { items };
265
+ }
163
266
  /**
164
267
  * 把一个会话移出注册表全局归档集合,恢复其正常可见性(其记账位从未
165
268
  * 移动,会话在原工作区位置重新出现)。幂等:未归档的已知会话直接返回
@@ -167,7 +270,7 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
167
270
  * @param sessionId - 要取消归档的会话。
168
271
  * @returns 更新后的完整归档集合。
169
272
  */
170
- async unarchiveSession(sessionId) {
273
+ async unarchiveSession(sessionId) {
171
274
  return this.enqueueOperation(async () => {
172
275
  if (!await this.sessionKnown(sessionId)) throw new ArchiveUnknownSessionError(sessionId);
173
276
  const state = this.requireState();
@@ -178,8 +281,108 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
178
281
  };
179
282
  await this.setState(next);
180
283
  return { archivedSessionIds: [...next.archivedSessionIds] };
181
- });
182
- }
284
+ });
285
+ }
286
+ /**
287
+ * 按宿主权威归档集合一次恢复全部、一个工作区或未分组的归档会话。
288
+ * 目标全部来自已归档集合,因此即使日志已被外部移除,也会清掉陈旧归档标记。
289
+ */
290
+ async unarchiveSessions(target) {
291
+ return this.enqueueOperation(async () => {
292
+ const unarchivedSessionIds = this.archivedSessionIdsForTarget(target);
293
+ if (unarchivedSessionIds.length === 0) return {
294
+ archivedSessionIds: [...this.requireState().archivedSessionIds],
295
+ unarchivedSessionIds: []
296
+ };
297
+ const restored = new Set(unarchivedSessionIds);
298
+ const state = this.requireState();
299
+ const next = {
300
+ ...state,
301
+ archivedSessionIds: state.archivedSessionIds.filter((id) => !restored.has(id))
302
+ };
303
+ await this.setState(next);
304
+ return {
305
+ archivedSessionIds: [...next.archivedSessionIds],
306
+ unarchivedSessionIds
307
+ };
308
+ });
309
+ }
310
+ /**
311
+ * 按作用域永久删除归档会话。跨会话文件删除无法组成事务,因此继续处理
312
+ * 后续目标并把成功、并发消失和失败分别返回给客户端。
313
+ */
314
+ async deleteArchivedSessions(target) {
315
+ return this.enqueueOperation(async () => {
316
+ const requestedSessionIds = this.archivedSessionIdsForTarget(target);
317
+ const deletedSessionIds = [];
318
+ const skippedSessionIds = [];
319
+ const failures = [];
320
+ for (const sessionId of requestedSessionIds) {
321
+ try {
322
+ await this.deleteSessionCore(sessionId);
323
+ deletedSessionIds.push(sessionId);
324
+ } catch (error) {
325
+ if (error instanceof ArchiveUnknownSessionError) {
326
+ // 转录已消失时仍需清完可达的持久痕迹;只有全部完成才算
327
+ // skipped,否则保留归档标记并作为 failure 暴露,允许重试。
328
+ try {
329
+ await this.cleanupUnknownArchivedSession(sessionId);
330
+ skippedSessionIds.push(sessionId);
331
+ } catch (cleanupError) {
332
+ failures.push({ sessionId, message: String(cleanupError) });
333
+ }
334
+ continue;
335
+ }
336
+ failures.push({ sessionId, message: String(error) });
337
+ }
338
+ }
339
+ return { requestedSessionIds, deletedSessionIds, skippedSessionIds, failures };
340
+ });
341
+ }
342
+ /**
343
+ * 清理已无转录的陈旧归档项。缓存墓碑只在清除在途写入期间短暂持有:
344
+ * workspace 没有可记录的旧 header 身份,永久保留它会挡住未来的冷复用。
345
+ * 归档标记最后清除,前序可失败步骤出错时批量入口仍能再次命中。
346
+ */
347
+ async cleanupUnknownArchivedSession(sessionId) {
348
+ const projCache = this.ctx.get("sessionProjectionCache");
349
+ await projCache?.whenIdle?.();
350
+ if (projCache !== void 0) {
351
+ await projCache.delete(sessionId);
352
+ // 等待删除期间可能已进入的写回观察到墓碑并完成补删,再允许
353
+ // 同 id 的未来新生命周期写入缓存。
354
+ await projCache.whenIdle?.();
355
+ projCache.clearTombstone?.(sessionId);
356
+ }
357
+ await this.cleanSpill(sessionId);
358
+ await this.removeFromWorkspaceAccounts(sessionId);
359
+ const state = this.requireState();
360
+ if (state.archivedSessionIds.includes(sessionId)) {
361
+ await this.setState({
362
+ ...state,
363
+ archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId)
364
+ });
365
+ }
366
+ }
367
+ /** 以归档集合顺序解析批量目标,避免依赖浏览器尚未加载完整的摘要投影。 */
368
+ archivedSessionIdsForTarget(target) {
369
+ target = archivedBatchTargetSchema.parse(target);
370
+ const state = this.requireState();
371
+ const archivedSessionIds = [...new Set(state.archivedSessionIds)];
372
+ if (target.scope === "all") return archivedSessionIds;
373
+ if (target.scope === "workspace") {
374
+ const workspace = this.requireTable().get(target.workspaceId);
375
+ if (workspace === void 0) throw new Error(`unknown workspace "${target.workspaceId}"`);
376
+ const accounted = new Set(workspace.sessionIds);
377
+ return archivedSessionIds.filter((id) => accounted.has(id));
378
+ }
379
+ const accounted = /* @__PURE__ */ new Set();
380
+ const table = this.requireTable();
381
+ for (const workspaceId of state.workspaceIds) {
382
+ for (const sessionId of table.get(workspaceId)?.sessionIds ?? []) accounted.add(sessionId);
383
+ }
384
+ return archivedSessionIds.filter((id) => !accounted.has(id));
385
+ }
183
386
  /**
184
387
  * 永久删除一个会话及其全部痕迹(转录目录、工作区记账、归档标记、
185
388
  * 投影缓存行)。
@@ -211,7 +414,8 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
211
414
  // dispose 的写后落盘必须先于缓存行删除完成,
212
415
  // 否则该行会在删除之后被写回(复活)。
213
416
  await projCache?.whenIdle?.();
214
- // 物理删除不可逆:先完成所有可失败的持久化清理,失败时仍可重试。
417
+ // 先完成父会话的可失败记账清理;SUBAGENT 级联跨目录且非事务,可能
418
+ // 部分完成,但父转录失败时仍保留索引供重试。
215
419
  const state = this.requireState();
216
420
  if (state.archivedSessionIds.includes(sessionId)) {
217
421
  await this.setState({
@@ -233,12 +437,15 @@ var ArchiveWorkspaceRegistry = class extends WorkspaceRegistry {
233
437
  * 从父类内存索引中遗忘已删除会话,并阻止后续 indexHeaders 把它加回。
234
438
  * 实时会话以同 id 重新出现时(自定义 id 复用)会撤掉墓碑。
235
439
  */
236
- clearTombstone(sessionId) {
237
- this.deletedSessionIds.delete(sessionId);
238
- this.deletedIdentities.delete(sessionId);
239
- const idx = this.deletedSessionOrder.indexOf(sessionId);
240
- if (idx !== -1) this.deletedSessionOrder.splice(idx, 1);
241
- }
440
+ clearTombstone(sessionId) {
441
+ this.deletedSessionIds.delete(sessionId);
442
+ this.deletedIdentities.delete(sessionId);
443
+ const idx = this.deletedSessionOrder.indexOf(sessionId);
444
+ if (idx !== -1) this.deletedSessionOrder.splice(idx, 1);
445
+ // workspace 与 projection-cache 共同描述同一删除生命周期;新生命周期
446
+ // 被接纳时必须同步撤销两处墓碑,否则缓存写入仍会永久被拦截。
447
+ this.ctx.get("sessionProjectionCache")?.clearTombstone?.(sessionId);
448
+ }
242
449
  forgetIndexedSession(sessionId) {
243
450
  for (const evicted of trackTombstone(this.deletedSessionIds, this.deletedSessionOrder, sessionId, this.deletedSessionTombstoneLimit)) this.deletedIdentities.delete(evicted);
244
451
  this.headers.delete(sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michengai/dsh-archive-manager",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "NPM-installable DSH Web plugin for managing archived sessions.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.21.0",