dsh-archived-chats 0.4.0 → 0.5.0

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.
@@ -15,7 +17,8 @@ Restart DSH once after installing, then open **Settings → Archived Chats**.
15
17
  ## Features
16
18
 
17
19
  - **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.
20
+ - **Search and sort** by title, filter by type (all / regular / subagent) and project, then order results by newest, oldest, or title.
21
+ - **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
22
  - **Unarchive** a single chat or a whole project group from the group's `⋯` menu — restored chats reappear in the sidebar immediately.
20
23
  - **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
24
  - 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.
@@ -29,6 +32,14 @@ Restart DSH once after installing, then open **Settings → Archived Chats**.
29
32
  - **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
33
  - **Browser half** (`lib/client.js`) registers a `settings.section` slot entry (order 30) and renders the page with React and DSH design tokens.
31
34
 
35
+ ## Development
36
+
37
+ ```sh
38
+ npm test
39
+ ```
40
+
41
+ The smoke test uses an isolated temporary DSH home plus mocked host and browser runtimes; it never reads or changes real sessions.
42
+
32
43
  ## Uninstall
33
44
 
34
45
  ```sh
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`)还记得它。这个插件在「设置」中补上一个「已归档的聊天」页面,让所有归档会话都可见、可搜索、可管理。
@@ -15,7 +17,8 @@ dsh plugin --profile web add dsh-archived-chats
15
17
  ## 功能
16
18
 
17
19
  - **完整归档列表**:按工作区(项目)分组并显示每组数量;每个分组都可折叠/展开,状态按浏览器记忆。
18
- - **搜索**标题,外加两个筛选器:类型(全部 / 普通会话 / 子代理会话)和项目。
20
+ - **搜索与排序**:按标题搜索,用类型(全部 / 普通会话 / 子代理会话)和项目筛选,并按最新、最早或标题排序。
21
+ - **灵活多选**:逐条选择、选择当前筛选结果或选择整个项目;选中后可一次取消归档或永久删除,隐藏在其他筛选结果中的选择不会丢失。
19
22
  - **取消归档**单个聊天,或从分组的 `⋯` 菜单整组取消——恢复的聊天会立刻回到侧边栏。
20
23
  - **删除**单个聊天、某个项目分组或全部(**全部删除**),均有确认弹窗。删除是彻底的:会话日志从磁盘移除、从工作区记录中摘除、注册表内存索引同步清理,主侧边栏的条目也会立即消失。
21
24
  - 仍驻留后台的会话也**当场删除**:插件按官方生命周期的拆除顺序原地停用并注销会话(取消 → 静默 → 落盘 → 拆纤程 → 摘出注册表),持久层随之释放写入通道,同一次请求内即完成物理删除——无需重启。若当前 DSH 版本不提供所需内部接口,则自动回退为「永久停用 + 下次启动完成删除」,停用期间会话保持隐藏。
@@ -29,6 +32,14 @@ dsh plugin --profile web add dsh-archived-chats
29
32
  - **标题缓存**:列表刷新时按 id 记忆已解析的标题,不再每次全量读日志;删除与取消归档会使对应缓存失效。
30
33
  - **浏览器半**(`lib/client.js`)注册 `settings.section` 插槽项(order 30),用 React 和 DSH 设计令牌渲染页面。
31
34
 
35
+ ## 开发
36
+
37
+ ```sh
38
+ npm test
39
+ ```
40
+
41
+ 测试使用隔离的临时 DSH 主目录和模拟的宿主/浏览器运行时,不会读取或修改真实会话。
42
+
32
43
  ## 卸载
33
44
 
