@yiliang114/dsh-session-deck 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +152 -2
- package/README.zh-CN.md +153 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +372 -0
- package/package.json +48 -4
- package/scripts/patch-sidebar-before.mjs +47 -0
- package/src/host.mjs +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yiliang114
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,153 @@
|
|
|
1
|
-
#
|
|
1
|
+
# DSH Session Deck
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[简体中文](https://github.com/yiliang114/dsh-session-deck/blob/main/README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
**Keep track of work across DSH workspaces without leaving your conversation.** Session Deck adds Activity and Favorites views to the native DeepSeek Harness sidebar, so you can spot running agents, reply to waiting sessions, and return to completed results.
|
|
6
|
+
|
|
7
|
+
A community plugin with an English interface, no extra npm dependencies, and no telemetry. Licensed under [MIT](LICENSE).
|
|
8
|
+
|
|
9
|
+
## Three views, one sidebar
|
|
10
|
+
|
|
11
|
+
| View | What it helps you do |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| **Activity** | Follow running, waiting, unread, and recent sessions across all workspaces. |
|
|
14
|
+
| **Favorites** | Keep important sessions close using DSH's native pinned sessions. |
|
|
15
|
+
| **Workspaces** | Return to the native workspace tree, its management tools, and other plugins' menus. |
|
|
16
|
+
|
|
17
|
+
Switching views keeps your current conversation open. Session rows show the title, workspace, time, and status; their menus provide favorite, rename, fork, archive, and copy-ID actions.
|
|
18
|
+
|
|
19
|
+
*Screenshots captured from a local DSH installation in English; session titles and workspace names anonymized.*
|
|
20
|
+
|
|
21
|
+
| Activity | Favorites | Workspaces |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| Follow active work and unread results. | Return to native pinned sessions. | Keep the complete workspace controls. |
|
|
24
|
+
|  |  |  |
|
|
25
|
+
|
|
26
|
+
Screenshots show the switcher above the workspace region without host patches, below global panels. The interface and anonymized session titles are English; browser menus, tabs, and the address bar are excluded.
|
|
27
|
+
|
|
28
|
+
## Install locally
|
|
29
|
+
|
|
30
|
+
The package name is **`@yiliang114/dsh-session-deck`**. A local checkout or tarball can be installed without npm publication; for registry installation, check version availability as described below.
|
|
31
|
+
|
|
32
|
+
Requirements:
|
|
33
|
+
|
|
34
|
+
- Node.js **22 or newer**.
|
|
35
|
+
- DSH Web GUI **0.2.1-rc.2**. This is the exact supported host version; other versions are unverified.
|
|
36
|
+
- DSH's native sidebar, session, and workspace plugins enabled.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
git clone https://github.com/yiliang114/dsh-session-deck.git
|
|
40
|
+
cd dsh-session-deck
|
|
41
|
+
npm run verify
|
|
42
|
+
dsh plugin --profile web add "link:$PWD" --offline --ignore-scripts
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
There are no dependencies to install. The repository includes the client bundle; `build` checks JavaScript syntax rather than generating it. Replace `web` if you use another profile. Keep the checkout in place while using a linked installation.
|
|
46
|
+
|
|
47
|
+
You can also install through **Plugins → Add plugin → Install a third-party plugin**: enter `link:/absolute/path/to/dsh-session-deck`, choose **Install**, then **Enable now**, and refresh the page.
|
|
48
|
+
|
|
49
|
+
The plugin's own labels are English. To make the rest of DSH English too, use **More → Settings → General → Language → English**. Session titles remain in the language you wrote them.
|
|
50
|
+
|
|
51
|
+
Refresh your existing DSH GUI, then choose **Workspaces / Activity / Favorites** above the workspace region, below global panels. On the supported DSH 0.2.1-rc.2 layout, the plugin places the switcher there automatically without patching installed DSH files. The native workspace browser remains available in Workspaces. Refresh after client changes too; hot reload is not assumed.
|
|
52
|
+
|
|
53
|
+
If you installed an earlier local version of this plugin, disable and remove it in DSH's plugin manager first to avoid two instances controlling the sidebar. You can keep its browser storage.
|
|
54
|
+
|
|
55
|
+
### Install from a tarball
|
|
56
|
+
|
|
57
|
+
To distribute the plugin without npm, run from the checkout:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
npm run verify
|
|
61
|
+
npm pack --ignore-scripts
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Copy the generated archive to the target machine, then install using its absolute path:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
dsh plugin --profile web add /absolute/path/yiliang114-dsh-session-deck-0.1.2.tgz --ignore-scripts
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Refresh the GUI. A tarball installation does not depend on the original checkout. Both linked and tarball installations place the switcher above the workspace region automatically on the supported rc.2 layout; neither needs a host patch.
|
|
71
|
+
|
|
72
|
+
### npm installation and market discovery
|
|
73
|
+
|
|
74
|
+
**Confirm that version 0.1.2 exists in the official registry before installing:**
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
npm view @yiliang114/dsh-session-deck@0.1.2 version --registry=https://registry.npmjs.org
|
|
78
|
+
dsh plugin --profile web add @yiliang114/dsh-session-deck@0.1.2 --ignore-scripts
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The [market index](market-index.json) lists only verified npm releases. An empty `plugins: []` means there are **no released entries** to display. DSH's custom plugin market uses user-added index sources; npm publication does not automatically collect or list a plugin in an official DSH market.
|
|
82
|
+
|
|
83
|
+
After a confirmed release is added to the index, its direct URL can be added through DSH's custom plugin market:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
https://raw.githubusercontent.com/yiliang114/dsh-session-deck/main/market-index.json
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Maintainers: see the [release guide](https://github.com/yiliang114/dsh-session-deck/blob/main/docs/releasing.md) for npm bootstrap, OIDC configuration, and index updates.
|
|
90
|
+
|
|
91
|
+
## How session status works
|
|
92
|
+
|
|
93
|
+
- **Activity keeps all running, waiting, and unread sessions**, plus at most **20 ordinary recent sessions**, sorted by DSH's `updatedAt` rather than last-opened time. Favorites has no 20-session limit.
|
|
94
|
+
- Favorites are **native DSH pins**, shared with the workspace tree. Archived, subagent, and empty sessions are omitted from the lists; ordinary forks remain visible.
|
|
95
|
+
- Status priority is **waiting for a reply → running → unread**. A parent remains running while any known subagent descendant is running; ordinary forks do not propagate status to their source.
|
|
96
|
+
- Aggregation covers only descendants known to DSH's session directory. **Independent background shell jobs are not tracked**: an idle session does not mean every process it launched has exited.
|
|
97
|
+
- A session becomes unread when it and its known descendants finish while you are viewing something else. Opening it clears the marker. Persisted observations can recover completions across a refresh, but cannot reconstruct work that started and finished while every page was closed or was never observed.
|
|
98
|
+
- Archiving an active session requires a **Stop and archive** confirmation. There is no permanent-delete action. Switch to Workspaces for the complete native menus, including actions from other plugins.
|
|
99
|
+
|
|
100
|
+
## DSH compatibility and switcher position
|
|
101
|
+
|
|
102
|
+
This plugin declares an exact `engines.dsh` match of **0.2.1-rc.2**. Automatic top placement uses a DOM compatibility adapter for that release, not an official DSH extension API.
|
|
103
|
+
|
|
104
|
+
| DSH sidebar | Placement mechanism | Session Deck support |
|
|
105
|
+
| --- | --- | --- |
|
|
106
|
+
| Official **0.2.1-rc.2**, without patches | No official `sidebar.workspaces.before` slot; the adapter places the switcher above the workspace region by default. | Supported target, with a precise rc.2 DOM structure guard. |
|
|
107
|
+
| **0.2.1-rc.2** with the legacy local patch | Reuses the existing `sidebar.workspaces.before` slot below global panels. | Compatible; the patch is no longer needed. |
|
|
108
|
+
| Official **0.2.1-rc.3** | The published client and slot contract still lack the before-slot. | Not supported: full compatibility is unverified and the exact engine declaration excludes it. |
|
|
109
|
+
|
|
110
|
+
On the recognized rc.2 layout, the adapter renders a React portal into its own DOM host immediately before the workspace region, below global panels such as Automation tasks. It does not write installed DSH files or replace the native workspace browser. An unknown DOM layout falls back to the footer. Disabling the plugin removes its scoped observer and DOM host.
|
|
111
|
+
|
|
112
|
+
The rc.3 published files were inspected on **2026-10-23**; that inspection does not establish plugin compatibility. Supporting another host version requires validating its session, workspace, slot, and UI APIs and DOM layout before widening `engines.dsh`.
|
|
113
|
+
|
|
114
|
+
## Legacy sidebar patch rollback
|
|
115
|
+
|
|
116
|
+
The [sidebar patch script](scripts/patch-sidebar-before.mjs) is retained for rollback and backward compatibility. New installations do not need it. If you previously installed its local before-slot patch, the plugin can reuse that slot, or you can restore the original sidebar.
|
|
117
|
+
|
|
118
|
+
Locate the `@deepseek-ai/dsh-client-ui-sidebar` package actually used by your running DSH. From this repository, replace the placeholder with that package's directory, not the DSH root or this plugin directory:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
node scripts/patch-sidebar-before.mjs /path/to/dsh-client-ui-sidebar --rollback
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Rollback requires the original adjacent `.dsa-before.backup` files and refuses to overwrite unrelated changes. Refresh the GUI after rollback: the new plugin still places the switcher above the workspace region on the supported unpatched rc.2 layout. If rolling back a DSH source checkout, rebuild the affected client artifacts as well.
|
|
125
|
+
|
|
126
|
+
## Privacy and local storage
|
|
127
|
+
|
|
128
|
+
Session Deck uses DSH's session and workspace APIs. It makes **no model calls, sends no telemetry, and has no external upload endpoint**.
|
|
129
|
+
|
|
130
|
+
Browser `localStorage` retains the original keys for compatibility:
|
|
131
|
+
|
|
132
|
+
| Key | Stored data |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| `dsh-session-activity.mode.v1` | Selected view. |
|
|
135
|
+
| `dsh-session-activity.pending.v1` | Session IDs, observed running state, and completion/unread metadata. |
|
|
136
|
+
|
|
137
|
+
No conversation bodies are stored by the plugin. Favorites remain in DSH's native pinned data. View and unread state sync between tabs on the same origin, not across devices. If storage is unavailable, state stays in memory. Clearing browser storage resets this metadata; processed records are capped at 1,000, while running and unread records are retained.
|
|
138
|
+
|
|
139
|
+
Disable or remove **`@yiliang114/dsh-session-deck`** in DSH's plugin manager to stop using it. Disabling removes the adapter's observer and DOM host and restores the native workspace browser. Removal does not clear browser storage or undo a legacy host patch; use the rollback command separately if needed.
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
npm run verify
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
This runs syntax checks, Node.js tests for session selection and state handling, and a dry-run package-content check. CI runs it on Node.js 22 and 24. There is no separate lint or TypeScript check, and automated checks do not replace testing the real DSH GUI.
|
|
148
|
+
|
|
149
|
+
The client lives in [lib/client.js](lib/client.js), with a minimal [host entry](src/host.mjs) and [Cordis configuration](cordis.patch.yml). Report bugs with your DSH version, installation method, and reproduction steps in [GitHub Issues](https://github.com/yiliang114/dsh-session-deck/issues).
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
[MIT](LICENSE). Independently maintained; not an official built-in DSH component.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# DSH Session Deck
|
|
2
|
+
|
|
3
|
+
[English](https://github.com/yiliang114/dsh-session-deck/blob/main/README.md)
|
|
4
|
+
|
|
5
|
+
**不用离开当前对话,就能掌握所有 DSH 工作区中的会话进展。** Session Deck 在 DeepSeek Harness 原生侧栏中加入 Activity 和 Favorites 视图,让你快速找到运行中的代理、等待回复的会话,以及尚未查看的完成结果。
|
|
6
|
+
|
|
7
|
+
社区维护的第三方插件,默认英文界面,无额外 npm 依赖、无遥测,采用 [MIT 许可证](LICENSE)。
|
|
8
|
+
|
|
9
|
+
## 三种视图,一个侧栏
|
|
10
|
+
|
|
11
|
+
| 视图 | 用途 |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| **Activity(活动)** | 跨工作区查看运行中、等待回复、未读和最近会话。 |
|
|
14
|
+
| **Favorites(收藏)** | 汇总 DSH 原生置顶会话,随时回到重要任务。 |
|
|
15
|
+
| **Workspaces(工作区)** | 使用原生工作区树、管理功能及其他插件提供的菜单。 |
|
|
16
|
+
|
|
17
|
+
切换视图不会离开当前对话。会话行显示标题、工作区、时间和状态,菜单提供收藏、重命名、分叉、归档和复制 ID 等操作。
|
|
18
|
+
|
|
19
|
+
*截图来自本地 DSH 安装的英文界面;会话标题和工作区名称已匿名化。*
|
|
20
|
+
|
|
21
|
+
| Activity | Favorites | Workspaces |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| 掌握运行状态和未读结果。 | 回到原生置顶的重要会话。 | 保留完整的工作区操作。 |
|
|
24
|
+
|  |  |  |
|
|
25
|
+
|
|
26
|
+
截图展示无需宿主补丁、自动位于全局面板下方和工作区区域上方的切换条。界面及匿名化后的会话标题使用英文;不包含浏览器顶部菜单、标签栏和地址栏。
|
|
27
|
+
|
|
28
|
+
## 本地安装
|
|
29
|
+
|
|
30
|
+
包名为 **`@yiliang114/dsh-session-deck`**。本地仓库和 tarball 安装不依赖 npm 发布;使用注册表安装时,请先按下文确认版本是否可用。
|
|
31
|
+
|
|
32
|
+
环境要求:
|
|
33
|
+
|
|
34
|
+
- Node.js **22 或以上**。
|
|
35
|
+
- DSH Web GUI **0.2.1-rc.2**,这是当前精确适配的宿主版本,其他版本未经验证。
|
|
36
|
+
- 已启用 DSH 原生侧栏、会话和工作区插件。
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
git clone https://github.com/yiliang114/dsh-session-deck.git
|
|
40
|
+
cd dsh-session-deck
|
|
41
|
+
npm run verify
|
|
42
|
+
dsh plugin --profile web add "link:$PWD" --offline --ignore-scripts
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
项目没有需要安装的依赖,仓库已包含客户端文件;`build` 仅检查 JavaScript 语法,不生成客户端。使用其他 profile 时替换 `web`。链接安装期间请保留仓库目录。
|
|
46
|
+
|
|
47
|
+
也可以通过 **Plugins → Add plugin → Install a third-party plugin** 安装:输入 `link:/absolute/path/to/dsh-session-deck`,点击 **Install**,然后 **Enable now**,最后刷新页面。
|
|
48
|
+
|
|
49
|
+
插件自身文案使用英文。要让 DSH 其他界面也显示英文,在 **更多 → 设置 → 通用设置 → 语言** 中选择 **English**。会话标题仍保留用户原来使用的语言。
|
|
50
|
+
|
|
51
|
+
刷新现有 DSH GUI,然后在全局面板下方、工作区区域上方选择 **Workspaces / Activity / Favorites**。在支持的 DSH 0.2.1-rc.2 布局中,插件会自动将切换条放到该位置,无需修改已安装的 DSH 文件。Workspaces 保留原生工作区浏览器。修改客户端后也需要刷新,不应假定热更新已启用。
|
|
52
|
+
|
|
53
|
+
如果已安装本插件的早期本地版本,请先在 DSH 插件管理中停用并移除旧实例,避免同时接管侧栏;浏览器存储可以保留。
|
|
54
|
+
|
|
55
|
+
### 使用 tarball 安装
|
|
56
|
+
|
|
57
|
+
不经过 npm 注册表分发时,在仓库根目录执行:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
npm run verify
|
|
61
|
+
npm pack --ignore-scripts
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
将生成的归档文件复制到目标机器,再用绝对路径安装:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
dsh plugin --profile web add /absolute/path/yiliang114-dsh-session-deck-0.1.2.tgz --ignore-scripts
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
安装后刷新 GUI。tarball 安装不依赖原仓库目录持续存在。链接和 tarball 两种安装方式都会在支持的 rc.2 布局中自动将切换条放到工作区区域上方,无需宿主补丁。
|
|
71
|
+
|
|
72
|
+
### npm 安装与市场发现
|
|
73
|
+
|
|
74
|
+
**安装前,先确认官方注册表中存在 0.1.2 版本:**
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
npm view @yiliang114/dsh-session-deck@0.1.2 version --registry=https://registry.npmjs.org
|
|
78
|
+
dsh plugin --profile web add @yiliang114/dsh-session-deck@0.1.2 --ignore-scripts
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
[市场索引](market-index.json)只列出经 npm 验证的发布版本。若内容为 `plugins: []`,表示**没有可显示的已发布条目**。DSH 自定义插件市场读取用户添加的索引源;发布 npm 不会触发官方 DSH 市场的自动收录。
|
|
82
|
+
|
|
83
|
+
确认发布且更新索引后,可以在 DSH 自定义插件市场中添加以下直接 JSON 地址:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
https://raw.githubusercontent.com/yiliang114/dsh-session-deck/main/market-index.json
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
维护者请参阅[发布指南(英文)](https://github.com/yiliang114/dsh-session-deck/blob/main/docs/releasing.md),了解首次 npm 发布、OIDC 授权和索引更新。
|
|
90
|
+
|
|
91
|
+
## 会话状态与边界
|
|
92
|
+
|
|
93
|
+
- Activity 保留**全部运行中、等待回复和未读会话**,以及最多 **20 条普通最近会话**,按 DSH 的 `updatedAt` 排序,而非最后打开时间。Favorites 不受 20 条限制。
|
|
94
|
+
- 收藏直接使用 **DSH 原生置顶数据**,与工作区树同步。归档、子代理和空白会话不单独显示;普通分叉正常显示。
|
|
95
|
+
- 状态优先级为**等待回复 → 运行中 → 未读**。已知子代理后代仍在运行时,父会话保持运行状态;普通分叉不会向来源会话传播状态。
|
|
96
|
+
- 仅聚合 DSH 会话目录中已知的子代理后代。**独立后台 shell 作业不在跟踪范围内**,会话空闲不代表它启动的所有进程均已退出。
|
|
97
|
+
- 会话及已知后代全部停止时,如果你正在查看其他会话,它会标记为未读;打开即清除。持久化记录可恢复刷新期间发生的完成状态,但无法补全所有页面关闭期间完整开始并结束、或从未观察到的运行。
|
|
98
|
+
- 归档活动会话需要确认 **Stop and archive(停止并归档)**。插件不提供永久删除;其他插件贡献的完整原生菜单请切回 Workspaces 使用。
|
|
99
|
+
|
|
100
|
+
## DSH 兼容性与切换条位置
|
|
101
|
+
|
|
102
|
+
插件的 `engines.dsh` 精确声明支持 **0.2.1-rc.2**。自动顶部定位使用针对该版本的 DOM 兼容适配层,并非 DSH 官方扩展 API。
|
|
103
|
+
|
|
104
|
+
| DSH 侧栏 | 定位方式 | Session Deck 支持情况 |
|
|
105
|
+
| --- | --- | --- |
|
|
106
|
+
| 官方 **0.2.1-rc.2**,未打补丁 | 没有官方 `sidebar.workspaces.before` 槽位;适配层默认将切换条放到工作区区域上方。 | 当前支持目标,严格检查 rc.2 DOM 结构。 |
|
|
107
|
+
| **0.2.1-rc.2** 加旧版本地补丁 | 复用全局面板下方已有的 `sidebar.workspaces.before` 槽位。 | 兼容,但已不再需要补丁。 |
|
|
108
|
+
| 官方 **0.2.1-rc.3** | 已发布的客户端及槽位声明中仍没有 before 槽位。 | 不支持:整体兼容性未经验证,精确版本声明不包含它。 |
|
|
109
|
+
|
|
110
|
+
识别到 rc.2 布局时,适配层在工作区区域紧前方、Automation tasks 等全局面板下方插入自己的 DOM 容器,通过 React portal 渲染切换条。它不会写入已安装的 DSH 文件,也不会替换原生工作区浏览器。遇到未知 DOM 布局时回退到底部。停用插件会移除其限定范围的观察器和 DOM 容器。
|
|
111
|
+
|
|
112
|
+
rc.3 发布文件检查于 **2026-10-23**,该检查不能证明插件兼容。支持其他宿主版本时,需要先验证会话、工作区、槽位、UI 接口及 DOM 布局,再扩大 `engines.dsh` 的版本范围。
|
|
113
|
+
|
|
114
|
+
## 旧版侧栏补丁回滚
|
|
115
|
+
|
|
116
|
+
[侧栏补丁脚本](scripts/patch-sidebar-before.mjs)保留用于回滚和向后兼容,新安装无需执行。如果之前安装过本地 before 槽位补丁,插件可以继续复用该槽位,也可以恢复原侧栏。
|
|
117
|
+
|
|
118
|
+
找到当前运行的 DSH 实际加载的 `@deepseek-ai/dsh-client-ui-sidebar` 包目录,在本仓库中执行以下命令。占位路径应替换为该侧栏包目录,而非 DSH 根目录或本插件目录:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
node scripts/patch-sidebar-before.mjs /path/to/dsh-client-ui-sidebar --rollback
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
恢复依赖原始的相邻 `.dsa-before.backup` 备份,并会拒绝覆盖无关改动。回滚后刷新 GUI:在支持的未打补丁 rc.2 布局中,新插件仍会将切换条放到工作区区域上方。如果回滚的是 DSH 源码仓库,还需重新构建相关客户端产物。
|
|
125
|
+
|
|
126
|
+
## 隐私与本地存储
|
|
127
|
+
|
|
128
|
+
插件使用 DSH 的会话和工作区 API,**不发起模型调用、不发送遥测、没有外部上传端点**。
|
|
129
|
+
|
|
130
|
+
为兼容早期版本,浏览器 `localStorage` 沿用以下键:
|
|
131
|
+
|
|
132
|
+
| 键 | 内容 |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| `dsh-session-activity.mode.v1` | 当前视图。 |
|
|
135
|
+
| `dsh-session-activity.pending.v1` | 会话 ID、观察到的运行状态和完成未读元数据。 |
|
|
136
|
+
|
|
137
|
+
插件不在其中保存会话正文。收藏仍使用 DSH 原生置顶数据。视图和未读状态在同源标签页间同步,不跨设备同步;存储不可用时仅保留在内存中。清除浏览器存储会重置这些元数据。已处理记录最多保留 1,000 条,运行中和未读记录不受此清理上限影响。
|
|
138
|
+
|
|
139
|
+
停用时,在 DSH 插件管理中禁用或移除 **`@yiliang114/dsh-session-deck`**。停用会移除适配层的观察器和 DOM 容器,并恢复原生工作区浏览器。移除不会清除浏览器存储,也不会撤销旧版宿主补丁;如需撤销,请单独执行恢复命令。
|
|
140
|
+
|
|
141
|
+
## 开发
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
npm run verify
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
该命令依次执行语法检查、会话筛选与状态逻辑的 Node.js 测试,以及打包清单 dry-run 检查。CI 使用 Node.js 22 和 24。没有独立 lint 或 TypeScript 检查;自动检查不能替代真实 DSH GUI 验证。
|
|
148
|
+
|
|
149
|
+
客户端位于 [lib/client.js](lib/client.js),另有最小[宿主入口](src/host.mjs)和 [Cordis 配置](cordis.patch.yml)。反馈问题时,请在 [GitHub Issues](https://github.com/yiliang114/dsh-session-deck/issues) 中提供 DSH 版本、安装方式和复现步骤。
|
|
150
|
+
|
|
151
|
+
## 许可证
|
|
152
|
+
|
|
153
|
+
[MIT](LICENSE)。本项目独立维护,不属于 DSH 官方内置组件。
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// Hand-written lazy ModuleLoader bundle: no dependency installation or build.
|
|
2
|
+
window.__ModuleLoader__.load({
|
|
3
|
+
id: '@yiliang114/dsh-session-deck',
|
|
4
|
+
factory: (require) => {
|
|
5
|
+
const { createElement: h, Fragment, useEffect, useLayoutEffect, useState, useSyncExternalStore } = require('react')
|
|
6
|
+
const { createPortal } = require('react-dom')
|
|
7
|
+
const { Menu, MenuItemButton, Modal, Button, writeClipboard, IconEllipsisOutlineRegular, IconArchiveOutlineRegular } = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
8
|
+
const KEY = 'dsh-session-activity.mode.v1'
|
|
9
|
+
const PENDING_KEY = 'dsh-session-activity.pending.v1'
|
|
10
|
+
const MAX_RECORDS = 1000
|
|
11
|
+
const identity = x => x
|
|
12
|
+
// Client SessionSummary exposes parentId; only subagent edges represent work.
|
|
13
|
+
// byId also includes retained children absent from the Host-list ids.
|
|
14
|
+
function aggregateRunning(list, statuses) {
|
|
15
|
+
const running = new Map(), queue = []
|
|
16
|
+
for (const s of Object.values(list.byId)) {
|
|
17
|
+
const value = statuses.get(s.id)?.running ?? s.running
|
|
18
|
+
running.set(s.id, value)
|
|
19
|
+
if (value === true) queue.push(s.id)
|
|
20
|
+
}
|
|
21
|
+
for (let i = 0; i < queue.length; i++) {
|
|
22
|
+
const s = list.byId[queue[i]]
|
|
23
|
+
if (s.origin !== 'subagent' || !running.has(s.parentId) || running.get(s.parentId) === true) continue
|
|
24
|
+
running.set(s.parentId, true)
|
|
25
|
+
queue.push(s.parentId)
|
|
26
|
+
}
|
|
27
|
+
return running
|
|
28
|
+
}
|
|
29
|
+
// Only observed runs or native unread signals create metadata, never idle history.
|
|
30
|
+
function createPendingStore(storage) {
|
|
31
|
+
const read = () => {
|
|
32
|
+
try {
|
|
33
|
+
const value = JSON.parse(storage?.getItem(PENDING_KEY) || '{}')
|
|
34
|
+
return Object.fromEntries(Object.entries(value).filter(([id, r]) => id && r &&
|
|
35
|
+
typeof r.running === 'boolean' && typeof r.pending === 'boolean' &&
|
|
36
|
+
typeof r.seeded === 'boolean' && Number.isFinite(r.revision)))
|
|
37
|
+
} catch { return {} }
|
|
38
|
+
}
|
|
39
|
+
let records = read()
|
|
40
|
+
const listeners = new Set(), observed = new Map()
|
|
41
|
+
const trim = value => {
|
|
42
|
+
let handled = 0
|
|
43
|
+
return Object.fromEntries(Object.entries(value).sort((a, b) => b[1].revision - a[1].revision)
|
|
44
|
+
.filter(([, r]) => r.pending || r.running || handled++ < MAX_RECORDS))
|
|
45
|
+
}
|
|
46
|
+
records = trim(records)
|
|
47
|
+
const merge = () => {
|
|
48
|
+
const next = { ...records }
|
|
49
|
+
for (const [id, r] of Object.entries(read())) if (!next[id] || r.revision > next[id].revision) next[id] = r
|
|
50
|
+
return next
|
|
51
|
+
}
|
|
52
|
+
function publish(next, persist) {
|
|
53
|
+
next = trim(next)
|
|
54
|
+
if (JSON.stringify(next) === JSON.stringify(records)) return
|
|
55
|
+
records = next
|
|
56
|
+
if (persist) { try { storage?.setItem(PENDING_KEY, JSON.stringify(records)) } catch {} }
|
|
57
|
+
for (const fn of listeners) fn()
|
|
58
|
+
}
|
|
59
|
+
function update(next, id, patch) {
|
|
60
|
+
const old = next[id]
|
|
61
|
+
next[id] = { running: false, pending: false, seeded: false, ...old, ...patch,
|
|
62
|
+
revision: Math.max(Date.now(), (old?.revision || 0) + 1) }
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
getSnapshot: () => records,
|
|
66
|
+
subscribe: fn => { listeners.add(fn); return () => listeners.delete(fn) },
|
|
67
|
+
sync: () => publish(merge(), false),
|
|
68
|
+
observe(list, statuses) {
|
|
69
|
+
// Hydration placeholders must not turn a persisted running baseline into a completion.
|
|
70
|
+
if (list.phase !== 'ready') return
|
|
71
|
+
const next = merge(), runningById = aggregateRunning(list, statuses)
|
|
72
|
+
for (const id of list.ids ?? []) {
|
|
73
|
+
const s = list.byId[id]
|
|
74
|
+
if (!s || s.origin === 'subagent' || s.blank) continue
|
|
75
|
+
const status = statuses.get(id), running = runningById.get(id)
|
|
76
|
+
if (typeof running !== 'boolean') continue
|
|
77
|
+
const old = next[id]
|
|
78
|
+
const previous = observed.has(id) ? observed.get(id) : old?.running
|
|
79
|
+
const finished = previous === true && !running && old?.running !== false
|
|
80
|
+
const seed = !running && !!status?.completionUnread && !old?.seeded
|
|
81
|
+
const read = (s.retainedBy?.mainView ?? 0) > 0
|
|
82
|
+
// A remote acknowledgement wins over an unchanged local snapshot.
|
|
83
|
+
if (finished || seed || (running && previous !== true) || (old && previous !== running)) {
|
|
84
|
+
update(next, id, { running, pending: !read && (!!old?.pending || finished || seed),
|
|
85
|
+
seeded: !!old?.seeded || seed })
|
|
86
|
+
} else if (read && next[id]?.pending) {
|
|
87
|
+
// Codex-style: viewing the session marks its finished output as read.
|
|
88
|
+
update(next, id, { pending: false })
|
|
89
|
+
}
|
|
90
|
+
observed.set(id, running)
|
|
91
|
+
}
|
|
92
|
+
publish(next, true)
|
|
93
|
+
},
|
|
94
|
+
acknowledge(id) {
|
|
95
|
+
const next = merge()
|
|
96
|
+
if (next[id]?.pending) update(next, id, { pending: false, seeded: true })
|
|
97
|
+
publish(next, true)
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function selectActivity(list, statuses, workspaces, limit = 20, pending = {}) {
|
|
102
|
+
const runningById = aggregateRunning(list, statuses)
|
|
103
|
+
const archived = new Set(workspaces.archivedSessionIds ?? [])
|
|
104
|
+
const pins = new Set(workspaces.pinnedSessionIds ?? [])
|
|
105
|
+
const membership = new Map()
|
|
106
|
+
for (const w of workspaces.items ?? []) for (const id of w.sessionIds) membership.set(id, w)
|
|
107
|
+
const rows = (list.ids ?? []).map(id => list.byId[id]).filter(s => s && s.origin !== 'subagent' && !archived.has(s.id) && !s.blank).map(s => {
|
|
108
|
+
const state = statuses.get(s.id)
|
|
109
|
+
const kind = state?.pendingInteraction ? 'waiting' : runningById.get(s.id) ? 'running' : pending[s.id]?.pending ? 'pending' : 'recent'
|
|
110
|
+
const workspace = membership.get(s.id)
|
|
111
|
+
return { ...s, kind, pinned: pins.has(s.id), workspace: workspace?.title || s.cwd?.split(/[\\/]/).filter(Boolean).pop() || 'Ungrouped', time: Number.isFinite(s.updatedAt) ? s.updatedAt : 0 }
|
|
112
|
+
}).sort((a, b) => b.time - a.time || a.id.localeCompare(b.id))
|
|
113
|
+
const recent = rows.filter(s => s.kind === 'recent').slice(0, limit)
|
|
114
|
+
const visible = new Set(recent.map(s => s.id))
|
|
115
|
+
return { active: rows.filter(s => s.kind !== 'recent'), recent, activity: rows.filter(s => s.kind !== 'recent' || visible.has(s.id)), favorites: rows.filter(s => s.pinned) }
|
|
116
|
+
}
|
|
117
|
+
function createModeStore(storage) {
|
|
118
|
+
let mode = 'workspace'
|
|
119
|
+
try { const saved = storage?.getItem(KEY); if (saved === 'activity' || saved === 'favorites') mode = saved } catch {}
|
|
120
|
+
const listeners = new Set()
|
|
121
|
+
return {
|
|
122
|
+
getSnapshot: () => mode,
|
|
123
|
+
subscribe: fn => { listeners.add(fn); return () => listeners.delete(fn) },
|
|
124
|
+
set(next, persist = true) {
|
|
125
|
+
next = next === 'activity' || next === 'favorites' ? next : 'workspace'
|
|
126
|
+
if (mode === next) return
|
|
127
|
+
mode = next
|
|
128
|
+
if (persist) { try { storage?.setItem(KEY, mode) } catch {} }
|
|
129
|
+
for (const fn of listeners) fn()
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function ago(time, now) {
|
|
134
|
+
if (!time) return ''
|
|
135
|
+
const minutes = Math.max(0, Math.floor((now - time) / 60000))
|
|
136
|
+
if (minutes < 1) return 'Just now'
|
|
137
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
138
|
+
if (minutes < 1440) return `${Math.floor(minutes / 60)}h ago`
|
|
139
|
+
return `${Math.floor(minutes / 1440)}d ago`
|
|
140
|
+
}
|
|
141
|
+
// Compatibility adapter for DSH 0.2.1-rc.2, NOT an official public slot.
|
|
142
|
+
// Only our container is inserted/removed; native and React-owned nodes stay put.
|
|
143
|
+
// Capture the anchor's own sidebar, never a document-wide first match.
|
|
144
|
+
function mountNavigationHost(anchor, onHost) {
|
|
145
|
+
const root = anchor?.closest('.hHd-Xa_root')
|
|
146
|
+
const shape = () => {
|
|
147
|
+
if (!root || anchor.closest('.hHd-Xa_root') !== root) return null
|
|
148
|
+
const children = [...root.children]
|
|
149
|
+
const regions = children.filter(node => node.classList.contains('hHd-Xa_regionArea'))
|
|
150
|
+
const footers = children.filter(node => node.classList.contains('hHd-Xa_footArea'))
|
|
151
|
+
return regions.length === 1 && footers.length === 1 && footers[0].contains(anchor) &&
|
|
152
|
+
children.indexOf(regions[0]) < children.indexOf(footers[0]) ? regions[0] : null
|
|
153
|
+
}
|
|
154
|
+
const Observer = anchor?.ownerDocument.defaultView?.MutationObserver
|
|
155
|
+
if (!shape() || !Observer) { onHost(null); return () => {} }
|
|
156
|
+
const host = anchor.ownerDocument.createElement('div')
|
|
157
|
+
host.className = 'dsa-navigation-host'
|
|
158
|
+
let active = true, published
|
|
159
|
+
const sync = () => {
|
|
160
|
+
if (!active) return
|
|
161
|
+
const region = shape()
|
|
162
|
+
if (region) {
|
|
163
|
+
if (host.parentNode !== root || host.nextSibling !== region) root.insertBefore(host, region)
|
|
164
|
+
} else host.remove()
|
|
165
|
+
const next = region ? host : null
|
|
166
|
+
if (published !== next) { published = next; onHost(next) }
|
|
167
|
+
}
|
|
168
|
+
// No subtree/body observation: portal rendering cannot trigger this observer.
|
|
169
|
+
// The adjacency guard makes our own insertion notification a no-op.
|
|
170
|
+
const observer = new Observer(sync)
|
|
171
|
+
observer.observe(root, { childList: true })
|
|
172
|
+
sync()
|
|
173
|
+
return () => { active = false; observer.disconnect(); host.remove() }
|
|
174
|
+
}
|
|
175
|
+
const labels = { running: 'Running', waiting: 'Waiting for reply', pending: 'Unread', recent: 'Recent sessions' }
|
|
176
|
+
const css = `
|
|
177
|
+
.dsa-root{display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden;color:var(--dsw-alias-label-primary,inherit)}
|
|
178
|
+
.dsa-header{display:flex;align-items:baseline;justify-content:space-between;padding:18px 18px 10px;gap:8px}
|
|
179
|
+
.dsa-heading{margin:0;font:inherit;font-size:12px;font-weight:600;letter-spacing:.02em}
|
|
180
|
+
.dsa-caption{font-size:10px;color:var(--dsw-alias-label-tertiary,#888)}
|
|
181
|
+
.dsa-scroll{overflow-y:auto;min-height:0;padding:0 8px 16px;scrollbar-width:thin}
|
|
182
|
+
.dsa-item{position:relative;margin:2px 0;border-radius:8px;isolation:isolate}
|
|
183
|
+
.dsa-item:hover,.dsa-item:focus-within,.dsa-item[data-current=true]{background:var(--dsw-alias-interactive-bg-hover,#8882)}
|
|
184
|
+
.dsa-row{display:block;box-sizing:border-box;width:100%;padding:9px 10px;border:0;border-radius:8px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
|
|
185
|
+
.dsa-line1{display:flex;align-items:center;gap:10px;min-width:0}
|
|
186
|
+
.dsa-title-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;line-height:19px;font-weight:450}
|
|
187
|
+
.dsa-trail{display:flex;align-items:center;gap:6px;flex:none;color:var(--dsw-alias-label-tertiary,#888);transition:opacity .12s ease}
|
|
188
|
+
.dsa-trail time{font-size:10px;font-variant-numeric:tabular-nums}
|
|
189
|
+
.dsa-pin-mark{font-size:11px;line-height:1;color:var(--dsw-alias-label-tertiary,#999)}
|
|
190
|
+
.dsa-dot{width:6px;height:6px;flex:none;border-radius:50%}
|
|
191
|
+
.dsa-dot[data-kind=waiting]{background:#d6a45c}
|
|
192
|
+
.dsa-dot[data-kind=pending]{background:#8fa9cf}
|
|
193
|
+
.dsa-spin{width:11px;height:11px;flex:none;color:var(--dsw-alias-label-secondary,#aaa);animation:dsa-rotate .9s linear infinite}
|
|
194
|
+
.dsa-spin-track{stroke:currentColor;opacity:.22}
|
|
195
|
+
.dsa-spin-arc{stroke:currentColor}
|
|
196
|
+
@keyframes dsa-rotate{to{transform:rotate(360deg)}}
|
|
197
|
+
.dsa-meta{margin:2px 0 0;font-size:11px;line-height:15px;color:var(--dsw-alias-label-tertiary,#888);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
198
|
+
.dsa-actions{position:absolute;right:6px;top:6px;display:flex;gap:1px;align-items:center;height:24px;opacity:0;transition:opacity .12s ease}
|
|
199
|
+
.dsa-item:hover .dsa-actions,.dsa-item:focus-within .dsa-actions,.dsa-item[data-menu-open=true] .dsa-actions{opacity:1}
|
|
200
|
+
.dsa-item:hover .dsa-trail,.dsa-item:focus-within .dsa-trail,.dsa-item[data-menu-open=true] .dsa-trail{opacity:0}
|
|
201
|
+
.dsa-menu-trigger{display:flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary,#aaa);cursor:pointer}
|
|
202
|
+
.dsa-menu-trigger:hover{background:var(--dsw-alias-interactive-bg-hover,#8883);color:var(--dsw-alias-label-primary,inherit)}
|
|
203
|
+
.dsa-menu-trigger:disabled{cursor:wait}
|
|
204
|
+
.dsa-row:focus-visible,.dsa-menu-trigger:focus-visible,.dsa-switch button:focus-visible{outline:2px solid var(--dsw-alias-label-secondary,#aaa);outline-offset:-2px}
|
|
205
|
+
.dsa-navigation-host{flex-shrink:0;padding:8px 0}
|
|
206
|
+
.dsa-switch{display:flex;gap:2px;padding:3px;border-radius:8px;background:var(--dsw-alias-interactive-bg-hover,#8881);flex-shrink:0;min-width:0}
|
|
207
|
+
.dsa-switch button{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;border:0;border-radius:5px;padding:6px 6px;background:transparent;color:var(--dsw-alias-label-tertiary,#888);font:inherit;font-size:11px;cursor:pointer;white-space:nowrap}
|
|
208
|
+
.dsa-switch[data-compact=true]{flex-direction:column}
|
|
209
|
+
.dsa-switch[data-compact=true] button{padding:6px 0}
|
|
210
|
+
.dsa-switch button[aria-pressed=true]{background:var(--dsw-alias-interactive-bg-hover,#8882);color:var(--dsw-alias-label-primary,inherit);font-weight:600}
|
|
211
|
+
.dsa-empty{margin:22px 12px;font-size:12px;line-height:1.8;color:var(--dsw-alias-label-tertiary,#888)}
|
|
212
|
+
.dsa-rename{box-sizing:border-box;width:100%;font:inherit;padding:8px;border-radius:6px;border:1px solid var(--dsw-alias-label-tertiary,#777);background:transparent;color:inherit}
|
|
213
|
+
@media(hover:none){.dsa-actions{opacity:1}.dsa-trail{opacity:0}}
|
|
214
|
+
@media(prefers-reduced-motion:reduce){.dsa-actions,.dsa-trail{transition:none}.dsa-spin{animation:none}}
|
|
215
|
+
`
|
|
216
|
+
|
|
217
|
+
function apply(ctx) {
|
|
218
|
+
let storage
|
|
219
|
+
try { storage = window.localStorage } catch {}
|
|
220
|
+
const mode = createModeStore(storage)
|
|
221
|
+
const pending = createPendingStore(storage)
|
|
222
|
+
const style = document.createElement('style')
|
|
223
|
+
style.id = 'yiliang114-dsh-session-deck-style'
|
|
224
|
+
style.textContent = css
|
|
225
|
+
const onStorage = event => { if (event.key === PENDING_KEY) pending.sync(); if (event.key === KEY || event.key === null) { let value; try { value = storage?.getItem(KEY) } catch {} mode.set(value, false) } }
|
|
226
|
+
ctx.effect(() => {
|
|
227
|
+
document.getElementById(style.id)?.remove()
|
|
228
|
+
document.head.appendChild(style)
|
|
229
|
+
window.addEventListener('storage', onStorage)
|
|
230
|
+
return () => { style.remove(); window.removeEventListener('storage', onStorage) }
|
|
231
|
+
})
|
|
232
|
+
function Switch({ wide, useSessions, useSessionStatus }) {
|
|
233
|
+
const list = useSessions(identity), statuses = useSessionStatus(identity)
|
|
234
|
+
useEffect(() => pending.observe(list, statuses), [list, statuses])
|
|
235
|
+
const selected = useSyncExternalStore(mode.subscribe, mode.getSnapshot)
|
|
236
|
+
return h('div', { className: 'dsa-switch', role: 'group', 'aria-label': 'Session navigation view', 'data-compact': !wide },
|
|
237
|
+
...[['workspace', 'Workspaces', 'W'], ['activity', 'Activity', 'A'], ['favorites', 'Favorites', '★']].map(([value, label, compact]) => h('button', { key: value, type: 'button', title: label, 'aria-label': label, 'aria-pressed': selected === value, onClick: () => mode.set(value) }, wide ? label : compact)))
|
|
238
|
+
}
|
|
239
|
+
function FooterNavigation(props) {
|
|
240
|
+
const [anchor, setAnchor] = useState(null)
|
|
241
|
+
const [host, setHost] = useState(null)
|
|
242
|
+
useLayoutEffect(() => mountNavigationHost(anchor, setHost), [anchor])
|
|
243
|
+
// The hidden callback ref stays in the registered footer's React tree.
|
|
244
|
+
// A portal retains slot/React context and all injected props. Unknown DOM
|
|
245
|
+
// shapes render inline in that footer instead of guessing a destination.
|
|
246
|
+
return h(Fragment, null, h('span', { hidden: true, ref: setAnchor }),
|
|
247
|
+
host ? createPortal(h(Switch, props), host) : h(Switch, props))
|
|
248
|
+
}
|
|
249
|
+
function Activity({ wide, expandSidebar, useSessions, useSessionStatus, useWorkspaces }) {
|
|
250
|
+
const list = useSessions(identity)
|
|
251
|
+
const statuses = useSessionStatus(identity)
|
|
252
|
+
const workspaces = useWorkspaces(identity)
|
|
253
|
+
const [now, setNow] = useState(Date.now)
|
|
254
|
+
useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 60000); return () => clearInterval(timer) }, [])
|
|
255
|
+
const selectedMode = useSyncExternalStore(mode.subscribe, mode.getSnapshot)
|
|
256
|
+
const queue = useSyncExternalStore(pending.subscribe, pending.getSnapshot)
|
|
257
|
+
const groups = selectActivity(list, statuses, workspaces, 20, queue)
|
|
258
|
+
if (!wide) return h('button', { type: 'button', className: 'dsa-row', title: 'Expand session activity', 'aria-label': 'Expand session activity', onClick: expandSidebar }, '◷')
|
|
259
|
+
const favorites = selectedMode === 'favorites'
|
|
260
|
+
const rows = favorites ? groups.favorites : groups.activity
|
|
261
|
+
return h('section', { className: 'dsa-root', 'aria-label': favorites ? 'Favorite sessions' : 'Session activity' },
|
|
262
|
+
h('header', { className: 'dsa-header' }, h('h2', { className: 'dsa-heading' }, favorites ? 'Favorites' : 'Activity'), h('span', { className: 'dsa-caption' }, 'All workspaces')),
|
|
263
|
+
h('div', { className: 'dsa-scroll' }, ...rows.map(s => h(SessionRow, { key: s.id, s, now, showPin: !favorites })),
|
|
264
|
+
!rows.length && h('p', { className: 'dsa-empty' }, list.phase !== 'ready' ? 'Loading sessions…' : favorites ? 'Favorite sessions to keep them close at hand.' : 'Recent sessions will appear here.')))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Native child slots (`sidebar.workspaces.session.menu.item` and
|
|
268
|
+
// `sidebar.workspaces.session.row.action`) are declared by the shipped
|
|
269
|
+
// WorkspaceBrowser entry. A replacing entry cannot declare them again, so
|
|
270
|
+
// the activity rows carry their own menu built from public uiWorkspace
|
|
271
|
+
// actions instead of rendering those slots.
|
|
272
|
+
function SessionRow({ s, now, showPin }) {
|
|
273
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
274
|
+
const [dialog, setDialog] = useState(null)
|
|
275
|
+
const [draft, setDraft] = useState('')
|
|
276
|
+
const [busy, setBusy] = useState(false)
|
|
277
|
+
const [error, setError] = useState('')
|
|
278
|
+
const title = s.displayTitle || s.title || s.id
|
|
279
|
+
const run = async operation => {
|
|
280
|
+
if (busy) return
|
|
281
|
+
setMenuOpen(false); setBusy(true); setError('')
|
|
282
|
+
try { await operation(); setDialog(null) }
|
|
283
|
+
catch (reason) { setError(reason?.message || String(reason)); setDialog('error') }
|
|
284
|
+
finally { setBusy(false) }
|
|
285
|
+
}
|
|
286
|
+
const archive = async (stopActivity = false) => {
|
|
287
|
+
if (busy) return
|
|
288
|
+
setMenuOpen(false); setBusy(true); setError('')
|
|
289
|
+
try {
|
|
290
|
+
await ctx.uiWorkspace.archiveSession(s.id, stopActivity ? { stopActivity: true } : undefined)
|
|
291
|
+
setDialog(null)
|
|
292
|
+
} catch (reason) {
|
|
293
|
+
if (!stopActivity && reason?.name === 'WorkspaceArchiveError' && reason?.rpcError?.code === 'workspace/session-active') setDialog('archive')
|
|
294
|
+
else { setError(reason?.message || String(reason)); setDialog('error') }
|
|
295
|
+
} finally { setBusy(false) }
|
|
296
|
+
}
|
|
297
|
+
const close = () => { if (!busy) { setDialog(null); setError('') } }
|
|
298
|
+
const rename = () => run(async () => {
|
|
299
|
+
const result = await ctx.sessions.using(s.id, { source: 'workspaceOperation' }, reference => reference.binding.session.rename(draft.trim()))
|
|
300
|
+
if (!result.ok) throw new Error(result.error.message)
|
|
301
|
+
})
|
|
302
|
+
const item = (label, onClick) => h(MenuItemButton, { key: label, disabled: busy, onSelect: onClick }, label)
|
|
303
|
+
const current = (s.retainedBy?.mainView ?? 0) > 0
|
|
304
|
+
const mark = s.kind === 'running' ? h('svg', { className: 'dsa-spin', viewBox: '0 0 16 16', role: 'img', 'aria-label': 'Running' },
|
|
305
|
+
h('circle', { className: 'dsa-spin-track', cx: 8, cy: 8, r: 6.5, fill: 'none', strokeWidth: 2 }),
|
|
306
|
+
h('circle', { className: 'dsa-spin-arc', cx: 8, cy: 8, r: 6.5, fill: 'none', strokeWidth: 2, strokeLinecap: 'round', strokeDasharray: '70 30', pathLength: 100 }))
|
|
307
|
+
: s.kind === 'waiting' ? h('span', { className: 'dsa-dot', 'data-kind': 'waiting', role: 'img', 'aria-label': 'Waiting for reply' })
|
|
308
|
+
: s.kind === 'pending' ? h('span', { className: 'dsa-dot', 'data-kind': 'pending', role: 'img', 'aria-label': 'Unread' }) : null
|
|
309
|
+
return h('div', { className: 'dsa-item', 'data-session-id': s.id, 'data-current': current, 'data-menu-open': menuOpen },
|
|
310
|
+
h('button', { type: 'button', className: 'dsa-row', 'aria-current': current ? 'true' : undefined,
|
|
311
|
+
title: `${title} · ${labels[s.kind]}\n${s.cwd || s.workspace}`, onClick: () => ctx.uiWorkspace.openSession(s.id) },
|
|
312
|
+
h('span', { className: 'dsa-line1' },
|
|
313
|
+
h('span', { className: 'dsa-title-text' }, title),
|
|
314
|
+
h('span', { className: 'dsa-trail' },
|
|
315
|
+
showPin && s.pinned && h('span', { className: 'dsa-pin-mark', role: 'img', 'aria-label': 'Favorited' }, '★'),
|
|
316
|
+
h('time', { dateTime: s.time ? new Date(s.time).toISOString() : undefined }, ago(s.time, now)),
|
|
317
|
+
mark)),
|
|
318
|
+
h('span', { className: 'dsa-meta' }, s.workspace)),
|
|
319
|
+
h('span', { className: 'dsa-actions' },
|
|
320
|
+
h('button', { type: 'button', className: 'dsa-menu-trigger dsa-archive-btn', disabled: busy,
|
|
321
|
+
'aria-label': `Archive session: ${title}`, title: 'Archive session', onClick: () => archive() },
|
|
322
|
+
h(IconArchiveOutlineRegular, { size: 14 })),
|
|
323
|
+
h(Menu, { open: menuOpen, onClose: () => setMenuOpen(false), portal: true,
|
|
324
|
+
anchor: h('button', { type: 'button', disabled: busy, className: 'dsa-menu-trigger', 'aria-label': `Session actions: ${title}`, 'aria-haspopup': 'menu', 'aria-expanded': menuOpen,
|
|
325
|
+
onClick: () => setMenuOpen(v => !v) }, h(IconEllipsisOutlineRegular, { size: 16 })) },
|
|
326
|
+
item(s.pinned ? 'Remove from favorites (unpin)' : 'Add to favorites (pin)', () => run(() => s.pinned ? ctx.uiWorkspace.unpinSession(s.id) : ctx.uiWorkspace.pinSession(s.id))),
|
|
327
|
+
item('Rename', () => { setMenuOpen(false); setDraft(title); setDialog('rename') }),
|
|
328
|
+
item('Fork session', () => run(() => ctx.uiWorkspace.forkSession(s.id, undefined, { allowMigration: false }))),
|
|
329
|
+
item('Archive session', () => archive()),
|
|
330
|
+
item('Copy session ID', () => run(async () => { if (!await writeClipboard(s.id)) throw new Error('Copy failed. Check clipboard permissions.') })),
|
|
331
|
+
item('Open in workspace for all actions', () => { setMenuOpen(false); mode.set('workspace'); ctx.uiWorkspace.openSession(s.id) }))),
|
|
332
|
+
dialog && h(Modal, { open: true, onClose: close, closeLabel: 'Close', title: dialog === 'rename' ? 'Rename session' : dialog === 'archive' ? 'Stop and archive session?' : 'Action failed',
|
|
333
|
+
footer: h('div', { style: { display: 'flex', gap: 8 } },
|
|
334
|
+
h(Button, { variant: 'outline', disabled: busy, onClick: close }, dialog === 'error' ? 'Close' : 'Cancel'),
|
|
335
|
+
dialog === 'rename' && h(Button, { variant: 'primary', disabled: busy || !draft.trim(), onClick: rename }, 'Save'),
|
|
336
|
+
dialog === 'archive' && h(Button, { variant: 'primary', disabled: busy, onClick: () => archive(true) }, 'Stop and archive')) },
|
|
337
|
+
dialog === 'rename' && h('input', { className: 'dsa-rename', value: draft, disabled: busy, 'aria-label': 'Session name', 'data-modal-autofocus': true,
|
|
338
|
+
onChange: e => setDraft(e.target.value), onKeyDown: e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing && draft.trim()) rename() } }),
|
|
339
|
+
dialog === 'archive' && h('p', null, `“${title}” still has running tasks. Continuing will stop those tasks and archive the session.`),
|
|
340
|
+
error && h('p', { role: 'alert' }, error)))
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
ctx.slots.inject('sidebar.footer.action', () => {
|
|
344
|
+
let unregister, current
|
|
345
|
+
const sync = () => {
|
|
346
|
+
const name = ctx.slots.spec('sidebar.workspaces.before') ? 'sidebar.workspaces.before' : 'sidebar.footer.action'
|
|
347
|
+
if (name === current) return
|
|
348
|
+
unregister?.()
|
|
349
|
+
current = name
|
|
350
|
+
unregister = ctx.slots.register({ name, id: '@yiliang114/dsh-session-deck.switch', order: 0, label: 'Session view' },
|
|
351
|
+
name === 'sidebar.workspaces.before' ? function NavigationSwitch(props) {
|
|
352
|
+
return h('div', { style: { flexShrink: 0, padding: props.wide ? '8px 12px' : '8px 4px' } }, h(Switch, props))
|
|
353
|
+
} : FooterNavigation)
|
|
354
|
+
}
|
|
355
|
+
sync()
|
|
356
|
+
const unsubscribe = ctx.slots.subscribe('sidebar.workspaces.before', sync)
|
|
357
|
+
return () => { unsubscribe(); unregister?.() }
|
|
358
|
+
})
|
|
359
|
+
ctx.slots.inject('sidebar.workspaces', () => {
|
|
360
|
+
let unregister
|
|
361
|
+
const sync = () => {
|
|
362
|
+
if (mode.getSnapshot() !== 'workspace' && !unregister) unregister = ctx.slots.register({ name: 'sidebar.workspaces', priority: -100 }, Activity)
|
|
363
|
+
if (mode.getSnapshot() === 'workspace' && unregister) { unregister(); unregister = undefined }
|
|
364
|
+
}
|
|
365
|
+
sync()
|
|
366
|
+
const unsubscribe = mode.subscribe(sync)
|
|
367
|
+
return () => { unsubscribe(); unregister?.() }
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
return { inject: ['slots', 'uiWorkspace', 'sessions'], apply, __test__: { mountNavigationHost, selectActivity, createModeStore, createPendingStore, ago, KEY, PENDING_KEY, MAX_RECORDS } }
|
|
371
|
+
},
|
|
372
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yiliang114/dsh-session-deck",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"author": "yiliang114",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public",
|
|
8
|
+
"registry": "https://registry.npmjs.org/"
|
|
9
|
+
},
|
|
10
|
+
"keywords": ["dsh-plugin", "deepseek-harness", "sidebar", "sessions", "session-management", "favorites", "ai-agents"],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"description": "Activity and Favorites views for DeepSeek Harness: track running, waiting, and unread sessions across workspaces.",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/yiliang114/dsh-session-deck.git"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/yiliang114/dsh-session-deck#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/yiliang114/dsh-session-deck/issues"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22",
|
|
23
|
+
"dsh": "0.2.1-rc.2"
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./src/host.mjs",
|
|
27
|
+
"./client": "./lib/client.js",
|
|
28
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"files": ["src", "lib", "scripts/patch-sidebar-before.mjs", "cordis.patch.yml", "README.md"],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "node --check src/host.mjs && node --check lib/client.js && node --check scripts/patch-sidebar-before.mjs && node --check scripts/check-package.mjs && node --check scripts/update-market-index.mjs && node --check scripts/verify-published.mjs",
|
|
34
|
+
"market:update": "node scripts/update-market-index.mjs",
|
|
35
|
+
"test": "node --test test/*.test.mjs",
|
|
36
|
+
"check:package": "node scripts/check-package.mjs",
|
|
37
|
+
"verify": "npm run build && npm test && npm run check:package"
|
|
38
|
+
},
|
|
39
|
+
"dsh": {
|
|
40
|
+
"bundle": {"patch": "./cordis.patch.yml"},
|
|
41
|
+
"client": {
|
|
42
|
+
"platform": "web",
|
|
43
|
+
"inject": [
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-sidebar",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-session",
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-workspace"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Local, reversible compatibility extension for the published sidebar bundle.
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'
|
|
4
|
+
import { resolve, join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2)
|
|
7
|
+
const rollback = args.includes('--rollback')
|
|
8
|
+
const target = args.find(arg => !arg.startsWith('--'))
|
|
9
|
+
if (!target) throw new Error('Usage: node scripts/patch-sidebar-before.mjs <sidebar-package-directory> [--rollback]')
|
|
10
|
+
const root = resolve(target)
|
|
11
|
+
const version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version
|
|
12
|
+
if (version !== '0.2.1-rc.2') throw new Error(`Unsupported sidebar version ${version}; review the extension against the new public contract first`)
|
|
13
|
+
const changes = [
|
|
14
|
+
['lib/client.js', [
|
|
15
|
+
['\t\t\t\t\t(0, react_jsx_runtime.jsx)("div", {\n\t\t\t\t\t\tclassName: SidebarRoot_module_css_default.regionArea,', '\t\t\t\t\trenderSlot("sidebar.workspaces.before", { wide }),\n\t\t\t\t\t(0, react_jsx_runtime.jsx)("div", {\n\t\t\t\t\t\tclassName: SidebarRoot_module_css_default.regionArea,'],
|
|
16
|
+
['\t\t\t\t\t"sidebar.workspaces": {', '\t\t\t\t\t"sidebar.workspaces.before": {\n\t\t\t\t\t\tkind: "list",\n\t\t\t\t\t\tscope: "root"\n\t\t\t\t\t},\n\t\t\t\t\t"sidebar.workspaces": {'],
|
|
17
|
+
]],
|
|
18
|
+
['lib/types/client/contract/slots.d.ts', [
|
|
19
|
+
[" 'sidebar.workspaces': {", " /** Additive controls after all global panel rows, before the browsing region. */\n 'sidebar.workspaces.before': {\n kind: 'list';\n scope: 'root';\n owner: SidebarFooterActionOwnerProps;\n };\n 'sidebar.workspaces': {"],
|
|
20
|
+
["'sidebar.panellist' | 'sidebar.workspaces'", "'sidebar.panellist' | 'sidebar.workspaces.before' | 'sidebar.workspaces'"],
|
|
21
|
+
]],
|
|
22
|
+
]
|
|
23
|
+
const plans = changes.map(([relative, replacements]) => {
|
|
24
|
+
const path = join(root, relative), backup = `${path}.dsa-before.backup`
|
|
25
|
+
const current = readFileSync(path, 'utf8')
|
|
26
|
+
const original = existsSync(backup) ? readFileSync(backup, 'utf8') : current
|
|
27
|
+
let patched = original
|
|
28
|
+
for (const [before, after] of replacements) {
|
|
29
|
+
if (patched.split(before).length !== 2 || patched.includes(after)) throw new Error(`Unknown or already modified anchor: ${relative}`)
|
|
30
|
+
patched = patched.replace(before, after)
|
|
31
|
+
}
|
|
32
|
+
if (current !== original && current !== patched) throw new Error(`Concurrent modification: ${relative}; refusing to overwrite`)
|
|
33
|
+
if (rollback && !existsSync(backup)) throw new Error(`No backup: ${relative}`)
|
|
34
|
+
return { path, backup, original, patched }
|
|
35
|
+
})
|
|
36
|
+
// Validate every target before changing either file. Backups preserve exact original bytes.
|
|
37
|
+
for (const { path, backup, original, patched } of plans) {
|
|
38
|
+
if (rollback) {
|
|
39
|
+
writeFileSync(path, original)
|
|
40
|
+
// backup is the checked absolute sibling of the target above.
|
|
41
|
+
unlinkSync(backup)
|
|
42
|
+
} else {
|
|
43
|
+
if (!existsSync(backup)) writeFileSync(backup, original, { flag: 'wx' })
|
|
44
|
+
writeFileSync(path, patched)
|
|
45
|
+
}
|
|
46
|
+
console.log(`${rollback ? 'Restored' : 'Patched'} ${path}`)
|
|
47
|
+
}
|
package/src/host.mjs
ADDED