dsh-archived-chats 0.3.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,18 +17,29 @@ 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
- - Sessions that are still resident in the background are **parked permanently** and physically removed on the next DSH start nothing is left half-deleted, and parked sessions stay hidden meanwhile.
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.
22
25
  - Works in light and dark schemes; localized in English and 中文.
23
26
 
24
27
  ## How it works
25
28
 
26
29
  - **Host half** (`lib/index.js`) registers the `/plugins/dsh-archived-chats/*` routes on the DSH web server: `GET /state`, `POST /unarchive`, `POST /unarchive-all`, `POST /delete`, `POST /delete-all`. Unarchiving writes through the workspace registry's own state path, so every connected client receives the `host/archived-sessions-changed` push. Mutating routes require a custom `x-dsh-archived-chats: 1` header as CSRF hardening.
27
- - **Pending-deletion store**: deleting a live session takes a "park and defer" paththe agent is cancelled permanently (`cancel({ kind: 'disposed' })`), the session stays archived and hidden, and the id is recorded in `$DSH_HOME/plugin-data/archived-chats/pending-deletions.json`. On the next boot the plugin sweeps the queue and completes the deletion through the ordinary delete path. Parked sessions are excluded from the listing; unarchiving one before the restart cancels its pending deletion.
30
+ - **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.
31
+ - **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.
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.
28
33
  - **Browser half** (`lib/client.js`) registers a `settings.section` slot entry (order 30) and renders the page with React and DSH design tokens.
29
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
+
30
43
  ## Uninstall
31
44
 
32
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,18 +17,29 @@ dsh plugin --profile web add dsh-archived-chats
15
17
  ## 功能
16
18
 
17
19
  - **完整归档列表**:按工作区(项目)分组并显示每组数量;每个分组都可折叠/展开,状态按浏览器记忆。
18
- - **搜索**标题,外加两个筛选器:类型(全部 / 普通会话 / 子代理会话)和项目。
20
+ - **搜索与排序**:按标题搜索,用类型(全部 / 普通会话 / 子代理会话)和项目筛选,并按最新、最早或标题排序。
21
+ - **灵活多选**:逐条选择、选择当前筛选结果或选择整个项目;选中后可一次取消归档或永久删除,隐藏在其他筛选结果中的选择不会丢失。
19
22
  - **取消归档**单个聊天,或从分组的 `⋯` 菜单整组取消——恢复的聊天会立刻回到侧边栏。
20
23
  - **删除**单个聊天、某个项目分组或全部(**全部删除**),均有确认弹窗。删除是彻底的:会话日志从磁盘移除、从工作区记录中摘除、注册表内存索引同步清理,主侧边栏的条目也会立即消失。
21
- - 仍驻留后台的会话会被**永久停用**,待下次启动 DSH 时完成物理删除——不会留下半删除状态,停用期间会话保持隐藏。
24
+ - 仍驻留后台的会话也**当场删除**:插件按官方生命周期的拆除顺序原地停用并注销会话(取消 → 静默 → 落盘 → 拆纤程 → 摘出注册表),持久层随之释放写入通道,同一次请求内即完成物理删除——无需重启。若当前 DSH 版本不提供所需内部接口,则自动回退为「永久停用 + 下次启动完成删除」,停用期间会话保持隐藏。
22
25
  - 适配浅色/深色主题,支持中文和英文界面。
23
26
 
24
27
  ## 实现原理
25
28
 
26
29
  - **Host 半**(`lib/index.js`)在 DSH Web 服务器上注册 `/plugins/dsh-archived-chats/*` 路由:`GET /state`、`POST /unarchive`、`POST /unarchive-all`、`POST /delete`、`POST /delete-all`。取消归档走 workspace registry 自身的状态写入通道,所有已连接的客户端都会收到 `host/archived-sessions-changed` 推送。写操作路由要求自定义请求头 `x-dsh-archived-chats: 1` 作为 CSRF 加固。
27
- - **待删队列**:删除仍在后台运行的会话时采用「停用并延后」策略——agent 被永久取消(`cancel({ kind: 'disposed' })`),会话保持归档与隐藏,id 记入 `$DSH_HOME/plugin-data/archived-chats/pending-deletions.json`;下次启动时插件会清扫该队列,通过常规删除路径完成物理删除。已入队的会话不会出现在列表中;若在重启前取消归档,待删标记会被撤销。
30
+ - **活会话原地删除**:删除仍驻留后台的会话时,插件复刻 agent 工厂自身 disposer 的顺序——`cancel({ kind: 'disposed' })` `whenIdle` → `flush` → `agent.scope.dispose()` → 依次 detach `agents` 与 `sessions` 两个 store 条目;session detach 发出 `session/disposed`,持久化协调器随之 retire(排空并释放)该会话的写入通道,之后冷删除路径在同一请求内完成。所涉 store 条目属于内部接口,每一步都做特性探测,探测失败即回退为停用+延后。
31
+ - **待删队列**(回退路径与崩溃兜底):id 记入 `$DSH_HOME/plugin-data/archived-chats/pending-deletions.json`,会话保持归档与隐藏;下次启动时插件清扫该队列,通过常规删除路径完成物理删除。原地删除也用该队列包裹(删除前登记、文件移除后清除),中途崩溃由下次启动补完。已入队的会话不会出现在列表中;取消归档会撤销待删标记。
32
+ - **标题缓存**:列表刷新时按 id 记忆已解析的标题,不再每次全量读日志;删除与取消归档会使对应缓存失效。
28
33
  - **浏览器半**(`lib/client.js`)注册 `settings.section` 插槽项(order 30),用 React 和 DSH 设计令牌渲染页面。
