dsh-archived-chats 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-archived-chats
2
2
 
3
+ > ⚡ **Deletion takes effect immediately — no restart.** Even sessions still resident in the background are torn down safely along the official lifecycle and wiped from disk the moment you click delete, instead of being "parked until the next restart".
4
+
3
5
  A settings page for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) that brings archived chats back into view.
4
6
 
5
7
  Once a conversation is archived in DeepSeek Harness it disappears from the sidebar, and there is no built-in way to browse it again — only the workspace store (`~/.dsh/storages/workspace.json`) still remembers it. This plugin adds an **Archived Chats** page under Settings where every archived session is visible, searchable, and manageable.
@@ -12,10 +14,15 @@ dsh plugin --profile web add dsh-archived-chats
12
14
 
13
15
  Restart DSH once after installing, then open **Settings → Archived Chats**.
14
16
 
17
+ ## Compatibility
18
+
19
+ Version 0.5.1 is tested against DeepSeek Harness `0.1.0-rc.7`. The plugin registers a top-level `settings.section`, so the rc.7 keyed-slot change for `settings.plugin.item` does not apply to it. Future Harness releases should still be checked with the smoke suite and a real-host UI pass before publishing a plugin update, because client slot and design-token contracts may evolve.
20
+
15
21
  ## Features
16
22
 
17
23
  - **Complete archived-session list**, grouped by workspace (project) with a per-group count. Every group can be collapsed or expanded, and the state is remembered per browser.
18
- - **Search** by title, plus two filters: type (all / regular / subagent) and project.
24
+ - **Search and sort** by title, filter by type (all / regular / subagent) and project, then order results by newest, oldest, or title.
25
+ - **Flexible multi-select**: select individual chats, every visible result, or an entire project. The selection bar can unarchive or permanently delete the chosen chats in one action, while selections hidden by another filter remain intact.
19
26
  - **Unarchive** a single chat or a whole project group from the group's `⋯` menu — restored chats reappear in the sidebar immediately.
20
27
  - **Delete** one chat, a project group, or everything (**Delete All**), each behind a confirmation dialog. Deletion is thorough: the session log is removed from disk, the session is detached from its workspace record, and the registry's in-memory header index is purged, so the sidebar drops the rows live.
21
28
  - Sessions still resident in the background are **deleted in place too**: the plugin disposes the session through the official lifecycle teardown order (cancel → quiesce → flush → fiber teardown → registry detach), the persistence layer releases the write path, and the physical delete completes within the same request — no restart. If the running DSH build does not expose the required internal seams, the plugin falls back to "park permanently + delete on the next start", with parked sessions staying hidden meanwhile.
@@ -27,7 +34,15 @@ Restart DSH once after installing, then open **Settings → Archived Chats**.
27
34
  - **In-place live deletion**: deleting a resident session replays the agent factory's own disposer sequence — `cancel({ kind: 'disposed' })` → `whenIdle` → `flush` → `agent.scope.dispose()` → detach of the `agents` and `sessions` store entries. The session detach emits `session/disposed`, the persistence coordinator retires (drains and releases) the write path, and the ordinary cold delete completes in the same request. The store entries are internal surfaces, so every step is feature-detected; anything missing falls back to park-and-defer.
28
35
  - **Pending-deletion store** (fallback path and crash bracket): the id is recorded in `$DSH_HOME/plugin-data/archived-chats/pending-deletions.json` while the session stays archived and hidden; the next boot sweeps the queue through the ordinary delete path. In-place deletes are bracketed by the same store (recorded before disposal, cleared once the files are gone), so a crash mid-delete is completed on the next start. Parked sessions are excluded from the listing; unarchiving cancels a pending deletion.
29
36
  - **Title cache**: resolved titles are memoized per id across list refreshes instead of re-reading every archived log; delete and unarchive invalidate their entries.
30
- - **Browser half** (`lib/client.js`) registers a `settings.section` slot entry (order 30) and renders the page with React and DSH design tokens.
37
+ - **Browser half** (`lib/client.js`) registers a `settings.section` slot entry (order 30) and renders the page with React and the rc.7 DSH overlay/state design tokens.
38
+
39
+ ## Development
40
+
41
+ ```sh
42
+ npm test
43
+ ```
44
+
45
+ The smoke test uses an isolated temporary DSH home plus mocked host and browser runtimes; it never reads or changes real sessions.
31
46
 
32
47
  ## Uninstall
33
48
 
package/README.zh.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-archived-chats
2
2
 
3
+ > ⚡ **删除即生效,无需重启。** 即使会话仍驻留在后台,也会沿官方生命周期当场安全拆除并从磁盘彻底删除——点下删除的那一刻就删干净,而不是"停用后等下次重启"。
4
+
3
5
  为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 新增一个「已归档的聊天」设置页,把被归档的会话重新找回来。
4
6
 
5
7
  在 DeepSeek Harness 里,聊天一旦归档就会从侧边栏消失,界面中没有任何入口可以再看到它,只有工作区存档(`~/.dsh/storages/workspace.json`)还记得它。这个插件在「设置」中补上一个「已归档的聊天」页面,让所有归档会话都可见、可搜索、可管理。
@@ -12,10 +14,15 @@ dsh plugin --profile web add dsh-archived-chats
12
14
 
13
15
  安装后重启一次 DSH,然后打开 **设置 → 已归档的聊天**。
14
16
 
17
+ ## 兼容性
18
+
19
+ 0.5.1 版本以 DeepSeek Harness `0.1.0-rc.7` 为验证基线。插件注册的是顶层 `settings.section`,因此 rc.7 针对 `settings.plugin.item` 的 keyed-slot 变更不影响本插件。以后 Harness 发布新版本时,仍应在发布插件更新前重跑冒烟测试并检查真实宿主页面,因为客户端插槽和设计令牌契约仍可能演进。
20
+
15
21
  ## 功能
16
22
 
17
23
  - **完整归档列表**:按工作区(项目)分组并显示每组数量;每个分组都可折叠/展开,状态按浏览器记忆。
18
- - **搜索**标题,外加两个筛选器:类型(全部 / 普通会话 / 子代理会话)和项目。
24
+ - **搜索与排序**:按标题搜索,用类型(全部 / 普通会话 / 子代理会话)和项目筛选,并按最新、最早或标题排序。
25
+ - **灵活多选**:逐条选择、选择当前筛选结果或选择整个项目;选中后可一次取消归档或永久删除,隐藏在其他筛选结果中的选择不会丢失。
19
26
  - **取消归档**单个聊天,或从分组的 `⋯` 菜单整组取消——恢复的聊天会立刻回到侧边栏。
20
27
  - **删除**单个聊天、某个项目分组或全部(**全部删除**),均有确认弹窗。删除是彻底的:会话日志从磁盘移除、从工作区记录中摘除、注册表内存索引同步清理,主侧边栏的条目也会立即消失。
