dsh-native-session-delete 1.0.7 → 1.1.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/AGENTS.md CHANGED
@@ -6,7 +6,7 @@ or remove `dsh-native-session-delete` in a selected DeepSeek Harness profile.
6
6
  ## Safety and responsibility boundary
7
7
 
8
8
  - Confirm the target DSH installation and profile. Use `web` only when it is the user's target.
9
- - Use the fixed v1.0.7 package below; never substitute a moving branch or an unreviewed source.
9
+ - Use the fixed v1.1.0 package below; never substitute a moving branch or an unreviewed source.
10
10
  - Do not print session contents, full profile files, transcript paths, credentials, or other private data.
11
11
  - Do not start, stop, or restart DSH without explicit permission.
12
12
  - Preserve all sessions, unrelated plugins, and user-owned profile changes.
@@ -22,16 +22,17 @@ requirements. A cancelled confirmation is the safe default and must not send a d
22
22
 
23
23
  ## Fixed package and standard bundle
24
24
 
25
- The v1.0.7 package is a standard DSH bundle with a `dsh.bundle` profile patch. Its exact package spec is:
25
+ The v1.1.0 package is a standard DSH bundle with a `dsh.bundle` profile patch. Its exact package spec is:
26
26
 
27
27
  ```text
28
- dsh-native-session-delete@1.0.7
28
+ dsh-native-session-delete@1.1.0
29
29
  ```
30
30
 
31
31
  The bundle disables the official workspace row while installed and inserts a uniquely identified native
32
32
  workspace row. Removing `dsh-native-session-delete` removes that layer, allowing DSH to restore the official
33
33
  workspace row. Do not install the tarball under `@deepseek-ai/dsh-client-ui-workspace`; that old aliasing
34
- approach is not the v1.0.7 contract.
34
+ approach is not the v1.1.0 contract. The product is shown to users as **DSH Native Session Manager** while
35
+ retaining the existing npm package identity for safe upgrades.
35
36
 
36
37
  ## Detect the target DSH
37
38
 
@@ -56,13 +57,13 @@ record the selected profile's relevant metadata before invoking it, without prin
56
57
  With an existing `dsh` command, run exactly:
57
58
 
58
59
  ```sh
59
- dsh plugin --profile web add dsh-native-session-delete@1.0.7
60
+ dsh plugin --profile web add dsh-native-session-delete@1.1.0
60
61
  ```
61
62
 
62
63
  With DSH-Portable, run from its root:
63
64
 
64
65
  ```powershell
65
- .\dsh.exe plugin --profile web add dsh-native-session-delete@1.0.7
66
+ .\dsh.exe plugin --profile web add dsh-native-session-delete@1.1.0
66
67
  ```
67
68
 
68
69
  Use the same `add` command to update or repair. On Windows, `install.ps1` from the same fixed Release may
@@ -86,17 +87,21 @@ dsh plugin --profile web list dsh-native-session-delete --depth 0
86
87
  Use `.\dsh.exe` in place of `dsh` for DSH-Portable. Static acceptance requires:
87
88
 
88
89
  1. The `dsh-native-session-delete` bundle appears exactly once in the requested profile.
89
- 2. Its direct package spec is the fixed `dsh-native-session-delete@1.0.7` npm version above.
90
+ 2. Its direct package spec is the fixed `dsh-native-session-delete@1.1.0` npm version above.
90
91
  3. The profile contains the bundle patch and no duplicate official workspace row from this plugin.
91
92
  4. No unrelated dependency, profile patch, or session data was changed by the operation.
92
93
 
93
94
  With permission to restart DSH, verify the live UI in dark mode:
94
95
 
95
- 1. The selected session's native actions menu contains **Archive session** and the red **Delete session…** action.
96
- 2. Opening Delete shows the target session name and a second confirmation.
97
- 3. Selecting **Cancel** closes the dialog, sends no delete request, and leaves the session visible.
98
- 4. A destructive check is allowed only with a disposable test session explicitly selected by the user.
99
- 5. After confirming that disposable session, verify it disappears in place without reloading the whole DSH page.
96
+ 1. The sidebar header contains an archive action that opens **Archived sessions**.
97
+ 2. The archive manager lists archived sessions, filters by name or workspace, and can search archived
98
+ user/assistant conversation content without exposing another session.
99
+ 3. Restoring a disposable archived session returns it to its original workspace position without reloading the page.
100
+ 4. The selected session's native actions menu contains **Archive session** and the red **Delete session** action.
101
+ 5. Opening Delete shows the target session name and a second confirmation.
102
+ 6. Selecting **Cancel** closes the dialog, sends no delete request, and leaves the session visible.
103
+ 7. A destructive check is allowed only with a disposable test session explicitly selected by the user.
104
+ 8. After confirming that disposable session, verify it disappears in place without reloading the whole DSH page.
100
105
 
101
106
  For source-checkout UI acceptance, the non-destructive smoke test is:
102
107
 
package/README.en.md CHANGED
@@ -1,10 +1,10 @@
1
1
  <div align="center">
2
2
 
3
- # DSH Native Session Delete
3
+ # DSH Native Session Manager
4
4
 
5
- **Bring permanent session deletion to the native DeepSeek Harness menu.**
5
+ **Search, restore, and safely clean up sessions from the native DeepSeek Harness sidebar.**
6
6
 
7
- Native dark menu · Second confirmation · Permanent delete · In-place list update
7
+ Archive manager · Conversation search · One-click restore · Safe permanent deletion
8
8
 
