dsh-archived-chats 0.8.1 → 0.9.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.en.md CHANGED
@@ -24,11 +24,11 @@ dsh plugin --profile web update dsh-archived-chats
24
24
 
25
25
  ## Compatibility
26
26
 
27
- Version 0.8.1 uses DeepSeek Harness `0.1.0-rc.7` as its automated compatibility baseline. The plugin registers a top-level `settings.section`, so the rc.7 keyed-slot change for `settings.plugin.item` does not apply to it. A local real-host UI pass was also completed on Harness `0.1.0-rc.8` for the archive list, search, metadata editor, bulk actions, group actions, and import preview. Future Harness releases should still be checked with the smoke suite and a real-host UI pass before publishing a plugin update, because client slot and design-token contracts may evolve.
27
+ Version 0.9.0 uses DeepSeek Harness `0.1.0-rc.7` as its automated compatibility baseline. The plugin registers a top-level `settings.section`, so the rc.7 keyed-slot change for `settings.plugin.item` does not apply to it. A local real-host UI pass was also completed on Harness `0.1.0-rc.8` for the archive list, search, metadata editor, bulk and group actions, and backup import preview. Future Harness releases should still be checked with the smoke suite and a real-host UI pass before publishing a plugin update, because client slot and design-token contracts may evolve.
28
28
 
29
- ## Screenshots
29
+ ## Preview
30
30
 
31
- These screenshots represent the 0.8.1 documentation release's unchanged 0.8.x UI and were captured in a local DeepSeek Harness web profile.
31
+ All screenshots below were captured from 0.9.0 in a local DeepSeek Harness `0.1.0-rc.8` web profile.
32
32
 
33
33
  ![Archived Chats overview](assets/screenshots/1-archived-chats.png)
34
34
  ![Search and filters](assets/screenshots/2-search.png)
@@ -42,9 +42,9 @@ These screenshots represent the 0.8.1 documentation release's unchanged 0.8.x UI
42
42
 
43
43
  1. Archive a conversation from the normal DSH session menu. Archiving removes it from the sidebar but keeps its session data in the workspace store.
44
44
  2. Open **Settings → Archived Chats**. The page groups archived conversations by workspace and remembers collapsed groups in this browser.
45
- 3. Search, filter, sort, or select conversations. Open a row's metadata editor to add tags and notes, or use the group menu for workspace-level actions.
46
- 4. Use **Export backup** for one conversation, the current selection, or all archived chats. To restore a backup, choose **Import backup**, review the preview, keep the non-conflicting sessions selected, and confirm.
47
- 5. Choose **Unarchive** to return a conversation to the sidebar. Choose **Delete** only when you want permanent removal; the confirmation dialog identifies the affected scope.
45
+ 3. Search, filter, or sort conversations. When you need multi-select, click **Select multiple** to reveal the checkboxes; completing the workflow returns to the clean list automatically. You can also open a row's metadata editor to add tags and notes, or use the group menu for workspace-level actions.
46
+ 4. Click **Import backup** to choose a ZIP produced by this plugin and confirm non-conflicting sessions after the preview. Click **Export backup** to export the current selection, or every archived chat when nothing is selected. Individual rows also have an export action.
47
+ 5. Choose **Unarchive** to return a conversation to the sidebar. Choose **Delete** only when you want permanent removal; the confirmation dialog identifies the affected scope. **Delete All** lives under the top **More** menu.
48
48
 
49
49
  ## Features
50
50
 
@@ -54,7 +54,8 @@ These screenshots represent the 0.8.1 documentation release's unchanged 0.8.x UI
54
54
  - **Storage insights**: a summary strip reports the archived count, total measured size, and how many sessions could not be measured; each row shows its own size. Measurement never follows symbolic links and skips sessions whose directories are unreadable.
55
55
  - **JSON + Markdown backups**: export one row, the current selection, or every archived chat as a ZIP. Each package has a versioned manifest, a lossless machine-readable session record, and a human-readable transcript for every included session.
56
56
  - **Preview-first import and restore**: choose a ZIP backup, inspect every session before writing, preselect only non-conflicting IDs, and restore selected sessions as archived chats. Existing IDs are skipped and never overwritten.
57
- - **Flexible multi-select**: select individual chats, every visible result, or an entire project. The selection bar can export, unarchive, or permanently delete the chosen chats in one action, while selections hidden by another filter remain intact.
57
+ - **Compact top-level actions**: common **Import backup** / **Export backup** actions are direct, while the low-frequency destructive action lives under **More**. The page stays focused on DSH archive management without a persistent source selector or redundant menus.
58
+ - **On-demand multi-select**: checkboxes stay hidden by default and appear only after clicking **Select multiple**. Select individual chats, every visible result, or an entire project; the selection bar can export, unarchive, or permanently delete the chosen chats in one action, while selections hidden by another filter remain intact.
58
59
  - **Unarchive** a single chat or a whole project group from the group's `⋯` menu — restored chats reappear in the sidebar immediately.
59
60
  - **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.
60
61
  - 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.
@@ -128,6 +129,13 @@ The suite (`test/*.test.mjs`) covers export records and real ZIP decoding, bound
128
129
 
129
130
  ## Version history
130
131
 
132
+ ### 0.9.0
133
+
134
+ - Added an on-demand multi-select mode: list checkboxes stay hidden until requested, then disappear automatically after a completed bulk action.
135
+ - Made common ZIP backup actions direct **Import backup / Export backup** controls and moved the destructive action under **More** for a cleaner header.
136
+ - Removed the cross-tool JSONL migration surface that could not provide native resume, keeping the plugin focused on DSH archived-chat management.
137
+ - Verified the new controls, backup preview, and single-line page title in a real DeepSeek Harness `0.1.0-rc.8` host.
138
+
131
139
  ### 0.8.1
132
140
 
133
141
  - Made the Chinese README the default repository and npm entry point; the English guide is now `README.en.md`.