21
28
  - 仍驻留后台的会话也**当场删除**:插件按官方生命周期的拆除顺序原地停用并注销会话(取消 → 静默 → 落盘 → 拆纤程 → 摘出注册表),持久层随之释放写入通道,同一次请求内即完成物理删除——无需重启。若当前 DSH 版本不提供所需内部接口,则自动回退为「永久停用 + 下次启动完成删除」,停用期间会话保持隐藏。
@@ -27,7 +34,15 @@ dsh plugin --profile web add dsh-archived-chats
27
34
  - **活会话原地删除**:删除仍驻留后台的会话时,插件复刻 agent 工厂自身 disposer 的顺序——`cancel({ kind: 'disposed' })` → `whenIdle` → `flush` → `agent.scope.dispose()` → 依次 detach `agents` 与 `sessions` 两个 store 条目;session detach 发出 `session/disposed`,持久化协调器随之 retire(排空并释放)该会话的写入通道,之后冷删除路径在同一请求内完成。所涉 store 条目属于内部接口,每一步都做特性探测,探测失败即回退为停用+延后。
28
35
  - **待删队列**(回退路径与崩溃兜底):id 记入 `$DSH_HOME/plugin-data/archived-chats/pending-deletions.json`,会话保持归档与隐藏;下次启动时插件清扫该队列,通过常规删除路径完成物理删除。原地删除也用该队列包裹(删除前登记、文件移除后清除),中途崩溃由下次启动补完。已入队的会话不会出现在列表中;取消归档会撤销待删标记。
29
36
  - **标题缓存**:列表刷新时按 id 记忆已解析的标题,不再每次全量读日志;删除与取消归档会使对应缓存失效。
30
- - **浏览器半**(`lib/client.js`)注册 `settings.section` 插槽项(order 30),用 React 和 DSH 设计令牌渲染页面。
37
+ - **浏览器半**(`lib/client.js`)注册 `settings.section` 插槽项(order 30),用 React 和 rc.7 的 DSH 浮层/状态设计令牌渲染页面。
38
+
39
+ ## 开发
40
+
41
+ ```sh
42
+ npm test
43
+ ```
44
+
45
+ 测试使用隔离的临时 DSH 主目录和模拟的宿主/浏览器运行时,不会读取或修改真实会话。
31
46
 
32
47
  ## 卸载
33
48
 
package/lib/client.js CHANGED
@@ -9,8 +9,8 @@
9
9
  // What this plugin is: one settings section — 已归档的聊天 / Archived Chats —
10
10
  // modeled 1:1 on the CodeX archived-chats page. The stock DSH sidebar hides
11
11
  // archived sessions with no list surface; this page restores one: search,
12
- // type/project filters, per-workspace grouping, unarchive, delete, and
13
- // delete-all, all driven through the host half's /plugins/dsh-archived-chats/*
12
+ // type/project filters, sorting, multi-selection, unarchive, delete, and
13
+ // batch actions, all driven through the host half's /plugins/dsh-archived-chats/*
14
14
  // routes (the archive set itself lives in the host's workspace registry).