34
45
  ```sh
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,13 +176,50 @@ 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}
222
+ .dac-title{margin:0;color:var(--dsw-alias-label-primary);font-size:18px;font-weight:500;line-height:28px;outline:none}
158
223
  .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
224
  .dac-deleteall:hover:not(:disabled){background:rgba(229,72,77,.18)}
160
225
  .dac-deleteall:disabled{opacity:.45;cursor:default}
@@ -167,8 +232,19 @@ 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:rgba(229,72,77,.28);color:#e5484d;background:rgba(229,72,77,.08)}
244
+ .dac-bulk-btn:disabled{opacity:.45;cursor:default}
170
245
  .dac-group{display:flex;flex-direction:column;gap:8px;margin-top:6px}
171
246
  .dac-group-head{position:relative;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:2px}
247
+ .dac-group-left{display:flex;align-items:center;gap:8px;min-width:0}
172
248
  .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
249
  .dac-group-toggle:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
174
250
  .dac-chev{display:inline-flex;color:var(--dsw-alias-label-tertiary);transform:rotate(-90deg);transition:transform .15s}
@@ -186,8 +262,10 @@ window.__ModuleLoader__.load({
186
262
  .dac-menu-item.dac-danger{color:#e5484d}
187
263
  .dac-menu-item.dac-danger:hover{background:rgba(229,72,77,.1)}
188
264
  .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}
265
+ .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
266
  .dac-row:hover{border-color:var(--dsw-alias-border-l1,var(--dsw-alias-border-l2))}
267
+ .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))}
268
+ .dac-row-select{display:flex;align-items:center;gap:11px;min-width:0;flex:1}
191
269
  .dac-row-main{min-width:0;display:flex;flex-direction:column;gap:4px}
192
270
  .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
271
  .dac-row-date{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
@@ -207,7 +285,8 @@ window.__ModuleLoader__.load({
207
285
  @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
286
  @media (prefers-reduced-motion:reduce){.dac-toast{animation:none}}
209
287
  .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}
288
+ .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;outline:none}
289
+ .dac-confirm:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-interactive-primary,#6e6ef7),0 16px 64px rgba(0,0,0,.3)}
211
290
  .dac-confirm-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:500;line-height:24px}
212
291
  .dac-confirm-body{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:21px}
213
292
  .dac-confirm-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}
@@ -218,6 +297,7 @@ window.__ModuleLoader__.load({
218
297
  .dac-btn-danger:disabled{opacity:.5;cursor:default}
219
298
  .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
299
  @keyframes dac-rotate{to{transform:rotate(360deg)}}
300
+ @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
301
  `;
222
302
 
223
303
  function ensureStyle() {
@@ -351,27 +431,85 @@ window.__ModuleLoader__.load({
351
431
  });
352
432
  }
353
433
 