package/README.md CHANGED
@@ -24,11 +24,11 @@ dsh plugin --profile web update dsh-archived-chats
24
24
 
25
25
  ## 兼容性
26
26
 
27
- 0.8.1 版本以 DeepSeek Harness `0.1.0-rc.7` 作为自动化兼容性基线。插件注册的是顶层 `settings.section`,因此 rc.7 针对 `settings.plugin.item` 的 keyed-slot 变更不影响本插件;同时已在 Harness `0.1.0-rc.8` 上完成真实宿主页面复核,覆盖归档列表、搜索、元数据编辑、批量操作、分组操作和导入预览。以后 Harness 发布新版本时,仍应在发布插件更新前重跑冒烟测试并检查真实宿主页面,因为客户端插槽和设计令牌契约仍可能演进。
27
+ 0.9.0 版本以 DeepSeek Harness `0.1.0-rc.7` 作为自动化兼容性基线。插件注册的是顶层 `settings.section`,因此 rc.7 针对 `settings.plugin.item` 的 keyed-slot 变更不影响本插件;同时已在 Harness `0.1.0-rc.8` 上完成真实宿主页面复核,覆盖归档列表、搜索、元数据编辑、批量与分组操作以及备份导入预览。以后 Harness 发布新版本时,仍应在发布插件更新前重跑冒烟测试并检查真实宿主页面,因为客户端插槽和设计令牌契约仍可能演进。
28
28
 
29
- ## 截图
29
+ ## 预览
30
30
 
31
- 以下截图来自当前 `0.8.1` 文档发布对应的 `0.8.x` UI,并在本地 DeepSeek Harness Web profile 中实际操作生成;本次 patch 不改变界面行为。
31
+ 以下截图均来自 `0.9.0` 在本地 DeepSeek Harness `0.1.0-rc.8` Web profile 中的实际操作。
32
32
 
33
33
  ![已归档的聊天总览](assets/screenshots/1-archived-chats.png)
34
34
  ![搜索与筛选](assets/screenshots/2-search.png)
@@ -42,9 +42,9 @@ dsh plugin --profile web update dsh-archived-chats
42
42
 
43
43
  1. 在 DSH 正常聊天的会话菜单中点击归档。归档只会把会话从侧边栏隐藏,工作区存档仍会保留会话数据。
44
44
  2. 打开 **设置 → 已归档的聊天**。页面按工作区分组,并在当前浏览器中记住分组的折叠状态。
45
- 3. 搜索、筛选、排序或选择会话;打开某一行的元数据编辑器添加标签与备注,或使用分组菜单执行项目级操作。
46
- 4. 使用 **导出备份** 导出单条、当前选中项或全部归档会话。恢复备份时点击 **导入备份**,先查看预览,保留无冲突会话的勾选状态后确认恢复。
47
- 5. 点击 **取消归档** 将会话放回侧边栏;只有确实需要永久删除时才点击 **删除**,确认弹窗会明确显示受影响的范围。
45
+ 3. 搜索、筛选或排序会话;需要多选时点击 **批量选择** 显示复选框,完成后会自动恢复简洁列表。也可打开某一行的元数据编辑器添加标签与备注,或使用分组菜单执行项目级操作。
46
+ 4. 点击顶部 **导入备份** 选择本插件导出的 ZIP,预览后确认无冲突会话;点击 **导出备份** 导出当前选中项,未选择时导出全部归档会话。单条会话也可以从行内操作导出。
47
+ 5. 点击 **取消归档** 将会话放回侧边栏;只有确实需要永久删除时才点击 **删除**,确认弹窗会明确显示受影响的范围。**全部删除** 收纳在顶部 **更多** 菜单中。
48
48
 
49
49
  ## 功能
50
50
 
@@ -54,7 +54,8 @@ dsh plugin --profile web update dsh-archived-chats
54
54
  - **存储统计**:概览条显示归档数量、已统计总大小与无法统计的会话数;每行显示各自占用。统计不会跟随符号链接,无法读取的会话目录显示为「无法统计」而非让请求失败。
55
55
  - **JSON + Markdown 备份**:可导出单条、当前选中项或全部归档会话。每个 ZIP 都包含带版本的清单、用于机器恢复的完整会话 JSON,以及方便阅读的 Markdown 对话稿。
56
56
  - **预览后导入与恢复**:选择 ZIP 备份后先检查全部会话,默认选中无冲突 ID 的项目,确认后作为已归档聊天恢复。已有 ID 会跳过,绝不会覆盖。
57
- - **灵活多选**:逐条选择、选择当前筛选结果或选择整个项目;选中后可一次导出、取消归档或永久删除,隐藏在其他筛选结果中的选择不会丢失。
57
+ - **紧凑顶部操作**:常用的 **导入备份** / **导出备份** 直接可用,低频危险操作收纳在 **更多**;页面专注于 DSH 归档管理,不常驻来源选择器或冗余菜单。
58
+ - **按需多选**:复选框默认隐藏,点击 **批量选择** 后才显示;可逐条选择、选择当前筛选结果或整个项目。选中后可一次导出、取消归档或永久删除,隐藏在其他筛选结果中的选择不会丢失。
58
59
  - **取消归档**单个聊天,或从分组的 `⋯` 菜单整组取消——恢复的聊天会立刻回到侧边栏。
59
60
  - **删除**单个聊天、某个项目分组或全部(**全部删除**),均有确认弹窗。删除是彻底的:会话日志从磁盘移除、从工作区记录中摘除、注册表内存索引同步清理,主侧边栏的条目也会立即消失。
60
61
  - 仍驻留后台的会话也**当场删除**:插件按官方生命周期的拆除顺序原地停用并注销会话(取消 → 静默 → 落盘 → 拆纤程 → 摘出注册表),持久层随之释放写入通道,同一次请求内即完成物理删除——无需重启。若当前 DSH 版本不提供所需内部接口,则自动回退为「永久停用 + 下次启动完成删除」,停用期间会话保持隐藏。
