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 +16 -3
- package/README.zh.md +16 -3
- package/lib/client.js +360 -49
- package/lib/index.js +125 -48
- package/package.json +5 -2
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,
|
|
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
|
|
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
|
-
- **
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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,
|
|
13
|
-
//
|
|
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
|
|
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
|
|
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 () =>
|
|
337
|
-
|
|
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.
|
|
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)("
|
|
378
|
-
|
|
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)(
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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) => !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)("
|
|
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
|
|
18
|
-
*
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* cold delete
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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 request — no 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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
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
|
|
237
|
-
const
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
|
263
|
-
* {@link
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
await
|
|
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.
|
|
4
|
-
"description": "DeepSeek Harness
|
|
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
|
".": {
|