354
- function ConfirmDialog({ title, body, confirmLabel, cancelLabel, busy, onConfirm, onCancel }) {
434
+ function SelectionCheckbox({ checked, indeterminate = false, disabled = false, ariaLabel, onChange }) {
435
+ const inputRef = _react.useRef(null);
355
436
  _react.useEffect(() => {
356
- const onKey = (e) => { if (e.key === "Escape") onCancel(); };
437
+ if (inputRef.current !== null) inputRef.current.indeterminate = indeterminate;
438
+ }, [indeterminate]);
439
+ return (0, jsx.jsx)("input", {
440
+ ref: inputRef,
441
+ type: "checkbox",
442
+ className: "dac-checkbox",
443
+ checked,
444
+ disabled,
445
+ "aria-label": ariaLabel,
446
+ "aria-checked": indeterminate ? "mixed" : checked,
447
+ onChange: (event) => onChange(event.target.checked)
448
+ });
449
+ }
450
+
451
+ function ConfirmDialog({ title, body, confirmLabel, cancelLabel, busy, returnFocus, fallbackFocusRef, onConfirm, onCancel }) {
452
+ const dialogRef = _react.useRef(null);
453
+ const cancelRef = _react.useRef(null);
454
+ _react.useEffect(() => {
455
+ const previousFocus = returnFocus ?? document.activeElement;
456
+ const dialog = dialogRef.current;
457
+ (cancelRef.current ?? dialog)?.focus?.();
458
+ const onKey = (e) => {
459
+ if (e.key === "Escape") {
460
+ e.preventDefault?.();
461
+ onCancel();
462
+ return;
463
+ }
464
+ if (e.key !== "Tab" || dialog === null) return;
465
+ const focusable = [...dialog.querySelectorAll('button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')];
466
+ if (focusable.length === 0) {
467
+ e.preventDefault();
468
+ dialog.focus?.();
469
+ return;
470
+ }
471
+ const first = focusable[0];
472
+ const last = focusable[focusable.length - 1];
473
+ const active = document.activeElement;
474
+ const focusIsInside = typeof dialog.contains === "function" ? dialog.contains(active) : focusable.includes(active);
475
+ if (e.shiftKey && (active === first || !focusIsInside)) {
476
+ e.preventDefault();
477
+ last.focus();
478
+ } else if (!e.shiftKey && (active === last || !focusIsInside)) {
479
+ e.preventDefault();
480
+ first.focus();
481
+ }
482
+ };
357
483
  document.addEventListener("keydown", onKey);
358
- return () => document.removeEventListener("keydown", onKey);
359
- }, [onCancel]);
484
+ return () => {
485
+ document.removeEventListener("keydown", onKey);
486
+ const previousIsUsable = previousFocus !== null
487
+ && previousFocus !== document.body
488
+ && typeof previousFocus?.focus === "function"
489
+ && (typeof document.contains !== "function" || document.contains(previousFocus));
490
+ const target = previousIsUsable ? previousFocus : fallbackFocusRef?.current;
491
+ target?.focus?.();
492
+ };
493
+ }, [onCancel, returnFocus, fallbackFocusRef]);
360
494
  return (0, jsx.jsx)("div", {
361
495
  className: "dac-confirm-overlay",
362
496
  onClick: onCancel,
363
497
  children: (0, jsx.jsxs)("div", {
498
+ ref: dialogRef,
364
499
  className: "dac-confirm",
365
500
  role: "alertdialog",
366
501
  "aria-modal": "true",
502
+ "aria-labelledby": "dac-confirm-title",
503
+ "aria-describedby": "dac-confirm-body",
504
+ tabIndex: -1,
367
505
  onClick: (e) => e.stopPropagation(),
368
506
  children: [
369
- (0, jsx.jsx)("div", { className: "dac-confirm-title", children: title }),
370
- (0, jsx.jsx)("div", { className: "dac-confirm-body", children: body }),
507
+ (0, jsx.jsx)("div", { id: "dac-confirm-title", className: "dac-confirm-title", children: title }),
508
+ (0, jsx.jsx)("div", { id: "dac-confirm-body", className: "dac-confirm-body", children: body }),
371
509
  (0, jsx.jsxs)("div", {
372
510
  className: "dac-confirm-actions",
373
511
  children: [
374
- (0, jsx.jsx)("button", { type: "button", className: "dac-btn", onClick: onCancel, children: cancelLabel }),
512
+ (0, jsx.jsx)("button", { ref: cancelRef, type: "button", className: "dac-btn", onClick: onCancel, children: cancelLabel }),
375
513
  (0, jsx.jsx)("button", { type: "button", className: "dac-btn-danger", disabled: busy, onClick: onConfirm, children: confirmLabel })
376
514
  ]
377
515
  })
@@ -380,7 +518,7 @@ window.__ModuleLoader__.load({
380
518
  });
381
519
  }
382
520
 
383
- function GroupSection({ group, t, collapsed, onToggleCollapsed, menuOpen, onToggleMenu, onUnarchive, onDelete, busy }) {
521
+ function GroupSection({ group, t, collapsed, onToggleCollapsed, menuOpen, onToggleMenu, onUnarchive, onDelete, busy, selected, onToggleSelected }) {
384
522
  const wrapRef = _react.useRef(null);
385
523
  _react.useEffect(() => {
386
524
  if (!menuOpen) return void 0;
@@ -388,7 +526,11 @@ window.__ModuleLoader__.load({
388
526
  document.addEventListener("mousedown", onDown);
389
527
  return () => document.removeEventListener("mousedown", onDown);
390
528
  }, [menuOpen, onToggleMenu]);
391
- const ids = group.items.map((s) => s.id);
529
+ const ids = group.selectionIds;
530
+ const selectedInGroup = ids.filter((id) => selected.has(id)).length;
531
+ const allSelected = ids.length > 0 && selectedInGroup === ids.length;
532
+ const someSelected = selectedInGroup > 0 && !allSelected;
533
+ const groupBusy = ids.some((id) => busy[id] === true);
392
534
  return (0, jsx.jsxs)("div", {
393
535
  className: "dac-group",
394
536
  children: [
@@ -396,16 +538,28 @@ window.__ModuleLoader__.load({
396
538
  className: "dac-group-head",
397
539
  ref: wrapRef,
398
540
  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),
541
+ (0, jsx.jsxs)("div", {
542
+ className: "dac-group-left",
405
543
  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 })
544
+ (0, jsx.jsx)(SelectionCheckbox, {
545
+ checked: allSelected,
546
+ indeterminate: someSelected,
547
+ disabled: groupBusy,
548
+ ariaLabel: selectProjectLabel(t, group.title),
549
+ onChange: (checked) => onToggleSelected(ids, checked)
550
+ }),
551
+ (0, jsx.jsxs)("button", {
552
+ type: "button",
553
+ className: "dac-group-toggle",
554
+ "aria-expanded": collapsed !== true,
555
+ "aria-label": collapsed === true ? t("group.expand") : t("group.collapse"),
556
+ onClick: () => onToggleCollapsed(group.key),
557
+ children: [
558
+ (0, jsx.jsx)("span", { className: collapsed === true ? "dac-chev" : "dac-chev open", children: (0, jsx.jsx)(IconChevron, {}) }),
559
+ (0, jsx.jsx)(IconFolder, {}),
560
+ (0, jsx.jsx)("span", { children: group.title })
561
+ ]
562
+ })
409
563
  ]
410
564
  }),
411
565
  (0, jsx.jsxs)("div", {
@@ -446,15 +600,26 @@ window.__ModuleLoader__.load({
446
600
  collapsed !== true && (0, jsx.jsx)("div", {
447
601
  className: "dac-list",
448
602
  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
- }),
603
+ className: selected.has(session.id) ? "dac-row dac-selected" : "dac-row",
604
+ children: [
605
+ (0, jsx.jsxs)("label", {
606
+ className: "dac-row-select",
607
+ children: [
608
+ (0, jsx.jsx)(SelectionCheckbox, {
609
+ checked: selected.has(session.id),
610
+ disabled: busy[session.id] === true,
611
+ ariaLabel: selectChatLabel(t, session.title ?? t("chat.untitled")),
612
+ onChange: (checked) => onToggleSelected([session.id], checked)
613
+ }),
614
+ (0, jsx.jsxs)("div", {
615
+ className: "dac-row-main",
616
+ children: [
617
+ (0, jsx.jsx)("div", { className: "dac-row-title", children: session.title ?? t("chat.untitled") }),
618
+ (0, jsx.jsx)("div", { className: "dac-row-date", children: formatDate(t, session.createdAt) })
619
+ ]
620
+ })
621
+ ]
622
+ }),
458
623
  (0, jsx.jsxs)("div", {
459
624
  className: "dac-row-actions",
460
625
  children: [
@@ -489,11 +654,15 @@ window.__ModuleLoader__.load({
489
654
  const [query, setQuery] = _react.useState("");
490
655
  const [typeFilter, setTypeFilter] = _react.useState("all");
491
656
  const [projectFilter, setProjectFilter] = _react.useState("all");
657
+ const [sortMode, setSortMode] = _react.useState("newest");
492
658
  const [menuFor, setMenuFor] = _react.useState(null);
493
659
  const [confirm, setConfirm] = _react.useState(null);
494
660
  const [busy, setBusy] = _react.useState({});
495
661
  const [notice, setNotice] = _react.useState(null);
496
662
  const [collapsed, setCollapsed] = _react.useState(readCollapsed);
663
+ const [selected, setSelected] = _react.useState(() => new Set());
664
+ const pageHeadingRef = _react.useRef(null);
665
+ const deleteReturnFocusRef = _react.useRef(null);
497
666
 
498
667
  // Success confirmations are transient toasts (CodeX behavior);
499
668
  // errors stay until dismissed.
@@ -524,6 +693,17 @@ window.__ModuleLoader__.load({
524
693
  }
525
694
  }, []);
526
695
  _react.useEffect(() => { refresh(); }, [refresh]);
696
+ _react.useEffect(() => {
697
+ if (sessions === null) return;
698
+ setSelected((current) => {
699
+ const next = reconcileSelection(current, sessions);
700
+ return next.size === current.size && [...next].every((id) => current.has(id)) ? current : next;
701
+ });
702
+ }, [sessions]);
703
+
704
+ const toggleSelected = (ids, checked) => {
705
+ setSelected((current) => setVisibleSelection(current, ids, checked));
706
+ };
527
707
 
528
708
  const markBusy = (ids, value) => {
529
709
  setBusy((prev) => {
@@ -543,6 +723,7 @@ window.__ModuleLoader__.load({
543
723
  try {
544
724
  await post("/unarchive-all", { sessionIds: ids });
545
725
  setSessions((prev) => (prev ?? []).filter((s) => !ids.includes(s.id)));
726
+ setSelected((current) => setVisibleSelection(current, ids, false));
546
727
  } catch (error) {
547
728
  setNotice({ kind: "error", text: String(error.message ?? error) });
548
729
  } finally {
@@ -562,10 +743,12 @@ window.__ModuleLoader__.load({
562
743
  const failed = result.failed ?? [];
563
744
  if (deleted.length > 0 || pending.length > 0) {
564
745
  changed = true;
746
+ const removed = [...deleted, ...pending];
565
747
  // Pending rows leave the list too: the deletion is accepted,
566
748
  // the session is parked and excluded from /state until the
567
749
  // next-boot sweep physically removes it.
568
- setSessions((prev) => (prev ?? []).filter((s) => !deleted.includes(s.id) && !pending.includes(s.id)));
750
+ setSessions((prev) => (prev ?? []).filter((s) => !removed.includes(s.id)));
751
+ setSelected((current) => setVisibleSelection(current, removed, false));
569
752
  }
570
753
  if (failed.length > 0) setNotice({ kind: "error", text: failureText(t, failed) });
571
754
  else if (pending.length > 0) setNotice({ kind: "error", text: pendingText(t, pending.length) });
@@ -576,7 +759,9 @@ window.__ModuleLoader__.load({
576
759
  const failed = error.body?.failed ?? [];
577
760
  if (deleted.length > 0 || pending.length > 0) {
578
761
  changed = true;
579
- setSessions((prev) => (prev ?? []).filter((s) => !deleted.includes(s.id) && !pending.includes(s.id)));
762
+ const removed = [...deleted, ...pending];
763
+ setSessions((prev) => (prev ?? []).filter((s) => !removed.includes(s.id)));
764
+ setSelected((current) => setVisibleSelection(current, removed, false));
580
765
  }
581
766
  setNotice({ kind: "error", text: failed.length > 0 ? failureText(t, failed) : String(error.message ?? error) });
582
767
  } finally {
@@ -590,9 +775,12 @@ window.__ModuleLoader__.load({
590
775
  }
591
776
  };
592
777
 
593
- const askDelete = (ids, groupName) => {
778
+ const askDelete = (ids, groupName, selectedScope = false) => {
594
779
  if (ids.length === 0) return;
595
- if (ids.length === 1 && groupName === null) {
780
+ deleteReturnFocusRef.current = document.activeElement;
781
+ if (selectedScope) {
782
+ setConfirm({ title: t("confirm.deleteSelected.title"), body: deleteSelectedBody(t, ids.length), ids });
783
+ } else if (ids.length === 1 && groupName === null) {
596
784
  setConfirm({ title: t("confirm.deleteOne.title"), body: t("confirm.deleteOne.body"), ids });
597
785
  } else if (groupName !== null && groupName !== void 0) {
598
786
  setConfirm({ title: t("confirm.deleteGroup.title"), body: deleteGroupBody(t, groupName, ids.length), ids });
@@ -612,6 +800,12 @@ window.__ModuleLoader__.load({
612
800
 
613
801
  const groups = _react.useMemo(() => {
614
802
  const q = query.trim().toLowerCase();
803
+ const projectIds = new Map();
804
+ for (const session of sessions ?? []) {
805
+ const key = session.workspaceId ?? "ungrouped";
806
+ if (!projectIds.has(key)) projectIds.set(key, []);
807
+ projectIds.get(key).push(session.id);
808
+ }
615
809
  const visible = (sessions ?? []).filter((s) => {
616
810
  if (projectFilter !== "all" && (s.workspaceId ?? "ungrouped") !== projectFilter) return false;
617
811
  if (typeFilter === "normal" && s.origin === "subagent") return false;
@@ -622,17 +816,23 @@ window.__ModuleLoader__.load({
622
816
  const byKey = new Map();
623
817
  for (const s of visible) {
624
818
  const key = s.workspaceId ?? "ungrouped";
625
- if (!byKey.has(key)) byKey.set(key, { key, title: s.workspaceTitle ?? t("group.noProject"), items: [] });
819
+ if (!byKey.has(key)) byKey.set(key, { key, title: s.workspaceTitle ?? t("group.noProject"), items: [], selectionIds: projectIds.get(key) ?? [] });
626
820
  byKey.get(key).items.push(s);
627
821
  }
628
822
  const list = [...byKey.values()];
629
- for (const g of list) g.items.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
823
+ for (const g of list) g.items = sortArchivedSessions(g.items, sortMode, t("locale.intl"));
824
+ const groupCollator = new Intl.Collator(t("locale.intl"), { numeric: true, sensitivity: "base" });
630
825
  list.sort((a, b) => {
631
826
  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);
827
+ if (sortMode === "title") return groupCollator.compare(a.title, b.title);
828
+ const aDate = typeof a.items[0]?.createdAt === "number" ? a.items[0].createdAt : null;
829
+ const bDate = typeof b.items[0]?.createdAt === "number" ? b.items[0].createdAt : null;
830
+ if ((aDate === null) !== (bDate === null)) return aDate === null ? 1 : -1;
831
+ if (aDate !== null && bDate !== null && aDate !== bDate) return sortMode === "oldest" ? aDate - bDate : bDate - aDate;
832
+ return groupCollator.compare(a.title, b.title);
633
833
  });
634
834
  return list;
635
- }, [sessions, query, projectFilter, typeFilter, t]);
835
+ }, [sessions, query, projectFilter, typeFilter, sortMode, t]);
636
836
 
637
837
  if (sessions === null && loadError === null) {
638
838
  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 +848,13 @@ window.__ModuleLoader__.load({
648
848
  }
649
849
 
650
850
  const allIds = (sessions ?? []).map((s) => s.id);
851
+ const visibleIds = groups.flatMap((group) => group.items.map((session) => session.id));
852
+ const selectedIds = allIds.filter((id) => selected.has(id));
853
+ const selectedVisibleCount = visibleIds.filter((id) => selected.has(id)).length;
854
+ const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length;
855
+ const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected;
856
+ const selectedBusy = selectedIds.some((id) => busy[id] === true);
857
+ const visibleBusy = visibleIds.some((id) => busy[id] === true);
651
858
  const filtering = query.trim() !== "" || projectFilter !== "all" || typeFilter !== "all";
652
859
 
653
860
  return (0, jsx.jsxs)("div", {
@@ -656,7 +863,7 @@ window.__ModuleLoader__.load({
656
863
  (0, jsx.jsxs)("div", {
657
864
  className: "dac-head",
658
865
  children: [
659
- (0, jsx.jsx)("div", { className: "dac-title", children: t("page.title") }),
866
+ (0, jsx.jsx)("h2", { ref: pageHeadingRef, tabIndex: -1, className: "dac-title", children: t("page.title") }),
660
867
  (0, jsx.jsxs)("button", {
661
868
  type: "button",
662
869
  className: "dac-deleteall",
@@ -699,6 +906,62 @@ window.__ModuleLoader__.load({
699
906
  { value: "all", label: t("filter.allProjects") },
700
907
  ...projects.map((p) => ({ value: p.value, label: p.title ?? t("group.noProject") }))
701
908
  ]
909
+ }),
910
+ (0, jsx.jsx)(Select, {
911
+ value: sortMode,
912
+ onChange: setSortMode,
913
+ ariaLabel: t("sort.label"),
914
+ options: [
915
+ { value: "newest", label: t("sort.newest") },
916
+ { value: "oldest", label: t("sort.oldest") },
917
+ { value: "title", label: t("sort.title") }
918
+ ]
919
+ }),
920
+ visibleIds.length > 0 && (0, jsx.jsxs)("label", {
921
+ className: "dac-selection-toggle",
922
+ children: [
923
+ (0, jsx.jsx)(SelectionCheckbox, {
924
+ checked: allVisibleSelected,
925
+ indeterminate: someVisibleSelected,
926
+ disabled: visibleBusy,
927
+ ariaLabel: t("selection.visible"),
928
+ onChange: (checked) => toggleSelected(visibleIds, checked)
929
+ }),
930
+ (0, jsx.jsx)("span", { children: t("selection.visible") })
931
+ ]
932
+ })
933
+ ]
934
+ }),
935
+ selectedIds.length > 0 && (0, jsx.jsxs)("div", {
936
+ className: "dac-bulkbar",
937
+ role: "region",
938
+ "aria-label": selectedCount(t, selectedIds.length),
939
+ children: [
940
+ (0, jsx.jsx)("span", { className: "dac-bulk-count", children: selectedCount(t, selectedIds.length) }),
941
+ (0, jsx.jsxs)("div", {
942
+ className: "dac-bulk-actions",
943
+ children: [
944
+ (0, jsx.jsx)("button", {
945
+ type: "button",
946
+ className: "dac-bulk-btn",
947
+ disabled: selectedBusy,
948
+ onClick: () => unarchive(selectedIds),
949
+ children: t("bulk.unarchive")
950
+ }),
951
+ (0, jsx.jsx)("button", {
952
+ type: "button",
953
+ className: "dac-bulk-btn dac-danger",
954
+ disabled: selectedBusy,
955
+ onClick: () => askDelete(selectedIds, null, true),
956
+ children: t("bulk.delete")
957
+ }),
958
+ (0, jsx.jsx)("button", {
959
+ type: "button",
960
+ className: "dac-bulk-btn",
961
+ onClick: () => setSelected(new Set()),
962
+ children: t("selection.clear")
963
+ })
964
+ ]
702
965
  })
703
966
  ]
704
967
  }),
@@ -736,7 +999,9 @@ window.__ModuleLoader__.load({
736
999
  onToggleMenu: setMenuFor,
737
1000
  onUnarchive: unarchive,
738
1001
  onDelete: askDelete,
739
- busy
1002
+ busy,
1003
+ selected,
1004
+ onToggleSelected: toggleSelected
740
1005
  }, group.key)),
741
1006
  confirm !== null && (0, jsx.jsx)(ConfirmDialog, {
742
1007
  title: confirm.title,
@@ -744,6 +1009,8 @@ window.__ModuleLoader__.load({
744
1009
  confirmLabel: t("confirm.delete"),
745
1010
  cancelLabel: t("confirm.cancel"),
746
1011
  busy: confirm.ids.some((id) => busy[id] === true),
1012
+ returnFocus: deleteReturnFocusRef.current,
1013
+ fallbackFocusRef: pageHeadingRef,
747
1014
  onConfirm: () => runDelete(confirm.ids),
748
1015
  onCancel: () => setConfirm(null)
749
1016
  })
@@ -782,6 +1049,7 @@ window.__ModuleLoader__.load({
782
1049
  //#endregion
783
1050
 
784
1051
  exports.SETTINGS_NS = SETTINGS_NS;
1052
+ exports.__test = { sortArchivedSessions, setVisibleSelection, reconcileSelection };
785
1053
  exports.apply = apply;
786
1054
  exports.inject = inject;
787
1055
  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.0",
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
  ".": {