@@ -114,7 +115,7 @@ JSON 会保留附件引用,但**本版不复制附件二进制,也不包含
114
115
 
115
116
  ## 实现概览
116
117
 
117
- 插件由两部分组成:Host 服务层负责读取和修改本地归档数据,浏览器设置页负责搜索、筛选、备份和恢复操作。所有修改都通过受保护的本地路由完成;导入会先预览,删除会优先尝试安全的生命周期拆除,能力不足时回退到下次启动处理。
118
+ 插件由两部分组成:Host 服务层负责读取和修改本地归档数据,浏览器设置页负责搜索、筛选、备份和恢复。所有修改都通过受保护的本地路由完成;导入会先预览,删除会优先尝试安全的生命周期拆除,能力不足时回退到下次启动处理。
118
119
 
119
120
  普通用户需要了解的数据保存、备份限制、删除结果和兼容性说明已列在本 README 中。路由清单、数据流、恢复事务、实时删除生命周期和失败回退等维护者细节请参阅 [架构文档](docs/ARCHITECTURE.md)。
120
121
 
@@ -124,10 +125,17 @@ JSON 会保留附件引用,但**本版不复制附件二进制,也不包含
124
125
  npm test
125
126
  ```
126
127
 
127
- 测试套件(`test/*.test.mjs`)覆盖导出记录与真实 ZIP 解包、有界导入校验、恢复事务、元数据存储、统计服务以及宿主+浏览器冒烟测试,使用隔离的临时 DSH 主目录和模拟运行时,不会读取或修改真实会话。
128
+ 测试套件(`test/*.test.mjs`)覆盖导出记录与真实 ZIP 解包、有界导入校验、恢复事务、元数据存储、统计服务,以及宿主+浏览器冒烟测试。测试使用隔离的临时 DSH 主目录和模拟运行时,不会读取或修改真实会话。
128
129
 
129
130
  ## 版本更新记录
130
131
 
132
+ ### 0.9.0
133
+
134
+ - 新增按需显示的批量选择模式:列表默认不展示复选框,点击入口后才显示,完成批量操作后自动退出。
135
+ - 将常用 ZIP 备份操作改为直接的 **导入备份 / 导出备份**,危险操作收纳到 **更多**,精简页头布局。
136
+ - 移除未提供原生继续能力的跨工具 JSONL 迁移入口,让插件专注于 DSH 已归档聊天管理。
137
+ - 在 DeepSeek Harness `0.1.0-rc.8` 真实宿主中复核新控件、备份预览和标题单行布局。
138
+
131
139
  ### 0.8.1
132
140
 
133
141
  - 将中文 README 设为仓库和 npm 包的默认入口,英文文档改为 `README.en.md`。
Binary file
Binary file
package/lib/client.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // packages emit.
8
8
  //
9
9
  // What this plugin is: one settings section — 已归档的聊天 / Archived Chats —
10
- // modeled 1:1 on the CodeX archived-chats page. The stock DSH sidebar hides
10
+ // modeled on the host archive-management layout. The stock DSH sidebar hides
11
11
  // archived sessions with no list surface; this page restores one: search,
12
12
  // type/project filters, sorting, multi-selection, unarchive, delete, and
13
13
  // batch actions, all driven through the host half's /plugins/dsh-archived-chats/*
@@ -48,6 +48,9 @@ window.__ModuleLoader__.load({
48
48
  "locale.intl": "zh-CN",
49
49
  "nav": "已归档的聊天",
50
50
  "page.title": "已归档的聊天",
51
+ "action.import": "导入备份",
52
+ "action.export": "导出备份",
53
+ "action.more": "更多",
51
54
  "delete.all": "全部删除",
52
55
  "import.action": "导入备份",
53
56
  "import.title": "导入归档备份",
@@ -80,6 +83,8 @@ window.__ModuleLoader__.load({
80
83
  "sort.title": "按标题",
81
84
  "selection.visible": "选择当前结果",
82
85
  "selection.group": "选择此项目",
86
+ "selection.start": "批量选择",
87
+ "selection.done": "完成",
83
88
  "selection.clear": "清除",
84
89
  "bulk.unarchive": "取消归档",
85
90
  "bulk.delete": "删除",
@@ -120,6 +125,9 @@ window.__ModuleLoader__.load({
120
125
  "locale.intl": "en-US",
121
126
  "nav": "Archived Chats",
122
127
  "page.title": "Archived Chats",
128
+ "action.import": "Import backup",
129
+ "action.export": "Export backup",
130
+ "action.more": "More",
123
131
  "delete.all": "Delete All",
124
132
  "import.action": "Import backup",
125
133
  "import.title": "Import archived backup",
@@ -152,6 +160,8 @@ window.__ModuleLoader__.load({
152
160
  "sort.title": "Title",
153
161
  "selection.visible": "Select visible chats",
154
162
  "selection.group": "Select this project",
163
+ "selection.start": "Select multiple",
164
+ "selection.done": "Done",
155
165
  "selection.clear": "Clear",
156
166
  "bulk.unarchive": "Unarchive",
157
167
  "bulk.delete": "Delete",
@@ -217,7 +227,7 @@ window.__ModuleLoader__.load({
217
227
  : n === 1
218
228
  ? "1 chat is parked and will be permanently deleted after DSH restarts"
219
229
  : `${n} chats are parked and will be permanently deleted after DSH restarts`;
220
- // Success confirmation after a completed delete — same copy as CodeX.
230
+ // Success confirmation after a completed delete.
221
231
  const deletedText = (t, n) => isZh(t)
222
232
  ? "已删除归档聊天"
223
233
  : n === 1 ? "Archived chat deleted" : `${n} archived chats deleted`;
@@ -358,17 +368,18 @@ window.__ModuleLoader__.load({
358
368
  const CSS = `
359
369
  .dac-page{position:relative;display:flex;flex-direction:column;gap:14px;padding:4px 0 28px;font-family:inherit}
360
370
  .dac-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
361
- .dac-title{margin:0;color:var(--dsw-alias-label-primary);font-size:18px;font-weight:500;line-height:28px;outline:none}
362
- .dac-head-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;flex-wrap:wrap}
363
- .dac-exportall{display:inline-flex;align-items:center;gap:6px;border:none;border-radius:999px;padding:6px 12px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background .15s,color .15s}
364
- .dac-exportall:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary)}
365
- .dac-exportall:disabled{opacity:.45;cursor:default}
366
- .dac-import{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:5px 11px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;cursor:pointer}
367
- .dac-import:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
368
- .dac-import:disabled{opacity:.45;cursor:default}
369
- .dac-deleteall{display:inline-flex;align-items:center;gap:6px;border:none;border-radius:999px;padding:6px 14px;background:transparent;color:var(--dsw-alias-state-error-primary);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background .15s}
370
- .dac-deleteall:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}
371
- .dac-deleteall:disabled{opacity:.45;cursor:default}
371
+ .dac-title{flex:none;white-space:nowrap;margin:0;color:var(--dsw-alias-label-primary);font-size:18px;font-weight:500;line-height:28px;outline:none}
372
+ .dac-head-actions{position:relative;display:flex;flex:0 1 auto;min-width:0;align-items:center;justify-content:flex-end;gap:6px;flex-wrap:nowrap}
373
+ .dac-action-wrap{position:relative;display:inline-flex}
374
+ .dac-action-trigger{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:5px 11px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;white-space:nowrap;cursor:pointer}
375
+ .dac-action-trigger:hover:not(:disabled),.dac-action-trigger[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
376
+ .dac-action-trigger:disabled{opacity:.45;cursor:default}
377
+ .dac-action-menu{position:absolute;right:0;top:calc(100% + 6px);z-index:60;min-width:190px;display:flex;flex-direction:column;gap:2px;padding:4px;border:1px solid var(--dsw-alias-border-inverted);border-radius:10px;background:var(--dsw-specific-menu);box-shadow:var(--dsw-shadow-lv3)}
378
+ .dac-action-menu-item{display:flex;align-items:center;width:100%;border:none;border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;text-align:left;padding:7px 10px;white-space:nowrap;cursor:pointer}
379
+ .dac-action-menu-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
380
+ .dac-action-menu-item:disabled{opacity:.45;cursor:default}
381
+ .dac-action-menu-item.dac-danger{color:var(--dsw-alias-state-error-primary)}
382
+ .dac-action-menu-item.dac-danger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}
372
383
  .dac-search{display:flex;align-items:center;gap:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-1);padding:8px 12px;color:var(--dsw-alias-label-tertiary);transition:border-color .15s}
373
384
  .dac-search:focus-within{border-color:var(--dsw-alias-border-l1,var(--dsw-alias-border-l2))}
374
385
  .dac-search input{flex:1;min-width:0;border:none;outline:none;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;padding:0}
@@ -378,7 +389,10 @@ window.__ModuleLoader__.load({
378
389
  .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}
379
390
  .dac-select:hover{border-color:var(--dsw-alias-border-l1,var(--dsw-alias-border-l2))}
380
391
  .dac-chevron{position:absolute;right:10px;pointer-events:none;color:var(--dsw-alias-label-tertiary);display:inline-flex}
381
- .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}
392
+ .dac-selection-controls{display:flex;align-items:center;gap:8px;margin-left:auto}
393
+ .dac-selection-toggle{display:inline-flex;align-items:center;gap:7px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;cursor:pointer;user-select:none}
394
+ .dac-selection-mode{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 12px;cursor:pointer}
395
+ .dac-selection-mode:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
382
396
  .dac-checkbox{width:16px;height:16px;margin:0;accent-color:var(--dsw-alias-interactive-primary,#6e6ef7);cursor:pointer;flex:none}
383
397
  .dac-checkbox:disabled{cursor:default;opacity:.45}
384
398
  .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)}
@@ -479,7 +493,7 @@ window.__ModuleLoader__.load({
479
493
  .dac-btn-primary{border:1px solid var(--dsw-alias-interactive-primary,#6e6ef7);border-radius:9px;background:var(--dsw-alias-interactive-primary,#6e6ef7);color:var(--dsw-alias-bg-layer-1);font:inherit;font-size:13px;line-height:20px;padding:5px 14px;cursor:pointer}
480
494
  .dac-btn-primary:hover:not(:disabled){opacity:.9}
481
495
  .dac-btn-primary:disabled{opacity:.5;cursor:default}
482
- @media (max-width:640px){[role="dialog"][data-dac-section-active="1"]>nav{display:none}[role="dialog"][data-dac-section-active="1"]>nav+div{width:100%;min-width:0}.dac-head{align-items:flex-start}.dac-head-actions{max-width:65%}.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}.dac-summary{gap:6px}.dac-row-meta{gap:4px}}
496
+ @media (max-width:640px){[role="dialog"][data-dac-section-active="1"]>nav{display:none}[role="dialog"][data-dac-section-active="1"]>nav+div{width:100%;min-width:0}.dac-head{align-items:flex-start;flex-wrap:wrap}.dac-head-actions{width:100%;justify-content:flex-start}.dac-action-trigger{padding:5px 9px}.dac-action-menu{left:0;right:auto;max-width:calc(100vw - 32px)}.dac-action-wrap:last-child .dac-action-menu{left:auto;right:0}.dac-selection-controls{width:100%;justify-content:space-between}.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}.dac-summary{gap:6px}.dac-row-meta{gap:4px}}
483
497
  `;
484
498
 
485
499
  function ensureStyle() {
@@ -987,7 +1001,7 @@ window.__ModuleLoader__.load({
987
1001
  });
988
1002
  }
989
1003
 
990
- function GroupSection({ group, t, collapsed, onToggleCollapsed, menuOpen, onToggleMenu, onUnarchive, onDelete, onExport, busy, selected, onToggleSelected, stats, metadataStatus, onEditMetadata }) {
1004
+ function GroupSection({ group, t, collapsed, onToggleCollapsed, menuOpen, onToggleMenu, onUnarchive, onDelete, onExport, busy, selectionMode, selected, onToggleSelected, stats, metadataStatus, onEditMetadata }) {
991
1005
  const wrapRef = _react.useRef(null);
992
1006
  _react.useEffect(() => {
993
1007
  if (!menuOpen) return void 0;
@@ -1010,7 +1024,7 @@ window.__ModuleLoader__.load({
1010
1024
  (0, jsx.jsxs)("div", {
1011
1025
  className: "dac-group-left",
1012
1026
  children: [
1013
- (0, jsx.jsx)(SelectionCheckbox, {
1027
+ selectionMode && (0, jsx.jsx)(SelectionCheckbox, {
1014
1028
  checked: allSelected,
1015
1029
  indeterminate: someSelected,
1016
1030
  disabled: groupBusy,
@@ -1069,12 +1083,12 @@ window.__ModuleLoader__.load({
1069
1083
  collapsed !== true && (0, jsx.jsx)("div", {
1070
1084
  className: "dac-list",
1071
1085
  children: group.items.map((session) => (0, jsx.jsxs)("div", {
1072
- className: selected.has(session.id) ? "dac-row dac-selected" : "dac-row",
1086
+ className: selectionMode && selected.has(session.id) ? "dac-row dac-selected" : "dac-row",
1073
1087
  children: [
1074
- (0, jsx.jsxs)("label", {
1088
+ (0, jsx.jsxs)(selectionMode ? "label" : "div", {
1075
1089
  className: "dac-row-select",
1076
1090
  children: [
1077
- (0, jsx.jsx)(SelectionCheckbox, {
1091
+ selectionMode && (0, jsx.jsx)(SelectionCheckbox, {
1078
1092
  checked: selected.has(session.id),
1079
1093
  disabled: busy[session.id] === true,
1080
1094
  ariaLabel: selectChatLabel(t, session.title ?? t("chat.untitled")),
@@ -1140,7 +1154,7 @@ window.__ModuleLoader__.load({
1140
1154
  }, group.key);
1141
1155
  }
1142
1156
 
1143
- /** The settings section page: archived chats, CodeX-layout. */
1157
+ /** The settings section page for archived-chat management. */
1144
1158
  function ArchivedChatsSection({ t, refreshSidebar }) {
1145
1159
  const [sessions, setSessions] = _react.useState(null);
1146
1160
  const [loadError, setLoadError] = _react.useState(null);
@@ -1161,18 +1175,41 @@ window.__ModuleLoader__.load({
1161
1175
  const [metaBusy, setMetaBusy] = _react.useState(false);
1162
1176
  const [importPreview, setImportPreview] = _react.useState(null);
1163
1177
  const [importBusy, setImportBusy] = _react.useState(false);
1178
+ const [selectionMode, setSelectionMode] = _react.useState(false);
1179
+ const [actionMenu, setActionMenu] = _react.useState(null);
1164
1180
  const pageRef = _react.useRef(null);
1165
1181
  const pageHeadingRef = _react.useRef(null);
1182
+ const actionMenuRef = _react.useRef(null);
1183
+ const moreActionRef = _react.useRef(null);
1166
1184
  const deleteReturnFocusRef = _react.useRef(null);
1167
1185
  const metaReturnFocusRef = _react.useRef(null);
1168
1186
  const importInputRef = _react.useRef(null);
1169
1187
 
1188
+ _react.useEffect(() => {
1189
+ if (actionMenu === null) return undefined;
1190
+ const onDown = (event) => {
1191
+ if (actionMenuRef.current && !actionMenuRef.current.contains(event.target)) setActionMenu(null);
1192
+ };
1193
+ document.addEventListener("mousedown", onDown);
1194
+ return () => {
1195
+ document.removeEventListener("mousedown", onDown);
1196
+ };
1197
+ }, [actionMenu]);
1198
+
1199
+ const closeActionMenuFromKeyboard = (event) => {
1200
+ if (event.key !== "Escape" || actionMenu === null) return;
1201
+ event.preventDefault();
1202
+ event.stopPropagation();
1203
+ setActionMenu(null);
1204
+ moreActionRef.current?.focus?.();
1205
+ };
1206
+
1170
1207
  _react.useEffect(() => {
1171
1208
  if (sessions === null || loadError !== null) return undefined;
1172
1209
  return markArchiveDialog(pageRef.current);
1173
1210
  }, [sessions, loadError]);
1174
1211
 
1175
- // Success confirmations are transient toasts (CodeX behavior);
1212
+ // Success confirmations are transient toasts;
1176
1213
  // errors stay until dismissed.
1177
1214
  _react.useEffect(() => {
1178
1215
  if (notice?.kind !== "ok") return undefined;
@@ -1242,6 +1279,10 @@ window.__ModuleLoader__.load({
1242
1279
  const toggleSelected = (ids, checked) => {
1243
1280
  setSelected((current) => setVisibleSelection(current, ids, checked));
1244
1281
  };
1282
+ const finishSelectionMode = () => {
1283
+ setSelected(new Set());
1284
+ setSelectionMode(false);
1285
+ };
1245
1286
 
1246
1287
  const markBusy = (ids, value) => {
1247
1288
  setBusy((prev) => {
@@ -1254,7 +1295,7 @@ window.__ModuleLoader__.load({
1254
1295
  });
1255
1296
  };
1256
1297
 
1257
- const unarchive = async (ids) => {
1298
+ const unarchive = async (ids, finishSelectionAfter = false) => {
1258
1299
  if (ids.length === 0) return;
1259
1300
  markBusy(ids, true);
1260
1301
  setNotice(null);
@@ -1262,6 +1303,7 @@ window.__ModuleLoader__.load({
1262
1303
  await post("/unarchive-all", { sessionIds: ids });
1263
1304
  setSessions((prev) => (prev ?? []).filter((s) => !ids.includes(s.id)));
1264
1305
  setSelected((current) => setVisibleSelection(current, ids, false));
1306
+ if (finishSelectionAfter) finishSelectionMode();
1265
1307
  } catch (error) {
1266
1308
  setNotice({ kind: "error", text: String(error.message ?? error) });
1267
1309
  } finally {
@@ -1269,7 +1311,7 @@ window.__ModuleLoader__.load({
1269
1311
  }
1270
1312
  };
1271
1313
 
1272
- const runDelete = async (ids) => {
1314
+ const runDelete = async (ids, finishSelectionAfter = false) => {
1273
1315
  if (ids.length === 0) return;
1274
1316
  markBusy(ids, true);
1275
1317
  setNotice(null);
@@ -1291,6 +1333,7 @@ window.__ModuleLoader__.load({
1291
1333
  if (failed.length > 0) setNotice({ kind: "error", text: failureText(t, failed) });
1292
1334
  else if (pending.length > 0) setNotice({ kind: "error", text: pendingText(t, pending.length) });
1293
1335
  else if (deleted.length > 0) setNotice({ kind: "ok", text: deletedText(t, deleted.length) });
1336
+ if (finishSelectionAfter && failed.length === 0 && deleted.length + pending.length === ids.length) finishSelectionMode();
1294
1337
  } catch (error) {
1295
1338
  const deleted = error.body?.deleted ?? [];
1296
1339
  const pending = error.body?.pending ?? [];
@@ -1317,7 +1360,7 @@ window.__ModuleLoader__.load({
1317
1360
  if (ids.length === 0) return;
1318
1361
  deleteReturnFocusRef.current = document.activeElement;
1319
1362
  if (selectedScope) {
1320
- setConfirm({ title: t("confirm.deleteSelected.title"), body: deleteSelectedBody(t, ids.length), ids });
1363
+ setConfirm({ title: t("confirm.deleteSelected.title"), body: deleteSelectedBody(t, ids.length), ids, finishSelectionAfter: true });
1321
1364
  } else if (ids.length === 1 && groupName === null) {
1322
1365
  setConfirm({ title: t("confirm.deleteOne.title"), body: t("confirm.deleteOne.body"), ids });
1323
1366
  } else if (groupName !== null && groupName !== void 0) {
@@ -1354,8 +1397,9 @@ window.__ModuleLoader__.load({
1354
1397
  };
1355
1398
 
1356
1399
  const exportSessions = (ids) => {
1357
- if (!submitExport(ids)) return;
1400
+ if (!submitExport(ids)) return false;
1358
1401
  setNotice({ kind: "ok", text: t("export.started") });
1402
+ return true;
1359
1403
  };
1360
1404
 
1361
1405
  const importFile = async (file) => {
@@ -1501,29 +1545,41 @@ window.__ModuleLoader__.load({
1501
1545
  children: [
1502
1546
  (0, jsx.jsx)("h2", { ref: pageHeadingRef, tabIndex: -1, className: "dac-title", children: t("page.title") }),
1503
1547
  (0, jsx.jsxs)("div", {
1548
+ ref: actionMenuRef,
1504
1549
  className: "dac-head-actions",
1505
- children: [
1550
+ onKeyDown: closeActionMenuFromKeyboard,
1551
+ children: [
1552
+ (0, jsx.jsx)("div", { className: "dac-action-wrap", children:
1506
1553
  (0, jsx.jsxs)("button", {
1507
1554
  type: "button",
1508
- className: "dac-import",
1555
+ className: "dac-action-trigger",
1509
1556
  disabled: importBusy,
1510
1557
  onClick: () => importInputRef.current?.click?.(),
1511
- children: [(0, jsx.jsx)(IconUpload, {}), (0, jsx.jsx)("span", { children: t("import.action") })]
1512
- }),
1513
- selectedIds.length === 0 && (0, jsx.jsxs)("button", {
1514
- type: "button",
1515
- className: "dac-exportall",
1516
- disabled: allIds.length === 0 || allBusy,
1517
- onClick: () => exportSessions(allIds),
1518
- children: [(0, jsx.jsx)(IconDownload, {}), (0, jsx.jsx)("span", { children: t("export.all") })]
1519
- }),
1558
+ children: [(0, jsx.jsx)(IconUpload, {}), (0, jsx.jsx)("span", { children: t("action.import") })]
1559
+ })
1560
+ }),
1561
+ (0, jsx.jsx)("div", { className: "dac-action-wrap", children:
1520
1562
  (0, jsx.jsxs)("button", {
1521
1563
  type: "button",
1522
- className: "dac-deleteall",
1523
- disabled: allIds.length === 0,
1524
- onClick: () => askDelete(allIds, void 0),
1525
- children: [(0, jsx.jsx)(IconTrash, {}), (0, jsx.jsx)("span", { children: t("delete.all") })]
1564
+ className: "dac-action-trigger",
1565
+ disabled: allIds.length === 0 || allBusy,
1566
+ onClick: () => { const didExport = exportSessions(selectedIds.length > 0 ? selectedIds : allIds); if (didExport && selectedIds.length > 0) finishSelectionMode(); },
1567
+ children: [(0, jsx.jsx)(IconDownload, {}), (0, jsx.jsx)("span", { children: t("action.export") })]
1526
1568
  })
1569
+ }),
1570
+ (0, jsx.jsxs)("div", { className: "dac-action-wrap", children: [
1571
+ (0, jsx.jsxs)("button", {
1572
+ ref: moreActionRef,
1573
+ type: "button",
1574
+ className: "dac-action-trigger",
1575
+ disabled: allIds.length === 0,
1576
+ "aria-controls": "dac-more-actions",
1577
+ "aria-expanded": actionMenu === "more",
1578
+ onClick: () => setActionMenu((current) => current === "more" ? null : "more"),
1579
+ children: [(0, jsx.jsx)(IconDots, {}), (0, jsx.jsx)("span", { children: t("action.more") })]
1580
+ }),
1581
+ actionMenu === "more" && (0, jsx.jsx)("div", { id: "dac-more-actions", className: "dac-action-menu", children: (0, jsx.jsx)("button", { type: "button", className: "dac-action-menu-item dac-danger", onClick: () => { setActionMenu(null); askDelete(allIds, void 0); }, children: t("delete.all") }) })
1582
+ ] })
1527
1583
  ]
1528
1584
  }),
1529
1585
  (0, jsx.jsx)("input", {
@@ -1603,17 +1659,28 @@ window.__ModuleLoader__.load({
1603
1659
  { value: "title", label: t("sort.title") }
1604
1660
  ]
1605
1661
  }),
1606
- visibleIds.length > 0 && (0, jsx.jsxs)("label", {
1607
- className: "dac-selection-toggle",
1662
+ (visibleIds.length > 0 || selectionMode) && (0, jsx.jsxs)("div", {
1663
+ className: "dac-selection-controls",
1608
1664
  children: [
1609
- (0, jsx.jsx)(SelectionCheckbox, {
1610
- checked: allVisibleSelected,
1611
- indeterminate: someVisibleSelected,
1612
- disabled: visibleBusy,
1613
- ariaLabel: t("selection.visible"),
1614
- onChange: (checked) => toggleSelected(visibleIds, checked)
1665
+ selectionMode && visibleIds.length > 0 && (0, jsx.jsxs)("label", {
1666
+ className: "dac-selection-toggle",
1667
+ children: [
1668
+ (0, jsx.jsx)(SelectionCheckbox, {
1669
+ checked: allVisibleSelected,
1670
+ indeterminate: someVisibleSelected,
1671
+ disabled: visibleBusy,
1672
+ ariaLabel: t("selection.visible"),
1673
+ onChange: (checked) => toggleSelected(visibleIds, checked)
1674
+ }),
1675
+ (0, jsx.jsx)("span", { children: t("selection.visible") })
1676
+ ]
1615
1677
  }),
1616
- (0, jsx.jsx)("span", { children: t("selection.visible") })
1678
+ (0, jsx.jsx)("button", {
1679
+ type: "button",
1680
+ className: "dac-selection-mode",
1681
+ onClick: selectionMode ? finishSelectionMode : () => setSelectionMode(true),
1682
+ children: t(selectionMode ? "selection.done" : "selection.start")
1683
+ })
1617
1684
  ]
1618
1685
  })
1619
1686
  ]
@@ -1631,15 +1698,15 @@ window.__ModuleLoader__.load({
1631
1698
  type: "button",
1632
1699
  className: "dac-bulk-btn",
1633
1700
  disabled: selectedBusy,
1634
- onClick: () => exportSessions(selectedIds),
1701
+ onClick: () => { if (exportSessions(selectedIds)) finishSelectionMode(); },
1635
1702
  children: t("export.selected")
1636
1703
  }),
1637
1704
  (0, jsx.jsx)("button", {
1638
1705
  type: "button",
1639
1706
  className: "dac-bulk-btn",
1640
- disabled: selectedBusy,
1641
- onClick: () => unarchive(selectedIds),
1642
- children: t("bulk.unarchive")
1707
+ disabled: selectedBusy,
1708
+ onClick: () => unarchive(selectedIds, true),
1709
+ children: t("bulk.unarchive")
1643
1710
  }),
1644
1711
  (0, jsx.jsx)("button", {
1645
1712
  type: "button",
@@ -1694,12 +1761,13 @@ window.__ModuleLoader__.load({
1694
1761
  onDelete: askDelete,
1695
1762
  onExport: exportSessions,
1696
1763
  busy,
1764
+ selectionMode,
1697
1765
  selected,
1698
1766
  onToggleSelected: toggleSelected,
1699
1767
  stats,
1700
1768
  metadataStatus,
1701
1769
  onEditMetadata: openMetadataEditor
1702
- }, group.key)),
1770
+ }, group.key)),
1703
1771
  importPreview !== null && (0, jsx.jsx)(ImportDialog, {
1704
1772
  preview: importPreview,
1705
1773
  t,
@@ -1727,7 +1795,7 @@ window.__ModuleLoader__.load({
1727
1795
  busy: confirm.ids.some((id) => busy[id] === true),
1728
1796
  returnFocus: deleteReturnFocusRef.current,
1729
1797
  fallbackFocusRef: pageHeadingRef,
1730
- onConfirm: () => runDelete(confirm.ids),
1798
+ onConfirm: () => runDelete(confirm.ids, confirm.finishSelectionAfter === true),
1731
1799
  onCancel: () => setConfirm(null)
1732
1800
  })
1733
1801
  ]
@@ -1765,7 +1833,7 @@ window.__ModuleLoader__.load({
1765
1833
  //#endregion
1766
1834
 
1767
1835
  exports.SETTINGS_NS = SETTINGS_NS;
1768
- exports.__test = { formatBytes, matchesArchivedSession, filterByTag, sortArchivedSessions, setVisibleSelection, reconcileSelection, markArchiveDialog, submitExport, submitImportFile, editIconSpec: EDIT_ICON_SPEC };
1836
+ exports.__test = { formatBytes, matchesArchivedSession, filterByTag, sortArchivedSessions, setVisibleSelection, reconcileSelection, markArchiveDialog, submitExport, submitImportFile, editIconSpec: EDIT_ICON_SPEC };
1769
1837
  exports.apply = apply;
1770
1838
  exports.inject = inject;
1771
1839
  return module.exports;
package/lib/index.js CHANGED
@@ -89,7 +89,7 @@ const PERSISTENCE_KEYS = ['sessionPersistence'];
89
89
  const ROUTE_PREFIX = '/plugins/dsh-archived-chats';
90
90
  /** Custom header required on POSTs: cheap CSRF hardening for a loopback UI. */
91
91
  const GUARD_HEADER = 'x-dsh-archived-chats';
92
- const PLUGIN_VERSION = '0.8.0';
92
+ const PLUGIN_VERSION = '0.9.0';
93
93
 
94
94
  //#region wire helpers
95
95
  /** Read and JSON-parse a request body (empty body → {}). */
@@ -109,8 +109,8 @@ function readBody(req) {
109
109
 
110
110
  const IMPORT_UPLOAD_LIMIT = IMPORT_LIMITS.maxCompressedBytes;
111
111
 
112
- /** Read one bounded multipart ZIP part. The body is never accepted as a path. */
113
- function readImportUpload(req) {
112
+ /** Read bounded multipart fields. Values stay in memory and are never paths. */
113
+ function readMultipartFields(req, limit, tooLargeMessage) {
114
114
  return new Promise((resolve, reject) => {
115
115
  const chunks = [];
116
116
  let bytes = 0;
@@ -128,29 +128,50 @@ function readImportUpload(req) {
128
128
  if (settled) return;
129
129
  const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
130
130
  bytes += value.length;
131
- if (bytes > IMPORT_UPLOAD_LIMIT) { fail('import upload is too large', 413); return; }
131
+ if (bytes > limit) { fail(tooLargeMessage, 413); return; }
132
132
  chunks.push(value);
133
133
  });
134
134
  req.on('end', () => {
135
135
  if (settled) return;
136
136
  try {
137
137
  const body = Buffer.concat(chunks);
138
- const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'));
139
- const firstBoundary = body.indexOf(boundary);
140
- if (firstBoundary !== 0 || headerEnd < 0) throw new Error('ZIP file field is missing');
141
- const headers = body.subarray(boundary.length + 2, headerEnd).toString('utf8').toLowerCase();
142
- if (!headers.includes('content-disposition:') || !headers.includes('name="file"')) throw new Error('ZIP file field is missing');
143
- const closing = Buffer.from(`\r\n${boundary.toString()}--`);
144
- const end = body.indexOf(closing, headerEnd + 4);
145
- if (end < 0) throw new Error('multipart body is incomplete');
138
+ if (!body.subarray(0, boundary.length).equals(boundary)) throw new Error('multipart body is incomplete');
139
+ const fields = new Map();
140
+ let cursor = 0;
141
+ while (cursor < body.length) {
142
+ if (!body.subarray(cursor, cursor + boundary.length).equals(boundary)) throw new Error('multipart body is incomplete');
143
+ cursor += boundary.length;
144
+ if (body.subarray(cursor, cursor + 2).toString('ascii') === '--') break;
145
+ if (body.subarray(cursor, cursor + 2).toString('ascii') !== '\r\n') throw new Error('multipart body is incomplete');
146
+ cursor += 2;
147
+ const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), cursor);
148
+ if (headerEnd < 0) throw new Error('multipart body is incomplete');
149
+ const headers = body.subarray(cursor, headerEnd).toString('utf8').toLowerCase();
150
+ const nameMatch = headers.match(/(?:^|\r\n)content-disposition:[^\r\n]*\bname="([^"]+)"/i);
151
+ if (!nameMatch) throw new Error('multipart field name is missing');
152
+ const contentStart = headerEnd + 4;
153
+ const next = body.indexOf(Buffer.from(`\r\n${boundary.toString()}`), contentStart);
154
+ if (next < 0) throw new Error('multipart body is incomplete');
155
+ if (!fields.has(nameMatch[1])) fields.set(nameMatch[1], body.subarray(contentStart, next));
156
+ cursor = next + 2;
157
+ }
146
158
  settled = true;
147
- resolve(new Uint8Array(body.subarray(headerEnd + 4, end)));
159
+ resolve(fields);
148
160
  } catch (error) { fail(String(error?.message ?? error)); }
149
161
  });
150
162
  req.on('error', (error) => { if (!settled) { settled = true; reject(error); } });
151
163
  });
152
164
  }
153
165
 
166
+ /** Read one bounded multipart ZIP part using the shared multipart reader. */
167
+ function readImportUpload(req) {
168
+ return readMultipartFields(req, IMPORT_UPLOAD_LIMIT, 'import upload is too large').then((fields) => {
169
+ const bytes = fields.get('file');
170
+ if (bytes === undefined) throw Object.assign(new Error('ZIP file field is missing'), { status: 400 });
171
+ return new Uint8Array(bytes);
172
+ });
173
+ }
174
+
154
175
  function createImportTokenStore({ now = () => Date.now(), ttlMs = 10 * 60 * 1000 } = {}) {
155
176
  const tokens = new Map();
156
177
  function cleanup() {
@@ -629,6 +650,7 @@ async function sweepPendingDeletions(ctx, registry, persistence, statsService, m
629
650
  }
630
651
  }
631
652
  }
653
+
632
654
  //#endregion
633
655
 
634
656
  //#region routes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-archived-chats",
3
- "version": "0.8.1",
4
- "description": "DeepSeek Harness 已归档会话管理页:搜索、标签、备注、存储统计、批量取消归档/删除、JSON/Markdown ZIP 备份,以及预览后导入恢复。Archived Chats management with search, tags, notes, storage insights, bulk actions, unarchive, delete, and preview-first JSON/Markdown ZIP import and restore.",
3
+ "version": "0.9.0",
4
+ "description": "DeepSeek Harness 已归档会话管理页:搜索、标签、批量操作与备份恢复。Archived Chats management with search, metadata, bulk actions, and backup restore.",
5
5
  "license": "MIT",
6
6
  "author": "Ultronen",
7
7
  "repository": {
@@ -17,6 +17,7 @@
17
17
  "dsh",
18
18
  "dsh-plugin",
19
19
  "archive",
20
+ "backup",
20
21
  "sessions",
21
22
  "web-ui"
22
23
  ],