dsh-code-server-app 0.3.57 → 0.3.59

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.
@@ -1,416 +0,0 @@
1
- // dshcs-editor-bridge / lib/ask-panel.js —— 「问 DSH」面板的纯逻辑(0.2.3)
2
- //
3
- // **一份模型,两边用**:
4
- // - 扩展侧(extension.js)用它保存面板状态,并组装每趟轮询 postMessage 的载荷;
5
- // - 面板侧(webview/src/app.jsx,由 scripts/build-webview.mjs 打成 webview/thread.js)
6
- // 用它把载荷变成视图 —— 消息正文交给 DSH **官方** markdown 渲染器
7
- // (`@deepseek-ai/dsh-client-ui-primitives` 的 MarkdownText,与 DSH 界面同一份代码)。
8
- //
9
- // 0.2.0–0.2.2 的面板自己拼 HTML 字符串、正文按 `white-space: pre-wrap` 原样显示,与 DSH 界面
10
- // 的排版不一致;0.2.3 起正文走官方渲染器,面板只保留"外壳"逻辑(上下文 / 状态 / 输入框)。
11
- //
12
- // 本文件不 require('vscode')、不碰 DOM,所以能在普通 Node 里单测
13
- // (scripts/test-bridge-extension.mjs)。
14
-
15
- 'use strict';
16
-
17
- /** 面板保留的对话条目上限(宿主侧 lib/bridge-thread.mjs 也有上限;这里再兜一层)。 */
18
- const MAX_ENTRIES = 200;
19
-
20
- /** 本地乐观提问(已发出、宿主还没回显)的上限。 */
21
- const MAX_PENDING = 6;
22
-
23
- /** 单条正文渲染上限(宿主已截到 8000,这里只防意外)。 */
24
- const MAX_TEXT = 20000;
25
-
26
- /** 授权卡片的默认等待窗口(毫秒);宿主随 /sync 给出真实值,这里只是兜底。 */
27
- const DEFAULT_HOLD_MS = 8000;
28
-
29
- /** 条目角色(与 lib/bridge-thread.mjs 的投影一致)。 */
30
- const ROLES = ['user', 'assistant', 'tool', 'approval'];
31
-
32
- /** 折叠空白:用于"本地乐观提问"与"宿主回显"的同一性比较。 */
33
- function normalizeText(text) {
34
- return String(text ?? '').replace(/\s+/g, ' ').trim();
35
- }
36
-
37
- /** 面板状态(纯数据,便于单测)。 */
38
- function createPanelState() {
39
- return {
40
- /** 对话条目(宿主权威;0.3.22 起只含**新内容**)。 */
41
- entries: [],
42
- /** 条目签名:变了才刷新面板(600ms 一趟轮询,不能每趟都重传几十 KB)。 */
43
- entriesSig: '',
44
- /** 已发出、宿主还没回显的提问(乐观显示)。 */
45
- pending: [],
46
- /** 待决授权请求 [{id, toolName, reason, callId, at}]。 */
47
- approvals: [],
48
- approvalSig: '',
49
- approvalHoldMs: DEFAULT_HOLD_MS,
50
- /** 宿主有没有对话流能力:null=还不知道,false=旧版宿主。 */
51
- available: null,
52
- /** 对话流的错误(订阅失败 / 旧版宿主)。 */
53
- threadError: null,
54
- /** 当前提问意图:'selection' | 'file'。 */
55
- mode: 'selection',
56
- /** 发送时那一次取的编辑器上下文。 */
57
- context: null,
58
- /** idle | sending | thinking | error */
59
- status: 'idle',
60
- error: null,
61
- /** 面板绑定的会话(host 回话里的 sessionId)。 */
62
- sessionId: null,
63
- /** DSH 界面的版本(host 的 /sync 报回来;面板据此提示渲染器版本不一致)。 */
64
- uiVersion: null,
65
- };
66
- }
67
-
68
- /** 一条宿主条目 → 规整过的渲染条目(字段白名单,不接受宿主塞别的东西)。 */
69
- function cleanEntry(raw) {
70
- if (raw === null || typeof raw !== 'object') return null;
71
- const role = ROLES.includes(raw.role) ? raw.role : null;
72
- if (role === null) return null;
73
- const text = typeof raw.text === 'string' ? raw.text.slice(0, MAX_TEXT) : '';
74
- return {
75
- role,
76
- text,
77
- // 思考过程(0.3.23):渲染成默认收起的「思考」行。
78
- thinking: typeof raw.thinking === 'string' ? raw.thinking.slice(0, MAX_TEXT) : '',
79
- // 注入的上下文(0.3.24):桥自己拼的位置行 + 选区代码块,渲染成默认收起的「上下文」行。
80
- context: typeof raw.context === 'string' ? raw.context.slice(0, MAX_TEXT) : '',
81
- // 消息来源(0.3.43):非 'user' = 上下文注入,渲染成折叠行。
82
- sourceKind: typeof raw.sourceKind === 'string' && raw.sourceKind !== '' ? raw.sourceKind : null,
83
- streaming: raw.streaming === true,
84
- name: typeof raw.name === 'string' ? raw.name : null,
85
- summary: typeof raw.summary === 'string' ? raw.summary : null,
86
- status: typeof raw.status === 'string' ? raw.status : null,
87
- callId: typeof raw.callId === 'string' ? raw.callId : null,
88
- approvalId: typeof raw.approvalId === 'string' ? raw.approvalId : null,
89
- };
90
- }
91
-
92
- /**
93
- * 条目签名:正文/思考只会"流式变长"或"被耐久消息原地替换"(此时 streaming 翻转),
94
- * 所以 角色 + 状态 + 长度 + 是否流式 足够判变化,不必每趟比对几十 KB 文本。
95
- */
96
- function entriesSignature(entries) {
97
- const parts = [];
98
- for (const entry of entries) {
99
- parts.push([
100
- entry.role,
101
- entry.status ?? '',
102
- entry.streaming ? 1 : 0,
103
- entry.text.length,
104
- entry.thinking.length,
105
- entry.context.length,
106
- entry.sourceKind === null ? '' : entry.sourceKind.length,
107
- entry.summary === null ? '' : entry.summary.length,
108
- ].join(':'));
109
- }
110
- return `${entries.length}|${parts.join(',')}`;
111
- }
112
-
113
- /** 本地乐观提问 → 渲染条目。 */
114
- function pendingEntries(state) {
115
- return state.pending.map((item) => ({
116
- role: 'user',
117
- text: item.text,
118
- thinking: '',
119
- context: '',
120
- streaming: false,
121
- name: null,
122
- summary: null,
123
- status: item.error === null || item.error === undefined ? 'sending' : 'error',
124
- callId: null,
125
- approvalId: null,
126
- }));
127
- }
128
-
129
- /** 面板要渲染的完整列表(宿主条目 + 尚未回显的本地提问)。 */
130
- function entriesOf(state) {
131
- const all = [...state.entries, ...pendingEntries(state)];
132
- return all.length > MAX_ENTRIES ? all.slice(all.length - MAX_ENTRIES) : all;
133
- }
134
-
135
- /**
136
- * 应用宿主 `/sync` 的 `thread` 快照。
137
- *
138
- * `thread === null` = 旧版宿主(没有这个字段)—— 必须**说出原因**,否则面板会永远停在"正在回答…"。
139
- * `thread.available === false` = 宿主有这个能力但当前没有 watch 到会话(还没提问过)。
140
- *
141
- * @returns {boolean} 是否有变化(调用方据此决定要不要刷新 webview)
142
- */
143
- function applyThread(state, thread, sessionId) {
144
- if (thread === null || thread === undefined || typeof thread !== 'object') {
145
- const changed = state.available !== false || state.threadError === null;
146
- state.available = false;
147
- state.threadError = '宿主没有对话流能力(插件版本过旧?):请重启 dsh web 让 host 侧升级到 0.3.22 以上';
148
- // 已经问过了却拿不到对话流:不能只留一行提示,面板必须报错(否则永远停在"正在回答…")。
149
- if (state.pending.length > 0 && state.status !== 'error') {
150
- state.status = 'error';
151
- state.error = state.threadError;
152
- }
153
- return changed;
154
- }
155
- let changed = false;
156
- const next = [];
157
- if (Array.isArray(thread.entries)) {
158
- for (const raw of thread.entries) {
159
- const entry = cleanEntry(raw);
160
- if (entry !== null) next.push(entry);
161
- }
162
- }
163
- const sig = entriesSignature(next);
164
- if (sig !== state.entriesSig) {
165
- state.entries = next;
166
- state.entriesSig = sig;
167
- changed = true;
168
- }
169
- const available = thread.available !== false;
170
- if (available !== state.available) {
171
- state.available = available;
172
- changed = true;
173
- }
174
- const error = typeof thread.error === 'string' && thread.error !== '' ? thread.error : null;
175
- if (error !== state.threadError) {
176
- state.threadError = error;
177
- changed = true;
178
- }
179
- const session = typeof thread.sessionId === 'string' && thread.sessionId !== ''
180
- ? thread.sessionId
181
- : (typeof sessionId === 'string' && sessionId !== '' ? sessionId : null);
182
- if (session !== null && session !== state.sessionId) {
183
- state.sessionId = session;
184
- changed = true;
185
- }
186
- // 宿主回显了同一段文字 → 本地乐观条目退场(不重复显示)。
187
- const before = state.pending.length;
188
- state.pending = state.pending.filter((item) => !next.some(
189
- (entry) => entry.role === 'user' && normalizeText(entry.text) === normalizeText(item.text),
190
- ));
191
- if (state.pending.length !== before) changed = true;
192
-
193
- // 忙碌判定:有待回声的提问、有流式条目、或有还在跑的工具 → thinking。
194
- const busy = state.pending.length > 0
195
- || next.some((entry) => entry.streaming === true || (entry.role === 'tool' && entry.status === 'running'));
196
- if (busy && state.status !== 'error') state.status = 'thinking';
197
- else if (!busy && (state.status === 'thinking' || state.status === 'sending')) state.status = 'idle';
198
- return changed;
199
- }
200
-
201
- /** 应用宿主 `/sync` 的 `approvals` 快照(待决授权请求)。 */
202
- function applyApprovals(state, approvals, holdMs) {
203
- const list = [];
204
- if (Array.isArray(approvals)) {
205
- for (const raw of approvals) {
206
- if (raw === null || typeof raw !== 'object') continue;
207
- if (typeof raw.id !== 'string' || raw.id === '') continue;
208
- list.push({
209
- id: raw.id,
210
- toolName: typeof raw.toolName === 'string' && raw.toolName !== '' ? raw.toolName : 'tool',
211
- reason: typeof raw.reason === 'string' ? raw.reason : '',
212
- callId: typeof raw.callId === 'string' ? raw.callId : null,
213
- at: Number.isSafeInteger(raw.at) ? raw.at : Date.now(),
214
- });
215
- }
216
- list.sort((a, b) => a.at - b.at);
217
- }
218
- const window = Number.isSafeInteger(holdMs) && holdMs > 0 ? holdMs : DEFAULT_HOLD_MS;
219
- const sig = `${window}|${JSON.stringify(list)}`;
220
- if (sig === state.approvalSig) return false;
221
- state.approvals = list;
222
- state.approvalHoldMs = window;
223
- state.approvalSig = sig;
224
- return true;
225
- }
226
-
227
- /**
228
- * 把宿主一趟 `/sync` 的结果并进面板状态(扩展侧只调这一个)。
229
- *
230
- * `result.thread === undefined` = 旧版宿主(没有这个字段)。
231
- * @returns {boolean} 是否有变化(调用方据此决定要不要刷新 webview)
232
- */
233
- function applySync(state, result) {
234
- let changed = false;
235
- if (applyThread(state, result === null || result === undefined ? null : result.thread, result?.sessionId ?? null)) {
236
- changed = true;
237
- }
238
- if (applyApprovals(state, result === null || result === undefined ? [] : result.approvals, result?.approvalHoldMs)) {
239
- changed = true;
240
- }
241
- const version = result === null || result === undefined ? undefined : result.uiVersion;
242
- if (typeof version === 'string' && version !== '' && version !== state.uiVersion) {
243
- state.uiVersion = version;
244
- changed = true;
245
- }
246
- return changed;
247
- }
248
-
249
- /**
250
- * 登记一条本地提问(乐观显示;宿主回显后自动退场)。
251
- * @returns {object} 刚登记的 pending 条目
252
- */
253
- function pendingQuestion(state, text, context) {
254
- const item = { text: String(text ?? ''), at: Date.now(), error: null };
255
- state.pending.push(item);
256
- while (state.pending.length > MAX_PENDING) state.pending.shift();
257
- if (context !== null && context !== undefined) state.context = context;
258
- state.status = 'sending';
259
- state.error = null;
260
- return item;
261
- }
262
-
263
- /** 投递失败 / 面板错误:状态行 + 最新一条本地提问都标上原因。 */
264
- function failPanel(state, message) {
265
- state.status = 'error';
266
- state.error = message;
267
- const last = state.pending[state.pending.length - 1];
268
- if (last !== undefined) last.error = message;
269
- return state;
270
- }
271
-
272
- /** 上下文 → 一行描述(标题栏):文件:行 + 选区行数。 */
273
- function describeContext(context) {
274
- if (context === null || context === undefined) return '';
275
- const name = typeof context.file === 'string' && context.file !== '' ? context.file : '(当前文件)';
276
- const base = name.replace(/^.*[\\/]/, '');
277
- if (Number.isSafeInteger(context.lineStart)) {
278
- const range = Number.isSafeInteger(context.lineEnd) && context.lineEnd !== context.lineStart
279
- ? `${context.lineStart}-${context.lineEnd}`
280
- : `${context.lineStart}`;
281
- return `${base}:${range}`;
282
- }
283
- return base;
284
- }
285
-
286
- /** 标题栏那一行:上下文 + 提问意图。 */
287
- function contextLine(state) {
288
- const where = state.context === null || state.context === undefined
289
- ? '来自编辑器'
290
- : `来自编辑器:${describeContext(state.context)}`;
291
- if (state.context === null || state.context === undefined) return where;
292
- return state.mode === 'file' ? `${where} · 针对当前文件` : `${where} · 针对选中内容`;
293
- }
294
-
295
- /** 状态 → 状态行文案(扩展侧组装,面板只显示)。 */
296
- function statusText(state) {
297
- if (state.status === 'error') return state.error ?? '出错了';
298
- if (state.status === 'sending') return '正在发给 DSH…';
299
- if (state.status === 'thinking') return 'DSH 正在回答…';
300
- return state.threadError ?? '';
301
- }
302
-
303
- /** 状态 → 面板可渲染的 JSON(postMessage 的载荷)。 */
304
- function panelPayload(state) {
305
- return {
306
- type: 'state',
307
- entries: entriesOf(state),
308
- approvals: state.approvals,
309
- approvalHoldMs: state.approvalHoldMs,
310
- contextText: contextLine(state),
311
- statusText: statusText(state),
312
- status: state.status,
313
- error: state.error,
314
- sessionId: state.sessionId,
315
- available: state.available,
316
- threadError: state.threadError,
317
- uiVersion: state.uiVersion,
318
- };
319
- }
320
-
321
- /** 面板侧:把载荷并进本地视图状态(只认白名单字段)。 */
322
- function applyPayload(state, payload) {
323
- if (payload === null || typeof payload !== 'object' || payload.type !== 'state') return state;
324
- return {
325
- ...state,
326
- entries: Array.isArray(payload.entries) ? payload.entries : [],
327
- approvals: Array.isArray(payload.approvals) ? payload.approvals : [],
328
- approvalHoldMs: Number.isSafeInteger(payload.approvalHoldMs) && payload.approvalHoldMs > 0
329
- ? payload.approvalHoldMs
330
- : DEFAULT_HOLD_MS,
331
- contextText: typeof payload.contextText === 'string' ? payload.contextText : '',
332
- statusText: typeof payload.statusText === 'string' ? payload.statusText : '',
333
- status: typeof payload.status === 'string' ? payload.status : 'idle',
334
- error: typeof payload.error === 'string' ? payload.error : null,
335
- sessionId: typeof payload.sessionId === 'string' ? payload.sessionId : null,
336
- available: payload.available === undefined ? null : payload.available,
337
- threadError: typeof payload.threadError === 'string' ? payload.threadError : null,
338
- uiVersion: typeof payload.uiVersion === 'string' ? payload.uiVersion : null,
339
- };
340
- }
341
-
342
- /** 面板视图的初始状态(同 applyPayload 的输出形状,首帧渲染用)。 */
343
- function createViewState() {
344
- return {
345
- entries: [],
346
- approvals: [],
347
- approvalHoldMs: DEFAULT_HOLD_MS,
348
- contextText: '来自编辑器',
349
- statusText: '',
350
- status: 'idle',
351
- error: null,
352
- sessionId: null,
353
- available: null,
354
- threadError: null,
355
- uiVersion: null,
356
- };
357
- }
358
-
359
- /** HTML 属性转义(只用于外壳里的 URI;正文一律走 React,不做字符串拼接)。 */
360
- function escapeAttribute(text) {
361
- return String(text)
362
- .replace(/&/g, '&')
363
- .replace(/</g, '&lt;')
364
- .replace(/>/g, '&gt;')
365
- .replace(/"/g, '&quot;')
366
- .replace(/'/g, '&#39;');
367
- }
368
-
369
- /**
370
- * 面板 HTML(一次性外壳):只加载打包好的 thread.js / thread.css,
371
- * 之后全靠 postMessage 更新 —— 输入框与滚动位置不受影响。
372
- *
373
- * @param {{cspSource: string, nonce: string, scriptUri: string, styleUri: string}} options
374
- */
375
- function renderPanelHtml({ cspSource, nonce, scriptUri, styleUri }) {
376
- const script = escapeAttribute(scriptUri);
377
- const style = escapeAttribute(styleUri);
378
- return `<!DOCTYPE html>
379
- <html lang="zh-CN">
380
- <head>
381
- <meta charset="utf-8">
382
- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${cspSource} data:; font-src ${cspSource}; style-src ${cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';">
383
- <link rel="stylesheet" href="${style}">
384
- <title>DSH 对话</title>
385
- </head>
386
- <body class="dshcs-panel">
387
- <div id="root"></div>
388
- <script nonce="${nonce}" src="${script}"></script>
389
- </body>
390
- </html>`;
391
- }
392
-
393
- module.exports = {
394
- MAX_ENTRIES,
395
- MAX_PENDING,
396
- MAX_TEXT,
397
- DEFAULT_HOLD_MS,
398
- normalizeText,
399
- createPanelState,
400
- cleanEntry,
401
- entriesSignature,
402
- entriesOf,
403
- applyThread,
404
- applyApprovals,
405
- applySync,
406
- pendingQuestion,
407
- failPanel,
408
- describeContext,
409
- contextLine,
410
- statusText,
411
- panelPayload,
412
- applyPayload,
413
- createViewState,
414
- escapeAttribute,
415
- renderPanelHtml,
416
- };
@@ -1,22 +0,0 @@
1
- # 面板 webview 打包进去的第三方代码
2
-
3
- 本目录下的 `thread.js` / `thread.css` 由 `scripts/build-webview.mjs` 打包生成,
4
- 其中包含下列第三方包(全部 MIT 许可)。它们不是本插件的代码,原样打包只为让面板的消息正文
5
- 与 DSH 界面完全一致(markdown 管线 / 语法高亮 / 排版令牌)。
6
-
7
- | 包 | 版本 | 许可 |
8
- | --- | --- | --- |
9
- | @deepseek-ai/dsh-client-ui-primitives | 0.1.6-alpha.1 | MIT |
10
- | @deepseek-ai/dsh-client-ui-theme | 0.1.6-alpha.1 | MIT |
11
- | @deepseek-ai/schemastery | 3.18.2 | MIT |
12
- | react | 18.3.1 | MIT |
13
- | react-dom | 18.3.1 | MIT |
14
-
15
- 生成时间:2026-09-19T12:45:33.924Z
16
- 渲染器:@deepseek-ai/dsh-client-ui-primitives@0.1.6-alpha.1(DSH 部署的界面版本:未知)
17
- 设计令牌:@deepseek-ai/dsh-client-ui-theme@0.1.6-alpha.1(11 段)
18
- KaTeX:已打包(公式排版与 DSH 界面一致)
19
- shiki 语法:typescript / shellscript / json(与 DSH 启动集一致)
20
- 令牌自检:通过
21
-
22
- 许可证原文见各包目录下的 LICENSE 文件(例如 `node_modules/@deepseek-ai/dsh-client-ui-primitives/LICENSE`)。
@@ -1,242 +0,0 @@
1
- // dshcs-editor-bridge / webview/src/app.jsx —— 「问 DSH」面板(0.2.3)
2
- //
3
- // 面板 = 外壳(上下文 / 状态 / 输入框 / 授权卡片)+ **DSH 官方渲染的对话内容**。
4
- // 对话数据来自宿主 `/sync` 的 `thread` 字段:只有**新内容**(0.3.22 起,历史不重放),
5
- // 助手正文逐条交给官方 markdown 渲染器(thread.jsx)。
6
- //
7
- // 与扩展侧的分工(消息协议,postMessage):
8
- // 扩展 → 面板:`{type:'state', …}`(面板状态载荷,形状见 lib/ask-panel.js 的 panelPayload)
9
- // 面板 → 扩展:`{type:'ready'}` / `{type:'ask', text}` / `{type:'approve', id, outcome}`
10
- //
11
- // 纯逻辑(状态合并 / 载荷解析)在 lib/ask-panel.js 里,与扩展侧同一份,可在 Node 里单测;
12
- // 本文件只做"把状态画出来"。
13
-
14
- import { useEffect, useRef, useState } from 'react';
15
- import { createRoot } from 'react-dom/client';
16
-
17
- import panel from '../../lib/ask-panel.js';
18
- import { ThreadEntry } from './thread.jsx';
19
- import { ApprovalCard } from './approval.jsx';
20
- import './panel.css';
21
-
22
- /** 打包时的官方渲染器版本(由 scripts/build-webview.mjs 注入)。 */
23
- const RENDERER_VERSION = typeof __DSHCS_RENDERER_VERSION__ === 'string' ? __DSHCS_RENDERER_VERSION__ : '';
24
- /** 打包时的官方 UI 包名(用于版本不一致时的提示文案)。 */
25
- const RENDERER_PACKAGE = typeof __DSHCS_RENDERER_PACKAGE__ === 'string' ? __DSHCS_RENDERER_PACKAGE__ : '';
26
-
27
- const vscode = acquireVsCodeApi();
28
-
29
- /** 宿主形态(0.2.5):`'dsh'` = 跑在 DSH 页面里的浮动对话框里,否则是 VS Code webview 面板。 */
30
- const HOST = typeof window.__DSHCS_HOST__ === 'string' ? window.__DSHCS_HOST__ : 'webview';
31
- /** 挂载点:对话框把它的容器交进来;webview 里就是 #root。 */
32
- const MOUNT = window.__DSHCS_MOUNT__ instanceof HTMLElement
33
- ? window.__DSHCS_MOUNT__
34
- : document.getElementById('root');
35
-
36
- /** 面板外壳的初始视图状态。 */
37
- const INITIAL = panel.createViewState();
38
-
39
- function App() {
40
- const [view, setView] = useState(INITIAL);
41
- const [now, setNow] = useState(() => Date.now());
42
- const [draft, setDraft] = useState('');
43
- const [sending, setSending] = useState(false);
44
- const [scriptError, setScriptError] = useState(null);
45
- const [decided, setDecided] = useState({});
46
- const logRef = useRef(null);
47
- const stickRef = useRef(true);
48
- const boxRef = useRef(null);
49
-
50
- // 对话框形态:壳子直接推状态(同一个 window,不走 postMessage —— DSH 页面里 postMessage 是公共广播)。
51
- useEffect(() => {
52
- if (HOST !== 'dsh') return undefined;
53
- window.__DSHCS_ASK_PUSH__ = (payload) => {
54
- if (payload === null || typeof payload !== 'object') return;
55
- setView((prev) => panel.applyPayload(prev, { ...payload, type: 'state' }));
56
- };
57
- return () => { delete window.__DSHCS_ASK_PUSH__; };
58
- }, []);
59
-
60
- // 扩展 → 面板的消息(webview 形态)。
61
- useEffect(() => {
62
- const onMessage = (event) => {
63
- const data = event.data;
64
- if (data === null || typeof data !== 'object') return;
65
- if (data.type === 'state') setView((prev) => panel.applyPayload(prev, data));
66
- };
67
- window.addEventListener('message', onMessage);
68
- // 面板脚本自己出错也要说出来:静默空白是最难查的一种失败。
69
- // **但"ResizeObserver loop completed with undelivered notifications"不是脚本错误**(0.3.41):
70
- // 那是 Chromium 在布局收敛时投递的良性告警(官方渲染器的视口高亮/折叠行都会触发它),
71
- // 浏览器自己会重试,重试后一帧内就恢复。把它当成"面板脚本出错"会误导用户去查一个不存在的问题。
72
- const BENIGN = /ResizeObserver loop/i;
73
- const onError = (event) => {
74
- const message = event && event.message ? String(event.message) : '未知';
75
- if (BENIGN.test(message)) return;
76
- setScriptError(`面板脚本出错:${message}`);
77
- };
78
- const onRejection = (event) => {
79
- const reason = event && event.reason ? String(event.reason) : '未知';
80
- if (BENIGN.test(reason)) return;
81
- setScriptError(`面板脚本出错:${reason}`);
82
- };
83
- window.addEventListener('error', onError);
84
- window.addEventListener('unhandledrejection', onRejection);
85
- vscode.postMessage({ type: 'ready' });
86
- return () => {
87
- window.removeEventListener('message', onMessage);
88
- window.removeEventListener('error', onError);
89
- window.removeEventListener('unhandledrejection', onRejection);
90
- };
91
- }, []);
92
-
93
- // 暗色主题:官方令牌表用 body[data-ds-dark-theme] 切换调色板。
94
- // **只在 webview 里做**:在 DSH 页面里那个属性属于 DSH 自己,面板不能去改它(会翻掉整个界面主题)。
95
- useEffect(() => {
96
- if (HOST === 'dsh') return undefined;
97
- const sync = () => {
98
- const dark = document.body.classList.contains('vscode-dark')
99
- || document.body.classList.contains('vscode-high-contrast');
100
- if (dark) document.body.setAttribute('data-ds-dark-theme', '');
101
- else document.body.removeAttribute('data-ds-dark-theme');
102
- };
103
- sync();
104
- const observer = new MutationObserver(sync);
105
- observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
106
- return () => observer.disconnect();
107
- }, []);
108
-
109
- // 授权倒计时:只在有卡片时跑。
110
- useEffect(() => {
111
- if (view.approvals.length === 0) return undefined;
112
- const timer = window.setInterval(() => setNow(Date.now()), 250);
113
- return () => window.clearInterval(timer);
114
- }, [view.approvals.length]);
115
-
116
- // 已提交的决策:卡片消失后清掉(不然 decided 会一直涨)。
117
- useEffect(() => {
118
- setDecided((prev) => {
119
- const ids = new Set(view.approvals.map((item) => item.id));
120
- const next = {};
121
- let changed = false;
122
- for (const [id, value] of Object.entries(prev)) {
123
- if (ids.has(id)) next[id] = value;
124
- else changed = true;
125
- }
126
- return changed ? next : prev;
127
- });
128
- }, [view.approvals]);
129
-
130
- // 贴底滚动:用户主动往上翻时不打扰。
131
- useEffect(() => {
132
- const node = logRef.current;
133
- if (node !== null && stickRef.current) node.scrollTop = node.scrollHeight;
134
- }, [view.entries]);
135
-
136
- // 任何一趟宿主状态回来都解除"发送中"的按钮锁(0.3.44)。
137
- // 老写法盯着 `view.status` 变化 —— 而对话框形态下宿主回的 status **一直是 'idle'**,
138
- // 于是那个 effect 永远不再触发:第一次发送之后按钮就永久变灰(实测"只能发送一次,没法追问")。
139
- useEffect(() => {
140
- setSending(false);
141
- }, [view]);
142
- // 兜底:万一某一趟状态没回来(宿主卡住/请求失败),最多 15 秒也把锁解开。
143
- useEffect(() => {
144
- if (sending !== true) return undefined;
145
- const timer = window.setTimeout(() => setSending(false), 15000);
146
- return () => window.clearTimeout(timer);
147
- }, [sending]);
148
-
149
- const onScroll = () => {
150
- const node = logRef.current;
151
- if (node === null) return;
152
- stickRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 48;
153
- };
154
-
155
- const submit = () => {
156
- const text = draft.trim();
157
- if (text === '' || sending) return;
158
- setSending(true);
159
- setDraft('');
160
- vscode.postMessage({ type: 'ask', text });
161
- };
162
-
163
- const onKeyDown = (event) => {
164
- if (event.key === 'Enter' && event.shiftKey === false) {
165
- event.preventDefault();
166
- submit();
167
- }
168
- };
169
-
170
- const onDecide = (id, outcome) => {
171
- setDecided((prev) => ({ ...prev, [id]: outcome }));
172
- vscode.postMessage({ type: 'approve', id, outcome });
173
- };
174
-
175
- const status = scriptError === null ? view.statusText : scriptError;
176
- const versionNote = view.uiVersion !== null && view.uiVersion !== undefined && RENDERER_VERSION !== ''
177
- && view.uiVersion !== RENDERER_VERSION
178
- ? `面板内置的渲染器是 ${RENDERER_PACKAGE}@${RENDERER_VERSION},而当前 DSH 界面是 ${view.uiVersion}:`
179
- + '排版可能有差异(重建面板:pnpm run build:webview)。'
180
- : null;
181
-
182
- return (
183
- <div className="dshcs-app">
184
- <header className="dshcs-header">
185
- <span className="dshcs-title">DSH</span>
186
- <span className="dshcs-where">{view.contextText}</span>
187
- {RENDERER_VERSION === '' ? null : <span className="dshcs-version">渲染器 {RENDERER_VERSION}</span>}
188
- <button
189
- type="button"
190
- className="dshcs-close"
191
- title="关闭(Shift+Esc)"
192
- aria-label="关闭"
193
- onClick={() => vscode.postMessage({ type: 'close' })}
194
- >
195
- ✕
196
- </button>
197
- </header>
198
-
199
- {view.threadError === null ? null : <div className="dshcs-warn">{view.threadError}</div>}
200
- {versionNote === null ? null : <div className="dshcs-warn">{versionNote}</div>}
201
-
202
- <main className="dshcs-log" ref={logRef} onScroll={onScroll}>
203
- {view.entries.length === 0
204
- ? <div className="dshcs-empty">在下面提问:DSH 的回答会像 DSH 界面那样显示在这里(思考折叠、正文按官方渲染)。</div>
205
- : view.entries.map((entry, index) => (
206
- <ThreadEntry key={`${entry.role}-${index}-${entry.callId ?? entry.approvalId ?? ''}`} entry={entry} />
207
- ))}
208
- </main>
209
-
210
- {view.approvals.map((item) => (
211
- <ApprovalCard
212
- key={item.id}
213
- item={item}
214
- holdMs={view.approvalHoldMs}
215
- now={now}
216
- decided={decided[item.id] ?? null}
217
- onDecide={onDecide}
218
- />
219
- ))}
220
-
221
- <div className="dshcs-status" data-status={view.status}>{status}</div>
222
-
223
- <footer className="dshcs-footer">
224
- <textarea
225
- ref={boxRef}
226
- value={draft}
227
- placeholder="问 DSH…(Enter 发送,Shift+Enter 换行)"
228
- onChange={(event) => setDraft(event.target.value)}
229
- onKeyDown={onKeyDown}
230
- autoFocus
231
- />
232
- <button type="button" disabled={sending || draft.trim() === ''} onClick={submit}>发送</button>
233
- </footer>
234
- </div>
235
- );
236
- }
237
-
238
- if (MOUNT === null || MOUNT === undefined) {
239
- console.error('[dshcs] 面板挂载点不存在(#root 或 window.__DSHCS_MOUNT__)');
240
- } else {
241
- createRoot(MOUNT).render(<App />);
242
- }