dsh-assist-chat 0.0.0-stage → 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-assist-chat contributors
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,98 @@
1
- # Temporary Holding Version
1
+ # dsh-assist-chat
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ English | [中文](README.zh.md)
4
+
5
+ Side chat for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
6
+
7
+ A right-dock seat that **branches the conversation you are in**: it carries the main thread history up to the moment you open it, so "that error above" and "the file we just edited" already make sense — then continues on its own branch without rewriting, interrupting, or derailing the main transcript.
8
+
9
+ The panel does not reimplement chat. It mounts the product own Conversation surface, so streaming, markdown, tool cards, approvals, Stop and the composer behave exactly like the main conversation.
10
+
11
+ Inspired by the side chat in ZCode (`zai-org/ZCode`): same shape — a tab in the right side pane backed by a real, parallel child conversation. Not a modal, and it never takes over the main area.
12
+
13
+ ## Why
14
+
15
+ You are mid-task and want to ask something tangential: a design question, a sanity check, "what does this error actually mean". Opening a new conversation loses the context; typing in the main thread derails it and rewrites the transcript you were about to hand off.
16
+
17
+ A side chat is the third option. It forks the current conversation, so it knows what the main thread knows, and it runs beside it.
18
+
19
+ ## Features
20
+
21
+ - **Branch, not blank slate** — every opening forks the main conversation as it is *right now*.
22
+ - **Isolated by construction** — its own Session, its own agent, its own transcript. Nothing you say here lands in the main thread.
23
+ - **Native chat surface** — messages, streaming, tool calls, code blocks, Stop, composer: all inherited, none reimplemented.
24
+ - **Per-conversation** — a branch of conversation A never surfaces while you are looking at B.
25
+ - **Tab lifecycle = branch lifecycle** — one branch while the tab is open (survives tab switches and dock collapse), retired when you close the tab.
26
+ - **No catalog noise** — the branch row is hidden from the left session list while alive, and archived once you close it.
27
+
28
+ ## Install
29
+
30
+ ### From the dsh market
31
+
32
+ Search for `dsh-assist-chat` under **Plugins → Discover**, or add an index source and install from there.
33
+
34
+ ### From npm
35
+
36
+ ```sh
37
+ dsh plugin --profile desktop add dsh-assist-chat
38
+ ```
39
+
40
+ ### From a local checkout
41
+
42
+ Point the profile at this directory:
43
+
44
+ ```sh
45
+ dsh plugin --profile desktop add link:/absolute/path/to/dsh-assist-chat
46
+ ```
47
+
48
+ Either way the package ships its own `cordis.patch.yml`, so the loader entry is inserted by the package itself. **Restart DeepSeek Harness afterwards.**
49
+
50
+ ## Usage
51
+
52
+ 1. Open a conversation that already has some history.
53
+ 2. Click **Side chat** in the session header (the same entry is on the right sidebar guide page).
54
+ 3. The dock opens with a branch of that conversation. Ask away.
55
+ 4. Close the tab when done. Opening it again later forks a fresh copy of whatever the main thread looks like by then.
56
+
57
+ ## How it works
58
+
59
+ | Piece | Registration |
60
+ | --- | --- |
61
+ | Tab type | `ctx.sidebarRightTabs.register({ id, kind: "assistchat", keepMounted: true, guide })` |
62
+ | Tab body | `ctx.slots.register({ name: "sidebar.right.pane.tab", key: <id>, children })` |
63
+ | Chat surface | `renderFactorySlot("conversation.content", { variant: "embedded", phase, hero }, { slots: { views: FixedChatView } })` |
64
+ | Entry point | `conversation.session.header.actions` — a list seat; `.corner` is single and already taken |
65
+
66
+ ### Session lifecycle
67
+
68
+ 1. Resolve the conversation on screen: `ctx.get("uiWorkspace").mainReference.sessionId`.
69
+ 2. `ctx.get("sessions").fork({ sessionId, increaseTitle: true })` — the child inherits the full transcript. A blank parent falls back to `create({ workspaceId })`.
70
+ 3. `ctx.get("sessions").retain(id, { source, signal })` yields the SessionReference the provider needs.
71
+ 4. On unmount (tab closed): release the reference, then archive the branch.
72
+
73
+ The parent id travels through the tab navigation params (`openTab(kind, { params: { parent } })`). The dock layout is stored per Session, so branch identity is keyed the same way.
74
+
75
+ ### Two things that will bite you
76
+
77
+ **Never archive a branch that is still in use.** DSH ships an `archived-session-gate` that rejects every `agent/pre-step` of an archived Session. Messages are accepted into the queue and then silently vanish, with no error at all. Archiving is only safe *after* the tab closes.
78
+
79
+ **Which forces hiding to be presentation-only.** The left catalog renders one row per Session with a stable `data-row-key="session:<id>"`, so a plugin can mark the rows it minted and hide them with `display: none`. That keeps the Session fully writable. Trade-offs: the "expand N more" counter still counts hidden rows, and the trick is tied to the row-key convention.
80
+
81
+ ## Compatibility
82
+
83
+ - DeepSeek Harness `>= 0.2.0` (`engines.dsh`).
84
+ - Client plugin API only — no host-side code, no patched DSH files.
85
+ - A branch is visible in the catalog while its tab is open, and archived when the tab closes.
86
+
87
+ ## Development
88
+
89
+ ```sh
90
+ npm test # loads client.js in a mocked host and asserts every seat and render
91
+ npm run check # syntax only
92
+ ```
93
+
94
+ `test/smoke.cjs` runs the shipped Client module in a `vm` sandbox with a mock React and a mock Cordis context. No GUI needed.
95
+
96
+ ## License
97
+
98
+ MIT
package/README.zh.md ADDED
@@ -0,0 +1,96 @@
1
+ # dsh-assist-chat
2
+
3
+ [English](README.md) | 中文
4
+
5
+ DeepSeek Harness 的**右侧栏辅助对话**。
6
+
7
+ 在右侧栏开一条**当前对话的分支**:它带着主对话到这一刻为止的完整历史(所以「上面那个报错」「刚才改的文件」它都懂),然后在自己的分支里继续跑,不改写、不打断主线。
8
+
9
+ 面板不自己画聊天框——它挂载产品自带的 Conversation 界面,所以流式输出、markdown、工具卡片、审批、Stop、composer 全部与主对话一致。
10
+
11
+ 对标 ZCode(`zai-org/ZCode`)的「辅助对话」:同样是右侧面板里的一个标签页,背后是与主任务并行的真实子会话。不是弹窗,也不占用主区域。
12
+
13
+ ## 为什么需要它
14
+
15
+ 干到一半想问点偏的:一个设计问题、一次 sanity check、一句「这报错到底什么意思」。单开一个窗口就丢了上下文;在主对话里问又会污染你正准备交付的 transcript。
16
+
17
+ 辅助对话是第三种选择:fork 当前对话,它知道主对话知道的一切,并且和它并排跑。
18
+
19
+ ## 特性
20
+
21
+ - **是分支,不是空白会话**——每次打开都 fork 主对话**此刻**的状态。
22
+ - **天然隔离**——独立 Session、独立 Agent、独立 transcript,这里说的一切都不会落进主线。
23
+ - **原生聊天界面**——消息、流式、工具调用、代码块、Stop、composer 全部继承,不重画。
24
+ - **按对话隔离**——A 的分支不会在你看 B 的时候冒出来。
25
+ - **标签生命周期 = 分支生命周期**——标签开着就是同一条分支(切标签、收起面板都不重建),关掉即结束。
26
+ - **不污染目录**——分支存活期间在左侧列表里被隐藏,关闭时归档。
27
+
28
+ ## 安装
29
+
30
+ ### 从插件市场
31
+
32
+ 在**插件 → 发现**里搜 `dsh-assist-chat`,或添加索引源后安装。
33
+
34
+ ### 从 npm
35
+
36
+ ```sh
37
+ dsh plugin --profile desktop add dsh-assist-chat
38
+ ```
39
+
40
+ ### 从本地目录
41
+
42
+ ```sh
43
+ dsh plugin --profile desktop add link:/本地绝对路径/dsh-assist-chat
44
+ ```
45
+
46
+ 包自带 `cordis.patch.yml`,loader 条目由包自己 insert,不用改 profile 的 patch 层。**装完重启 DeepSeek Harness。**
47
+
48
+ ## 用法
49
+
50
+ 1. 打开一个已有内容的会话;
51
+ 2. 点会话 header 里的**辅助对话**(右侧栏引导页里也有同一个入口);
52
+ 3. 右栏出现该对话的分支,直接提问;
53
+ 4. 用完关掉标签;下次再打开会重新 fork 当时最新的主对话。
54
+
55
+ ## 实现要点
56
+
57
+ | 部件 | 注册方式 |
58
+ | --- | --- |
59
+ | 标签类型 | `ctx.sidebarRightTabs.register({ id, kind: "assistchat", keepMounted: true, guide })` |
60
+ | 标签正文 | `ctx.slots.register({ name: "sidebar.right.pane.tab", key: <id>, children })` |
61
+ | 聊天界面 | `renderFactorySlot("conversation.content", { variant: "embedded", phase, hero }, { slots: { views: FixedChatView } })` |
62
+ | 入口 | `conversation.session.header.actions` —— 是 list 槽;`.corner` 是 single 且已被占用 |
63
+
64
+ ### 会话生命周期
65
+
66
+ 1. 取屏幕上的会话:`ctx.get("uiWorkspace").mainReference.sessionId`;
67
+ 2. `ctx.get("sessions").fork({ sessionId, increaseTitle: true })` —— 子会话继承完整历史;父会话为空时回退 `create({ workspaceId })`;
68
+ 3. `ctx.get("sessions").retain(id, { source, signal })` 拿 SessionReference;
69
+ 4. 组件卸载(标签关闭)时先 release,再归档。
70
+
71
+ 父会话 id 通过标签的导航参数传进去(`openTab(kind, { params: { parent } })`)。dock 布局本来就按 Session 存,分支身份也按同样的键存。
72
+
73
+ ### 两个会咬人的地方
74
+
75
+ **别归档还在用的分支。** DSH 的 `archived-session-gate` 会 reject 归档会话的每一个 `agent/pre-step`:消息进了队列然后静默消失,一点报错都没有。只能在标签关闭**之后**归档。
76
+
77
+ **所以隐藏只能是显示层的。** 左侧目录每行都有稳定的 `data-row-key="session:<id>"`,插件可以给自己造的行走 `display: none`,会话本身完全可写。代价:「展开其余 N 个会话」的计数仍包含隐藏行;这个做法依赖 row-key 约定。
78
+
79
+ ## 兼容性
80
+
81
+ - DeepSeek Harness `>= 0.2.0`(`engines.dsh`)。
82
+ - 纯 Client 插件 API——没有 host 侧代码,也不修改 DSH 文件。
83
+ - 分支在标签打开期间会出现在会话目录里,关闭后归档。
84
+
85
+ ## 开发
86
+
87
+ ```sh
88
+ npm test # 在 mock 宿主里加载 client.js,断言每个 seat 与渲染
89
+ npm run check # 只查语法
90
+ ```
91
+
92
+ `test/smoke.cjs` 用 `vm` + mock React + mock Cordis context 跑真实的 Client 模块,不需要开 GUI。
93
+
94
+ ## 许可
95
+
96
+ MIT
package/client.js ADDED
@@ -0,0 +1,661 @@
1
+ // Assist chat — Client half.
2
+ //
3
+ // A right-dock seat that opens a *separate* Session: the side conversation has
4
+ // its own transcript, its own agent and its own context, so asking it something
5
+ // never disturbs the main thread. The panel reuses the product's own
6
+ // Conversation surface ("conversation.content") instead of redrawing a chat, so
7
+ // markdown, tool calls, approvals and streaming all behave exactly like the main
8
+ // conversation.
9
+ window.__ModuleLoader__.load({
10
+ id: 'dsh-assist-chat',
11
+ factory(require) {
12
+ const React = require('react');
13
+ const h = React.createElement;
14
+ const Fragment = React.Fragment;
15
+
16
+ const NS = 'dsh-assist-chat';
17
+ const IMPL_ID = 'dsh-assist-chat';
18
+ const KIND = 'assistchat';
19
+ const CONV_SLOT = 'sidebar.assist.conversation';
20
+ const DEBUG = false;
21
+
22
+ function log() {
23
+ if (!DEBUG) return;
24
+ try { console.log.apply(console, ['[assist-chat]'].concat([].slice.call(arguments))); } catch (e) {}
25
+ }
26
+ function warn() {
27
+ try { console.warn.apply(console, ['[assist-chat]'].concat([].slice.call(arguments))); } catch (e) {}
28
+ }
29
+
30
+ const ZH = {
31
+ 'tab.title': '辅助对话',
32
+ 'guide.title': '辅助对话',
33
+ 'guide.description': '开一个与主对话隔离的旁支会话,随时追问而不打断主线',
34
+ 'header.button': '辅助对话',
35
+ 'header.buttonTitle': '在右侧栏开启辅助对话',
36
+ 'status.creating': '正在准备辅助对话…',
37
+ 'status.error': '无法开启辅助对话',
38
+ 'retry': '重试'
39
+ };
40
+ const EN = {
41
+ 'tab.title': 'Side chat',
42
+ 'guide.title': 'Side chat',
43
+ 'guide.description': 'Open a side conversation isolated from the main thread',
44
+ 'header.button': 'Side chat',
45
+ 'header.buttonTitle': 'Open side chat in the right sidebar',
46
+ 'status.creating': 'Preparing side chat…',
47
+ 'status.error': 'Could not open side chat',
48
+ 'retry': 'Retry'
49
+ };
50
+
51
+ let rootCtx = null;
52
+ let localeSvc = null;
53
+ let registryState = null;
54
+
55
+ function t(key, vars) {
56
+ let value;
57
+ try {
58
+ if (localeSvc && typeof localeSvc.t === 'function') value = localeSvc.t(NS, key, vars);
59
+ } catch (e) {}
60
+ if (value === undefined || value === null) value = ZH[key] || EN[key] || key;
61
+ if (vars) {
62
+ value = String(value).replace(/\{(\w+)\}/g, function (m, k) {
63
+ return vars[k] === undefined ? m : String(vars[k]);
64
+ });
65
+ }
66
+ return String(value);
67
+ }
68
+
69
+ /* ---------------- session plumbing ---------------- */
70
+
71
+ // Branches that must not show up in the left catalog. Hidden at the DOM level
72
+ // rather than archived: an archived Session cannot run a model step, so
73
+ // hiding through data would silently make the side chat read-only.
74
+ const hiddenBranchIds = new Set();
75
+
76
+ // Lifecycle of a branch is the lifecycle of its tab, deliberately:
77
+ // open the seat -> fork the main conversation *as it is right now*
78
+ // keep it open -> the same branch (keepMounted keeps it mounted)
79
+ // close the tab -> retire that branch (archive it, so the catalog stays clean)
80
+ // Caching per parent across openings would serve a stale snapshot forever,
81
+ // which is exactly what a side chat must not do.
82
+
83
+ function getService(name) {
84
+ try { return rootCtx ? rootCtx.get(name) : undefined; } catch (e) { return undefined; }
85
+ }
86
+
87
+ function sessionsService() {
88
+ return getService('sessions') || null;
89
+ }
90
+
91
+ function pickCwd(sessions) {
92
+ try {
93
+ const snapshot = sessions.list.getSnapshot();
94
+ const ids = (snapshot && snapshot.ids) || [];
95
+ let best;
96
+ for (let i = 0; i < ids.length; i += 1) {
97
+ const row = snapshot.byId[ids[i]];
98
+ if (!row || !row.cwd) continue;
99
+ if (!best || (row.updatedAt || 0) > (best.updatedAt || 0)) best = row;
100
+ }
101
+ return best ? best.cwd : undefined;
102
+ } catch (e) {
103
+ return undefined;
104
+ }
105
+ }
106
+
107
+ /** The Workspace a side conversation should be filed under. */
108
+ function pickWorkspaceId() {
109
+ try {
110
+ const uiWorkspace = getService('uiWorkspace');
111
+ if (!uiWorkspace) return undefined;
112
+ const list = uiWorkspace.workspaces && uiWorkspace.workspaces.list;
113
+ const items = (list && list.getSnapshot && list.getSnapshot().items) || [];
114
+ if (!items.length) return undefined;
115
+ const mainReference = uiWorkspace.mainReference;
116
+ const currentId = mainReference && mainReference.sessionId;
117
+ if (currentId) {
118
+ const holder = items.find(function (item) {
119
+ return item && item.sessionIds && item.sessionIds.indexOf(currentId) >= 0;
120
+ });
121
+ if (holder) return holder.workspaceId;
122
+ }
123
+ const sessions = sessionsService();
124
+ const snapshot = sessions && sessions.list.getSnapshot();
125
+ const byId = (snapshot && snapshot.byId) || {};
126
+ let bestId;
127
+ let bestAt = -1;
128
+ for (let i = 0; i < items.length; i += 1) {
129
+ const item = items[i];
130
+ if (!item || !item.workspaceId) continue;
131
+ const ids = item.sessionIds || [];
132
+ let latest = 0;
133
+ for (let j = 0; j < ids.length; j += 1) {
134
+ const row = byId[ids[j]];
135
+ if (row && (row.updatedAt || 0) > latest) latest = row.updatedAt || 0;
136
+ }
137
+ if (latest > bestAt) { bestAt = latest; bestId = item.workspaceId; }
138
+ }
139
+ return bestId || items[0].workspaceId;
140
+ } catch (e) {
141
+ return undefined;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * An archived Session cannot run a model step: DSH's archived-session gate
147
+ * rejects every pre-step. Earlier builds archived side conversations to keep
148
+ * the catalog clean, which silently ate every message, so restore any branch
149
+ * that is still archived before it is used.
150
+ */
151
+ function ensureWritable(sessionId) {
152
+ try {
153
+ const uiWorkspace = getService('uiWorkspace');
154
+ if (!uiWorkspace || typeof uiWorkspace.unarchiveSession !== 'function') return;
155
+ const archived = uiWorkspace.workspaces
156
+ && uiWorkspace.workspaces.list.getSnapshot().archivedSessionIds;
157
+ if (!archived || archived.indexOf(sessionId) < 0) return;
158
+ const pending = uiWorkspace.unarchiveSession(sessionId);
159
+ if (pending && typeof pending.catch === 'function') pending.catch(function (error) {
160
+ warn('unarchive side session failed', error);
161
+ });
162
+ } catch (e) {
163
+ warn('unarchive check failed', e);
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Keep our branches out of the left catalog without archiving them.
169
+ *
170
+ * The catalog renders one row per Session carrying a stable
171
+ * data-row-key="session:<id>", so hiding is a matter of marking the rows
172
+ * whose id we minted. Presentation only: the Session keeps running, keeps
173
+ * accepting prompts, and stays reachable through its dock tab.
174
+ */
175
+ function installCatalogHiding() {
176
+ if (typeof document === 'undefined' || typeof MutationObserver !== 'function') {
177
+ return function () {};
178
+ }
179
+ const ATTR = 'data-assist-hidden';
180
+ let raf = 0;
181
+ let styleEl = null;
182
+ let observer = null;
183
+
184
+ const mark = function () {
185
+ raf = 0;
186
+ try {
187
+ const rows = document.querySelectorAll('[data-row-key^="session:"]');
188
+ for (let i = 0; i < rows.length; i += 1) {
189
+ const row = rows[i];
190
+ const key = row.getAttribute('data-row-key') || '';
191
+ const id = key.slice('session:'.length);
192
+ const hide = hiddenBranchIds.has(id);
193
+ if (hide && !row.hasAttribute(ATTR)) row.setAttribute(ATTR, '1');
194
+ else if (!hide && row.hasAttribute(ATTR)) row.removeAttribute(ATTR);
195
+ }
196
+ } catch (e) {
197
+ warn('catalog hide failed', e);
198
+ }
199
+ };
200
+
201
+ const schedule = function () {
202
+ if (raf) return;
203
+ try {
204
+ raf = window.requestAnimationFrame(mark);
205
+ } catch (e) {
206
+ mark();
207
+ }
208
+ };
209
+
210
+ styleEl = document.createElement('style');
211
+ styleEl.setAttribute('data-plugin', IMPL_ID + '/catalog-hide');
212
+ styleEl.textContent = '[data-assist-hidden]{display:none !important}';
213
+ document.head.appendChild(styleEl);
214
+
215
+ observer = new MutationObserver(schedule);
216
+ try {
217
+ observer.observe(document.body, { childList: true, subtree: true });
218
+ } catch (e) {
219
+ warn('catalog observer failed', e);
220
+ }
221
+ mark();
222
+
223
+ return function () {
224
+ try { observer.disconnect(); } catch (e) {}
225
+ if (styleEl) { try { styleEl.remove(); } catch (e) {} }
226
+ if (raf) { try { window.cancelAnimationFrame(raf); } catch (e) {} }
227
+ };
228
+ }
229
+
230
+ /** Give the side conversation a recognisable title in the session catalog. */
231
+ let sideCounter = 0;
232
+
233
+ function labelSideSession(sessions, sessionId) {
234
+ sideCounter += 1;
235
+ const title = t('tab.title') + ' ' + sideCounter;
236
+ try {
237
+ const renamed = sessions.rename(sessionId, title);
238
+ if (renamed && typeof renamed.catch === 'function') renamed.catch(function () {});
239
+ return title;
240
+ } catch (e) {
241
+ return undefined;
242
+ }
243
+ }
244
+
245
+ /** The Session occupying the main column: the context a side chat must share. */
246
+ function currentMainSessionId() {
247
+ try {
248
+ const uiWorkspace = getService('uiWorkspace');
249
+ const reference = uiWorkspace && uiWorkspace.mainReference;
250
+ const id = reference && reference.sessionId;
251
+ return typeof id === 'string' && id !== '' ? id : undefined;
252
+ } catch (e) {
253
+ return undefined;
254
+ }
255
+ }
256
+
257
+ /** Pull a Session id out of every shape the client contract has used. */
258
+ function idOf(result) {
259
+ if (typeof result === 'string' && result !== '') return result;
260
+ if (result && typeof result.sessionId === 'string' && result.sessionId !== '') return result.sessionId;
261
+ if (result && result.ok === true) {
262
+ const value = result.value;
263
+ if (value && typeof value.sessionId === 'string' && value.sessionId !== '') return value.sessionId;
264
+ if (typeof value === 'string' && value !== '') return value;
265
+ }
266
+ const error = result && result.error;
267
+ const details = error && (error.details || error.data);
268
+ if (error && error.code === 'session/workspace-attach-failed' && details && details.sessionId) {
269
+ return details.sessionId;
270
+ }
271
+ return undefined;
272
+ }
273
+
274
+ /** True when the Session has no turns yet, so forking it would inherit nothing. */
275
+ function isBlankSession(sessions, sessionId) {
276
+ try {
277
+ const snapshot = sessions.list.getSnapshot();
278
+ const row = snapshot && snapshot.byId && snapshot.byId[sessionId];
279
+ if (!row) return false;
280
+ return row.blank === true;
281
+ } catch (e) {
282
+ return false;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Fork the main conversation. The child carries the parent's whole transcript
288
+ * up to this instant, which is exactly the point of a side chat: it already
289
+ * knows what "this" and "that error above" refer to, and it can continue on
290
+ * its own without touching the main thread.
291
+ */
292
+ function forkSideSession(sessions, parentSessionId) {
293
+ return Promise.resolve()
294
+ .then(function () {
295
+ return sessions.fork({ sessionId: parentSessionId, increaseTitle: true });
296
+ })
297
+ .then(function (result) {
298
+ const id = idOf(result);
299
+ if (!id) throw new Error('assist-chat: fork returned no Session id');
300
+ return id;
301
+ });
302
+ }
303
+
304
+ /** Last resort: a brand-new Session in the current Workspace. */
305
+ function createFreshSession(sessions) {
306
+ const workspaceId = pickWorkspaceId();
307
+ const cwd = workspaceId === undefined ? pickCwd(sessions) : undefined;
308
+ const request = workspaceId !== undefined ? { workspaceId: workspaceId } : (cwd === undefined ? {} : { cwd: cwd });
309
+ return Promise.resolve()
310
+ .then(function () { return sessions.create(request); })
311
+ .then(function (result) {
312
+ const id = idOf(result);
313
+ if (id) return id;
314
+ let detail;
315
+ try { detail = JSON.stringify(result); } catch (e) { detail = String(result); }
316
+ const error = result && result.error;
317
+ if (error) detail = '[' + [error.code, error.message].filter(Boolean).join(' / ') + '] ' + detail;
318
+ throw new Error('assist-chat: create failed ' + (detail || 'unknown error').slice(0, 300));
319
+ });
320
+ }
321
+
322
+ function isAlive(sessions, sessionId) {
323
+ try {
324
+ const snapshot = sessions.list.getSnapshot();
325
+ return !!(snapshot && snapshot.byId && snapshot.byId[sessionId]);
326
+ } catch (e) {
327
+ return true;
328
+ }
329
+ }
330
+
331
+ function createSideSession(parentSessionId) {
332
+ const sessions = sessionsService();
333
+ if (!sessions) return Promise.reject(new Error('assist-chat: sessions service is unavailable'));
334
+ const canFork = parentSessionId !== undefined && parentSessionId !== '' && !isBlankSession(sessions, parentSessionId);
335
+ return Promise.resolve()
336
+ .then(function () {
337
+ if (!canFork) return undefined;
338
+ return forkSideSession(sessions, parentSessionId).catch(function (error) {
339
+ warn('fork failed, falling back to a fresh Session', error);
340
+ return undefined;
341
+ });
342
+ })
343
+ .then(function (forked) {
344
+ if (forked !== undefined) return forked;
345
+ return createFreshSession(sessions);
346
+ })
347
+ .then(function (sessionId) {
348
+ if (!sessionId) throw new Error('assist-chat: no Session was produced for the side chat');
349
+ hiddenBranchIds.add(sessionId);
350
+ ensureWritable(sessionId);
351
+ labelSideSession(sessions, sessionId);
352
+ return sessionId;
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Retire a branch whose tab was closed: archive it so the left catalog does
358
+ * not grow one entry per visit. Safe here only because the branch is idle —
359
+ * the archived-session gate rejects model steps, and we never step it again.
360
+ */
361
+ function retireSideSession(sessionId) {
362
+ if (!sessionId) return;
363
+ try {
364
+ const uiWorkspace = getService('uiWorkspace');
365
+ if (!uiWorkspace || typeof uiWorkspace.archiveSession !== 'function') return;
366
+ const pending = uiWorkspace.archiveSession(sessionId);
367
+ if (pending && typeof pending.catch === 'function') pending.catch(function () {});
368
+ } catch (e) {
369
+ warn('retire side session failed', e);
370
+ }
371
+ }
372
+
373
+ /* ---------------- components ---------------- */
374
+
375
+ function FixedChatView(props) {
376
+ return h(Fragment, null, props.renderSlot('conversation.session', { view: 'chat' }));
377
+ }
378
+
379
+ function AssistConversation(props) {
380
+ const parts = props || {};
381
+ const session = parts.useSession(function (value) { return value; });
382
+ const conversation = parts.useConversation(function (value) { return value; });
383
+ const summaryBlank = parts.useSessions
384
+ ? parts.useSessions(function (state) { return state.byId[parts.sessionId] && state.byId[parts.sessionId].blank; })
385
+ : undefined;
386
+ const activeTargets = conversation && conversation.activeTargets ? conversation.activeTargets.size : 0;
387
+ const shellPhase = activeTargets > 0 || (!session.blank && !session.awaitingFirstTurn) || session.running
388
+ ? 'active'
389
+ : (session.promptAttempted ? 'engaging' : 'blank');
390
+ const settling = shellPhase === 'blank' && session.openState === 'loading' && summaryBlank !== true;
391
+ const hero = shellPhase === 'blank' && (session.openState === 'open' || summaryBlank === true);
392
+ if (typeof parts.renderFactorySlot !== 'function') {
393
+ return h('div', { className: 'dac-status' },
394
+ h('div', { className: 'dac-status-text' }, t('status.error')));
395
+ }
396
+ return parts.renderFactorySlot('conversation.content', {
397
+ variant: 'embedded',
398
+ phase: settling ? 'settling' : (hero ? 'hero' : 'active'),
399
+ hero: hero
400
+ }, { slots: { views: FixedChatView } });
401
+ }
402
+
403
+ function useSideSession(parentSessionId) {
404
+ const statePair = React.useState({ phase: 'creating' });
405
+ const setState = statePair[1];
406
+ const attemptPair = React.useState(0);
407
+ const setAttempt = attemptPair[1];
408
+ const liveRef = React.useRef(null);
409
+
410
+ React.useEffect(function () {
411
+ let cancelled = false;
412
+ let live = null;
413
+ const controller = new AbortController();
414
+ const sessions = sessionsService();
415
+ setState({ phase: 'creating' });
416
+ if (!sessions) {
417
+ setState({ phase: 'error', error: new Error('assist-chat: sessions service is unavailable') });
418
+ return undefined;
419
+ }
420
+ (async function () {
421
+ try {
422
+ // Always a fresh fork: the seat shows the main conversation as it is
423
+ // at the moment you open it, never a snapshot from an earlier visit.
424
+ const sessionId = await createSideSession(parentSessionId);
425
+ if (cancelled) { retireSideSession(sessionId); return; }
426
+ const reference = sessions.retain(sessionId, { source: IMPL_ID + '/sidebar', signal: controller.signal });
427
+ live = { sessionId: sessionId, reference: reference };
428
+ liveRef.current = live;
429
+ if (cancelled) return;
430
+ setState({ phase: 'ready', sessionId: sessionId, reference: reference });
431
+ } catch (error) {
432
+ if (!cancelled) setState({ phase: 'error', error: error });
433
+ }
434
+ })();
435
+ return function () {
436
+ cancelled = true;
437
+ try { controller.abort(); } catch (e) {}
438
+ const current = liveRef.current || live;
439
+ liveRef.current = null;
440
+ if (current) {
441
+ try { current.reference.release(); } catch (e) {}
442
+ retireSideSession(current.sessionId);
443
+ }
444
+ };
445
+ }, [attemptPair[0], parentSessionId]);
446
+
447
+ const retry = React.useCallback(function () {
448
+ setAttempt(function (n) { return n + 1; });
449
+ }, []);
450
+
451
+ return [statePair[0], retry];
452
+ }
453
+
454
+ function AssistTab(props) {
455
+ const parts = props || {};
456
+ // The dock's layout is per-Session, so the seat knows which main
457
+ // conversation it belongs to through its own tab navigation params.
458
+ // The guide-page entry has no params, so fall back to whatever the main
459
+ // column is showing right now.
460
+ const useTabInfo = parts.useTabInfo;
461
+ const tabInfo = typeof useTabInfo === 'function' ? useTabInfo() : null;
462
+ const navigation = tabInfo && tabInfo.tab ? tabInfo.tab.navigation : null;
463
+ const params = navigation ? navigation.params : null;
464
+ const parentParam = params && typeof params.parent === 'string' ? params.parent : '';
465
+ const scopeSessionId = typeof parts.sessionId === 'string' ? parts.sessionId : '';
466
+ const parentSessionId = parentParam !== '' ? parentParam : (scopeSessionId !== '' ? scopeSessionId : currentMainSessionId());
467
+ const sideState = useSideSession(parentSessionId);
468
+ const state = sideState[0];
469
+ const retry = sideState[1];
470
+
471
+ if (state.phase !== 'ready') {
472
+ const failed = state.phase === 'error';
473
+ const detail = failed && state.error ? String((state.error && state.error.message) || state.error) : '';
474
+ return h('div', { className: 'dac-status' },
475
+ h('div', { className: 'dac-status-text' }, failed ? t('status.error') : t('status.creating')),
476
+ detail ? h('div', { className: 'dac-status-detail' }, detail) : null,
477
+ failed ? h('button', { type: 'button', className: 'dac-btn', onClick: retry }, t('retry')) : null
478
+ );
479
+ }
480
+ if (typeof parts.renderSlot !== 'function' || !parts.SessionProvider) {
481
+ return h('div', { className: 'dac-status' },
482
+ h('div', { className: 'dac-status-text' }, t('status.error')));
483
+ }
484
+ return h('div', { className: 'dac-root', 'data-assist-chat': '' },
485
+ h(parts.SessionProvider, { session: state.reference }, parts.renderSlot(CONV_SLOT, {}))
486
+ );
487
+ }
488
+
489
+ function AssistGlyph() {
490
+ return h('svg', {
491
+ width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none',
492
+ stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round',
493
+ 'aria-hidden': 'true'
494
+ },
495
+ h('path', { d: 'M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z' }),
496
+ h('path', { d: 'M12 8v6M9 11h6' }));
497
+ }
498
+
499
+ function AssistHeaderButton() {
500
+ return h('button', {
501
+ type: 'button',
502
+ className: 'dac-header-btn',
503
+ title: t('header.buttonTitle'),
504
+ 'aria-label': t('header.buttonTitle'),
505
+ onClick: function (event) {
506
+ try {
507
+ if (event) { event.preventDefault(); event.stopPropagation(); }
508
+ } catch (e) {}
509
+ openSideChat();
510
+ }
511
+ }, h(AssistGlyph, null), h('span', { className: 'dac-header-btn-text' }, t('header.button')));
512
+ }
513
+
514
+ /** Open (or focus) the side-chat seat for the conversation on screen. */
515
+ function openSideChat() {
516
+ const sidebarRight = getService('sidebarRight');
517
+ if (!sidebarRight || typeof sidebarRight.openTab !== 'function') {
518
+ warn('sidebarRight service is unavailable');
519
+ return;
520
+ }
521
+ const parentSessionId = currentMainSessionId();
522
+ try {
523
+ // The parent id rides the tab's navigation params so this seat always
524
+ // renders the branch belonging to the conversation that owns it.
525
+ if (parentSessionId) sidebarRight.openTab(KIND, { params: { parent: parentSessionId } });
526
+ else sidebarRight.openTab(KIND);
527
+ return;
528
+ } catch (error) {
529
+ warn('openTab failed', error);
530
+ }
531
+ // Fall back to just revealing the dock; the seat renders its own state.
532
+ const layout = getService('layout');
533
+ try {
534
+ if (layout && typeof layout.openRightbar === 'function') layout.openRightbar();
535
+ } catch (e) {
536
+ warn('openRightbar failed', e);
537
+ }
538
+ }
539
+
540
+ function newSideChat() {
541
+ const sidebarRight = getService('sidebarRight');
542
+ if (!sidebarRight) return;
543
+ // Closing unmounts the seat, which retires the old branch; reopening then
544
+ // forks the main conversation again at its current state.
545
+ try {
546
+ if (typeof sidebarRight.close === 'function') sidebarRight.close(KIND);
547
+ } catch (e) {
548
+ warn('close failed', e);
549
+ }
550
+ setTimeout(function () { openSideChat(); }, 30);
551
+ }
552
+
553
+ /* ---------------- styles ---------------- */
554
+
555
+ const CSS = [
556
+ '.dac-root{display:flex;flex-direction:column;min-height:0;height:100%;width:100%;background:var(--dsw-alias-bg-base,transparent);color:var(--dsw-alias-label-primary,#111)}',
557
+ '.dac-status{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;height:100%;padding:24px;font-size:13px;color:var(--dsw-alias-label-secondary,#6b7280)}',
558
+ '.dac-status-text{text-align:center;line-height:1.6}',
559
+ '.dac-status-detail{max-width:320px;text-align:center;line-height:1.5;font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-tertiary,#9aa3ad);word-break:break-word}',
560
+ '.dac-btn{font:inherit;font-size:12px;padding:5px 12px;border-radius:8px;cursor:pointer;border:1px solid var(--dsw-alias-border-l1,#dcdfe4);background:var(--dsw-alias-bg-layer-2,#f3f4f6);color:var(--dsw-alias-label-primary,#111)}',
561
+ '.dac-btn:hover{filter:brightness(.97)}',
562
+ '.dac-header-btn{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 9px;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,#6b7280);font:inherit;font-size:12px;line-height:1;cursor:pointer}',
563
+ '.dac-header-btn:hover{background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.05));color:var(--dsw-alias-label-primary,#111)}',
564
+ '.dac-header-btn:active{transform:translateY(.5px)}',
565
+ '.dac-header-btn svg{flex:none;opacity:.9}',
566
+ '.dac-header-btn-text{white-space:nowrap}'
567
+ ].join('\n');
568
+
569
+ /* ---------------- plugin ---------------- */
570
+
571
+ return {
572
+ inject: ['slots', 'layout', 'sessions', 'sidebarRight', 'sidebarRightTabs'],
573
+ apply: function (ctx) {
574
+ rootCtx = ctx;
575
+
576
+ try {
577
+ const service = ctx.get('locale');
578
+ if (service && typeof service.register === 'function') {
579
+ localeSvc = service;
580
+ try { service.register(NS, 'zh', ZH); } catch (e) { warn('locale zh', e); }
581
+ try { service.register(NS, 'en', EN); } catch (e) { warn('locale en', e); }
582
+ }
583
+ } catch (e) { warn('locale service', e); }
584
+
585
+ ctx.effect(function () {
586
+ const el = document.createElement('style');
587
+ el.setAttribute('data-plugin', IMPL_ID);
588
+ el.textContent = CSS;
589
+ document.head.appendChild(el);
590
+ return function () { try { el.remove(); } catch (e) {} };
591
+ });
592
+
593
+ ctx.effect(function () { return installCatalogHiding(); });
594
+
595
+ ctx.effect(function () {
596
+ const registry = getService('sidebarRightTabs');
597
+ if (!registry || typeof registry.register !== 'function') {
598
+ registryState = { registered: false, error: 'sidebarRightTabs service is unavailable' };
599
+ warn(registryState.error);
600
+ return function () {};
601
+ }
602
+ const state = { registered: true, error: null };
603
+ registryState = state;
604
+ return withRegistry(registry, state);
605
+ });
606
+
607
+ function withRegistry(registry, state) {
608
+ try {
609
+ return registry.register({
610
+ id: IMPL_ID,
611
+ kind: KIND,
612
+ priority: 'extension',
613
+ // Kept mounted across tab switches and dock collapse so the branch
614
+ // and its transcript survive until the tab itself is closed.
615
+ keepMounted: true,
616
+ title: function () { return t('tab.title'); },
617
+ guide: [{
618
+ id: 'assist',
619
+ order: 30,
620
+ title: function () { return t('guide.title'); },
621
+ description: function () { return t('guide.description'); },
622
+ icon: AssistGlyph
623
+ }]
624
+ });
625
+ } catch (error) {
626
+ state.registered = false;
627
+ state.error = String((error && error.message) || error);
628
+ warn('tab type registration failed', error);
629
+ return function () {};
630
+ }
631
+ }
632
+
633
+ ctx.effect(function () {
634
+ return ctx.slots.inject('sidebar.right.pane.tab', function () {
635
+ return ctx.slots.register({
636
+ name: 'sidebar.right.pane.tab',
637
+ key: IMPL_ID,
638
+ children: { 'sidebar.assist.conversation': { kind: 'single', scope: 'session' } }
639
+ }, AssistTab);
640
+ });
641
+ });
642
+
643
+ ctx.effect(function () {
644
+ return ctx.slots.inject(CONV_SLOT, function () {
645
+ return ctx.slots.register({ name: CONV_SLOT }, AssistConversation);
646
+ });
647
+ });
648
+
649
+ ctx.effect(function () {
650
+ return ctx.slots.inject('conversation.session.header.actions', function () {
651
+ return ctx.slots.register({
652
+ name: 'conversation.session.header.actions',
653
+ id: 'assist-chat-open',
654
+ order: 60
655
+ }, AssistHeaderButton);
656
+ });
657
+ });
658
+ }
659
+ };
660
+ }
661
+ });
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: assist-chat
3
+ name: 'dsh-assist-chat'
package/icon.svg ADDED
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
2
+ <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
3
+ <path d="M12 8v6M9 11h6"/>
4
+ </svg>
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Host half of the assist-chat bundle; the Client module owns everything visible. */
2
+ export function apply() {}
package/locale/en.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "tab.title": "Side chat",
3
+ "guide.title": "Side chat",
4
+ "guide.description": "Open a side conversation isolated from the main thread",
5
+ "action.open": "Side chat",
6
+ "action.openTitle": "Open side chat",
7
+ "action.new": "New side chat",
8
+ "action.newTitle": "Start a fresh side conversation",
9
+ "header.button": "Side chat",
10
+ "header.buttonTitle": "Open side chat in the right sidebar",
11
+ "status.creating": "Preparing side chat…",
12
+ "status.error": "Could not open side chat",
13
+ "retry": "Retry",
14
+ "empty.hint": "Ask anything — the main thread stays untouched."
15
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "tab.title": "辅助对话",
3
+ "guide.title": "辅助对话",
4
+ "guide.description": "开一个与主对话隔离的旁支会话,随时追问而不打断主线",
5
+ "action.open": "辅助对话",
6
+ "action.openTitle": "打开辅助对话",
7
+ "action.new": "新建辅助对话",
8
+ "action.newTitle": "开一个全新的旁支会话",
9
+ "header.button": "辅助对话",
10
+ "header.buttonTitle": "在右侧栏开启辅助对话",
11
+ "status.creating": "正在准备辅助对话…",
12
+ "status.error": "无法开启辅助对话",
13
+ "retry": "重试",
14
+ "empty.hint": "问点什么吧,这里不会打断主对话。"
15
+ }
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "dsh-assist-chat",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Side chat for DeepSeek Harness: a right-dock seat that branches the current conversation, so you can ask follow-ups without derailing the main thread. 右侧栏辅助对话:从当前会话 fork 一条旁支,追问不打断主线。",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "icon": "./icon.svg",
8
+ "keywords": [
9
+ "dsh",
10
+ "dsh-plugin",
11
+ "deepseek-harness",
12
+ "side-chat",
13
+ "assist",
14
+ "sidebar",
15
+ "fork",
16
+ "conversation"
17
+ ],
18
+ "engines": {
19
+ "node": ">=20",
20
+ "dsh": ">=0.2.0"
21
+ },
22
+ "exports": {
23
+ ".": "./index.js",
24
+ "./client": "./client.js",
25
+ "./locale/*.json": "./locale/*.json",
26
+ "./icon": "./icon.svg"
27
+ },
28
+ "files": [
29
+ "index.js",
30
+ "client.js",
31
+ "cordis.patch.yml",
32
+ "locale/*.json",
33
+ "icon.svg",
34
+ "README.md",
35
+ "README.zh.md",
36
+ "LICENSE",
37
+ "test/*.cjs"
38
+ ],
39
+ "dsh": {
40
+ "manifestVersion": 1,
41
+ "bundle": {
42
+ "patch": "./cordis.patch.yml"
43
+ },
44
+ "client": {
45
+ "platform": "web",
46
+ "immediately": true,
47
+ "inject": []
48
+ }
49
+ },
50
+ "scripts": {
51
+ "test": "node test/smoke.cjs",
52
+ "check": "node --check client.js",
53
+ "prepublishOnly": "node --check client.js && node test/smoke.cjs"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ },
58
+ "author": "sleep123937 <1347351715@qq.com> (https://github.com/sleep123937)",
59
+ "repository": {
60
+ "type": "git",
61
+ "url": "git+https://github.com/sleep123937/dsh-assist-chat.git"
62
+ },
63
+ "homepage": "https://github.com/sleep123937/dsh-assist-chat#readme",
64
+ "bugs": {
65
+ "url": "https://github.com/sleep123937/dsh-assist-chat/issues"
66
+ }
67
+ }
package/test/smoke.cjs ADDED
@@ -0,0 +1,190 @@
1
+ // Smoke test: load the assist-chat Client module in a mocked host and prove that
2
+ // apply() wires the right seats and that every component renders safely.
3
+ const fs = require('fs');
4
+ const vm = require('vm');
5
+ const path = require('path');
6
+
7
+ const SRC = path.join(__dirname, '..', 'client.js');
8
+ const code = fs.readFileSync(SRC, 'utf8');
9
+
10
+ let failures = 0;
11
+ function ok(name, cond, detail) {
12
+ if (cond) { console.log('ok ' + name); return; }
13
+ failures += 1;
14
+ console.log('FAIL ' + name + (detail ? ' :: ' + detail : ''));
15
+ }
16
+
17
+ let captured = null;
18
+ const React = {
19
+ createElement: (type, props, ...children) => ({ type, props: props || {}, children }),
20
+ useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
21
+ useEffect: () => {},
22
+ useRef: (initial) => ({ current: initial }),
23
+ useCallback: (fn) => fn,
24
+ Fragment: Symbol('Fragment'),
25
+ };
26
+
27
+ const createdStyles = [];
28
+ const sandbox = {
29
+ console,
30
+ AbortController,
31
+ setTimeout, clearTimeout,
32
+ Promise,
33
+ navigator: { language: 'zh-CN' },
34
+ document: {
35
+ addEventListener: () => {},
36
+ removeEventListener: () => {},
37
+ querySelector: () => null,
38
+ querySelectorAll: () => [],
39
+ createElement: () => {
40
+ const el = { attrs: {}, textContent: '', setAttribute(k, v) { this.attrs[k] = v; }, remove() {} };
41
+ createdStyles.push(el);
42
+ return el;
43
+ },
44
+ head: { appendChild: () => {} },
45
+ body: { addEventListener: () => {}, removeEventListener: () => {} },
46
+ },
47
+ localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
48
+ };
49
+ sandbox.window = {
50
+ __ModuleLoader__: { load(def) { captured = def; } },
51
+ localStorage: sandbox.localStorage,
52
+ innerWidth: 1400, innerHeight: 900,
53
+ addEventListener: () => {}, removeEventListener: () => {},
54
+ };
55
+ vm.createContext(sandbox);
56
+
57
+ try {
58
+ vm.runInContext(code, sandbox, { filename: 'client.js' });
59
+ } catch (e) {
60
+ console.log('FAIL module load :: ' + e.message);
61
+ process.exit(1);
62
+ }
63
+
64
+ ok('module registers through __ModuleLoader__', !!captured);
65
+ ok('factory id is the package name', captured && captured.id === 'dsh-assist-chat', captured && captured.id);
66
+
67
+ const plugin = captured.factory((name) => {
68
+ if (name === 'react') return React;
69
+ throw new Error('unexpected require: ' + name);
70
+ });
71
+
72
+ ok('factory returns a plugin object', !!plugin && typeof plugin.apply === 'function');
73
+ ok('plugin injects slots', Array.isArray(plugin.inject) && plugin.inject[0] === 'slots', JSON.stringify(plugin.inject));
74
+
75
+ /* ---------- host mocks ---------- */
76
+ const registered = [];
77
+ const tabTypes = [];
78
+ const createdSessions = [];
79
+ const retained = [];
80
+ const released = [];
81
+
82
+ const sessions = {
83
+ list: { getSnapshot: () => ({ ids: ['s1'], byId: { s1: { sessionId: 's1', cwd: 'D:/work', updatedAt: 5 } } }) },
84
+ create: async (opts) => { createdSessions.push(opts); return { ok: true, value: { sessionId: 'side-1' } }; },
85
+ retain: (id, options) => {
86
+ const ref = { sessionId: id, options, release: () => released.push(id) };
87
+ retained.push(ref);
88
+ return ref;
89
+ },
90
+ };
91
+
92
+ const services = {
93
+ locale: { register() {}, t: (ns, key) => undefined },
94
+ sessions,
95
+ sidebarRightTabs: { register(def) { tabTypes.push(def); return () => {}; } },
96
+ sidebarRight: { openTab: (kind) => { services.sidebarRight.opened = kind; }, close: () => {} },
97
+ layout: { openRightbar() {}, closeRightbar() {} },
98
+ };
99
+
100
+ const effectDisposers = [];
101
+ const ctx = {
102
+ get(key) { return services[key]; },
103
+ effect(fn) { const d = fn(); effectDisposers.push(d); return () => { if (typeof d === 'function') d(); }; },
104
+ slots: {
105
+ inject(slot, cb) { cb(); },
106
+ register(options, Component) { registered.push({ options, Component }); return () => {}; },
107
+ },
108
+ };
109
+
110
+ try {
111
+ plugin.apply(ctx);
112
+ ok('apply() completes without throwing', true);
113
+ } catch (e) {
114
+ ok('apply() completes without throwing', false, e.message + '\n' + e.stack);
115
+ }
116
+
117
+ const slotIds = registered.map((r) => r.options.name + ':' + (r.options.key || r.options.id || ''));
118
+ console.log(' registered slots: ' + JSON.stringify(slotIds));
119
+ console.log(' tab types: ' + JSON.stringify(tabTypes.map((d) => d.id + '/' + d.kind + '/' + d.priority)));
120
+
121
+ ok('registers the sidebar tab body keyed by impl id', slotIds.indexOf('sidebar.right.pane.tab:dsh-assist-chat') >= 0);
122
+ ok('registers the sidebar conversation surface', slotIds.indexOf('sidebar.assist.conversation:') >= 0);
123
+ ok('registers the session-header entry', slotIds.indexOf('conversation.session.header.actions:assist-chat-open') >= 0);
124
+ ok('registers exactly one sidebar tab type', tabTypes.length === 1, String(tabTypes.length));
125
+ ok('tab type kind is assistchat', tabTypes[0] && tabTypes[0].kind === 'assistchat', tabTypes[0] && tabTypes[0].kind);
126
+ ok('tab type id is the package name', tabTypes[0] && tabTypes[0].id === 'dsh-assist-chat');
127
+ ok('tab type title is a function', tabTypes[0] && typeof tabTypes[0].title === 'function');
128
+ ok('tab type ships a guide card', !!(tabTypes[0] && Array.isArray(tabTypes[0].guide) && tabTypes[0].guide.length === 1), JSON.stringify(tabTypes[0] && tabTypes[0].guide));
129
+ ok('guide card title is a function', !!(tabTypes[0] && tabTypes[0].guide && typeof tabTypes[0].guide[0].title === 'function'));
130
+ ok('guide card has an icon component', !!(tabTypes[0] && tabTypes[0].guide && typeof tabTypes[0].guide[0].icon === 'function'));
131
+ ok('injects one style element', createdStyles.length === 1, String(createdStyles.length));
132
+
133
+ /* ---------- components render ---------- */
134
+ function renderEntry(name) {
135
+ const entry = registered.find((r) => r.options.name === name);
136
+ if (!entry) return { error: 'not registered' };
137
+ try {
138
+ return { tree: entry.Component({}) };
139
+ } catch (e) {
140
+ return { error: e.message + '\n' + e.stack };
141
+ }
142
+ }
143
+
144
+ const header = renderEntry('conversation.session.header.actions');
145
+ ok('header entry renders', !header.error, header.error);
146
+ ok('header entry is a button', header.tree && header.tree.type === 'button', header.tree && String(header.tree.type));
147
+
148
+ const tab = renderEntry('sidebar.right.pane.tab');
149
+ ok('tab body renders in the creating phase', !tab.error, tab.error);
150
+ ok('tab body shows a status shell before ready', tab.tree && tab.tree.props && tab.tree.props.className === 'dac-status',
151
+ tab.tree && JSON.stringify(tab.tree.props));
152
+
153
+ const convEntry = registered.find((r) => r.options.name === 'sidebar.assist.conversation');
154
+ const conversationProps = {
155
+ sessionId: 'side-1',
156
+ useSession: (sel) => sel({ blank: true, running: false, awaitingFirstTurn: false, openState: 'open', promptAttempted: false }),
157
+ useConversation: (sel) => sel({ activeTargets: new Set() }),
158
+ useSessions: (sel) => sel({ byId: { 'side-1': { blank: true } } }),
159
+ renderFactorySlot: (factory, props, extra) => ({ type: 'factory', factory, props, extra }),
160
+ };
161
+ let convTree = null;
162
+ let convError = null;
163
+ try {
164
+ convTree = convEntry.Component(conversationProps);
165
+ } catch (e) {
166
+ convError = e.message + '\n' + e.stack;
167
+ }
168
+ ok('conversation surface renders', !convError, convError);
169
+ ok('conversation surface renders the conversation.content factory',
170
+ convTree && convTree.type === 'factory' && convTree.factory === 'conversation.content',
171
+ convTree && JSON.stringify({ type: convTree.type, factory: convTree.factory }));
172
+ ok('conversation surface asks for the embedded variant', convTree && convTree.props && convTree.props.variant === 'embedded');
173
+ ok('conversation surface pins the chat view', convTree && convTree.extra && convTree.extra.slots && typeof convTree.extra.slots.views === 'function');
174
+
175
+ /* ---------- disposal ---------- */
176
+ let disposeError = null;
177
+ try {
178
+ effectDisposers.forEach((d) => { if (typeof d === 'function') d(); });
179
+ } catch (e) {
180
+ disposeError = e.message;
181
+ }
182
+ ok('disposal is clean', !disposeError, disposeError);
183
+
184
+ console.log('');
185
+ if (failures === 0) {
186
+ console.log('ALL PASS');
187
+ } else {
188
+ console.log(failures + ' FAILURE(S)');
189
+ process.exitCode = 1;
190
+ }