29
34
 
35
+ ## 开发
36
+
37
+ ```sh
38
+ npm test
39
+ ```
40
+
41
+ 测试使用隔离的临时 DSH 主目录和模拟的宿主/浏览器运行时,不会读取或修改真实会话。
42
+
30
43
  ## 卸载
31
44
 
32
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 ?? ""}`;
@@ -124,6 +152,19 @@ window.__ModuleLoader__.load({
124
152
  : n === 1
125
153
  ? "1 chat is parked and will be permanently deleted after DSH restarts"
126
154
  : `${n} chats are parked and will be permanently deleted after DSH restarts`;
155
+ // Success confirmation after a completed delete — same copy as CodeX.
156
+ const deletedText = (t, n) => isZh(t)
157
+ ? "已删除归档聊天"
158
+ : n === 1 ? "Archived chat deleted" : `${n} archived chats deleted`;
159
+ // Pin the success toast just inside the settings dialog's top edge: the
160
+ // toast is fixed-positioned (viewport coordinates), so measure where the
161
+ // dialog actually starts instead of hardcoding an offset.
162
+ const toastTop = () => {
163
+ try {
164
+ const top = document.querySelector('[role="dialog"]')?.getBoundingClientRect().top;
165
+ return typeof top === "number" && top >= 0 ? Math.round(top) + 16 : 24;
166
+ } catch { return 24; }
167
+ };
127
168
  function formatDate(t, ms) {
128
169
  if (typeof ms !== "number" || !Number.isFinite(ms)) return "";
129
170
  try {
@@ -135,13 +176,50 @@ window.__ModuleLoader__.load({
135
176
  return new Date(ms).toLocaleString();
136
177
  }
137
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
+ }
138
216
  //#endregion
139
217
 
140
218
  //#region styles
141
219
  const CSS = `
142
- .dac-page{display:flex;flex-direction:column;gap:14px;padding:4px 0 28px;font-family:inherit}
220
+ .dac-page{position:relative;display:flex;flex-direction:column;gap:14px;padding:4px 0 28px;font-family:inherit}
143
221
  .dac-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
144
- .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}
145
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}
146
224
  .dac-deleteall:hover:not(:disabled){background:rgba(229,72,77,.18)}
147
225
  .dac-deleteall:disabled{opacity:.45;cursor:default}
@@ -154,8 +232,19 @@ window.__ModuleLoader__.load({
154
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}
155
233
  .dac-select:hover{border-color:var(--dsw-alias-border-l1,var(--dsw-alias-border-l2))}
156
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}
157
245
  .dac-group{display:flex;flex-direction:column;gap:8px;margin-top:6px}
158
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}
159
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}
160
249
  .dac-group-toggle:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
161
250
  .dac-chev{display:inline-flex;color:var(--dsw-alias-label-tertiary);transform:rotate(-90deg);transition:transform .15s}
@@ -173,8 +262,10 @@ window.__ModuleLoader__.load({
173
262
  .dac-menu-item.dac-danger{color:#e5484d}
174
263
  .dac-menu-item.dac-danger:hover{background:rgba(229,72,77,.1)}
175
264
  .dac-list{display:flex;flex-direction:column;gap:8px}
176
- .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}
177
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}
178
269
  .dac-row-main{min-width:0;display:flex;flex-direction:column;gap:4px}
179
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}
180
271
  .dac-row-date{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
@@ -187,8 +278,15 @@ window.__ModuleLoader__.load({
187
278
  .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}
188
279
  .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}
189
280
  .dac-notice button{border:none;background:transparent;color:inherit;cursor:pointer;font:inherit;padding:0;display:inline-flex}
281
+ .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 svg{flex:none}
283
+ .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}
284
+ .dac-toast button:hover{opacity:1;background:rgba(48,164,108,.12)}
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)}}
286
+ @media (prefers-reduced-motion:reduce){.dac-toast{animation:none}}
190
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)}
191
- .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)}
192
290
  .dac-confirm-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:500;line-height:24px}
193
291
  .dac-confirm-body{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:21px}
194
292
  .dac-confirm-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}