9
9
  [![Release](https://img.shields.io/github/v/release/WSL043/dsh-native-session-delete?display_name=tag&style=flat-square)](https://github.com/WSL043/dsh-native-session-delete/releases/latest)
10
10
  [![Checks](https://img.shields.io/github/actions/workflow/status/WSL043/dsh-native-session-delete/ci.yml?branch=main&label=checks&style=flat-square)](https://github.com/WSL043/dsh-native-session-delete/actions/workflows/ci.yml)
@@ -17,12 +17,17 @@ Native dark menu · Second confirmation · Permanent delete · In-place list upd
17
17
  </div>
18
18
 
19
19
  <p align="center">
20
- <img src="https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.0.7/docs/assets/hero.en.png" alt="Red Delete session action in the native DeepSeek Harness dark-mode session menu">
20
+ <img src="https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.1.0/docs/assets/hero.en.png" alt="Native DeepSeek Harness session management and permanent deletion">
21
21
  </p>
22
22
 
23
- | Native | Direct | Smooth |
23
+ | Recover archives | Search conversations | Delete safely |
24
24
  | --- | --- | --- |
25
- | The action lives in the native session menu and keeps Archive available | A second confirmation permanently deletes the target; running work is stopped first | The list updates in place without reloading the whole DSH page |
25
+ | Open the archive manager from the sidebar and restore hidden sessions | Search archived names, workspaces, and user/assistant conversation content | Keep native second confirmation; running work is stopped safely before local records are removed |
26
+
27
+ <p align="center">
28
+ <img src="https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.1.0/docs/assets/archive-manager.en.png" width="382" alt="Native DSH archive manager with search, restore, and permanent deletion">
29
+ <br><sub>Native interface on DeepSeek Harness 0.1.1-rc.2</sub>
30
+ </p>
26
31
 
27
32
  ## Install
28
33
 
@@ -31,7 +36,7 @@ Native dark menu · Second confirmation · Permanent delete · In-place list upd
31
36
  Open PowerShell and paste one line:
32
37
 
33
38
  ```powershell
34
- irm 'https://github.com/WSL043/dsh-native-session-delete/releases/download/v1.0.7/install.ps1' | iex
39
+ irm 'https://github.com/WSL043/dsh-native-session-delete/releases/download/v1.1.0/install.ps1' | iex
35
40
  ```
36
41
 
37
42
  The helper checks the current directory, PATH, `DSH_PORTABLE_ROOT`, Downloads/Desktop/Documents, and up to
@@ -46,7 +51,7 @@ If it still finds nothing, enter the actual DSH-Portable folder and rerun the sa
46
51
  ### Official CLI (macOS, Linux, or direct review)
47
52
 
48
53
  ```sh
49
- dsh plugin --profile web add dsh-native-session-delete@1.0.7
54
+ dsh plugin --profile web add dsh-native-session-delete@1.1.0
50
55
  ```
51
56
 
52
57
  The helper and direct command use the same standard bundle mechanism. The helper is only a Windows entry
@@ -57,19 +62,31 @@ bundle configuration becomes active.
57
62
 
58
63
  ### Agent installation
59
64
 
60
- Use the fixed-version [AGENTS.md](https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.0.7/AGENTS.md).
65
+ Use the fixed-version [AGENTS.md](https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.1.0/AGENTS.md).
61
66
  It defines installation, update, acceptance, uninstall, and safety boundaries. Do not use the `main`
62
67
  branch document as an installation contract.
63
68
 
64
69
  ## Use
65
70
 
71
+ ### Manage archives
72
+
73
+ 1. Select the archive icon in the sidebar header to open **Archived sessions**.
74
+ 2. Browse every archive or search by session name, workspace, and user/assistant conversation content.
75
+ 3. Select **Restore** to return a session to its original workspace position. To remove it completely,
76
+ start the permanent-delete confirmation from the same list.
77
+
78
+ Archive and restore only change DSH's hidden state; they do not delete conversation history. Content search
79
+ is limited to current user and assistant messages inside archived sessions.
80
+
81
+ ### Delete permanently
82
+
66
83
  1. Open the native actions menu beside the target session in the sidebar.
67
- 2. Choose the red **Delete session…** action.
84
+ 2. Choose the red **Delete session** action.
68
85
  3. Check the session name and select **Delete permanently** in the confirmation dialog, or select
69
86
  **Cancel** to leave it unchanged.
70
87
 
71
88
  <p align="center">
72
- <img src="https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.0.7/docs/assets/confirm-delete.en.png" width="560" alt="English dark-mode permanent deletion confirmation dialog">
89
+ <img src="https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.1.0/docs/assets/confirm-delete.en.png" width="560" alt="English dark-mode permanent deletion confirmation dialog">
73
90
  <br><sub>Permanent deletion cannot be undone; the dialog identifies the target session.</sub>
74
91
  </p>
75
92
 
@@ -104,15 +121,16 @@ without warranty.
104
121
  Supports DeepSeek Harness: `0.1.0-rc.6`, `0.1.0-rc.7`, `0.1.0-rc.8`, `0.1.1-rc.1`, `0.1.1-rc.2`.
105
122
  <!-- /dsh-compatibility -->
106
123
 
107
- The plugin supports DSH's default per-session JSONL storage. It adds deletion to the native session menu;
108
- uninstalling restores the original DSH menu.
124
+ Archive browsing, restore, and content search use DSH's workspace registry and session-query capabilities.
125
+ Permanent deletion supports DSH's default per-session JSONL storage. Installing replaces the native workspace
126
+ list with the session-management version; uninstalling restores DSH's original list.
109
127
 
110
128
  ## Update and uninstall
111
129
 
112
- Update by rerunning the quick installer or installing the new npm version. For v1.0.7:
130
+ Update by rerunning the quick installer or installing the new npm version. For v1.1.0:
113
131
 
114
132
  ```sh
115
- dsh plugin --profile web add dsh-native-session-delete@1.0.7
133
+ dsh plugin --profile web add dsh-native-session-delete@1.1.0
116
134
  ```
117
135
 
118
136
  Uninstall removes only this plugin's bundle layer and never deletes sessions:
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  <div align="center">
2
2
 
3
- # DSH Native Session Delete
3
+ # DSH Native Session Manager
4
4
 
5
- **把“永久删除会话”带回 DeepSeek Harness 原生菜单。**
5
+ **在 DeepSeek Harness 原生侧边栏中搜索、恢复和安全清理会话。**
6
6
 
7
- 原生深色菜单 · 二次确认 · 永久删除 · 原地更新列表
7
+ 归档管理 · 聊天记录搜索 · 一键恢复 · 安全永久删除
8
8
 
9
9
  [![Release](https://img.shields.io/github/v/release/WSL043/dsh-native-session-delete?display_name=tag&style=flat-square)](https://github.com/WSL043/dsh-native-session-delete/releases/latest)
10
10
  [![Checks](https://img.shields.io/github/actions/workflow/status/WSL043/dsh-native-session-delete/ci.yml?branch=main&label=checks&style=flat-square)](https://github.com/WSL043/dsh-native-session-delete/actions/workflows/ci.yml)
@@ -17,12 +17,17 @@
17
17
  </div>
18
18
 
19
19
  <p align="center">
20
- <img src="https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.0.7/docs/assets/hero.png" alt="DeepSeek Harness 深色模式原生会话菜单中的红色删除会话选项">
20
+ <img src="https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.1.0/docs/assets/hero.png" alt="DeepSeek Harness 原生会话管理与永久删除功能">
21
21
  </p>
22
22
 
23
- | 原生 | 直接 | 顺滑 |
23
+ | 归档可找回 | 聊天可搜索 | 删除更稳妥 |
24
24
  | --- | --- | --- |
25
- | 删除入口就在会话原生操作菜单中,归档仍然保留 | 二次确认后永久删除目标会话;正在运行的任务会先停止 | 删除成功后原地刷新列表,不重载整个 DSH 页面 |
25
+ | 从侧边栏打开归档管理器,查看并恢复隐藏的会话 | 按会话名、工作区或用户与助手的聊天内容搜索归档 | 原生菜单保留二次确认;运行中的任务先安全停止,再删除本机会话记录 |
26
+
27
+ <p align="center">
28
+ <img src="https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.1.0/docs/assets/archive-manager.png" width="382" alt="DSH 原生归档会话管理器,包含搜索、恢复和永久删除">
29
+ <br><sub>DeepSeek Harness 0.1.1-rc.2 中的原生界面</sub>
30
+ </p>
26
31
 
27
32
  ## 安装
28
33
 
@@ -31,7 +36,7 @@
31
36
  打开 PowerShell,复制这一行:
32
37
 
33
38
  ```powershell
34
- irm 'https://github.com/WSL043/dsh-native-session-delete/releases/download/v1.0.7/install.ps1' | iex
39
+ irm 'https://github.com/WSL043/dsh-native-session-delete/releases/download/v1.1.0/install.ps1' | iex
35
40
  ```
36
41
 
37
42
  助手依次检查当前目录、PATH、`DSH_PORTABLE_ROOT`、下载/桌面/文档目录,以及这些目录和
@@ -44,7 +49,7 @@ irm 'https://github.com/WSL043/dsh-native-session-delete/releases/download/v1.0.
44
49
  ### 官方 CLI(macOS、Linux 或希望直接审阅命令)
45
50
 
46
51
  ```sh
47
- dsh plugin --profile web add dsh-native-session-delete@1.0.7
52
+ dsh plugin --profile web add dsh-native-session-delete@1.1.0
48
53
  ```
49
54
 
50
55
  助手和这条命令使用的是同一个标准 bundle 安装机制;助手只是 Windows 入口,不接管安装事务。
@@ -53,17 +58,28 @@ dsh plugin --profile web add dsh-native-session-delete@1.0.7
53
58
 
54
59
  ### 交给 Agent
55
60
 
56
- 请使用固定版本的 [AGENTS.md](https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.0.7/AGENTS.md),
61
+ 请使用固定版本的 [AGENTS.md](https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.1.0/AGENTS.md),
57
62
  其中写明了安装、更新、验收、卸载和安全边界。不要把 `main` 分支文档当作安装依据。
58
63
 
59
64
  ## 使用
60
65
 
66
+ ### 管理归档
67
+
68
+ 1. 点击侧边栏标题区域的归档图标,打开 **归档会话**。
69
+ 2. 直接浏览全部归档,或按会话名、工作区和用户/助手聊天内容搜索。
70
+ 3. 点击 **恢复** 让会话回到原来的工作区位置;需要彻底清理时,可从同一列表进入永久删除确认。
71
+
72
+ 归档和恢复只改变 DSH 的隐藏状态,不删除聊天记录。搜索范围仅限已归档会话中的当前用户与助手消息,
73
+ 不会把其他会话或插件数据混入结果。
74
+
75
+ ### 永久删除
76
+
61
77
  1. 打开侧边栏中目标会话右侧的原生操作菜单。
62
- 2. 选择红色的 **删除会话…**。
63
- 3. 在确认弹窗中再次确认会话名称并点击 **永久删除**;也可以随时点击 **取消**。
78
+ 2. 选择红色的 **删除会话**。
79
+ 3. 在确认弹窗中核对会话名称并再次确认 **永久删除**;也可以随时点击 **取消**。
64
80
 
65
81
  <p align="center">
66
- <img src="https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.0.7/docs/assets/confirm-delete.png" width="560" alt="中文深色模式永久删除二次确认弹窗">
82
+ <img src="https://raw.githubusercontent.com/WSL043/dsh-native-session-delete/v1.1.0/docs/assets/confirm-delete.png" width="560" alt="中文深色模式永久删除二次确认弹窗">
67
83
  <br><sub>永久删除无法撤销,确认弹窗会明确显示目标会话</sub>
68
84
  </p>
69
85
 
@@ -94,14 +110,15 @@ dsh plugin --profile web add dsh-native-session-delete@1.0.7
94
110
  支持 DeepSeek Harness:`0.1.0-rc.6`、`0.1.0-rc.7`、`0.1.0-rc.8`、`0.1.1-rc.1`、`0.1.1-rc.2`。
95
111
  <!-- /dsh-compatibility -->
96
112
 
97
- 适用于 DSH 默认的逐会话 JSONL 存储。安装后在会话原生菜单中增加删除选项;卸载后恢复 DSH 原有菜单。
113
+ 归档浏览、恢复和内容搜索使用 DSH 的工作区注册表与会话查询能力;永久删除适用于 DSH 默认的逐会话
114
+ JSONL 存储。安装后替换为带会话管理功能的原生工作区列表;卸载后恢复 DSH 原有列表。
98
115
 
99
116
  ## 更新与卸载
100
117
 
101
- 更新可重新运行快速安装助手,或安装 npm 上的新版本。v1.0.7 的直接命令是:
118
+ 更新可重新运行快速安装助手,或安装 npm 上的新版本。v1.1.0 的直接命令是:
102
119
 
103
120
  ```sh
104
- dsh plugin --profile web add dsh-native-session-delete@1.0.7
121
+ dsh plugin --profile web add dsh-native-session-delete@1.1.0
105
122
  ```
106
123
 
107
124
  卸载只移除这个插件的 bundle 层,不删除任何会话:
package/lib/client.js CHANGED
@@ -1,4 +1,4 @@
1
- // Modified from @deepseek-ai/dsh-client-ui-workspace 0.1.1-rc.2 by DSH Native Session Delete. See THIRD_PARTY_NOTICES.md.
1
+ // Modified from @deepseek-ai/dsh-client-ui-workspace 0.1.1-rc.2 by DSH Native Session Manager. See THIRD_PARTY_NOTICES.md.
2
2
  window.__ModuleLoader__.load({
3
3
  id: "dsh-native-session-delete",
4
4
  factory: (require) => {
@@ -1656,7 +1656,7 @@ window.__ModuleLoader__.load({
1656
1656
  * @param props - composed slot props (shell owner share + store + injected actions).
1657
1657
  * @returns the region element tree.
1658
1658
  */
1659
- function WorkspaceBrowser({ wide, expandSidebar, useSessions, useWorkspaces, useStore, actions, startSession, open, renameSession, forkSession, renameWorkspace, deleteWorkspace, insertWorkspaceBefore, archiveSession, deleteSession, insertSessionBefore, createWorkspace, searchSessions, searchResultLimit, useDirectoryFlow, useHostDescription, renderSlot, t }) {
1659
+ function WorkspaceBrowser({ wide, expandSidebar, useSessions, useWorkspaces, useStore, actions, startSession, open, renameSession, forkSession, renameWorkspace, deleteWorkspace, insertWorkspaceBefore, archiveSession, deleteSession, restoreSession, searchArchivedSessions, insertSessionBefore, createWorkspace, searchSessions, searchResultLimit, useDirectoryFlow, useHostDescription, renderSlot, t }) {
1660
1660
  const home = useHostDescription((description) => description?.home);
1661
1661
  const workspaces = useWorkspaces((state) => state.items);
1662
1662
  const workspacePhase = useWorkspaces((state) => state.phase);
@@ -1858,6 +1858,60 @@ window.__ModuleLoader__.load({
1858
1858
  console.warn("session archive rejected:", reason);
1859
1859
  });
1860
1860
  };
1861
+ const archiveSessionList = useSessions((state) => state);
1862
+ const [archiveManagerOpen, setArchiveManagerOpen] = (0, react.useState)(false);
1863
+ const [archiveQuery, setArchiveQuery] = (0, react.useState)("");
1864
+ const [archiveSearch, setArchiveSearch] = (0, react.useState)({ query: "", status: "idle", items: [], hasMore: false });
1865
+ const [archiveBusyId, setArchiveBusyId] = (0, react.useState)(null);
1866
+ const [archiveError, setArchiveError] = (0, react.useState)(null);
1867
+ const normalizedArchiveQuery = archiveQuery.trim();
1868
+ (0, react.useEffect)(() => {
1869
+ if (!archiveManagerOpen || normalizedArchiveQuery === "") {
1870
+ setArchiveSearch({ query: "", status: "idle", items: [], hasMore: false });
1871
+ return;
1872
+ }
1873
+ const controller = new AbortController();
1874
+ setArchiveSearch({ query: normalizedArchiveQuery, status: "loading", items: [], hasMore: false });
1875
+ const timer = window.setTimeout(() => {
1876
+ searchArchivedSessions(normalizedArchiveQuery, controller.signal).then((result) => {
1877
+ if (!controller.signal.aborted) setArchiveSearch({ query: normalizedArchiveQuery, status: "ready", items: result.items, hasMore: result.hasMore });
1878
+ }).catch(() => {
1879
+ if (!controller.signal.aborted) setArchiveSearch({ query: normalizedArchiveQuery, status: "error", items: [], hasMore: false });
1880
+ });
1881
+ }, 250);
1882
+ return () => { window.clearTimeout(timer); controller.abort(); };
1883
+ }, [archiveManagerOpen, normalizedArchiveQuery, searchArchivedSessions]);
1884
+ const archiveWorkspaceBySession = (0, react.useMemo)(() => {
1885
+ const result = /* @__PURE__ */ new Map();
1886
+ for (const workspace of workspaces) for (const sessionId of workspace.sessionIds) if (!result.has(sessionId)) result.set(sessionId, workspace.title);
1887
+ return result;
1888
+ }, [workspaces]);
1889
+ const archiveSnippets = (0, react.useMemo)(() => new Map(archiveSearch.items.map((item) => [item.sessionId, item.snippet])), [archiveSearch.items]);
1890
+ const archiveRows = (0, react.useMemo)(() => {
1891
+ const query = normalizedArchiveQuery.toLowerCase();
1892
+ const remoteIds = new Set(archiveSearch.items.map((item) => item.sessionId));
1893
+ const rows = archivedSessionIds.map((sessionId) => {
1894
+ const summary = archiveSessionList.byId[sessionId];
1895
+ return {
1896
+ id: sessionId,
1897
+ title: summary === void 0 ? sessionId : sessionTitle(summary),
1898
+ workspace: archiveWorkspaceBySession.get(sessionId) ?? t("group.ungrouped"),
1899
+ updatedAt: summary?.updatedAt ?? 0
1900
+ };
1901
+ });
1902
+ rows.sort((a, b) => b.updatedAt - a.updatedAt);
1903
+ if (query === "") return rows;
1904
+ return rows.filter((row) => row.title.toLowerCase().includes(query) || row.workspace.toLowerCase().includes(query) || remoteIds.has(row.id));
1905
+ }, [archiveSessionList, archivedSessionIds, archiveSearch.items, archiveWorkspaceBySession, normalizedArchiveQuery, t]);
1906
+ const onArchiveRestore = (sessionId) => {
1907
+ if (archiveBusyId !== null) return;
1908
+ setArchiveBusyId(sessionId);
1909
+ setArchiveError(null);
1910
+ restoreSession(sessionId).then(() => setArchiveBusyId(null)).catch((reason) => {
1911
+ setArchiveBusyId(null);
1912
+ setArchiveError(reason instanceof Error ? reason.message : String(reason));
1913
+ });
1914
+ };
1861
1915
  const [sessionDeleteTarget, setSessionDeleteTarget] = (0, react.useState)(null);
1862
1916
  const [sessionDeleting, setSessionDeleting] = (0, react.useState)(false);
1863
1917
  const [sessionDeleteError, setSessionDeleteError] = (0, react.useState)(null);
@@ -1982,7 +2036,19 @@ window.__ModuleLoader__.load({
1982
2036
  }),
1983
2037
  (0, react_jsx_runtime.jsxs)("div", {
1984
2038
  className: clsx(WorkspaceBrowser_module_css_default.headerActions, wide && searchExpanded && WorkspaceBrowser_module_css_default.headerActionsHidden),
1985
- children: [wide && (0, react_jsx_runtime.jsx)(ViewOptionsMenu, {
2039
+ children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
2040
+ label: t("archive.manager.title"),
2041
+ side: "bottom",
2042
+ delayMs: 500,
2043
+ children: (0, react_jsx_runtime.jsx)("button", {
2044
+ id: "archived-sessions",
2045
+ type: "button",
2046
+ className: WorkspaceBrowser_module_css_default.iconButton,
2047
+ "aria-label": t("archive.manager.title"),
2048
+ onClick: () => { setArchiveError(null); setArchiveManagerOpen(true); },
2049
+ children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconArchiveOutline20, { size: wide ? 16 : 18 })
2050
+ })
2051
+ }), wide && (0, react_jsx_runtime.jsx)(ViewOptionsMenu, {
1986
2052
  groupBy,
1987
2053
  orderBy,
1988
2054
  onGroupPick: (mode) => {
@@ -2210,6 +2276,77 @@ window.__ModuleLoader__.load({
2210
2276
  children: sessionRenameError
2211
2277
  })]
2212
2278
  }),
2279
+ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
2280
+ open: archiveManagerOpen,
2281
+ onClose: () => { if (archiveBusyId === null) setArchiveManagerOpen(false); },
2282
+ closeLabel: t("close"),
2283
+ title: t("archive.manager.title"),
2284
+ description: t("archive.manager.description", { n: archivedSessionIds.length }),
2285
+ footer: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2286
+ variant: "outline",
2287
+ disabled: archiveBusyId !== null,
2288
+ onClick: () => setArchiveManagerOpen(false),
2289
+ children: t("close")
2290
+ }),
2291
+ children: [(0, react_jsx_runtime.jsx)("input", {
2292
+ className: WorkspaceBrowser_module_css_default.renameInput,
2293
+ type: "search",
2294
+ value: archiveQuery,
2295
+ maxLength: SEARCH_QUERY_MAX_CODE_UNITS,
2296
+ placeholder: t("archive.manager.searchPlaceholder"),
2297
+ "aria-label": t("archive.manager.searchPlaceholder"),
2298
+ onChange: (event) => { setArchiveQuery(event.target.value); setArchiveError(null); }
2299
+ }), archiveSearch.status === "loading" && (0, react_jsx_runtime.jsx)("div", {
2300
+ className: WorkspaceBrowser_module_css_default.deleteStatus,
2301
+ role: "status",
2302
+ style: { marginTop: 8 },
2303
+ children: t("archive.manager.searching")
2304
+ }), archiveSearch.status === "error" && (0, react_jsx_runtime.jsx)("div", {
2305
+ className: WorkspaceBrowser_module_css_default.renameError,
2306
+ role: "status",
2307
+ children: t("archive.manager.searchUnavailable")
2308
+ }), (0, react_jsx_runtime.jsx)("div", {
2309
+ style: { display: "flex", flexDirection: "column", gap: 8, maxHeight: "52vh", overflowY: "auto", marginTop: 12 },
2310
+ children: archiveRows.length === 0 ? (0, react_jsx_runtime.jsx)("div", {
2311
+ className: WorkspaceBrowser_module_css_default.deleteStatus,
2312
+ children: normalizedArchiveQuery === "" ? t("archive.manager.empty") : t("archive.manager.noMatches")
2313
+ }) : archiveRows.map((row) => (0, react_jsx_runtime.jsxs)("div", {
2314
+ style: { border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 12, padding: 12, display: "flex", alignItems: "center", gap: 12 },
2315
+ children: [(0, react_jsx_runtime.jsxs)("div", {
2316
+ style: { minWidth: 0, flex: 1 },
2317
+ children: [(0, react_jsx_runtime.jsx)("div", {
2318
+ style: { color: "var(--dsw-alias-label-primary)", fontSize: 13, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
2319
+ children: row.title
2320
+ }), (0, react_jsx_runtime.jsx)("div", {
2321
+ style: { color: "var(--dsw-alias-label-tertiary)", fontSize: 12, marginTop: 2 },
2322
+ children: row.workspace
2323
+ }), archiveSnippets.has(row.id) && (0, react_jsx_runtime.jsx)("div", {
2324
+ style: { color: "var(--dsw-alias-label-secondary)", fontSize: 12, lineHeight: "18px", marginTop: 6 },
2325
+ children: archiveSnippets.get(row.id)
2326
+ })]
2327
+ }), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2328
+ variant: "outline",
2329
+ disabled: archiveBusyId !== null,
2330
+ onClick: () => onArchiveRestore(row.id),
2331
+ children: archiveBusyId === row.id ? t("archive.manager.restoring") : t("archive.manager.restore")
2332
+ }), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2333
+ variant: "outline",
2334
+ className: WorkspaceBrowser_module_css_default.deleteAction,
2335
+ disabled: archiveBusyId !== null,
2336
+ onClick: () => onSessionDelete(row.id, row.title),
2337
+ children: t("archive.manager.delete")
2338
+ })]
2339
+ }, row.id))
2340
+ }), archiveSearch.hasMore && (0, react_jsx_runtime.jsx)("div", {
2341
+ className: WorkspaceBrowser_module_css_default.deleteStatus,
2342
+ style: { marginTop: 8 },
2343
+ children: t("archive.manager.hasMore")
2344
+ }), archiveError !== null && (0, react_jsx_runtime.jsx)("div", {
2345
+ className: WorkspaceBrowser_module_css_default.renameError,
2346
+ role: "alert",
2347
+ children: archiveError
2348
+ })]
2349
+ }),
2213
2350
  (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
2214
2351
  open: sessionDeleteTarget !== null,
2215
2352
  onClose: closeSessionDelete,
@@ -2317,6 +2454,17 @@ window.__ModuleLoader__.load({
2317
2454
  "delete.pending": "正在删除工作区…",
2318
2455
  "menu.fork": "分叉会话",
2319
2456
  "menu.archiveSession": "归档会话",
2457
+ "archive.manager.title": "归档会话",
2458
+ "archive.manager.description": "共 {n} 个归档会话。可按名称、工作区或聊天内容搜索。",
2459
+ "archive.manager.searchPlaceholder": "搜索归档名称、工作区或聊天内容…",
2460
+ "archive.manager.searching": "正在搜索归档聊天记录…",
2461
+ "archive.manager.searchUnavailable": "内容搜索暂不可用,仅显示名称与工作区匹配。",
2462
+ "archive.manager.empty": "暂无归档会话",
2463
+ "archive.manager.noMatches": "没有匹配的归档会话",
2464
+ "archive.manager.hasMore": "仅显示前 20 条内容匹配,请缩小搜索范围。",
2465
+ "archive.manager.restore": "恢复",
2466
+ "archive.manager.restoring": "恢复中…",
2467
+ "archive.manager.delete": "永久删除",
2320
2468
  "menu.deleteSession": "删除会话",
2321
2469
  "delete.session.title": "永久删除会话?",
2322
2470
  "delete.session.desc": "“{name}”的会话记录将从本机永久删除,且无法恢复。正在运行的任务会先安全停止。",
@@ -2387,6 +2535,17 @@ window.__ModuleLoader__.load({
2387
2535
  "delete.pending": "Deleting workspace…",
2388
2536
  "menu.fork": "Fork session",
2389
2537
  "menu.archiveSession": "Archive session",
2538
+ "archive.manager.title": "Archived sessions",
2539
+ "archive.manager.description": "{n} archived sessions. Search by name, workspace, or conversation content.",
2540
+ "archive.manager.searchPlaceholder": "Search archived names, workspaces, or conversation content…",
2541
+ "archive.manager.searching": "Searching archived conversation history…",
2542
+ "archive.manager.searchUnavailable": "Content search is temporarily unavailable. Showing name and workspace matches.",
2543
+ "archive.manager.empty": "No archived sessions",
2544
+ "archive.manager.noMatches": "No matching archived sessions",
2545
+ "archive.manager.hasMore": "Showing the first 20 content matches. Narrow your search.",
2546
+ "archive.manager.restore": "Restore",
2547
+ "archive.manager.restoring": "Restoring…",
2548
+ "archive.manager.delete": "Delete permanently",
2390
2549
  "menu.deleteSession": "Delete session",
2391
2550
  "delete.session.title": "Permanently delete session?",
2392
2551
  "delete.session.desc": "The local record for “{name}” will be permanently deleted and cannot be recovered. Running work will be stopped safely before deletion.",
@@ -2515,6 +2674,31 @@ window.__ModuleLoader__.load({
2515
2674
  if (refresh.status === "rejected") console.warn("session deletion succeeded but runtime refresh failed:", refresh.reason);
2516
2675
  }
2517
2676
  },
2677
+ restoreSession: async (sessionId) => {
2678
+ const response = await fetch("/plugins/dsh-session-delete/restore", {
2679
+ method: "POST",
2680
+ headers: {
2681
+ "content-type": "application/json",
2682
+ "x-dsh-session-manager-action": "restore-session"
2683
+ },
2684
+ body: JSON.stringify({ sessionId })
2685
+ });
2686
+ const payload = await response.json().catch(() => null);
2687
+ if (!response.ok || payload?.ok !== true) throw new Error(payload?.error?.message ?? `Restore failed (HTTP ${response.status})`);
2688
+ const refreshes = await Promise.allSettled([ctx.sessions.refresh(), ctx.workspaces.refresh()]);
2689
+ for (const refresh of refreshes) if (refresh.status === "rejected") console.warn("session restore succeeded but runtime refresh failed:", refresh.reason);
2690
+ },
2691
+ searchArchivedSessions: async (query, signal) => {
2692
+ const response = await fetch("/plugins/dsh-session-delete/archive-search", {
2693
+ method: "POST",
2694
+ headers: { "content-type": "application/json" },
2695
+ body: JSON.stringify({ query }),
2696
+ signal
2697
+ });
2698
+ const payload = await response.json().catch(() => null);
2699
+ if (!response.ok || payload?.ok !== true) throw new Error(payload?.error?.message ?? `Archived search failed (HTTP ${response.status})`);
2700
+ return payload.value;
2701
+ },
2518
2702
  insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
2519
2703
  await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId);
2520
2704
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-native-session-delete",
3
- "version": "1.0.7",
4
- "description": "Permanent session deletion in the native DeepSeek Harness menu",
3
+ "version": "1.1.0",
4
+ "description": "Native DeepSeek Harness session management: archive search, restore, and safe permanent deletion",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
@@ -16,6 +16,7 @@
16
16
  "scripts/build-client.mjs",
17
17
  "scripts/smoke-ui.mjs",
18
18
  "src/index.js",
19
+ "src/host/archive-manager.mjs",
19
20
  "src/host/delete-session.mjs",
20
21
  "README.md",
21
22
  "README.en.md",
@@ -43,6 +44,13 @@
43
44
  "dsh-plugin",
44
45
  "deepseek-harness",
45
46
  "session",
47
+ "session-manager",
48
+ "session-management",
49
+ "conversation",
50
+ "archive",
51
+ "unarchive",
52
+ "restore",
53
+ "history-search",
46
54
  "delete",
47
55
  "privacy"
48
56
  ],
@@ -99,7 +99,7 @@ export function patchWorkspaceClient(upstream, upstreamVersion = LATEST_UPSTREAM
99
99
  workspaceBrowserSignature,
100
100
  workspaceBrowserSignature.replace(
101
101
  'deleteWorkspace, insertWorkspaceBefore, archiveSession, insertSessionBefore,',
102
- 'deleteWorkspace, insertWorkspaceBefore, archiveSession, deleteSession, insertSessionBefore,',
102
+ 'deleteWorkspace, insertWorkspaceBefore, archiveSession, deleteSession, restoreSession, searchArchivedSessions, insertSessionBefore,',
103
103
  ),
104
104
  'workspace browser delete action prop',
105
105
  )
@@ -108,6 +108,65 @@ export function patchWorkspaceClient(upstream, upstreamVersion = LATEST_UPSTREAM
108
108
  `\t\t\tconst onSessionArchive = (sessionId) => {\n\t\t\t\tarchiveSession(sessionId).catch((reason) => {\n\t\t\t\t\tconsole.warn("session archive rejected:", reason);\n\t\t\t\t});\n\t\t\t};\n\t\t\tconst [sessionDeleteTarget, setSessionDeleteTarget] = (0, react.useState)(null);\n\t\t\tconst [sessionDeleting, setSessionDeleting] = (0, react.useState)(false);\n\t\t\tconst [sessionDeleteError, setSessionDeleteError] = (0, react.useState)(null);\n\t\t\tconst onSessionDelete = (sessionId, title) => {\n\t\t\t\tsetSessionDeleteTarget({ sessionId, title });\n\t\t\t\tsetSessionDeleteError(null);\n\t\t\t};\n\t\t\tconst closeSessionDelete = () => {\n\t\t\t\tif (sessionDeleting) return;\n\t\t\t\tsetSessionDeleteTarget(null);\n\t\t\t\tsetSessionDeleteError(null);\n\t\t\t};\n\t\t\tconst confirmSessionDelete = () => {\n\t\t\t\tif (sessionDeleting || sessionDeleteTarget === null) return;\n\t\t\t\tsetSessionDeleting(true);\n\t\t\t\tsetSessionDeleteError(null);\n\t\t\t\tdeleteSession(sessionDeleteTarget.sessionId).then(() => {\n\t\t\t\t\tsetSessionDeleting(false);\n\t\t\t\t\tsetSessionDeleteTarget(null);\n\t\t\t\t\tsetSessionDeleteError(null);\n\t\t\t\t}).catch((reason) => {\n\t\t\t\t\tsetSessionDeleting(false);\n\t\t\t\t\tsetSessionDeleteError(reason instanceof Error ? reason.message : String(reason));\n\t\t\t\t});\n\t\t\t};\n`,
109
109
  'session delete dialog state',
110
110
  )
111
+ patch(
112
+ 'const [sessionDeleteTarget, setSessionDeleteTarget] = (0, react.useState)(null);',
113
+ `const archiveSessionList = useSessions((state) => state);
114
+ \t\t\tconst [archiveManagerOpen, setArchiveManagerOpen] = (0, react.useState)(false);
115
+ \t\t\tconst [archiveQuery, setArchiveQuery] = (0, react.useState)("");
116
+ \t\t\tconst [archiveSearch, setArchiveSearch] = (0, react.useState)({ query: "", status: "idle", items: [], hasMore: false });
117
+ \t\t\tconst [archiveBusyId, setArchiveBusyId] = (0, react.useState)(null);
118
+ \t\t\tconst [archiveError, setArchiveError] = (0, react.useState)(null);
119
+ \t\t\tconst normalizedArchiveQuery = archiveQuery.trim();
120
+ \t\t\t(0, react.useEffect)(() => {
121
+ \t\t\t\tif (!archiveManagerOpen || normalizedArchiveQuery === "") {
122
+ \t\t\t\t\tsetArchiveSearch({ query: "", status: "idle", items: [], hasMore: false });
123
+ \t\t\t\t\treturn;
124
+ \t\t\t\t}
125
+ \t\t\t\tconst controller = new AbortController();
126
+ \t\t\t\tsetArchiveSearch({ query: normalizedArchiveQuery, status: "loading", items: [], hasMore: false });
127
+ \t\t\t\tconst timer = window.setTimeout(() => {
128
+ \t\t\t\t\tsearchArchivedSessions(normalizedArchiveQuery, controller.signal).then((result) => {
129
+ \t\t\t\t\t\tif (!controller.signal.aborted) setArchiveSearch({ query: normalizedArchiveQuery, status: "ready", items: result.items, hasMore: result.hasMore });
130
+ \t\t\t\t\t}).catch(() => {
131
+ \t\t\t\t\t\tif (!controller.signal.aborted) setArchiveSearch({ query: normalizedArchiveQuery, status: "error", items: [], hasMore: false });
132
+ \t\t\t\t\t});
133
+ \t\t\t\t}, 250);
134
+ \t\t\t\treturn () => { window.clearTimeout(timer); controller.abort(); };
135
+ \t\t\t}, [archiveManagerOpen, normalizedArchiveQuery, searchArchivedSessions]);
136
+ \t\t\tconst archiveWorkspaceBySession = (0, react.useMemo)(() => {
137
+ \t\t\t\tconst result = /* @__PURE__ */ new Map();
138
+ \t\t\t\tfor (const workspace of workspaces) for (const sessionId of workspace.sessionIds) if (!result.has(sessionId)) result.set(sessionId, workspace.title);
139
+ \t\t\t\treturn result;
140
+ \t\t\t}, [workspaces]);
141
+ \t\t\tconst archiveSnippets = (0, react.useMemo)(() => new Map(archiveSearch.items.map((item) => [item.sessionId, item.snippet])), [archiveSearch.items]);
142
+ \t\t\tconst archiveRows = (0, react.useMemo)(() => {
143
+ \t\t\t\tconst query = normalizedArchiveQuery.toLowerCase();
144
+ \t\t\t\tconst remoteIds = new Set(archiveSearch.items.map((item) => item.sessionId));
145
+ \t\t\t\tconst rows = archivedSessionIds.map((sessionId) => {
146
+ \t\t\t\t\tconst summary = archiveSessionList.byId[sessionId];
147
+ \t\t\t\t\treturn {
148
+ \t\t\t\t\t\tid: sessionId,
149
+ \t\t\t\t\t\ttitle: summary === void 0 ? sessionId : sessionTitle(summary),
150
+ \t\t\t\t\t\tworkspace: archiveWorkspaceBySession.get(sessionId) ?? t("group.ungrouped"),
151
+ \t\t\t\t\t\tupdatedAt: summary?.updatedAt ?? 0
152
+ \t\t\t\t\t};
153
+ \t\t\t\t});
154
+ \t\t\t\trows.sort((a, b) => b.updatedAt - a.updatedAt);
155
+ \t\t\t\tif (query === "") return rows;
156
+ \t\t\t\treturn rows.filter((row) => row.title.toLowerCase().includes(query) || row.workspace.toLowerCase().includes(query) || remoteIds.has(row.id));
157
+ \t\t\t}, [archiveSessionList, archivedSessionIds, archiveSearch.items, archiveWorkspaceBySession, normalizedArchiveQuery, t]);
158
+ \t\t\tconst onArchiveRestore = (sessionId) => {
159
+ \t\t\t\tif (archiveBusyId !== null) return;
160
+ \t\t\t\tsetArchiveBusyId(sessionId);
161
+ \t\t\t\tsetArchiveError(null);
162
+ \t\t\t\trestoreSession(sessionId).then(() => setArchiveBusyId(null)).catch((reason) => {
163
+ \t\t\t\t\tsetArchiveBusyId(null);
164
+ \t\t\t\t\tsetArchiveError(reason instanceof Error ? reason.message : String(reason));
165
+ \t\t\t\t});
166
+ \t\t\t};
167
+ \t\t\tconst [sessionDeleteTarget, setSessionDeleteTarget] = (0, react.useState)(null);`,
168
+ 'archive manager state',
169
+ )
111
170
  patch(
112
171
  '\t\t\t\t\t\t\tonSessionArchive,\n\t\t\t\t\t\t\tarchivedSessionIds,\n',
113
172
  '\t\t\t\t\t\t\tonSessionArchive,\n\t\t\t\t\t\t\tonSessionDelete,\n\t\t\t\t\t\t\tarchivedSessionIds,\n',
@@ -118,6 +177,101 @@ export function patchWorkspaceClient(upstream, upstreamVersion = LATEST_UPSTREAM
118
177
  '\t\t\t\t\t\t\tonSessionArchive,\n\t\t\t\t\t\t\tonSessionDelete,\n\t\t\t\t\t\t\tforkSession,\n',
119
178
  'session tree delete handler',
120
179
  )
180
+ patch(
181
+ 'children: [wide && (0, react_jsx_runtime.jsx)(ViewOptionsMenu, {',
182
+ `children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
183
+ \t\t\t\t\t\t\t\t\tlabel: t("archive.manager.title"),
184
+ \t\t\t\t\t\t\t\t\tside: "bottom",
185
+ \t\t\t\t\t\t\t\t\tdelayMs: 500,
186
+ \t\t\t\t\t\t\t\t\tchildren: (0, react_jsx_runtime.jsx)("button", {
187
+ \t\t\t\t\t\t\t\t\t\tid: "archived-sessions",
188
+ \t\t\t\t\t\t\t\t\t\ttype: "button",
189
+ \t\t\t\t\t\t\t\t\t\tclassName: WorkspaceBrowser_module_css_default.iconButton,
190
+ \t\t\t\t\t\t\t\t\t\t"aria-label": t("archive.manager.title"),
191
+ \t\t\t\t\t\t\t\t\t\tonClick: () => { setArchiveError(null); setArchiveManagerOpen(true); },
192
+ \t\t\t\t\t\t\t\t\t\tchildren: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconArchiveOutline20, { size: wide ? 16 : 18 })
193
+ \t\t\t\t\t\t\t\t\t})
194
+ \t\t\t\t\t\t\t\t}), wide && (0, react_jsx_runtime.jsx)(ViewOptionsMenu, {`,
195
+ 'archive manager header action',
196
+ )
197
+ patch(
198
+ `\t\t\t\t\t(0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {\n\t\t\t\t\t\topen: deleteTarget !== null,\n`,
199
+ `\t\t\t\t\t(0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
200
+ \t\t\t\t\t\topen: archiveManagerOpen,
201
+ \t\t\t\t\t\tonClose: () => { if (archiveBusyId === null) setArchiveManagerOpen(false); },
202
+ \t\t\t\t\t\tcloseLabel: t("close"),
203
+ \t\t\t\t\t\ttitle: t("archive.manager.title"),
204
+ \t\t\t\t\t\tdescription: t("archive.manager.description", { n: archivedSessionIds.length }),
205
+ \t\t\t\t\t\tfooter: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
206
+ \t\t\t\t\t\t\tvariant: "outline",
207
+ \t\t\t\t\t\t\tdisabled: archiveBusyId !== null,
208
+ \t\t\t\t\t\t\tonClick: () => setArchiveManagerOpen(false),
209
+ \t\t\t\t\t\t\tchildren: t("close")
210
+ \t\t\t\t\t\t}),
211
+ \t\t\t\t\t\tchildren: [(0, react_jsx_runtime.jsx)("input", {
212
+ \t\t\t\t\t\t\tclassName: WorkspaceBrowser_module_css_default.renameInput,
213
+ \t\t\t\t\t\t\ttype: "search",
214
+ \t\t\t\t\t\t\tvalue: archiveQuery,
215
+ \t\t\t\t\t\t\tmaxLength: SEARCH_QUERY_MAX_CODE_UNITS,
216
+ \t\t\t\t\t\t\tplaceholder: t("archive.manager.searchPlaceholder"),
217
+ \t\t\t\t\t\t\t"aria-label": t("archive.manager.searchPlaceholder"),
218
+ \t\t\t\t\t\t\tonChange: (event) => { setArchiveQuery(event.target.value); setArchiveError(null); }
219
+ \t\t\t\t\t\t}), archiveSearch.status === "loading" && (0, react_jsx_runtime.jsx)("div", {
220
+ \t\t\t\t\t\t\tclassName: WorkspaceBrowser_module_css_default.deleteStatus,
221
+ \t\t\t\t\t\t\trole: "status",
222
+ \t\t\t\t\t\t\tstyle: { marginTop: 8 },
223
+ \t\t\t\t\t\t\tchildren: t("archive.manager.searching")
224
+ \t\t\t\t\t\t}), archiveSearch.status === "error" && (0, react_jsx_runtime.jsx)("div", {
225
+ \t\t\t\t\t\t\tclassName: WorkspaceBrowser_module_css_default.renameError,
226
+ \t\t\t\t\t\t\trole: "status",
227
+ \t\t\t\t\t\t\tchildren: t("archive.manager.searchUnavailable")
228
+ \t\t\t\t\t\t}), (0, react_jsx_runtime.jsx)("div", {
229
+ \t\t\t\t\t\t\tstyle: { display: "flex", flexDirection: "column", gap: 8, maxHeight: "52vh", overflowY: "auto", marginTop: 12 },
230
+ \t\t\t\t\t\t\tchildren: archiveRows.length === 0 ? (0, react_jsx_runtime.jsx)("div", {
231
+ \t\t\t\t\t\t\t\tclassName: WorkspaceBrowser_module_css_default.deleteStatus,
232
+ \t\t\t\t\t\t\t\tchildren: normalizedArchiveQuery === "" ? t("archive.manager.empty") : t("archive.manager.noMatches")
233
+ \t\t\t\t\t\t\t}) : archiveRows.map((row) => (0, react_jsx_runtime.jsxs)("div", {
234
+ \t\t\t\t\t\t\t\tstyle: { border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 12, padding: 12, display: "flex", alignItems: "center", gap: 12 },
235
+ \t\t\t\t\t\t\t\tchildren: [(0, react_jsx_runtime.jsxs)("div", {
236
+ \t\t\t\t\t\t\t\t\tstyle: { minWidth: 0, flex: 1 },
237
+ \t\t\t\t\t\t\t\t\tchildren: [(0, react_jsx_runtime.jsx)("div", {
238
+ \t\t\t\t\t\t\t\t\t\tstyle: { color: "var(--dsw-alias-label-primary)", fontSize: 13, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
239
+ \t\t\t\t\t\t\t\t\t\tchildren: row.title
240
+ \t\t\t\t\t\t\t\t\t}), (0, react_jsx_runtime.jsx)("div", {
241
+ \t\t\t\t\t\t\t\t\t\tstyle: { color: "var(--dsw-alias-label-tertiary)", fontSize: 12, marginTop: 2 },
242
+ \t\t\t\t\t\t\t\t\t\tchildren: row.workspace
243
+ \t\t\t\t\t\t\t\t\t}), archiveSnippets.has(row.id) && (0, react_jsx_runtime.jsx)("div", {
244
+ \t\t\t\t\t\t\t\t\t\tstyle: { color: "var(--dsw-alias-label-secondary)", fontSize: 12, lineHeight: "18px", marginTop: 6 },
245
+ \t\t\t\t\t\t\t\t\t\tchildren: archiveSnippets.get(row.id)
246
+ \t\t\t\t\t\t\t\t\t})]
247
+ \t\t\t\t\t\t\t\t}), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
248
+ \t\t\t\t\t\t\t\t\tvariant: "outline",
249
+ \t\t\t\t\t\t\t\t\tdisabled: archiveBusyId !== null,
250
+ \t\t\t\t\t\t\t\t\tonClick: () => onArchiveRestore(row.id),
251
+ \t\t\t\t\t\t\t\t\tchildren: archiveBusyId === row.id ? t("archive.manager.restoring") : t("archive.manager.restore")
252
+ \t\t\t\t\t\t\t\t}), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
253
+ \t\t\t\t\t\t\t\t\tvariant: "outline",
254
+ \t\t\t\t\t\t\t\t\tclassName: WorkspaceBrowser_module_css_default.deleteAction,
255
+ \t\t\t\t\t\t\t\t\tdisabled: archiveBusyId !== null,
256
+ \t\t\t\t\t\t\t\t\tonClick: () => onSessionDelete(row.id, row.title),
257
+ \t\t\t\t\t\t\t\t\tchildren: t("archive.manager.delete")
258
+ \t\t\t\t\t\t\t\t})]
259
+ \t\t\t\t\t\t\t}, row.id))
260
+ \t\t\t\t\t\t}), archiveSearch.hasMore && (0, react_jsx_runtime.jsx)("div", {
261
+ \t\t\t\t\t\t\tclassName: WorkspaceBrowser_module_css_default.deleteStatus,
262
+ \t\t\t\t\t\t\tstyle: { marginTop: 8 },
263
+ \t\t\t\t\t\t\tchildren: t("archive.manager.hasMore")
264
+ \t\t\t\t\t\t}), archiveError !== null && (0, react_jsx_runtime.jsx)("div", {
265
+ \t\t\t\t\t\t\tclassName: WorkspaceBrowser_module_css_default.renameError,
266
+ \t\t\t\t\t\t\trole: "alert",
267
+ \t\t\t\t\t\t\tchildren: archiveError
268
+ \t\t\t\t\t\t})]
269
+ \t\t\t\t\t}),
270
+ \t\t\t\t\t(0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
271
+ \t\t\t\t\t\topen: deleteTarget !== null,
272
+ `,
273
+ 'archive manager modal',
274
+ )
121
275
  patch(
122
276
  `\t\t\t\t\t(0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {\n\t\t\t\t\t\topen: deleteTarget !== null,\n`,
123
277
  `\t\t\t\t\t(0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {\n\t\t\t\t\t\topen: sessionDeleteTarget !== null,\n\t\t\t\t\t\tonClose: closeSessionDelete,\n\t\t\t\t\t\tcloseLabel: t("close"),\n\t\t\t\t\t\ttitle: t("delete.session.title"),\n\t\t\t\t\t\t...sessionDeleteTarget === null ? {} : { description: t("delete.session.desc", { name: sessionDeleteTarget.title }) },\n\t\t\t\t\t\tfooter: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {\n\t\t\t\t\t\t\tvariant: "outline",\n\t\t\t\t\t\t\tdisabled: sessionDeleting,\n\t\t\t\t\t\t\tonClick: closeSessionDelete,\n\t\t\t\t\t\t\tchildren: t("cancel")\n\t\t\t\t\t\t}), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {\n\t\t\t\t\t\t\tvariant: "outline",\n\t\t\t\t\t\t\tclassName: WorkspaceBrowser_module_css_default.deleteAction,\n\t\t\t\t\t\t\tdisabled: sessionDeleting,\n\t\t\t\t\t\t\tonClick: confirmSessionDelete,\n\t\t\t\t\t\t\tchildren: t("delete.session.confirm")\n\t\t\t\t\t\t})] }),\n\t\t\t\t\t\tchildren: [sessionDeleting && (0, react_jsx_runtime.jsx)("div", {\n\t\t\t\t\t\t\tclassName: WorkspaceBrowser_module_css_default.deleteStatus,\n\t\t\t\t\t\t\trole: "status",\n\t\t\t\t\t\t\tchildren: t("delete.session.pending")\n\t\t\t\t\t\t}), sessionDeleteError !== null && (0, react_jsx_runtime.jsx)("div", {\n\t\t\t\t\t\t\tclassName: WorkspaceBrowser_module_css_default.renameError,\n\t\t\t\t\t\t\trole: "alert",\n\t\t\t\t\t\t\tchildren: sessionDeleteError\n\t\t\t\t\t\t})]\n\t\t\t\t\t}),\n\t\t\t\t\t(0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {\n\t\t\t\t\t\topen: deleteTarget !== null,\n`,
@@ -125,12 +279,12 @@ export function patchWorkspaceClient(upstream, upstreamVersion = LATEST_UPSTREAM
125
279
  )
126
280
  patch(
127
281
  '\t\t\t"menu.archiveSession": "归档会话",\n',
128
- '\t\t\t"menu.archiveSession": "归档会话",\n\t\t\t"menu.deleteSession": "删除会话",\n\t\t\t"delete.session.title": "永久删除会话?",\n\t\t\t"delete.session.desc": "“{name}”的会话记录将从本机永久删除,且无法恢复。正在运行的任务会先安全停止。",\n\t\t\t"delete.session.confirm": "永久删除",\n\t\t\t"delete.session.pending": "正在永久删除会话…",\n',
282
+ '\t\t\t"menu.archiveSession": "归档会话",\n\t\t\t"archive.manager.title": "归档会话",\n\t\t\t"archive.manager.description": "共 {n} 个归档会话。可按名称、工作区或聊天内容搜索。",\n\t\t\t"archive.manager.searchPlaceholder": "搜索归档名称、工作区或聊天内容…",\n\t\t\t"archive.manager.searching": "正在搜索归档聊天记录…",\n\t\t\t"archive.manager.searchUnavailable": "内容搜索暂不可用,仅显示名称与工作区匹配。",\n\t\t\t"archive.manager.empty": "暂无归档会话",\n\t\t\t"archive.manager.noMatches": "没有匹配的归档会话",\n\t\t\t"archive.manager.hasMore": "仅显示前 20 条内容匹配,请缩小搜索范围。",\n\t\t\t"archive.manager.restore": "恢复",\n\t\t\t"archive.manager.restoring": "恢复中…",\n\t\t\t"archive.manager.delete": "永久删除",\n\t\t\t"menu.deleteSession": "删除会话",\n\t\t\t"delete.session.title": "永久删除会话?",\n\t\t\t"delete.session.desc": "“{name}”的会话记录将从本机永久删除,且无法恢复。正在运行的任务会先安全停止。",\n\t\t\t"delete.session.confirm": "永久删除",\n\t\t\t"delete.session.pending": "正在永久删除会话…",\n',
129
283
  'Chinese delete locale',
130
284
  )
131
285
  patch(
132
286
  '\t\t\t"menu.archiveSession": "Archive session",\n',
133
- '\t\t\t"menu.archiveSession": "Archive session",\n\t\t\t"menu.deleteSession": "Delete session",\n\t\t\t"delete.session.title": "Permanently delete session?",\n\t\t\t"delete.session.desc": "The local record for “{name}” will be permanently deleted and cannot be recovered. Running work will be stopped safely before deletion.",\n\t\t\t"delete.session.confirm": "Delete permanently",\n\t\t\t"delete.session.pending": "Permanently deleting session…",\n',
287
+ '\t\t\t"menu.archiveSession": "Archive session",\n\t\t\t"archive.manager.title": "Archived sessions",\n\t\t\t"archive.manager.description": "{n} archived sessions. Search by name, workspace, or conversation content.",\n\t\t\t"archive.manager.searchPlaceholder": "Search archived names, workspaces, or conversation content…",\n\t\t\t"archive.manager.searching": "Searching archived conversation history…",\n\t\t\t"archive.manager.searchUnavailable": "Content search is temporarily unavailable. Showing name and workspace matches.",\n\t\t\t"archive.manager.empty": "No archived sessions",\n\t\t\t"archive.manager.noMatches": "No matching archived sessions",\n\t\t\t"archive.manager.hasMore": "Showing the first 20 content matches. Narrow your search.",\n\t\t\t"archive.manager.restore": "Restore",\n\t\t\t"archive.manager.restoring": "Restoring…",\n\t\t\t"archive.manager.delete": "Delete permanently",\n\t\t\t"menu.deleteSession": "Delete session",\n\t\t\t"delete.session.title": "Permanently delete session?",\n\t\t\t"delete.session.desc": "The local record for “{name}” will be permanently deleted and cannot be recovered. Running work will be stopped safely before deletion.",\n\t\t\t"delete.session.confirm": "Delete permanently",\n\t\t\t"delete.session.pending": "Permanently deleting session…",\n',
134
288
  'English delete locale',
135
289
  )
136
290
  patch(
@@ -138,6 +292,37 @@ export function patchWorkspaceClient(upstream, upstreamVersion = LATEST_UPSTREAM
138
292
  `\t\t\t\tarchiveSession: async (sessionId) => {\n\t\t\t\t\tawait ctx.workspaces.archiveSession(sessionId);\n\t\t\t\t},\n\t\t\t\tdeleteSession: async (sessionId) => {\n\t\t\t\t\tconst response = await fetch("/plugins/dsh-session-delete/delete", {\n\t\t\t\t\t\tmethod: "POST",\n\t\t\t\t\t\theaders: {\n\t\t\t\t\t\t\t"content-type": "application/json",\n\t\t\t\t\t\t\t"x-dsh-session-delete-confirmation": "delete-session"\n\t\t\t\t\t\t},\n\t\t\t\t\t\tbody: JSON.stringify({ sessionId })\n\t\t\t\t\t});\n\t\t\t\t\tconst payload = await response.json().catch(() => null);\n\t\t\t\t\tif (!response.ok || payload?.ok !== true) {\n\t\t\t\t\t\tthrow new Error(payload?.error?.message ?? \`Delete failed (HTTP \${response.status})\`);\n\t\t\t\t\t}\n\t\t\t\t\tif (ctx.sessions.list.getSnapshot().current === sessionId) ctx.sessions.clear();\n\t\t\t\t\tconst refreshes = await Promise.allSettled([\n\t\t\t\t\t\tctx.sessions.refresh(),\n\t\t\t\t\t\tctx.workspaces.refresh()\n\t\t\t\t\t]);\n\t\t\t\t\tfor (const refresh of refreshes) {\n\t\t\t\t\t\tif (refresh.status === "rejected") console.warn("session deletion succeeded but runtime refresh failed:", refresh.reason);\n\t\t\t\t\t}\n\t\t\t\t},\n`,
139
293
  'browser delete request',
140
294
  )
295
+ patch(
296
+ '\t\t\t\tinsertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {\n',
297
+ `\t\t\t\trestoreSession: async (sessionId) => {
298
+ \t\t\t\t\tconst response = await fetch("/plugins/dsh-session-delete/restore", {
299
+ \t\t\t\t\t\tmethod: "POST",
300
+ \t\t\t\t\t\theaders: {
301
+ \t\t\t\t\t\t\t"content-type": "application/json",
302
+ \t\t\t\t\t\t\t"x-dsh-session-manager-action": "restore-session"
303
+ \t\t\t\t\t\t},
304
+ \t\t\t\t\t\tbody: JSON.stringify({ sessionId })
305
+ \t\t\t\t\t});
306
+ \t\t\t\t\tconst payload = await response.json().catch(() => null);
307
+ \t\t\t\t\tif (!response.ok || payload?.ok !== true) throw new Error(payload?.error?.message ?? \`Restore failed (HTTP \${response.status})\`);
308
+ \t\t\t\t\tconst refreshes = await Promise.allSettled([ctx.sessions.refresh(), ctx.workspaces.refresh()]);
309
+ \t\t\t\t\tfor (const refresh of refreshes) if (refresh.status === "rejected") console.warn("session restore succeeded but runtime refresh failed:", refresh.reason);
310
+ \t\t\t\t},
311
+ \t\t\t\tsearchArchivedSessions: async (query, signal) => {
312
+ \t\t\t\t\tconst response = await fetch("/plugins/dsh-session-delete/archive-search", {
313
+ \t\t\t\t\t\tmethod: "POST",
314
+ \t\t\t\t\t\theaders: { "content-type": "application/json" },
315
+ \t\t\t\t\t\tbody: JSON.stringify({ query }),
316
+ \t\t\t\t\t\tsignal
317
+ \t\t\t\t\t});
318
+ \t\t\t\t\tconst payload = await response.json().catch(() => null);
319
+ \t\t\t\t\tif (!response.ok || payload?.ok !== true) throw new Error(payload?.error?.message ?? \`Archived search failed (HTTP \${response.status})\`);
320
+ \t\t\t\t\treturn payload.value;
321
+ \t\t\t\t},
322
+ \t\t\t\tinsertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
323
+ `,
324
+ 'browser archive manager requests',
325
+ )
141
326
  const homePathCall = '(0, _deepseek_ai_dsh_client_runtime_client.abbreviateHomePath)(row.cwd, home)'
142
327
  if (source.includes(homePathCall)) {
143
328
  patch(
@@ -147,7 +332,7 @@ export function patchWorkspaceClient(upstream, upstreamVersion = LATEST_UPSTREAM
147
332
  )
148
333
  }
149
334
 
150
- const notice = `// Modified from @deepseek-ai/dsh-client-ui-workspace ${upstreamVersion} by DSH Native Session Delete. See THIRD_PARTY_NOTICES.md.\n`
335
+ const notice = `// Modified from @deepseek-ai/dsh-client-ui-workspace ${upstreamVersion} by DSH Native Session Manager. See THIRD_PARTY_NOTICES.md.\n`
151
336
  return `${notice}${source}`
152
337
  }
153
338
 
@@ -133,6 +133,11 @@ export async function runSmoke(options) {
133
133
  })
134
134
  }
135
135
  }
136
+ await page.locator('#archived-sessions').click()
137
+ const archiveDialog = page.getByRole('dialog', { name: /^(Archived sessions|归档会话)$/ })
138
+ await archiveDialog.getByRole('searchbox').waitFor()
139
+ await archiveDialog.getByRole('button', { name: /^(Close|关闭)$/ }).filter({ hasText: /^(Close|关闭)$/ }).click()
140
+ await archiveDialog.waitFor({ state: 'hidden' })
136
141
  const matchingTitles = page.getByText(options.session, { exact: true })
137
142
  await matchingTitles.first().waitFor()
138
143
  const rowCount = await matchingTitles.count()
@@ -193,8 +198,8 @@ export async function runSmoke(options) {
193
198
  return {
194
199
  ok: true,
195
200
  checks: options.simulateDeleteSuccess === true
196
- ? ['Archive session', 'red Delete session', 'confirmation dialog', 'successful delete without reload']
197
- : ['Archive session', 'red Delete session', 'confirmation dialog', 'cancel without request'],
201
+ ? ['archive manager', 'Archive session', 'red Delete session', 'confirmation dialog', 'successful delete without reload']
202
+ : ['archive manager', 'Archive session', 'red Delete session', 'confirmation dialog', 'cancel without request'],
198
203
  }
199
204
  } finally {
200
205
  await browser.close()
@@ -0,0 +1,251 @@
1
+ const SEARCH_LIMIT = 20
2
+ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
3
+ const MAX_REQUEST_BYTES = 8 * 1024
4
+
5
+ const assertSessionId = sessionId => {
6
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || sessionId.length > 512 || sessionId.includes('\0')) {
7
+ throw new TypeError('invalid archived session id')
8
+ }
9
+ return sessionId
10
+ }
11
+
12
+ const archiveIds = workspaceRegistry => {
13
+ const ids = workspaceRegistry?.archivedSessionIds
14
+ if (!Array.isArray(ids) || ids.some(id => typeof id !== 'string')) {
15
+ throw new Error('unsupported workspace registry archive snapshot')
16
+ }
17
+ return [...ids]
18
+ }
19
+
20
+ const plainSnippet = (text, query) => {
21
+ const compact = text.replace(/\s+/g, ' ').trim()
22
+ if (compact.length <= 240) return compact
23
+ const matchAt = compact.toLowerCase().indexOf(query.toLowerCase())
24
+ const start = Math.max(0, (matchAt === -1 ? 0 : matchAt) - 80)
25
+ const end = Math.min(compact.length, start + 240)
26
+ return `${start > 0 ? '…' : ''}${compact.slice(start, end)}${end < compact.length ? '…' : ''}`
27
+ }
28
+
29
+ const scanArchivedEvents = async (sessionQuery, archivedSessionIds, query, signal) => {
30
+ if (typeof sessionQuery?.filterEvents !== 'function') {
31
+ throw new Error('archived history scan is unavailable')
32
+ }
33
+ const filters = [
34
+ { kind: 'type', values: ['user/message', 'assistant/message'] },
35
+ { kind: 'surface', values: ['current'] },
36
+ { kind: 'text', text: query },
37
+ ]
38
+ const items = []
39
+ for (let offset = 0; offset < archivedSessionIds.length && items.length <= SEARCH_LIMIT; offset += 4) {
40
+ signal?.throwIfAborted()
41
+ const batch = archivedSessionIds.slice(offset, offset + 4)
42
+ const matches = await Promise.all(batch.map(sessionId => sessionQuery.filterEvents(sessionId, filters)))
43
+ for (let index = 0; index < batch.length; index += 1) {
44
+ const match = Array.isArray(matches[index])
45
+ ? matches[index].find(event => typeof event?.text === 'string' && event.text.trim().length > 0)
46
+ : undefined
47
+ if (match !== undefined) items.push({ sessionId: batch[index], snippet: plainSnippet(match.text, query) })
48
+ if (items.length > SEARCH_LIMIT) break
49
+ }
50
+ }
51
+ return { items: items.slice(0, SEARCH_LIMIT), hasMore: items.length > SEARCH_LIMIT }
52
+ }
53
+
54
+ /**
55
+ * Remove one id from DSH's registry-global archive set without touching the
56
+ * session log or its workspace accounting position. DSH does not expose a
57
+ * public unarchive method yet, so every private seam is checked before use.
58
+ */
59
+ export async function restoreArchivedSession(workspaceRegistry, sessionId) {
60
+ const id = assertSessionId(sessionId)
61
+ if (
62
+ typeof workspaceRegistry?.enqueueOperation !== 'function'
63
+ || typeof workspaceRegistry?.requireState !== 'function'
64
+ || typeof workspaceRegistry?.setState !== 'function'
65
+ ) {
66
+ throw new Error('unsupported workspace registry restore seam')
67
+ }
68
+
69
+ return workspaceRegistry.enqueueOperation(async () => {
70
+ const state = workspaceRegistry.requireState()
71
+ if (!Array.isArray(state?.archivedSessionIds)) {
72
+ throw new Error('unsupported workspace registry restore state')
73
+ }
74
+ if (!state.archivedSessionIds.includes(id)) {
75
+ return { restored: false, archivedSessionIds: [...state.archivedSessionIds] }
76
+ }
77
+ const archivedSessionIds = state.archivedSessionIds.filter(candidate => candidate !== id)
78
+ await workspaceRegistry.setState({ ...state, archivedSessionIds })
79
+ return { restored: true, archivedSessionIds }
80
+ })
81
+ }
82
+
83
+ /** Keep DSH's archive registry free of ids whose storage was permanently removed. */
84
+ export async function deleteSessionAndReconcileArchive({ workspaceRegistry, deleteSession, warn = console.warn }, sessionId) {
85
+ const result = await deleteSession(sessionId)
86
+ if (result?.ok !== true) return result
87
+ try {
88
+ await restoreArchivedSession(workspaceRegistry, sessionId)
89
+ return { ok: true, value: { ...result.value, archiveReconciled: true } }
90
+ } catch (error) {
91
+ warn('session storage was deleted but its archive marker could not be reconciled:', error)
92
+ return { ok: true, value: { ...result.value, archiveReconciled: false } }
93
+ }
94
+ }
95
+
96
+ /** Search current user/assistant message history inside the archive set only. */
97
+ export async function searchArchivedSessions({ workspaceRegistry, sessionQuery }, query, signal) {
98
+ const normalized = typeof query === 'string' ? query.trim() : ''
99
+ if (normalized.length === 0 || normalized.length > 500 || normalized.includes('\0')) {
100
+ throw new TypeError('invalid archive search query')
101
+ }
102
+ if (typeof sessionQuery?.searchSessions !== 'function') {
103
+ throw new Error('archived history search is unavailable')
104
+ }
105
+
106
+ const archivedSessionIds = archiveIds(workspaceRegistry)
107
+ if (archivedSessionIds.length === 0) return { items: [], hasMore: false }
108
+
109
+ let page
110
+ try {
111
+ page = await sessionQuery.searchSessions({
112
+ query: normalized,
113
+ sessionFilters: [{ kind: 'id', values: archivedSessionIds }],
114
+ eventFilters: [
115
+ { kind: 'type', values: ['user/message', 'assistant/message'] },
116
+ { kind: 'surface', values: ['current'] },
117
+ ],
118
+ limit: SEARCH_LIMIT,
119
+ }, { signal })
120
+ } catch (error) {
121
+ if (error?.code !== 'SESSION_QUERY_SEARCH_DISABLED') throw error
122
+ return scanArchivedEvents(sessionQuery, archivedSessionIds, normalized, signal)
123
+ }
124
+
125
+ const archived = new Set(archivedSessionIds)
126
+ const items = []
127
+ const included = new Set()
128
+ for (const hit of Array.isArray(page?.items) ? page.items : []) {
129
+ const sessionId = hit?.header?.id
130
+ const match = hit?.bestMatch
131
+ if (
132
+ typeof sessionId !== 'string'
133
+ || !archived.has(sessionId)
134
+ || included.has(sessionId)
135
+ || match?.sessionId !== sessionId
136
+ || match?.surface !== 'current'
137
+ || !MESSAGE_TYPES.has(match?.type)
138
+ || typeof match?.snippet !== 'string'
139
+ ) continue
140
+ included.add(sessionId)
141
+ items.push({ sessionId, snippet: match.snippet })
142
+ if (items.length >= SEARCH_LIMIT) break
143
+ }
144
+
145
+ return { items, hasMore: page?.nextCursor !== undefined }
146
+ }
147
+
148
+ const sendJson = (res, status, payload) => {
149
+ res.writeHead(status, {
150
+ 'content-type': 'application/json; charset=utf-8',
151
+ 'cache-control': 'no-store',
152
+ 'x-content-type-options': 'nosniff',
153
+ })
154
+ res.end(JSON.stringify(payload))
155
+ }
156
+
157
+ const failure = (code, message) => ({ ok: false, error: { code, message } })
158
+
159
+ const readJsonBody = async req => {
160
+ const chunks = []
161
+ let size = 0
162
+ for await (const chunk of req) {
163
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
164
+ size += buffer.length
165
+ if (size > MAX_REQUEST_BYTES) throw new Error('request-too-large')
166
+ chunks.push(buffer)
167
+ }
168
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
169
+ }
170
+
171
+ const acceptJsonPost = (req, res) => {
172
+ if (req.method !== 'POST') {
173
+ sendJson(res, 405, failure('method-not-allowed', '只允许使用 POST 管理归档会话。'))
174
+ return false
175
+ }
176
+ const host = req.headers.host
177
+ if (typeof host !== 'string' || req.headers.origin !== `http://${host}`) {
178
+ sendJson(res, 403, failure('forbidden', '归档管理请求未通过同源校验。'))
179
+ return false
180
+ }
181
+ const contentType = req.headers['content-type']
182
+ if (typeof contentType !== 'string' || !contentType.toLowerCase().startsWith('application/json')) {
183
+ sendJson(res, 415, failure('unsupported-media-type', '归档管理请求必须使用 JSON。'))
184
+ return false
185
+ }
186
+ return true
187
+ }
188
+
189
+ const parseBody = async (req, res) => {
190
+ try {
191
+ return await readJsonBody(req)
192
+ } catch (error) {
193
+ const tooLarge = error instanceof Error && error.message === 'request-too-large'
194
+ sendJson(
195
+ res,
196
+ tooLarge ? 413 : 400,
197
+ failure(tooLarge ? 'request-too-large' : 'invalid-json', tooLarge ? '归档管理请求过大。' : '归档管理请求不是有效 JSON。'),
198
+ )
199
+ return undefined
200
+ }
201
+ }
202
+
203
+ /** Same-origin HTTP boundary consumed by the native archive manager UI. */
204
+ export function createArchiveRequestHandlers({ restore, search, warn = console.warn }) {
205
+ return {
206
+ restore: async (req, res) => {
207
+ if (!acceptJsonPost(req, res)) return
208
+ if (req.headers['x-dsh-session-manager-action'] !== 'restore-session') {
209
+ sendJson(res, 403, failure('forbidden', '恢复请求缺少明确的操作标记。'))
210
+ return
211
+ }
212
+ const body = await parseBody(req, res)
213
+ if (body === undefined) return
214
+ try {
215
+ const sessionId = assertSessionId(body?.sessionId)
216
+ sendJson(res, 200, { ok: true, value: await restore(sessionId) })
217
+ } catch (error) {
218
+ if (error instanceof TypeError) {
219
+ sendJson(res, 400, failure('invalid-session-id', '会话 ID 无效。'))
220
+ } else {
221
+ sendJson(res, 500, failure('restore-failed', '取消归档失败,归档状态未确认改变。'))
222
+ }
223
+ }
224
+ },
225
+ search: async (req, res) => {
226
+ if (!acceptJsonPost(req, res)) return
227
+ const body = await parseBody(req, res)
228
+ if (body === undefined) return
229
+ const query = typeof body?.query === 'string' ? body.query.trim() : ''
230
+ if (query.length === 0 || query.length > 500 || query.includes('\0')) {
231
+ sendJson(res, 400, failure('invalid-query', '搜索内容无效。'))
232
+ return
233
+ }
234
+ const controller = new AbortController()
235
+ const abort = () => controller.abort(new Error('archive search request closed'))
236
+ req.once('aborted', abort)
237
+ if (typeof res.once === 'function') res.once('close', abort)
238
+ try {
239
+ const value = await search(query, controller.signal)
240
+ sendJson(res, 200, { ok: true, value })
241
+ } catch (error) {
242
+ if (controller.signal.aborted) return
243
+ warn('archived history search failed:', error)
244
+ sendJson(res, 503, failure('search-unavailable', '归档内容搜索暂不可用。'))
245
+ } finally {
246
+ req.off('aborted', abort)
247
+ if (typeof res.off === 'function') res.off('close', abort)
248
+ }
249
+ },
250
+ }
251
+ }
package/src/index.js CHANGED
@@ -3,9 +3,15 @@ import {
3
3
  deleteSessionSafely,
4
4
  installAgentHandleTracker,
5
5
  } from './host/delete-session.mjs'
6
+ import {
7
+ createArchiveRequestHandlers,
8
+ deleteSessionAndReconcileArchive,
9
+ restoreArchivedSession,
10
+ searchArchivedSessions,
11
+ } from './host/archive-manager.mjs'
6
12
 
7
13
  export const name = 'dsh-session-delete'
8
- export const inject = ['webServer', 'sessionPersistence', 'sessions', 'agents']
14
+ export const inject = ['webServer', 'sessionPersistence', 'sessions', 'agents', 'workspaceRegistry']
9
15
 
10
16
  export function apply(ctx) {
11
17
  const sessionRoot = ctx.sessionPersistence?.root
@@ -16,13 +22,24 @@ export function apply(ctx) {
16
22
  const agentHandles = installAgentHandleTracker(ctx.agents, ctx.sessions)
17
23
  ctx.effect(() => () => agentHandles.release(), 'dsh-session-delete: agent lifecycle tracking')
18
24
 
25
+ const deleteSession = sessionId => deleteSessionSafely({
26
+ sessions: ctx.sessions,
27
+ agents: ctx.agents,
28
+ agentHandles,
29
+ sessionPersistence: ctx.sessionPersistence,
30
+ }, { sessionRoot, sessionId })
19
31
  const handler = createDeleteRequestHandler({
20
- deleteSession: sessionId => deleteSessionSafely({
21
- sessions: ctx.sessions,
22
- agents: ctx.agents,
23
- agentHandles,
24
- sessionPersistence: ctx.sessionPersistence,
25
- }, { sessionRoot, sessionId }),
32
+ deleteSession: sessionId => deleteSessionAndReconcileArchive({
33
+ workspaceRegistry: ctx.workspaceRegistry,
34
+ deleteSession,
35
+ }, sessionId),
36
+ })
37
+ const archiveHandlers = createArchiveRequestHandlers({
38
+ restore: sessionId => restoreArchivedSession(ctx.workspaceRegistry, sessionId),
39
+ search: (query, signal) => searchArchivedSessions({
40
+ workspaceRegistry: ctx.workspaceRegistry,
41
+ sessionQuery: ctx.get('sessionQuery'),
42
+ }, query, signal),
26
43
  })
27
44
 
28
45
  ctx.effect(
@@ -33,6 +50,22 @@ export function apply(ctx) {
33
50
  }),
34
51
  'dsh-session-delete: confirmed permanent deletion route',
35
52
  )
53
+ ctx.effect(
54
+ () => ctx.webServer.register({
55
+ kind: 'exact',
56
+ path: '/plugins/dsh-session-delete/restore',
57
+ handler: archiveHandlers.restore,
58
+ }),
59
+ 'dsh-session-delete: archived session restore route',
60
+ )
61
+ ctx.effect(
62
+ () => ctx.webServer.register({
63
+ kind: 'exact',
64
+ path: '/plugins/dsh-session-delete/archive-search',
65
+ handler: archiveHandlers.search,
66
+ }),
67
+ 'dsh-session-delete: archived history search route',
68
+ )
36
69
  }
37
70
 
38
71
  export {
@@ -41,3 +74,10 @@ export {
41
74
  deleteSessionSafely,
42
75
  installAgentHandleTracker,
43
76
  } from './host/delete-session.mjs'
77
+
78
+ export {
79
+ createArchiveRequestHandlers,
80
+ deleteSessionAndReconcileArchive,
81
+ restoreArchivedSession,
82
+ searchArchivedSessions,
83
+ } from './host/archive-manager.mjs'