15
15
  window.__ModuleLoader__.load({
16
16
  id: "dsh-archived-chats",
@@ -54,6 +54,15 @@ window.__ModuleLoader__.load({
54
54
  "filter.normal": "普通会话",
55
55
  "filter.subagent": "子代理会话",
56
56
  "filter.allProjects": "所有项目",
57
+ "sort.label": "排序方式",
58
+ "sort.newest": "最新优先",
59
+ "sort.oldest": "最早优先",
60
+ "sort.title": "按标题",
61
+ "selection.visible": "选择当前结果",
62
+ "selection.group": "选择此项目",
63
+ "selection.clear": "清除",
64
+ "bulk.unarchive": "取消归档",
65
+ "bulk.delete": "删除",
57
66
  "group.noProject": "未分组",
58
67
  "chat.untitled": "未命名会话",
59
68
  "chat.unarchive": "取消归档",
@@ -65,6 +74,7 @@ window.__ModuleLoader__.load({
65
74
  "confirm.deleteOne.body": "这将永久删除已归档聊天",
66
75
  "confirm.deleteAll.title": "删除全部已归档聊天?",
67
76
  "confirm.deleteGroup.title": "删除该项目下的已归档聊天?",
77
+ "confirm.deleteSelected.title": "删除选中的已归档聊天?",
68
78
  "confirm.cancel": "取消",
69
79
  "confirm.delete": "删除",
70
80
  "state.loading": "加载中…",
@@ -84,6 +94,15 @@ window.__ModuleLoader__.load({
84
94
  "filter.normal": "Regular chats",
85
95
  "filter.subagent": "Subagent chats",
86
96
  "filter.allProjects": "All projects",
97
+ "sort.label": "Sort order",
98
+ "sort.newest": "Newest first",
99
+ "sort.oldest": "Oldest first",
100
+ "sort.title": "Title",
101
+ "selection.visible": "Select visible chats",
102
+ "selection.group": "Select this project",
103
+ "selection.clear": "Clear",
104
+ "bulk.unarchive": "Unarchive",
105
+ "bulk.delete": "Delete",
87
106
  "group.noProject": "Ungrouped",
88
107
  "chat.untitled": "Untitled chat",
89
108
  "chat.unarchive": "Unarchive",
@@ -95,6 +114,7 @@ window.__ModuleLoader__.load({
95
114
  "confirm.deleteOne.body": "This permanently deletes the archived chat",
96
115
  "confirm.deleteAll.title": "Delete all archived chats?",
97
116
  "confirm.deleteGroup.title": "Delete this project's archived chats?",
117
+ "confirm.deleteSelected.title": "Delete selected archived chats?",
98
118
  "confirm.cancel": "Cancel",
99
119
  "confirm.delete": "Delete",
100
120
  "state.loading": "Loading…",
@@ -115,6 +135,14 @@ window.__ModuleLoader__.load({
115
135
  const deleteGroupBody = (t, name, n) => isZh(t)
116
136
  ? `这将永久删除「${name}」下 ${n} 个已归档聊天`
117
137
  : `This permanently deletes ${n} archived chat${n === 1 ? "" : "s"} under "${name}"`;
138
+ const deleteSelectedBody = (t, n) => isZh(t)
139
+ ? `这将永久删除选中的 ${n} 个已归档聊天`
140
+ : `This permanently deletes the ${n} selected archived chat${n === 1 ? "" : "s"}`;
141
+ const selectedCount = (t, n) => isZh(t)
142
+ ? `已选择 ${n} 个聊天`
143
+ : `${n} chat${n === 1 ? "" : "s"} selected`;
144
+ const selectChatLabel = (t, title) => isZh(t) ? `选择 ${title}` : `Select ${title}`;
145
+ const selectProjectLabel = (t, title) => `${t("selection.group")}${isZh(t) ? ":" : ": "}${title}`;
118
146
  const failureText = (t, failed) => {
119
147
  if (isZh(t)) return `${failed.length} 个会话删除失败:${failed[0]?.message ?? ""}`;
120
148
  return `${failed.length} chat${failed.length === 1 ? "" : "s"} failed to delete: ${failed[0]?.message ?? ""}`;
@@ -148,15 +176,52 @@ window.__ModuleLoader__.load({
148
176
  return new Date(ms).toLocaleString();
149
177
  }
150
178
  }
179
+
180
+ function sortArchivedSessions(items, mode, locale) {
181
+ const collator = new Intl.Collator(locale, { numeric: true, sensitivity: "base" });
182
+ return items.map((item, index) => ({ item, index })).sort((left, right) => {
183
+ const a = left.item;
184
+ const b = right.item;
185
+ if (mode === "title") {
186
+ const aTitle = typeof a.title === "string" && a.title.trim() !== "" ? a.title.trim() : null;
187
+ const bTitle = typeof b.title === "string" && b.title.trim() !== "" ? b.title.trim() : null;
188
+ if ((aTitle === null) !== (bTitle === null)) return aTitle === null ? 1 : -1;
189
+ if (aTitle !== null && bTitle !== null) {
190
+ const compared = collator.compare(aTitle, bTitle);
191
+ if (compared !== 0) return compared;
192
+ }
193
+ } else {
194
+ const aDate = typeof a.createdAt === "number" && Number.isFinite(a.createdAt) ? a.createdAt : null;
195
+ const bDate = typeof b.createdAt === "number" && Number.isFinite(b.createdAt) ? b.createdAt : null;
196
+ if ((aDate === null) !== (bDate === null)) return aDate === null ? 1 : -1;
197
+ if (aDate !== null && bDate !== null && aDate !== bDate) return mode === "oldest" ? aDate - bDate : bDate - aDate;
198
+ }
199
+ return left.index - right.index;
200
+ }).map(({ item }) => item);
201
+ }
202
+
203
+ function setVisibleSelection(selected, visibleIds, checked) {
204
+ const next = new Set(selected);
205
+ for (const id of visibleIds) {
206
+ if (checked) next.add(id);
207
+ else next.delete(id);
208
+ }
209
+ return next;
210
+ }
211
+
212
+ function reconcileSelection(selected, sessions) {
213
+ const currentIds = new Set(sessions.map((session) => session.id));
214
+ return new Set([...selected].filter((id) => currentIds.has(id)));
215
+ }
151
216
  //#endregion
152
217
 
153
218
  //#region styles
154
219
  const CSS = `
155
220
  .dac-page{position:relative;display:flex;flex-direction:column;gap:14px;padding:4px 0 28px;font-family:inherit}
156
221
  .dac-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
157
- .dac-title{color:var(--dsw-alias-label-primary);font-size:18px;font-weight:500;line-height:28px}
158
- .dac-deleteall{display:inline-flex;align-items:center;gap:6px;border:none;border-radius:999px;padding:6px 14px;background:rgba(229,72,77,.1);color:#e5484d;font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background .15s}
159
- .dac-deleteall:hover:not(:disabled){background:rgba(229,72,77,.18)}
222
+ .dac-title{margin:0;color:var(--dsw-alias-label-primary);font-size:18px;font-weight:500;line-height:28px;outline:none}
223
+ .dac-deleteall{display:inline-flex;align-items:center;gap:6px;border:none;border-radius:999px;padding:6px 14px;background:transparent;color:var(--dsw-alias-state-error-primary);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background .15s}
224
+ .dac-deleteall:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}
160
225
  .dac-deleteall:disabled{opacity:.45;cursor:default}
161
226
  .dac-search{display:flex;align-items:center;gap:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-1);padding:8px 12px;color:var(--dsw-alias-label-tertiary);transition:border-color .15s}
162
227
  .dac-search:focus-within{border-color:var(--dsw-alias-border-l1,var(--dsw-alias-border-l2))}
@@ -167,8 +232,20 @@ window.__ModuleLoader__.load({
167
232
  .dac-select{appearance:none;-webkit-appearance:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;padding:5px 28px 5px 14px;cursor:pointer;outline:none}
168
233
  .dac-select:hover{border-color:var(--dsw-alias-border-l1,var(--dsw-alias-border-l2))}
169
234
  .dac-chevron{position:absolute;right:10px;pointer-events:none;color:var(--dsw-alias-label-tertiary);display:inline-flex}
235
+ .dac-selection-toggle{display:inline-flex;align-items:center;gap:7px;margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;cursor:pointer;user-select:none}
236
+ .dac-checkbox{width:16px;height:16px;margin:0;accent-color:var(--dsw-alias-interactive-primary,#6e6ef7);cursor:pointer;flex:none}
237
+ .dac-checkbox:disabled{cursor:default;opacity:.45}
238
+ .dac-bulkbar{position:sticky;top:8px;z-index:30;display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid color-mix(in srgb,var(--dsw-alias-interactive-primary,#6e6ef7) 35%,var(--dsw-alias-border-l2));border-radius:12px;background:color-mix(in srgb,var(--dsw-alias-interactive-primary,#6e6ef7) 8%,var(--dsw-alias-bg-layer-2));padding:9px 12px;box-shadow:0 5px 18px rgba(0,0,0,.08)}
239
+ .dac-bulk-count{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:20px}
240
+ .dac-bulk-actions{display:flex;align-items:center;gap:6px}
241
+ .dac-bulk-btn{border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px;padding:5px 11px;cursor:pointer}
242
+ .dac-bulk-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
243
+ .dac-bulk-btn.dac-danger{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:transparent}
244
+ .dac-bulk-btn.dac-danger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}
245
+ .dac-bulk-btn:disabled{opacity:.45;cursor:default}
170
246
  .dac-group{display:flex;flex-direction:column;gap:8px;margin-top:6px}
171
247
  .dac-group-head{position:relative;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:2px}
248
+ .dac-group-left{display:flex;align-items:center;gap:8px;min-width:0}
172
249
  .dac-group-toggle{display:inline-flex;align-items:center;gap:7px;border:none;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:20px;cursor:pointer;padding:3px 6px;margin:-3px -6px;border-radius:7px}
173
250
  .dac-group-toggle:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
174
251
  .dac-chev{display:inline-flex;color:var(--dsw-alias-label-tertiary);transform:rotate(-90deg);transition:transform .15s}
@@ -179,15 +256,17 @@ window.__ModuleLoader__.load({
179
256
  .dac-iconbtn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;transition:background .15s,color .15s;padding:0}
180
257
  .dac-iconbtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary)}
181
258
  .dac-iconbtn:disabled{opacity:.45;cursor:default}
182
- .dac-iconbtn.dac-danger:hover:not(:disabled){color:#e5484d;background:rgba(229,72,77,.1)}
183
- .dac-menu{position:absolute;right:0;top:30px;z-index:40;min-width:150px;display:flex;flex-direction:column;gap:2px;padding:4px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);box-shadow:0 8px 28px rgba(0,0,0,.18)}
259
+ .dac-iconbtn.dac-danger:hover:not(:disabled){color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover-danger)}
260
+ .dac-menu{position:absolute;right:0;top:30px;z-index:40;min-width:150px;display:flex;flex-direction:column;gap:2px;padding:4px;border:1px solid var(--dsw-alias-border-inverted);border-radius:10px;background:var(--dsw-specific-menu);box-shadow:var(--dsw-shadow-lv3)}
184
261
  .dac-menu-item{border:none;border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;text-align:left;padding:7px 10px;cursor:pointer}
185
262
  .dac-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
186
- .dac-menu-item.dac-danger{color:#e5484d}
187
- .dac-menu-item.dac-danger:hover{background:rgba(229,72,77,.1)}
263
+ .dac-menu-item.dac-danger{color:var(--dsw-alias-state-error-primary)}
264
+ .dac-menu-item.dac-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}
188
265
  .dac-list{display:flex;flex-direction:column;gap:8px}
189
- .dac-row{display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-1);padding:12px 16px;transition:border-color .15s}
266
+ .dac-row{display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-1);padding:12px 16px;transition:border-color .15s,background .15s}
190
267
  .dac-row:hover{border-color:var(--dsw-alias-border-l1,var(--dsw-alias-border-l2))}
268
+ .dac-row.dac-selected{border-color:color-mix(in srgb,var(--dsw-alias-interactive-primary,#6e6ef7) 45%,var(--dsw-alias-border-l2));background:color-mix(in srgb,var(--dsw-alias-interactive-primary,#6e6ef7) 6%,var(--dsw-alias-bg-layer-1))}
269
+ .dac-row-select{display:flex;align-items:center;gap:11px;min-width:0;flex:1}
191
270
  .dac-row-main{min-width:0;display:flex;flex-direction:column;gap:4px}
192
271
  .dac-row-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
193
272
  .dac-row-date{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
@@ -198,26 +277,28 @@ window.__ModuleLoader__.load({
198
277
  .dac-empty{display:flex;flex-direction:column;align-items:center;gap:10px;padding:56px 0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px}
199
278
  .dac-center{display:flex;align-items:center;justify-content:center;gap:8px;padding:48px 0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px}
200
279
  .dac-retry{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;padding:4px 12px;cursor:pointer}
201
- .dac-notice{display:flex;align-items:center;justify-content:space-between;gap:10px;border:1px solid rgba(229,72,77,.45);border-radius:10px;background:rgba(229,72,77,.08);color:#e5484d;font-size:12px;line-height:18px;padding:8px 12px}
280
+ .dac-notice{display:flex;align-items:center;justify-content:space-between;gap:10px;border:1px solid var(--dsw-alias-state-error-primary);border-radius:10px;background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;padding:8px 12px}
202
281
  .dac-notice button{border:none;background:transparent;color:inherit;cursor:pointer;font:inherit;padding:0;display:inline-flex}
203
- .dac-toast{position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:1200;display:flex;align-items:center;gap:8px;border:1px solid rgba(48,164,108,.4);border-radius:999px;background:color-mix(in srgb,#30a46c 9%,var(--dsw-alias-bg-primary,#fff));color:#2f9e68;font-size:13px;font-weight:500;line-height:20px;padding:7px 12px 7px 14px;box-shadow:0 6px 20px rgba(0,0,0,.10),0 1px 3px rgba(0,0,0,.06);animation:dac-toast-in .28s cubic-bezier(.21,1.02,.55,1) both}
282
+ .dac-toast{position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:1200;display:flex;align-items:center;gap:8px;border:1px solid var(--dsw-alias-state-success-secondary);border-radius:999px;background:var(--dsw-alias-state-success-tertiary);color:var(--dsw-alias-state-success-primary);font-size:13px;font-weight:500;line-height:20px;padding:7px 12px 7px 14px;box-shadow:var(--dsw-shadow-lv3);animation:dac-toast-in .28s cubic-bezier(.21,1.02,.55,1) both}
204
283
  .dac-toast svg{flex:none}
205
284
  .dac-toast button{border:none;background:transparent;color:inherit;opacity:.55;cursor:pointer;font:inherit;padding:2px;margin-left:2px;display:inline-flex;border-radius:999px}
206
- .dac-toast button:hover{opacity:1;background:rgba(48,164,108,.12)}
285
+ .dac-toast button:hover{opacity:1;background:var(--dsw-alias-state-success-secondary)}
207
286
  @keyframes dac-toast-in{from{opacity:0;transform:translateX(-50%) translateY(-10px) scale(.96)}to{opacity:1;transform:translateX(-50%) translateY(0) scale(1)}}
208
287
  @media (prefers-reduced-motion:reduce){.dac-toast{animation:none}}
209
288
  .dac-confirm-overlay{position:fixed;inset:0;z-index:1400;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.4)}
210
- .dac-confirm{width:min(400px,calc(100vw - 64px));display:flex;flex-direction:column;gap:12px;border-radius:16px;background:var(--dsw-alias-bg-layer-2);box-shadow:0 16px 64px rgba(0,0,0,.3);padding:20px}
289
+ .dac-confirm{width:min(400px,calc(100vw - 64px));display:flex;flex-direction:column;gap:12px;border:1px solid var(--dsw-alias-border-inverted);border-radius:16px;background:var(--dsw-specific-menu);box-shadow:var(--dsw-shadow-lv3);padding:20px;outline:none}
290
+ .dac-confirm:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-interactive-primary,#6e6ef7),var(--dsw-shadow-lv3)}
211
291
  .dac-confirm-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:500;line-height:24px}
212
292
  .dac-confirm-body{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:21px}
213
293
  .dac-confirm-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}
214
294
  .dac-btn{border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;padding:5px 14px;cursor:pointer}
215
295
  .dac-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
216
- .dac-btn-danger{border:none;border-radius:9px;background:#e5484d;color:#fff;font:inherit;font-size:13px;line-height:20px;padding:6px 14px;cursor:pointer}
217
- .dac-btn-danger:hover:not(:disabled){background:#d13438}
296
+ .dac-btn-danger{border:1px solid var(--dsw-alias-state-error-primary);border-radius:9px;background:transparent;color:var(--dsw-alias-state-error-primary);font:inherit;font-size:13px;line-height:20px;padding:5px 14px;cursor:pointer}
297
+ .dac-btn-danger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}
218
298
  .dac-btn-danger:disabled{opacity:.5;cursor:default}
219
299
  .dac-spin{display:inline-block;width:14px;height:14px;border:2px solid var(--dsw-alias-label-tertiary);border-top-color:transparent;border-radius:50%;animation:dac-rotate .8s linear infinite}
220
300
  @keyframes dac-rotate{to{transform:rotate(360deg)}}
301
+ @media (max-width:640px){.dac-selection-toggle{margin-left:0}.dac-bulkbar{align-items:flex-start;flex-direction:column}.dac-bulk-actions{width:100%;flex-wrap:wrap}.dac-row{align-items:flex-start}.dac-row-actions{gap:4px}.dac-unarchive{padding:5px 10px}}
221
302
  `;
222
303
 
223
304
  function ensureStyle() {
@@ -241,24 +322,35 @@ window.__ModuleLoader__.load({
241
322
  const NAV_LABELS = ["已归档的聊天", "Archived Chats"];
242
323
  const ARCHIVE_ICON_INNER = '<rect x="3" y="4" width="18" height="5" rx="1"></rect><path d="M5 9v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9"></path><path d="M10 13h4"></path>';
243
324
 
325
+ function safeQueryAll(root, selector) {
326
+ try {
327
+ return typeof root?.querySelectorAll === "function" ? root.querySelectorAll(selector) : [];
328
+ } catch {
329
+ return [];
330
+ }
331
+ }
332
+
244
333
  function patchNavIcons() {
245
334
  if (typeof document === "undefined" || document.body === null) return;
246
- const dialogs = document.querySelectorAll('[role="dialog"]');
335
+ const dialogs = safeQueryAll(document, '[role="dialog"]');
247
336
  for (const dialog of dialogs) {
248
- const buttons = dialog.querySelectorAll("nav button");
337
+ const buttons = safeQueryAll(dialog, "nav button");
249
338
  for (const button of buttons) {
250
- const text = button.textContent ?? "";
339
+ const text = typeof button?.textContent === "string" ? button.textContent : "";
251
340
  if (!NAV_LABELS.some((label) => text.includes(label))) continue;
252
- const svg = button.querySelector("svg");
253
- if (svg === null || svg.dataset.dacPatched === "1") continue;
254
- svg.dataset.dacPatched = "1";
255
- svg.setAttribute("viewBox", "0 0 24 24");
256
- svg.setAttribute("fill", "none");
257
- svg.setAttribute("stroke", "currentColor");
258
- svg.setAttribute("stroke-width", "1.5");
259
- svg.setAttribute("stroke-linecap", "round");
260
- svg.setAttribute("stroke-linejoin", "round");
261
- svg.innerHTML = ARCHIVE_ICON_INNER;
341
+ let svg = null;
342
+ try { svg = typeof button.querySelector === "function" ? button.querySelector("svg") : null; } catch { continue; }
343
+ if (svg === null || svg.dataset === void 0 || svg.dataset.dacPatched === "1" || typeof svg.setAttribute !== "function") continue;
344
+ try {
345
+ svg.dataset.dacPatched = "1";
346
+ svg.setAttribute("viewBox", "0 0 24 24");
347
+ svg.setAttribute("fill", "none");
348
+ svg.setAttribute("stroke", "currentColor");
349
+ svg.setAttribute("stroke-width", "1.5");
350
+ svg.setAttribute("stroke-linecap", "round");
351
+ svg.setAttribute("stroke-linejoin", "round");
352
+ svg.innerHTML = ARCHIVE_ICON_INNER;
353
+ } catch { /* keep the host-provided fallback icon */ }
262
354
  }
263
355
  }
264
356
  }
@@ -267,15 +359,22 @@ window.__ModuleLoader__.load({
267
359
 
268
360
  function startNavIconPatch() {
269
361
  patchNavIcons();
270
- const Observer = typeof window !== "undefined" ? window.MutationObserver : void 0;
362
+ let Observer;
363
+ try { Observer = typeof window !== "undefined" ? window.MutationObserver : void 0; } catch { return; }
271
364
  if (Observer === void 0 || typeof document === "undefined" || document.body === null) return;
272
365
  if (navObserver !== null) return;
273
- navObserver = new Observer(() => patchNavIcons());
274
- navObserver.observe(document.body, { childList: true, subtree: true });
366
+ let observer = null;
367
+ try {
368
+ observer = new Observer(() => patchNavIcons());
369
+ observer.observe(document.body, { childList: true, subtree: true });
370
+ navObserver = observer;
371
+ } catch {
372
+ try { observer?.disconnect?.(); } catch { /* best-effort decoration cleanup */ }
373
+ }
275
374
  }
276
375
 
277
376
  function stopNavIconPatch() {
278
- navObserver?.disconnect();
377
+ try { navObserver?.disconnect?.(); } catch { /* best-effort decoration cleanup */ }
279
378
  navObserver = null;
280
379
  }
281
380
  //#endregion
@@ -351,27 +450,85 @@ window.__ModuleLoader__.load({
351
450
  });
352
451
  }
353
452
 
354
- function ConfirmDialog({ title, body, confirmLabel, cancelLabel, busy, onConfirm, onCancel }) {
453
+ function SelectionCheckbox({ checked, indeterminate = false, disabled = false, ariaLabel, onChange }) {
454
+ const inputRef = _react.useRef(null);
455
+ _react.useEffect(() => {
456
+ if (inputRef.current !== null) inputRef.current.indeterminate = indeterminate;
457
+ }, [indeterminate]);
458
+ return (0, jsx.jsx)("input", {
459
+ ref: inputRef,
460
+ type: "checkbox",
461
+ className: "dac-checkbox",
462
+ checked,
463
+ disabled,
464
+ "aria-label": ariaLabel,
465
+ "aria-checked": indeterminate ? "mixed" : checked,
466
+ onChange: (event) => onChange(event.target.checked)
467
+ });
468
+ }
469
+
470
+ function ConfirmDialog({ title, body, confirmLabel, cancelLabel, busy, returnFocus, fallbackFocusRef, onConfirm, onCancel }) {
471
+ const dialogRef = _react.useRef(null);
472
+ const cancelRef = _react.useRef(null);
355
473
  _react.useEffect(() => {
356
- const onKey = (e) => { if (e.key === "Escape") onCancel(); };
474
+ const previousFocus = returnFocus ?? document.activeElement;
475
+ const dialog = dialogRef.current;
476
+ (cancelRef.current ?? dialog)?.focus?.();
477
+ const onKey = (e) => {
478
+ if (e.key === "Escape") {
479
+ e.preventDefault?.();
480
+ onCancel();
481
+ return;
482
+ }
483
+ if (e.key !== "Tab" || dialog === null) return;
484
+ const focusable = [...dialog.querySelectorAll('button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')];
485
+ if (focusable.length === 0) {
486
+ e.preventDefault();
487
+ dialog.focus?.();
488
+ return;
489
+ }
490
+ const first = focusable[0];
491
+ const last = focusable[focusable.length - 1];
492
+ const active = document.activeElement;
493
+ const focusIsInside = typeof dialog.contains === "function" ? dialog.contains(active) : focusable.includes(active);
494
+ if (e.shiftKey && (active === first || !focusIsInside)) {
495
+ e.preventDefault();
496
+ last.focus();
497
+ } else if (!e.shiftKey && (active === last || !focusIsInside)) {
498
+ e.preventDefault();
499
+ first.focus();
500
+ }
501
+ };
357
502
  document.addEventListener("keydown", onKey);
358
- return () => document.removeEventListener("keydown", onKey);
359
- }, [onCancel]);
503
+ return () => {
504
+ document.removeEventListener("keydown", onKey);
505
+ const previousIsUsable = previousFocus !== null
506
+ && previousFocus !== document.body
507
+ && typeof previousFocus?.focus === "function"
508
+ && (typeof document.contains !== "function" || document.contains(previousFocus));
509
+ const target = previousIsUsable ? previousFocus : fallbackFocusRef?.current;
510
+ target?.focus?.();
511
+ };
512
+ }, [onCancel, returnFocus, fallbackFocusRef]);
360
513
  return (0, jsx.jsx)("div", {
361
514
  className: "dac-confirm-overlay",
362
515
  onClick: onCancel,
363
516
  children: (0, jsx.jsxs)("div", {
517
+ ref: dialogRef,
364
518
  className: "dac-confirm",
365
519
  role: "alertdialog",
366
520
  "aria-modal": "true",
521
+ "aria-labelledby": "dac-confirm-title",
522
+ "aria-describedby": "dac-confirm-body",
523
+ tabIndex: -1,
367
524
  onClick: (e) => e.stopPropagation(),
368
525
  children: [
369
- (0, jsx.jsx)("div", { className: "dac-confirm-title", children: title }),
370
- (0, jsx.jsx)("div", { className: "dac-confirm-body", children: body }),
526
+ (0, jsx.jsx)("div", { id: "dac-confirm-title", className: "dac-confirm-title", children: title }),
527
+ (0, jsx.jsx)("div", { id: "dac-confirm-body", className: "dac-confirm-body", children: body }),
371
528
  (0, jsx.jsxs)("div", {
372
529
  className: "dac-confirm-actions",
373
530
  children: [
374
- (0, jsx.jsx)("button", { type: "button", className: "dac-btn", onClick: onCancel, children: cancelLabel }),
531
+ (0, jsx.jsx)("button", { ref: cancelRef, type: "button", className: "dac-btn", onClick: onCancel, children: cancelLabel }),
375
532
  (0, jsx.jsx)("button", { type: "button", className: "dac-btn-danger", disabled: busy, onClick: onConfirm, children: confirmLabel })
376
533
  ]
377
534
  })
@@ -380,7 +537,7 @@ window.__ModuleLoader__.load({
380
537
  });
381
538
  }
382
539
 
383
- function GroupSection({ group, t, collapsed, onToggleCollapsed, menuOpen, onToggleMenu, onUnarchive, onDelete, busy }) {
540
+ function GroupSection({ group, t, collapsed, onToggleCollapsed, menuOpen, onToggleMenu, onUnarchive, onDelete, busy, selected, onToggleSelected }) {
384
541
  const wrapRef = _react.useRef(null);
385
542
  _react.useEffect(() => {
386
543
  if (!menuOpen) return void 0;
@@ -388,7 +545,11 @@ window.__ModuleLoader__.load({
388
545
  document.addEventListener("mousedown", onDown);
389
546
  return () => document.removeEventListener("mousedown", onDown);
390
547
  }, [menuOpen, onToggleMenu]);
391
- const ids = group.items.map((s) => s.id);
548
+ const ids = group.selectionIds;
549
+ const selectedInGroup = ids.filter((id) => selected.has(id)).length;
550
+ const allSelected = ids.length > 0 && selectedInGroup === ids.length;
551
+ const someSelected = selectedInGroup > 0 && !allSelected;
552
+ const groupBusy = ids.some((id) => busy[id] === true);
392
553
  return (0, jsx.jsxs)("div", {
393
554
  className: "dac-group",
394
555
  children: [
@@ -396,16 +557,28 @@ window.__ModuleLoader__.load({
396
557
  className: "dac-group-head",
397
558
  ref: wrapRef,
398
559
  children: [
399
- (0, jsx.jsxs)("button", {
400
- type: "button",
401
- className: "dac-group-toggle",
402
- "aria-expanded": collapsed !== true,
403
- "aria-label": collapsed === true ? t("group.expand") : t("group.collapse"),
404
- onClick: () => onToggleCollapsed(group.key),
560
+ (0, jsx.jsxs)("div", {
561
+ className: "dac-group-left",
405
562
  children: [
406
- (0, jsx.jsx)("span", { className: collapsed === true ? "dac-chev" : "dac-chev open", children: (0, jsx.jsx)(IconChevron, {}) }),
407
- (0, jsx.jsx)(IconFolder, {}),
408
- (0, jsx.jsx)("span", { children: group.title })
563
+ (0, jsx.jsx)(SelectionCheckbox, {
564
+ checked: allSelected,
565
+ indeterminate: someSelected,
566
+ disabled: groupBusy,
567
+ ariaLabel: selectProjectLabel(t, group.title),
568
+ onChange: (checked) => onToggleSelected(ids, checked)
569
+ }),
570
+ (0, jsx.jsxs)("button", {
571
+ type: "button",
572
+ className: "dac-group-toggle",
573
+ "aria-expanded": collapsed !== true,
574
+ "aria-label": collapsed === true ? t("group.expand") : t("group.collapse"),
575
+ onClick: () => onToggleCollapsed(group.key),
576
+ children: [
577
+ (0, jsx.jsx)("span", { className: collapsed === true ? "dac-chev" : "dac-chev open", children: (0, jsx.jsx)(IconChevron, {}) }),
578
+ (0, jsx.jsx)(IconFolder, {}),
579
+ (0, jsx.jsx)("span", { children: group.title })
580
+ ]
581
+ })
409
582
  ]
410
583
  }),
411
584
  (0, jsx.jsxs)("div", {
@@ -446,15 +619,26 @@ window.__ModuleLoader__.load({
446
619
  collapsed !== true && (0, jsx.jsx)("div", {
447
620
  className: "dac-list",
448
621
  children: group.items.map((session) => (0, jsx.jsxs)("div", {
449
- className: "dac-row",
450
- children: [
451
- (0, jsx.jsxs)("div", {
452
- className: "dac-row-main",
453
- children: [
454
- (0, jsx.jsx)("div", { className: "dac-row-title", children: session.title ?? t("chat.untitled") }),
455
- (0, jsx.jsx)("div", { className: "dac-row-date", children: formatDate(t, session.createdAt) })
456
- ]
457
- }),
622
+ className: selected.has(session.id) ? "dac-row dac-selected" : "dac-row",
623
+ children: [
624
+ (0, jsx.jsxs)("label", {
625
+ className: "dac-row-select",
626
+ children: [
627
+ (0, jsx.jsx)(SelectionCheckbox, {
628
+ checked: selected.has(session.id),
629
+ disabled: busy[session.id] === true,
630
+ ariaLabel: selectChatLabel(t, session.title ?? t("chat.untitled")),
631
+ onChange: (checked) => onToggleSelected([session.id], checked)
632
+ }),
633
+ (0, jsx.jsxs)("div", {
634
+ className: "dac-row-main",
635
+ children: [
636
+ (0, jsx.jsx)("div", { className: "dac-row-title", children: session.title ?? t("chat.untitled") }),
637
+ (0, jsx.jsx)("div", { className: "dac-row-date", children: formatDate(t, session.createdAt) })
638
+ ]
639
+ })
640
+ ]
641
+ }),
458
642
  (0, jsx.jsxs)("div", {
459
643
  className: "dac-row-actions",
460
644
  children: [
@@ -489,11 +673,15 @@ window.__ModuleLoader__.load({
489
673
  const [query, setQuery] = _react.useState("");
490
674
  const [typeFilter, setTypeFilter] = _react.useState("all");
491
675
  const [projectFilter, setProjectFilter] = _react.useState("all");
676
+ const [sortMode, setSortMode] = _react.useState("newest");
492
677
  const [menuFor, setMenuFor] = _react.useState(null);
493
678
  const [confirm, setConfirm] = _react.useState(null);
494
679
  const [busy, setBusy] = _react.useState({});
495
680
  const [notice, setNotice] = _react.useState(null);
496
681
  const [collapsed, setCollapsed] = _react.useState(readCollapsed);
682
+ const [selected, setSelected] = _react.useState(() => new Set());
683
+ const pageHeadingRef = _react.useRef(null);
684
+ const deleteReturnFocusRef = _react.useRef(null);
497
685
 
498
686
  // Success confirmations are transient toasts (CodeX behavior);
499
687
  // errors stay until dismissed.
@@ -524,6 +712,17 @@ window.__ModuleLoader__.load({
524
712
  }
525
713
  }, []);
526
714
  _react.useEffect(() => { refresh(); }, [refresh]);
715
+ _react.useEffect(() => {
716
+ if (sessions === null) return;
717
+ setSelected((current) => {
718
+ const next = reconcileSelection(current, sessions);
719
+ return next.size === current.size && [...next].every((id) => current.has(id)) ? current : next;
720
+ });
721
+ }, [sessions]);
722
+
723
+ const toggleSelected = (ids, checked) => {
724
+ setSelected((current) => setVisibleSelection(current, ids, checked));
725
+ };
527
726
 
528
727
  const markBusy = (ids, value) => {
529
728
  setBusy((prev) => {
@@ -543,6 +742,7 @@ window.__ModuleLoader__.load({
543
742
  try {
544
743
  await post("/unarchive-all", { sessionIds: ids });
545
744
  setSessions((prev) => (prev ?? []).filter((s) => !ids.includes(s.id)));
745
+ setSelected((current) => setVisibleSelection(current, ids, false));
546
746
  } catch (error) {
547
747
  setNotice({ kind: "error", text: String(error.message ?? error) });
548
748
  } finally {
@@ -562,10 +762,12 @@ window.__ModuleLoader__.load({
562
762
  const failed = result.failed ?? [];
563
763
  if (deleted.length > 0 || pending.length > 0) {
564
764
  changed = true;
765
+ const removed = [...deleted, ...pending];
565
766
  // Pending rows leave the list too: the deletion is accepted,
566
767
  // the session is parked and excluded from /state until the
567
768
  // next-boot sweep physically removes it.
568
- setSessions((prev) => (prev ?? []).filter((s) => !deleted.includes(s.id) && !pending.includes(s.id)));
769
+ setSessions((prev) => (prev ?? []).filter((s) => !removed.includes(s.id)));
770
+ setSelected((current) => setVisibleSelection(current, removed, false));
569
771
  }
570
772
  if (failed.length > 0) setNotice({ kind: "error", text: failureText(t, failed) });
571
773
  else if (pending.length > 0) setNotice({ kind: "error", text: pendingText(t, pending.length) });
@@ -576,7 +778,9 @@ window.__ModuleLoader__.load({
576
778
  const failed = error.body?.failed ?? [];
577
779
  if (deleted.length > 0 || pending.length > 0) {
578
780
  changed = true;
579
- setSessions((prev) => (prev ?? []).filter((s) => !deleted.includes(s.id) && !pending.includes(s.id)));
781
+ const removed = [...deleted, ...pending];
782
+ setSessions((prev) => (prev ?? []).filter((s) => !removed.includes(s.id)));
783
+ setSelected((current) => setVisibleSelection(current, removed, false));
580
784
  }
581
785
  setNotice({ kind: "error", text: failed.length > 0 ? failureText(t, failed) : String(error.message ?? error) });
582
786
  } finally {
@@ -590,9 +794,12 @@ window.__ModuleLoader__.load({
590
794
  }
591
795
  };
592
796
 
593
- const askDelete = (ids, groupName) => {
797
+ const askDelete = (ids, groupName, selectedScope = false) => {
594
798
  if (ids.length === 0) return;
595
- if (ids.length === 1 && groupName === null) {
799
+ deleteReturnFocusRef.current = document.activeElement;
800
+ if (selectedScope) {
801
+ setConfirm({ title: t("confirm.deleteSelected.title"), body: deleteSelectedBody(t, ids.length), ids });
802
+ } else if (ids.length === 1 && groupName === null) {
596
803
  setConfirm({ title: t("confirm.deleteOne.title"), body: t("confirm.deleteOne.body"), ids });
597
804
  } else if (groupName !== null && groupName !== void 0) {
598
805
  setConfirm({ title: t("confirm.deleteGroup.title"), body: deleteGroupBody(t, groupName, ids.length), ids });
@@ -612,6 +819,12 @@ window.__ModuleLoader__.load({
612
819
 
613
820
  const groups = _react.useMemo(() => {
614
821
  const q = query.trim().toLowerCase();
822
+ const projectIds = new Map();
823
+ for (const session of sessions ?? []) {
824
+ const key = session.workspaceId ?? "ungrouped";
825
+ if (!projectIds.has(key)) projectIds.set(key, []);
826
+ projectIds.get(key).push(session.id);
827
+ }
615
828
  const visible = (sessions ?? []).filter((s) => {
616
829
  if (projectFilter !== "all" && (s.workspaceId ?? "ungrouped") !== projectFilter) return false;
617
830
  if (typeFilter === "normal" && s.origin === "subagent") return false;
@@ -622,17 +835,23 @@ window.__ModuleLoader__.load({
622
835
  const byKey = new Map();
623
836
  for (const s of visible) {
624
837
  const key = s.workspaceId ?? "ungrouped";
625
- if (!byKey.has(key)) byKey.set(key, { key, title: s.workspaceTitle ?? t("group.noProject"), items: [] });
838
+ if (!byKey.has(key)) byKey.set(key, { key, title: s.workspaceTitle ?? t("group.noProject"), items: [], selectionIds: projectIds.get(key) ?? [] });
626
839
  byKey.get(key).items.push(s);
627
840
  }
628
841
  const list = [...byKey.values()];
629
- for (const g of list) g.items.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
842
+ for (const g of list) g.items = sortArchivedSessions(g.items, sortMode, t("locale.intl"));
843
+ const groupCollator = new Intl.Collator(t("locale.intl"), { numeric: true, sensitivity: "base" });
630
844
  list.sort((a, b) => {
631
845
  if ((a.key === "ungrouped") !== (b.key === "ungrouped")) return a.key === "ungrouped" ? 1 : -1;
632
- return (b.items[0]?.createdAt ?? 0) - (a.items[0]?.createdAt ?? 0);
846
+ if (sortMode === "title") return groupCollator.compare(a.title, b.title);
847
+ const aDate = typeof a.items[0]?.createdAt === "number" ? a.items[0].createdAt : null;
848
+ const bDate = typeof b.items[0]?.createdAt === "number" ? b.items[0].createdAt : null;
849
+ if ((aDate === null) !== (bDate === null)) return aDate === null ? 1 : -1;
850
+ if (aDate !== null && bDate !== null && aDate !== bDate) return sortMode === "oldest" ? aDate - bDate : bDate - aDate;
851
+ return groupCollator.compare(a.title, b.title);
633
852
  });
634
853
  return list;
635
- }, [sessions, query, projectFilter, typeFilter, t]);
854
+ }, [sessions, query, projectFilter, typeFilter, sortMode, t]);
636
855
 
637
856
  if (sessions === null && loadError === null) {
638
857
  return (0, jsx.jsx)("div", { className: "dac-center", children: [(0, jsx.jsx)("span", { className: "dac-spin" }), (0, jsx.jsx)("span", { children: t("state.loading") })] });
@@ -648,6 +867,13 @@ window.__ModuleLoader__.load({
648
867
  }
649
868
 
650
869
  const allIds = (sessions ?? []).map((s) => s.id);
870
+ const visibleIds = groups.flatMap((group) => group.items.map((session) => session.id));
871
+ const selectedIds = allIds.filter((id) => selected.has(id));
872
+ const selectedVisibleCount = visibleIds.filter((id) => selected.has(id)).length;
873
+ const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length;
874
+ const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected;
875
+ const selectedBusy = selectedIds.some((id) => busy[id] === true);
876
+ const visibleBusy = visibleIds.some((id) => busy[id] === true);
651
877
  const filtering = query.trim() !== "" || projectFilter !== "all" || typeFilter !== "all";
652
878
 
653
879
  return (0, jsx.jsxs)("div", {
@@ -656,7 +882,7 @@ window.__ModuleLoader__.load({
656
882
  (0, jsx.jsxs)("div", {
657
883
  className: "dac-head",
658
884
  children: [
659
- (0, jsx.jsx)("div", { className: "dac-title", children: t("page.title") }),
885
+ (0, jsx.jsx)("h2", { ref: pageHeadingRef, tabIndex: -1, className: "dac-title", children: t("page.title") }),
660
886
  (0, jsx.jsxs)("button", {
661
887
  type: "button",
662
888
  className: "dac-deleteall",
@@ -699,6 +925,62 @@ window.__ModuleLoader__.load({
699
925
  { value: "all", label: t("filter.allProjects") },
700
926
  ...projects.map((p) => ({ value: p.value, label: p.title ?? t("group.noProject") }))
701
927
  ]
928
+ }),
929
+ (0, jsx.jsx)(Select, {
930
+ value: sortMode,
931
+ onChange: setSortMode,
932
+ ariaLabel: t("sort.label"),
933
+ options: [
934
+ { value: "newest", label: t("sort.newest") },
935
+ { value: "oldest", label: t("sort.oldest") },
936
+ { value: "title", label: t("sort.title") }
937
+ ]
938
+ }),
939
+ visibleIds.length > 0 && (0, jsx.jsxs)("label", {
940
+ className: "dac-selection-toggle",
941
+ children: [
942
+ (0, jsx.jsx)(SelectionCheckbox, {
943
+ checked: allVisibleSelected,
944
+ indeterminate: someVisibleSelected,
945
+ disabled: visibleBusy,
946
+ ariaLabel: t("selection.visible"),
947
+ onChange: (checked) => toggleSelected(visibleIds, checked)
948
+ }),
949
+ (0, jsx.jsx)("span", { children: t("selection.visible") })
950
+ ]
951
+ })
952
+ ]
953
+ }),
954
+ selectedIds.length > 0 && (0, jsx.jsxs)("div", {
955
+ className: "dac-bulkbar",
956
+ role: "region",
957
+ "aria-label": selectedCount(t, selectedIds.length),
958
+ children: [
959
+ (0, jsx.jsx)("span", { className: "dac-bulk-count", children: selectedCount(t, selectedIds.length) }),
960
+ (0, jsx.jsxs)("div", {
961
+ className: "dac-bulk-actions",
962
+ children: [
963
+ (0, jsx.jsx)("button", {
964
+ type: "button",
965
+ className: "dac-bulk-btn",
966
+ disabled: selectedBusy,
967
+ onClick: () => unarchive(selectedIds),
968
+ children: t("bulk.unarchive")
969
+ }),
970
+ (0, jsx.jsx)("button", {
971
+ type: "button",
972
+ className: "dac-bulk-btn dac-danger",
973
+ disabled: selectedBusy,
974
+ onClick: () => askDelete(selectedIds, null, true),
975
+ children: t("bulk.delete")
976
+ }),
977
+ (0, jsx.jsx)("button", {
978
+ type: "button",
979
+ className: "dac-bulk-btn",
980
+ onClick: () => setSelected(new Set()),
981
+ children: t("selection.clear")
982
+ })
983
+ ]
702
984
  })
703
985
  ]
704
986
  }),
@@ -736,7 +1018,9 @@ window.__ModuleLoader__.load({
736
1018
  onToggleMenu: setMenuFor,
737
1019
  onUnarchive: unarchive,
738
1020
  onDelete: askDelete,
739
- busy
1021
+ busy,
1022
+ selected,
1023
+ onToggleSelected: toggleSelected
740
1024
  }, group.key)),
741
1025
  confirm !== null && (0, jsx.jsx)(ConfirmDialog, {
742
1026
  title: confirm.title,
@@ -744,6 +1028,8 @@ window.__ModuleLoader__.load({
744
1028
  confirmLabel: t("confirm.delete"),
745
1029
  cancelLabel: t("confirm.cancel"),
746
1030
  busy: confirm.ids.some((id) => busy[id] === true),
1031
+ returnFocus: deleteReturnFocusRef.current,
1032
+ fallbackFocusRef: pageHeadingRef,
747
1033
  onConfirm: () => runDelete(confirm.ids),
748
1034
  onCancel: () => setConfirm(null)
749
1035
  })
@@ -782,6 +1068,7 @@ window.__ModuleLoader__.load({
782
1068
  //#endregion
783
1069
 
784
1070
  exports.SETTINGS_NS = SETTINGS_NS;
1071
+ exports.__test = { sortArchivedSessions, setVisibleSelection, reconcileSelection };
785
1072
  exports.apply = apply;
786
1073
  exports.inject = inject;
787
1074
  return module.exports;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-archived-chats",
3
- "version": "0.4.0",
4
- "description": "DeepSeek Harness 已归档会话管理页:在设置里查看、搜索、取消归档、删除已归档的聊天,按项目分组。An Archived Chats settings page for DeepSeek Harness: browse, search, unarchive, and delete archived sessions, grouped by workspace.",
3
+ "version": "0.5.1",
4
+ "description": "DeepSeek Harness 已归档会话管理页:搜索、排序、批量选择、取消归档和删除。An Archived Chats settings page for DeepSeek Harness with search, sorting, bulk selection, unarchive, and delete.",
5
5
  "license": "MIT",
6
6
  "author": "Ultronen",
7
7
  "repository": {
@@ -21,6 +21,9 @@
21
21
  "web-ui"
22
22
  ],
23
23
  "type": "module",
24
+ "scripts": {
25
+ "test": "node --test test/smoke.test.mjs"
26
+ },
24
27
  "main": "lib/index.js",
25
28
  "exports": {
26
29
  ".": {