@@ -199,6 +297,7 @@ window.__ModuleLoader__.load({
199
297
  .dac-btn-danger:disabled{opacity:.5;cursor:default}
200
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}
201
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}}
202
301
  `;
203
302
 
204
303
  function ensureStyle() {
@@ -310,6 +409,9 @@ window.__ModuleLoader__.load({
310
409
  function IconClose({ size = 12 }) {
311
410
  return (0, jsx.jsx)("svg", { ...svgProps(size), children: (0, jsx.jsx)("path", { d: "M6 6l12 12M18 6 6 18" }) });
312
411
  }
412
+ function IconCheckCircle({ size = 15 }) {
413
+ return (0, jsx.jsxs)("svg", { ...svgProps(size), children: [(0, jsx.jsx)("circle", { cx: 12, cy: 12, r: 9 }), (0, jsx.jsx)("path", { d: "m8.5 12.2 2.4 2.4 4.6-5" })] });
414
+ }
313
415
  //#endregion
314
416
 
315
417
  //#region components
@@ -329,27 +431,85 @@ window.__ModuleLoader__.load({
329
431
  });
330
432
  }
331
433
 
332
- 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);
436
+ _react.useEffect(() => {
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);
333
454
  _react.useEffect(() => {
334
- const onKey = (e) => { if (e.key === "Escape") onCancel(); };
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
+ };
335
483
  document.addEventListener("keydown", onKey);
336
- return () => document.removeEventListener("keydown", onKey);
337
- }, [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]);
338
494
  return (0, jsx.jsx)("div", {
339
495
  className: "dac-confirm-overlay",
340
496
  onClick: onCancel,
341
497
  children: (0, jsx.jsxs)("div", {
498
+ ref: dialogRef,
342
499
  className: "dac-confirm",
343
500
  role: "alertdialog",
344
501
  "aria-modal": "true",
502
+ "aria-labelledby": "dac-confirm-title",
503
+ "aria-describedby": "dac-confirm-body",
504
+ tabIndex: -1,
345
505
  onClick: (e) => e.stopPropagation(),
346
506
  children: [
347
- (0, jsx.jsx)("div", { className: "dac-confirm-title", children: title }),
348
- (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 }),
349
509
  (0, jsx.jsxs)("div", {
350
510
  className: "dac-confirm-actions",
351
511
  children: [
352
- (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 }),
353
513
  (0, jsx.jsx)("button", { type: "button", className: "dac-btn-danger", disabled: busy, onClick: onConfirm, children: confirmLabel })
354
514
  ]
355
515
  })
@@ -358,7 +518,7 @@ window.__ModuleLoader__.load({
358
518
  });
359
519
  }
360
520
 
361
- 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 }) {
362
522
  const wrapRef = _react.useRef(null);
363
523
  _react.useEffect(() => {
364
524
  if (!menuOpen) return void 0;
@@ -366,7 +526,11 @@ window.__ModuleLoader__.load({
366
526
  document.addEventListener("mousedown", onDown);
367
527
  return () => document.removeEventListener("mousedown", onDown);
368
528
  }, [menuOpen, onToggleMenu]);
369
- 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);
370
534
  return (0, jsx.jsxs)("div", {
371
535
  className: "dac-group",
372
536
  children: [
@@ -374,16 +538,28 @@ window.__ModuleLoader__.load({
374
538
  className: "dac-group-head",
375
539
  ref: wrapRef,
376
540
  children: [
377
- (0, jsx.jsxs)("button", {
378
- type: "button",
379
- className: "dac-group-toggle",
380
- "aria-expanded": collapsed !== true,
381
- "aria-label": collapsed === true ? t("group.expand") : t("group.collapse"),
382
- onClick: () => onToggleCollapsed(group.key),
541
+ (0, jsx.jsxs)("div", {
542
+ className: "dac-group-left",
383
543
  children: [
384
- (0, jsx.jsx)("span", { className: collapsed === true ? "dac-chev" : "dac-chev open", children: (0, jsx.jsx)(IconChevron, {}) }),
385
- (0, jsx.jsx)(IconFolder, {}),
386
- (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
+ })
387
563
  ]
388
564
  }),
389
565
  (0, jsx.jsxs)("div", {
@@ -424,15 +600,26 @@ window.__ModuleLoader__.load({
424
600
  collapsed !== true && (0, jsx.jsx)("div", {
425
601
  className: "dac-list",
426
602
  children: group.items.map((session) => (0, jsx.jsxs)("div", {
427
- className: "dac-row",
428
- children: [
429
- (0, jsx.jsxs)("div", {
430
- className: "dac-row-main",
431
- children: [
432
- (0, jsx.jsx)("div", { className: "dac-row-title", children: session.title ?? t("chat.untitled") }),
433
- (0, jsx.jsx)("div", { className: "dac-row-date", children: formatDate(t, session.createdAt) })
434
- ]
435
- }),
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
+ }),
436
623
  (0, jsx.jsxs)("div", {
437
624
  className: "dac-row-actions",
438
625
  children: [
@@ -467,11 +654,25 @@ window.__ModuleLoader__.load({
467
654
  const [query, setQuery] = _react.useState("");
468
655
  const [typeFilter, setTypeFilter] = _react.useState("all");
469
656
  const [projectFilter, setProjectFilter] = _react.useState("all");
657
+ const [sortMode, setSortMode] = _react.useState("newest");
470
658
  const [menuFor, setMenuFor] = _react.useState(null);
471
659
  const [confirm, setConfirm] = _react.useState(null);
472
660
  const [busy, setBusy] = _react.useState({});
473
661
  const [notice, setNotice] = _react.useState(null);
474
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);
666
+
667
+ // Success confirmations are transient toasts (CodeX behavior);
668
+ // errors stay until dismissed.
669
+ _react.useEffect(() => {
670
+ if (notice?.kind !== "ok") return undefined;
671
+ const timer = setTimeout(() => {
672
+ setNotice((current) => (current === notice ? null : current));
673
+ }, 4000);
674
+ return () => clearTimeout(timer);
675
+ }, [notice]);
475
676
 
476
677
  const toggleCollapsed = (key) => {
477
678
  setCollapsed((prev) => {
@@ -492,6 +693,17 @@ window.__ModuleLoader__.load({
492
693
  }
493
694
  }, []);
494
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
+ };
495
707
 
496
708
  const markBusy = (ids, value) => {
497
709
  setBusy((prev) => {
@@ -511,8 +723,9 @@ window.__ModuleLoader__.load({
511
723
  try {
512
724
  await post("/unarchive-all", { sessionIds: ids });
513
725
  setSessions((prev) => (prev ?? []).filter((s) => !ids.includes(s.id)));
726
+ setSelected((current) => setVisibleSelection(current, ids, false));
514
727
  } catch (error) {
515
- setNotice(String(error.message ?? error));
728
+ setNotice({ kind: "error", text: String(error.message ?? error) });
516
729
  } finally {
517
730
  markBusy(ids, false);
518
731
  }
@@ -530,22 +743,27 @@ window.__ModuleLoader__.load({
530
743
  const failed = result.failed ?? [];
531
744
  if (deleted.length > 0 || pending.length > 0) {
532
745
  changed = true;
746
+ const removed = [...deleted, ...pending];
533
747
  // Pending rows leave the list too: the deletion is accepted,
534
748
  // the session is parked and excluded from /state until the
535
749
  // next-boot sweep physically removes it.
536
- 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));
537
752
  }
538
- if (failed.length > 0) setNotice(failureText(t, failed));
539
- else if (pending.length > 0) setNotice(pendingText(t, pending.length));
753
+ if (failed.length > 0) setNotice({ kind: "error", text: failureText(t, failed) });
754
+ else if (pending.length > 0) setNotice({ kind: "error", text: pendingText(t, pending.length) });
755
+ else if (deleted.length > 0) setNotice({ kind: "ok", text: deletedText(t, deleted.length) });
540
756
  } catch (error) {
541
757
  const deleted = error.body?.deleted ?? [];
542
758
  const pending = error.body?.pending ?? [];
543
759
  const failed = error.body?.failed ?? [];
544
760
  if (deleted.length > 0 || pending.length > 0) {
545
761
  changed = true;
546
- 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));
547
765
  }
548
- setNotice(failed.length > 0 ? failureText(t, failed) : String(error.message ?? error));
766
+ setNotice({ kind: "error", text: failed.length > 0 ? failureText(t, failed) : String(error.message ?? error) });
549
767
  } finally {
550
768
  markBusy(ids, false);
551
769
  setConfirm(null);
@@ -557,9 +775,12 @@ window.__ModuleLoader__.load({
557
775
  }
558
776
  };
559
777
 
560
- const askDelete = (ids, groupName) => {
778
+ const askDelete = (ids, groupName, selectedScope = false) => {
561
779
  if (ids.length === 0) return;
562
- 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) {
563
784
  setConfirm({ title: t("confirm.deleteOne.title"), body: t("confirm.deleteOne.body"), ids });
564
785
  } else if (groupName !== null && groupName !== void 0) {
565
786
  setConfirm({ title: t("confirm.deleteGroup.title"), body: deleteGroupBody(t, groupName, ids.length), ids });
@@ -579,6 +800,12 @@ window.__ModuleLoader__.load({
579
800
 
580
801
  const groups = _react.useMemo(() => {
581
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
+ }
582
809
  const visible = (sessions ?? []).filter((s) => {
583
810
  if (projectFilter !== "all" && (s.workspaceId ?? "ungrouped") !== projectFilter) return false;
584
811
  if (typeFilter === "normal" && s.origin === "subagent") return false;
@@ -589,17 +816,23 @@ window.__ModuleLoader__.load({
589
816
  const byKey = new Map();
590
817
  for (const s of visible) {
591
818
  const key = s.workspaceId ?? "ungrouped";
592
- 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) ?? [] });
593
820
  byKey.get(key).items.push(s);
594
821
  }
595
822
  const list = [...byKey.values()];
596
- 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" });
597
825
  list.sort((a, b) => {
598
826
  if ((a.key === "ungrouped") !== (b.key === "ungrouped")) return a.key === "ungrouped" ? 1 : -1;
599
- 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);
600
833
  });
601
834
  return list;
602
- }, [sessions, query, projectFilter, typeFilter, t]);
835
+ }, [sessions, query, projectFilter, typeFilter, sortMode, t]);
603
836
 
604
837
  if (sessions === null && loadError === null) {
605
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") })] });
@@ -615,6 +848,13 @@ window.__ModuleLoader__.load({
615
848
  }
616
849
 
617
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);
618
858
  const filtering = query.trim() !== "" || projectFilter !== "all" || typeFilter !== "all";
619
859
 
620
860
  return (0, jsx.jsxs)("div", {
@@ -623,7 +863,7 @@ window.__ModuleLoader__.load({
623
863
  (0, jsx.jsxs)("div", {
624
864
  className: "dac-head",
625
865
  children: [
626
- (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") }),
627
867
  (0, jsx.jsxs)("button", {
628
868
  type: "button",
629
869
  className: "dac-deleteall",
@@ -666,13 +906,79 @@ window.__ModuleLoader__.load({
666
906
  { value: "all", label: t("filter.allProjects") },
667
907
  ...projects.map((p) => ({ value: p.value, label: p.title ?? t("group.noProject") }))
668
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
+ ]
669
965
  })
670
966
  ]
671
967
  }),
672
- notice !== null && (0, jsx.jsxs)("div", {
968
+ notice !== null && notice.kind === "ok" && (0, jsx.jsxs)("div", {
969
+ className: "dac-toast",
970
+ role: "status",
971
+ style: { top: `${toastTop()}px` },
972
+ children: [
973
+ (0, jsx.jsx)(IconCheckCircle, {}),
974
+ (0, jsx.jsx)("span", { children: notice.text }),
975
+ (0, jsx.jsx)("button", { type: "button", "aria-label": t("notice.dismiss"), onClick: () => setNotice(null), children: (0, jsx.jsx)(IconClose, {}) })
976
+ ]
977
+ }),
978
+ notice !== null && notice.kind !== "ok" && (0, jsx.jsxs)("div", {
673
979
  className: "dac-notice",
674
980
  children: [
675
- (0, jsx.jsx)("span", { children: notice }),
981
+ (0, jsx.jsx)("span", { children: notice.text }),
676
982
  (0, jsx.jsx)("button", { type: "button", "aria-label": t("notice.dismiss"), onClick: () => setNotice(null), children: (0, jsx.jsx)(IconClose, {}) })
677
983
  ]
678
984
  }),
@@ -693,7 +999,9 @@ window.__ModuleLoader__.load({
693
999
  onToggleMenu: setMenuFor,
694
1000
  onUnarchive: unarchive,
695
1001
  onDelete: askDelete,
696
- busy
1002
+ busy,
1003
+ selected,
1004
+ onToggleSelected: toggleSelected
697
1005
  }, group.key)),
698
1006
  confirm !== null && (0, jsx.jsx)(ConfirmDialog, {
699
1007
  title: confirm.title,
@@ -701,6 +1009,8 @@ window.__ModuleLoader__.load({
701
1009
  confirmLabel: t("confirm.delete"),
702
1010
  cancelLabel: t("confirm.cancel"),
703
1011
  busy: confirm.ids.some((id) => busy[id] === true),
1012
+ returnFocus: deleteReturnFocusRef.current,
1013
+ fallbackFocusRef: pageHeadingRef,
704
1014
  onConfirm: () => runDelete(confirm.ids),
705
1015
  onCancel: () => setConfirm(null)
706
1016
  })
@@ -739,6 +1049,7 @@ window.__ModuleLoader__.load({
739
1049
  //#endregion
740
1050
 
741
1051
  exports.SETTINGS_NS = SETTINGS_NS;
1052
+ exports.__test = { sortArchivedSessions, setVisibleSelection, reconcileSelection };
742
1053
  exports.apply = apply;
743
1054
  exports.inject = inject;
744
1055
  return module.exports;
package/lib/index.js CHANGED
@@ -14,8 +14,10 @@
14
14
  * POST /plugins/dsh-archived-chats/delete-all → { sessionIds: [...] }
15
15
  *
16
16
  * Mutating responses carry { ok, deleted, pending, failed }: `deleted` is
17
- * complete cold deletes, `pending` is live sessions accepted for deferred
18
- * deletion (parked now, physically removed on the next boot).
17
+ * complete deletes (cold sessions, and live sessions this build lets us
18
+ * dispose in place), `pending` is live sessions accepted for deferred
19
+ * deletion (parked now, physically removed on the next boot — the fallback
20
+ * when in-place disposal is unavailable).
19
21
  *
20
22
  * The stock workspace registry only exposes `archiveSession` — unarchiving and
21
23
  * deleting live here, behind one HTTP surface the browser half can drive.
@@ -25,20 +27,32 @@
25
27
  * updates live). Delete additionally detaches the session from its workspace
26
28
  * record and removes the session-log directory from disk.
27
29
  *
28
- * Deleting a COLD session is immediate. A session that is still LIVE (opened
29
- * at least once this boot — web agents stay resident until shutdown and the
30
- * stock composition exposes no dispose) cannot have its log ripped out from
31
- * under it: the persistence coordinator would hit ENOENT on its next append.
32
- * Instead the live path parks the agent permanently (`cancel({kind:
33
- * 'disposed'})` parked input never wakes the driver again), waits for
34
- * quiescence, flushes durability, then records the id in a small
35
- * pending-deletions store while KEEPING it archived so it stays invisible.
36
- * The boot sweep (`sweepPendingDeletions`, launched once when the plugin's
37
- * three services have all bound) completes the removal through the ordinary
38
- * cold delete path on the next bootwhen every session is cold. Unarchiving
39
- * a parked session drops it from the pending store, and the sweep skips ids
40
- * that are no longer archived, so an unarchive always wins over a parked
41
- * deletion.
30
+ * Deleting a COLD session is immediate. A LIVE session (opened at least once
31
+ * this boot — web agents stay resident until shutdown) cannot have its log
32
+ * ripped out from under it: the persistence coordinator would hit ENOENT on
33
+ * its next append. The live path therefore disposes the session first, the
34
+ * same sequence the agent factory's own lifecycle disposer runs: cancel the
35
+ * agent with the `disposed` cause, wait for quiescence, flush durability,
36
+ * tear down the agent's fiber (`agent.scope.dispose()`), then detach the
37
+ * `agents` and `sessions` store entries the session-store detach emits
38
+ * `session/disposed`, which the persistence coordinator answers by retiring
39
+ * (draining and releasing) the session's write path. Once retired the session
40
+ * is cold and the ordinary delete completes in the same requestno restart.
41
+ *
42
+ * The store entries and their `detach` are internal surfaces of this dsh
43
+ * build (plain properties, not exported API), so every step is feature-
44
+ * detected. When any piece is missing the live path falls back to the
45
+ * original conservative behavior: the agent is left parked (cancelled,
46
+ * flushed) and the id is recorded in a small pending-deletions store while
47
+ * KEEPING it archived so it stays invisible. The boot sweep
48
+ * (`sweepPendingDeletions`, launched once when the plugin's three services
49
+ * have all bound) completes the removal through the ordinary cold delete path
50
+ * on the next boot — when every session is cold. The pending store also
51
+ * brackets the successful live path (recorded before disposal, cleared after
52
+ * the files are gone), so a crash mid-delete is swept on the next boot
53
+ * instead of leaving a half-deleted ghost. Unarchiving a parked session drops
54
+ * it from the pending store, and the sweep skips ids that are no longer
55
+ * archived, so an unarchive always wins over a parked deletion.
42
56
  *
43
57
  * Routes bind lazily (same posture as dsh-agent-teams): the web server, the
44
58
  * workspace registry, and session persistence may mount after this plugin
@@ -166,9 +180,13 @@ function extractTitle(events) {
166
180
  /**
167
181
  * Build the archived-session listing: one row per archived id with the
168
182
  * resolved title, creation time, and owning workspace (accounting slot first,
169
- * canonical cwd second, ungrouped last).
183
+ * canonical cwd second, ungrouped last). Resolved titles are memoized in
184
+ * `titleCache` (id → title) so refreshes stop re-reading every archived log:
185
+ * only non-null titles are cached — a title-less session stays cheap to
186
+ * re-inspect and picks up a late `session/title` event on the next refresh.
187
+ * Delete and unarchive invalidate their ids.
170
188
  */
171
- async function listArchived(ctx, registry, persistence) {
189
+ async function listArchived(ctx, registry, persistence, titleCache) {
172
190
  const archivedIds = registry.archivedSessionIds.map(String);
173
191
  if (archivedIds.length === 0) return [];
174
192
  const headerById = new Map();
@@ -182,12 +200,15 @@ async function listArchived(ctx, registry, persistence) {
182
200
  const rows = [];
183
201
  for (const id of archivedIds) {
184
202
  const header = headerById.get(id) ?? live?.get(id)?.header;
185
- let title;
186
- try {
187
- const inspection = await persistence.inspect(id);
188
- title = extractTitle(inspection.events);
189
- } catch {
190
- // A torn/unreadable log still lists — the row is manageable without a title.
203
+ let title = titleCache?.get(id);
204
+ if (title === undefined) {
205
+ try {
206
+ const inspection = await persistence.inspect(id);
207
+ title = extractTitle(inspection.events);
208
+ if (title !== undefined) titleCache?.set(id, title);
209
+ } catch {
210
+ // A torn/unreadable log still lists — the row is manageable without a title.
211
+ }
191
212
  }
192
213
  const workspace = workspaces.find((w) => w.sessionIds.map(String).includes(id));
193
214
  rows.push({
@@ -227,14 +248,29 @@ async function unarchiveIds(registry, ids) {
227
248
  }
228
249
 
229
250
  /**
230
- * Park a live session for deletion: cancel its agent with the `disposed`
231
- * cause (parked input never wakes the driver again), wait for quiescence so
232
- * in-flight closing events land, then flush durability. Afterwards the log is
233
- * frozen and the id joins the pending-deletions store; the session STAYS
234
- * archived so it keeps hidden until the next-boot sweep completes the delete.
251
+ * Dispose a live session in place so its deletion can complete without a
252
+ * restart. Mirrors the agent factory's own lifecycle disposer: cancel the
253
+ * agent with the `disposed` cause (parked input never wakes the driver
254
+ * again), wait for quiescence so in-flight closing events land, flush
255
+ * durability, tear down the agent's fiber, then detach the `agents` and
256
+ * `sessions` store entries. The session-store detach emits `session/disposed`
257
+ * — the persistence coordinator answers by retiring (draining and releasing)
258
+ * the session's write path, after which the log directory is free to remove.
259
+ *
260
+ * The store maps and their entries' `detach` are internal surfaces of this
261
+ * dsh build, so every step is feature-detected. Returns true when the session
262
+ * ends up fully detached (cold); false when disposal is unavailable or did
263
+ * not converge — the session is then left in the parked state (cancelled and
264
+ * flushed) and the caller falls back to the deferred next-boot deletion.
235
265
  */
236
- async function parkLiveSession(ctx, id) {
237
- const agent = ctx.get('agents')?.get(id);
266
+ async function disposeLiveSession(ctx, id) {
267
+ const sessions = ctx.get('sessions');
268
+ const agents = ctx.get('agents');
269
+ const session = sessions?.get(id);
270
+ if (session === undefined) return true;
271
+ // Park first — this is also the complete fallback outcome: the loop can
272
+ // never run again and the log's buffered tail is durable.
273
+ const agent = agents?.get(id);
238
274
  if (agent !== undefined) {
239
275
  try {
240
276
  agent.cancel({ kind: 'disposed' });
@@ -246,27 +282,60 @@ async function parkLiveSession(ctx, id) {
246
282
  ctx.logger.warn(`archived-chats: parking ${id} did not fully converge: ${String(error)}`);
247
283
  }
248
284
  }
249
- const sessions = ctx.get('sessions');
250
- const live = sessions?.get(id);
251
- if (live !== undefined && typeof sessions.flush === 'function') {
252
- try { await sessions.flush(live); }
253
- catch (error) { ctx.logger.warn(`archived-chats: flush before parking ${id} failed: ${String(error)}`); }
285
+ if (typeof sessions.flush === 'function') {
286
+ try { await sessions.flush(session); }
287
+ catch (error) { ctx.logger.warn(`archived-chats: flush before disposing ${id} failed: ${String(error)}`); }
288
+ }
289
+ // Feature-detect the internal store entries before touching anything else:
290
+ // without BOTH detach capabilities the session must stay parked. The
291
+ // session-store entry carries its own `detach`; the agent-registry entry
292
+ // does not (its detach closure lives with the creator), so the agent side
293
+ // goes through the registry's `detachEntered(entry)` — the same method
294
+ // that closure calls.
295
+ const sessionEntry = sessions.store instanceof Map ? sessions.store.get(id) : undefined;
296
+ const agentEntry = agents?.store instanceof Map ? agents.store.get(id) : undefined;
297
+ if (typeof sessionEntry?.detach !== 'function') return false;
298
+ if (agent !== undefined && (agentEntry === undefined
299
+ || typeof agents.detachEntered !== 'function'
300
+ || agentEntry.announcing === true)) return false;
301
+ // Factory disposer order: agent fiber first, then unregister the agent,
302
+ // then detach the session (emitting `session/disposed`).
303
+ try { await agent?.scope?.dispose?.(); }
304
+ catch (error) { ctx.logger.warn(`archived-chats: agent fiber teardown for ${id} failed: ${String(error)}`); }
305
+ try { if (agentEntry !== undefined) agents.detachEntered(agentEntry); }
306
+ catch (error) { ctx.logger.warn(`archived-chats: agent detach for ${id} failed: ${String(error)}`); }
307
+ try { sessionEntry.detach(); }
308
+ catch (error) {
309
+ ctx.logger.warn(`archived-chats: session detach for ${id} failed: ${String(error)}`);
310
+ return false;
254
311
  }
255
- await addPending(id);
312
+ // Detach can defer while a creation announce or append is unwinding — if
313
+ // the session is still in the store, treat it as parked, not disposed.
314
+ if (sessions.get(id) !== undefined) return false;
315
+ // The retirement triggered by `session/disposed` drains asynchronously.
316
+ // The pre-detach flush left nothing buffered, so a short settle keeps the
317
+ // log-directory removal below clear of the drain's final serialize.
318
+ await new Promise((resolve) => setTimeout(resolve, 250));
319
+ return true;
256
320
  }
257
321
 
258
322
  /**
259
323
  * Delete one archived session. A COLD session is removed end to end right
260
324
  * away: unarchive, detach from the owning workspace record, remove the
261
325
  * session-log directory from disk, and purge the registry's stale header
262
- * index. A LIVE session is parked and deferred instead (see
263
- * {@link parkLiveSession}) and reported as `pending`.
326
+ * index. A LIVE session is first disposed in place (see
327
+ * {@link disposeLiveSession}) and then follows the same cold path in this
328
+ * request; when in-place disposal is unavailable on this build it stays
329
+ * parked and is reported as `pending` for the next-boot sweep. Either way the
330
+ * id sits in the pending-deletions store from before disposal until the files
331
+ * are gone, so a crash mid-delete is completed by the sweep instead of
332
+ * leaving a half-deleted ghost.
264
333
  */
265
- async function deleteSession(ctx, registry, persistence, id) {
266
- const live = ctx.get('sessions');
267
- if (live?.get(id) !== undefined) {
268
- await parkLiveSession(ctx, id);
269
- return 'pending';
334
+ async function deleteSession(ctx, registry, persistence, id, titleCache) {
335
+ if (ctx.get('sessions')?.get(id) !== undefined) {
336
+ await addPending(id);
337
+ const disposed = await disposeLiveSession(ctx, id);
338
+ if (!disposed) return 'pending';
270
339
  }
271
340
  await unarchiveIds(registry, [id]);
272
341
  for (const workspace of registry.list()) {
@@ -295,6 +364,10 @@ async function deleteSession(ctx, registry, persistence, id) {
295
364
  const map = registry[key];
296
365
  if (map instanceof Map) map.delete(id);
297
366
  }
367
+ titleCache?.delete(id);
368
+ // Clear the crash bracket (and any pre-existing parked mark) now that the
369
+ // files are gone. A no-op for ids that were never pending.
370
+ await removePending([id]);
298
371
  return 'deleted';
299
372
  }
300
373
 
@@ -335,13 +408,13 @@ async function sweepPendingDeletions(ctx, registry, persistence) {
335
408
  //#endregion
336
409
 
337
410
  //#region routes
338
- function registerRoutes(ctx, webServer, registry, persistence) {
411
+ function registerRoutes(ctx, webServer, registry, persistence, titleCache) {
339
412
  ctx.effect(() => webServer.register({
340
413
  kind: 'exact',
341
414
  path: `${ROUTE_PREFIX}/state`,
342
415
  handler: async (req, res) => {
343
416
  try {
344
- const sessions = await listArchived(ctx, registry, persistence);
417
+ const sessions = await listArchived(ctx, registry, persistence, titleCache);
345
418
  send(res, 200, { sessions });
346
419
  } catch (error) {
347
420
  ctx.logger.warn(`archived-chats: state failed: ${String(error)}`);
@@ -362,6 +435,7 @@ function registerRoutes(ctx, webServer, registry, persistence) {
362
435
  return;
363
436
  }
364
437
  const archivedSessionIds = await unarchiveIds(registry, [body.sessionId]);
438
+ titleCache.delete(body.sessionId);
365
439
  await removePending([body.sessionId]);
366
440
  send(res, 200, { ok: true, archivedSessionIds });
367
441
  } catch (error) {
@@ -383,6 +457,7 @@ function registerRoutes(ctx, webServer, registry, persistence) {
383
457
  return;
384
458
  }
385
459
  const archivedSessionIds = await unarchiveIds(registry, ids);
460
+ for (const id of ids) titleCache.delete(id);
386
461
  await removePending(ids);
387
462
  send(res, 200, { ok: true, archivedSessionIds });
388
463
  } catch (error) {
@@ -407,7 +482,7 @@ function registerRoutes(ctx, webServer, registry, persistence) {
407
482
  const failed = [];
408
483
  for (const id of ids) {
409
484
  try {
410
- const outcome = await deleteSession(ctx, registry, persistence, id);
485
+ const outcome = await deleteSession(ctx, registry, persistence, id, titleCache);
411
486
  if (outcome === 'pending') pending.push(id);
412
487
  else deleted.push(id);
413
488
  } catch (error) {
@@ -439,6 +514,8 @@ function registerRoutes(ctx, webServer, registry, persistence) {
439
514
  */
440
515
  export function apply(ctx) {
441
516
  let registered = false;
517
+ /** Memoized session titles for {@link listArchived} (id → title). */
518
+ const titleCache = new Map();
442
519
  const registerWebSurface = () => {
443
520
  if (registered) return;
444
521
  const webServer = ctx.get(WEB_SERVER_KEYS[0]) ?? ctx.get(WEB_SERVER_KEYS[1]);
@@ -446,7 +523,7 @@ export function apply(ctx) {
446
523
  const persistence = ctx.get(PERSISTENCE_KEYS[0]);
447
524
  if (webServer === undefined || registry === undefined || persistence === undefined) return;
448
525
  registered = true;
449
- registerRoutes(ctx, webServer, registry, persistence);
526
+ registerRoutes(ctx, webServer, registry, persistence, titleCache);
450
527
  // Boot sweep: every pending-deletion id is cold now (plugin activation
451
528
  // precedes any client resume), so complete each deferred deletion.
452
529
  void sweepPendingDeletions(ctx, registry, persistence)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-archived-chats",
3
- "version": "0.3.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
  ".": {