dsh-bots 0.0.1 → 0.2.10

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/lib/client.js ADDED
@@ -0,0 +1,2199 @@
1
+ "use strict";
2
+ /**
3
+ * dsh-bots — client half (web), formal-plugin runtime.
4
+ *
5
+ * Rendered by the dsh web shell through the standard slot system with full
6
+ * browser DOM access. Two rules keep the surface native rather than
7
+ * native-looking:
8
+ *
9
+ * 1. Components come from `@deepseek-ai/dsh-client-ui-primitives`, which the
10
+ * shell publishes in its static module map alongside `react`. Buttons,
11
+ * inputs, icons, state dots and the markdown renderer are therefore the
12
+ * shipped ones, not reimplementations. Every lookup degrades to a local
13
+ * fallback so a primitives reshuffle can never blank the plugin.
14
+ * 2. Our own CSS uses stable `dbs-` class names carrying the *values* read
15
+ * out of the shipped stylesheets (row heights, radii, the composer var
16
+ * family), expressed through dsh theme variables. No hashed class name is
17
+ * borrowed, so a dsh rebuild cannot break the visuals.
18
+ *
19
+ * Sidebar integration: the left-nav workspace region (`sidebar.workspaces` is
20
+ * a single slot) is shadowed at a low priority — an officially supported move,
21
+ * the shell's own error text reads "register at a different priority to shadow
22
+ * it (lowest renders)" — and re-rendered as a two-group collapsible nav:
23
+ * - 「工作区」 — delegates the ORIGINAL shipped workspace browser, with its
24
+ * child slots and its rail branch intact (see DelegatedBrowser).
25
+ * - 「Bots」 — our bot/group tree; clicking opens the chat.
26
+ *
27
+ * Chat lives in the `shell.overlay` layer but is inset to the frame's centre
28
+ * column, so the sidebar stays visible and usable while a bot conversation is
29
+ * open — matching how a native session behaves.
30
+ *
31
+ * Live data: the host keeps an SSE ring fed from the sdk-bots `/events`
32
+ * channel. ONE bus drains it here and fans channels out to subscribers; no
33
+ * component owns the cursor and no component polls the gateway directly.
34
+ * @module dsh-bots/client
35
+ */
36
+ ;
37
+ (() => {
38
+ const loader = window.__ModuleLoader__;
39
+ if (loader === undefined)
40
+ return;
41
+ loader.load({
42
+ id: 'dsh-bots',
43
+ factory: (require) => {
44
+ const module = { exports: {} };
45
+ const exports = module.exports;
46
+ const React = require('react');
47
+ const e = React.createElement;
48
+ /**
49
+ * Shipped primitives. Present in the shell's static module registry
50
+ * next to `react`; the guard keeps a missing/renamed package from
51
+ * taking the plugin down with it.
52
+ */
53
+ let NATIVE = {};
54
+ try {
55
+ NATIVE = require('@deepseek-ai/dsh-client-ui-primitives') ?? {};
56
+ }
57
+ catch {
58
+ NATIVE = {};
59
+ }
60
+ /** Render a shipped icon by export name, or nothing if it is gone. */
61
+ function Ico(name, props) {
62
+ const C = NATIVE[name];
63
+ return C === undefined ? null : e(C, props ?? {});
64
+ }
65
+ /**
66
+ * The shipped composer's send glyph. It is not a named primitive —
67
+ * the official chatbar draws this SVG inline — so we replicate the
68
+ * path byte-exact from the shipped bundle (arrow-up, currentColor).
69
+ */
70
+ function SendUpIcon() {
71
+ return e('svg', { viewBox: '0 0 16 16', width: '16', height: '16', 'aria-hidden': true }, e('path', {
72
+ d: 'M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z',
73
+ fill: 'currentColor',
74
+ }));
75
+ }
76
+ /**
77
+ * The official chatbar morphs send into a stop square while a run is
78
+ * active (§12-29); replicate that glyph byte-simple — a rounded rect,
79
+ * currentColor — so the stop affordance reads native.
80
+ */
81
+ function StopSquareIcon() {
82
+ return e('svg', { viewBox: '0 0 16 16', width: '16', height: '16', 'aria-hidden': true }, e('rect', { x: 3, y: 3, width: 10, height: 10, rx: 2.5, fill: 'currentColor' }));
83
+ }
84
+ /** Shipped component by export name, or a local stand-in. */
85
+ function nat(name, fallback) {
86
+ return NATIVE[name] ?? fallback;
87
+ }
88
+ // ---- Fallback stand-ins (only used if a primitive export disappears) ----
89
+ function FallbackButton(p) {
90
+ const { variant, size, icon, children, ...rest } = p;
91
+ return e('button', { type: 'button', ...rest }, icon ?? null, children);
92
+ }
93
+ function FallbackInput(p) {
94
+ const { icon, ...rest } = p;
95
+ return e('input', rest);
96
+ }
97
+ function FallbackText(p) {
98
+ return e('div', { className: 'dbs-plain' }, p.text);
99
+ }
100
+ /**
101
+ * Local markdown renderer for the (observed-in-the-wild) case where the
102
+ * shell's static module map serves the primitives package without
103
+ * MarkdownText: block-level fenced code / headings / hr / blockquote /
104
+ * lists / paragraphs, inline bold / italic / code / links. Built from
105
+ * createElement only — no HTML string ever crosses in. Streaming-safe:
106
+ * an unterminated fence renders as a code block running to the tail.
107
+ */
108
+ /**
109
+ * Inline image preview. `![alt](url)` and bare image URLs render as a
110
+ * clickable thumbnail (click opens the full image in a new tab); a
111
+ * failed load degrades to the browser's broken-image box inside the
112
+ * same link, so the URL is never lost. Extension-based detection plus
113
+ * an allowlist for extension-less media hosts the crew actually posts
114
+ * (Pollinations MCP tool results).
115
+ */
116
+ const IMG_EXT = /\.(png|jpe?g|gif|webp|avif|bmp|svg)(\?[^)\s]*)?$/i;
117
+ const IMG_HOSTS = new Set(['media.pollinations.ai', 'image.pollinations.ai']);
118
+ function isImageUrl(url) {
119
+ if (IMG_EXT.test(url))
120
+ return true;
121
+ try {
122
+ return IMG_HOSTS.has(new URL(url).hostname);
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
128
+ function imgLink(src, alt) {
129
+ return e('a', { href: src, target: '_blank', rel: 'noreferrer', className: 'dbs-imglink' }, e('img', { src, alt: alt !== '' ? alt : src, loading: 'lazy' }));
130
+ }
131
+ function mdInline(text) {
132
+ const out = [];
133
+ let buf = '';
134
+ let i = 0;
135
+ const push = () => { if (buf !== '') {
136
+ out.push(buf);
137
+ buf = '';
138
+ } };
139
+ while (i < text.length) {
140
+ const rest = text.slice(i);
141
+ let m;
142
+ if (rest.startsWith('`') && (m = /^`([^`\n]+)`/.exec(rest)) !== null) {
143
+ push();
144
+ out.push(e('code', null, m[1]));
145
+ i += m[0].length;
146
+ continue;
147
+ }
148
+ if (rest.startsWith('**') && (m = /^\*\*([\s\S]+?)\*\*/.exec(rest)) !== null) {
149
+ push();
150
+ out.push(e('strong', null, mdInline(m[1])));
151
+ i += m[0].length;
152
+ continue;
153
+ }
154
+ if (rest.startsWith('*') && (m = /^\*([^*\n]+)\*/.exec(rest)) !== null) {
155
+ push();
156
+ out.push(e('em', null, mdInline(m[1])));
157
+ i += m[0].length;
158
+ continue;
159
+ }
160
+ if ((m = /^!\[([^\]\n]*)\]\((https?:\/\/[^)\s]+)\)/.exec(rest)) !== null) {
161
+ push();
162
+ out.push(imgLink(m[2], m[1]));
163
+ i += m[0].length;
164
+ continue;
165
+ }
166
+ if ((m = /^\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/.exec(rest)) !== null) {
167
+ push();
168
+ out.push(e('a', { href: m[2], target: '_blank', rel: 'noreferrer' }, m[1]));
169
+ i += m[0].length;
170
+ continue;
171
+ }
172
+ if ((m = /^(https?:\/\/[^\s<>()[\]{}'"]+)/.exec(rest)) !== null) {
173
+ push();
174
+ if (isImageUrl(m[1])) {
175
+ out.push(imgLink(m[1], m[1]));
176
+ i += m[0].length;
177
+ continue;
178
+ }
179
+ out.push(e('a', { href: m[1], target: '_blank', rel: 'noreferrer' }, m[1]));
180
+ i += m[0].length;
181
+ continue;
182
+ }
183
+ buf += text[i];
184
+ i += 1;
185
+ }
186
+ push();
187
+ return out;
188
+ }
189
+ function mdBlocks(text) {
190
+ const lines = text.split('\n');
191
+ const out = [];
192
+ let i = 0;
193
+ let para = [];
194
+ const flushPara = () => { if (para.length > 0) {
195
+ out.push(e('p', null, mdInline(para.join('\n'))));
196
+ para = [];
197
+ } };
198
+ while (i < lines.length) {
199
+ const line = lines[i];
200
+ const fence = /^\s*```(\w*)\s*$/.exec(line);
201
+ if (fence !== null) {
202
+ flushPara();
203
+ const body = [];
204
+ i += 1;
205
+ while (i < lines.length && /^\s*```\s*$/.test(lines[i]) === false) {
206
+ body.push(lines[i]);
207
+ i += 1;
208
+ }
209
+ i += 1; // consume the closing fence; at EOF the block just ends open
210
+ out.push(e('pre', null, e('code', fence[1] !== '' ? { 'data-lang': fence[1] } : null, body.join('\n'))));
211
+ continue;
212
+ }
213
+ const h = /^(#{1,4})\s+(.*)$/.exec(line);
214
+ if (h !== null) {
215
+ flushPara();
216
+ out.push(e('h' + String(h[1].length), null, mdInline(h[2])));
217
+ i += 1;
218
+ continue;
219
+ }
220
+ if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) {
221
+ flushPara();
222
+ out.push(e('hr', null));
223
+ i += 1;
224
+ continue;
225
+ }
226
+ const q = /^>\s?(.*)$/.exec(line);
227
+ if (q !== null) {
228
+ flushPara();
229
+ const body = [q[1]];
230
+ i += 1;
231
+ for (; i < lines.length; i += 1) {
232
+ const m2 = /^>\s?(.*)$/.exec(lines[i]);
233
+ if (m2 === null)
234
+ break;
235
+ body.push(m2[1]);
236
+ }
237
+ out.push(e('blockquote', null, e('p', null, mdInline(body.join('\n')))));
238
+ continue;
239
+ }
240
+ const isUl = /^\s*[-*+]\s+/.test(line);
241
+ const isOl = /^\s*\d+[.)]\s+/.test(line);
242
+ if (isUl || isOl) {
243
+ flushPara();
244
+ const items = [];
245
+ for (; i < lines.length; i += 1) {
246
+ const m2 = isUl ? /^\s*[-*+]\s+(.*)$/.exec(lines[i]) : /^\s*\d+[.)]\s+(.*)$/.exec(lines[i]);
247
+ if (m2 === null)
248
+ break;
249
+ items.push(e('li', null, mdInline(m2[1])));
250
+ }
251
+ out.push(e(isUl ? 'ul' : 'ol', null, items));
252
+ continue;
253
+ }
254
+ if (line.trim() === '') {
255
+ flushPara();
256
+ i += 1;
257
+ continue;
258
+ }
259
+ para.push(line);
260
+ i += 1;
261
+ }
262
+ flushPara();
263
+ return out;
264
+ }
265
+ function MarkdownFallback(p) {
266
+ return e('div', { className: 'dbs-md' }, mdBlocks(String(p.text ?? '')));
267
+ }
268
+ const Button = nat('Button', FallbackButton);
269
+ const Input = nat('Input', FallbackInput);
270
+ const MarkdownText = nat('MarkdownText', MarkdownFallback);
271
+ const MessageText = nat('MessageText', FallbackText);
272
+ const StateDot = NATIVE.StateDot ?? null;
273
+ // ---- CSS: own stable class names, values mirrored from the shell ----
274
+ const CSS = `
275
+ .dbs-nav{flex:1;min-height:0;display:flex;flex-direction:column;gap:2px;font-family:var(--dsw-font-family,inherit)}
276
+ .dbs-navGroup{display:flex;flex-direction:column;min-height:0}
277
+ .dbs-navGroup[data-open="true"]{flex:1 1 auto}
278
+ .dbs-navGroup[data-open="false"]{flex:none}
279
+ .dbs-navBody{display:flex;flex-direction:column;min-height:0;flex:1}
280
+ .dbs-botsBody{overflow-y:auto;scrollbar-gutter:stable;padding-right:var(--dsh-sidebar-inline-padding,8px)}
281
+ .dbs-navBodyErr{padding:6px 12px;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary)}
282
+ .dbs-prow,.dbs-srow{cursor:pointer;user-select:none;color:var(--dsw-alias-label-primary);border-radius:8px;align-items:center;gap:6px;padding:0 8px;display:flex;box-sizing:border-box}
283
+ /* Native mirror (dsh-client-ui-sidebar/workspace): header-class rows outdent
284
+ their label 4px (pl:4 → x=16 from the window edge) while list rows keep
285
+ pl:8 (→ x=20, identical to the native sessionRow). */
286
+ .dbs-prow{padding-left:4px}
287
+ .dbs-prow:hover,.dbs-srow:hover{background:var(--dsw-alias-interactive-bg-hover)}
288
+ .dbs-srow.dbs-selected{background:var(--dsw-alias-interactive-bg-hover)}
289
+ .dbs-prow{height:34px}
290
+ .dbs-srow{height:32px;gap:0}
291
+ .dbs-slot{width:16px;height:20px;color:var(--dsw-alias-label-tertiary);flex:none;justify-content:center;align-items:center;display:inline-flex}
292
+ .dbs-chevron{color:var(--dsw-alias-label-caption);display:inline-flex}
293
+ .dbs-arrow{transition:transform .15s var(--ds-ease-in-out)}
294
+ .dbs-arrowOpen{transform:rotate(90deg)}
295
+ .dbs-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:14px;line-height:20px;overflow:hidden;flex:1;margin:0 6px 0 4px}
296
+ .dbs-prow .dbs-title{font-weight:500}
297
+ .dbs-time{color:var(--dsw-alias-label-tertiary);flex:none;font-size:12px;line-height:20px}
298
+ .dbs-meta{text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:20px;overflow:hidden}
299
+ .dbs-rowActions{flex:none;align-items:center;gap:4px;display:none}
300
+ .dbs-prow:hover .dbs-rowActions,.dbs-srow:hover .dbs-rowActions{display:inline-flex}
301
+ .dbs-prow:hover .dbs-hideOnHover,.dbs-srow:hover .dbs-hideOnHover{display:none}
302
+ .dbs-avatar{width:20px;height:20px;border-radius:6px;flex:none;display:grid;place-items:center;font-size:11px;line-height:1;font-weight:600;color:#fff;overflow:hidden;user-select:none}
303
+ .dbs-avatar.dbs-group{border-radius:999px}
304
+ .dbs-avatar img{width:100%;height:100%;object-fit:cover;display:block}
305
+ .dbs-badge{min-width:16px;height:16px;padding:0 5px;border-radius:999px;background:var(--dsw-alias-state-business-primary,#1a6dff);color:#fff;font-size:11px;line-height:16px;text-align:center;flex:none;font-variant-numeric:tabular-nums}
306
+ .dbs-railBtn{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0}
307
+ .dbs-railBtn:hover{background:var(--dsw-alias-interactive-bg-hover)}
308
+ .dbs-railBtn[data-active="true"]{color:var(--dsw-alias-state-business-primary)}
309
+ .dbs-rail{display:flex;flex-direction:column;align-items:center;gap:2px}
310
+ .dbs-railWrap{display:flex;flex-direction:column;min-height:0;flex:1;gap:2px}
311
+ .dbs-form{display:flex;flex-direction:column;gap:6px;padding:8px;border-radius:8px;margin:2px 0;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.2))}
312
+ .dbs-formRow{display:flex;gap:6px;align-items:center}
313
+ .dbs-members{display:flex;flex-direction:column;gap:2px;max-height:150px;overflow-y:auto}
314
+ .dbs-member{display:flex;align-items:center;gap:6px;font-size:13px;line-height:20px;padding:3px 6px;border-radius:6px;color:var(--dsw-alias-label-primary);cursor:pointer}
315
+ .dbs-member:hover{background:var(--dsw-alias-interactive-bg-hover)}
316
+ .dbs-member.checked{color:var(--dsw-alias-state-business-primary,#1a6dff)}
317
+ .dbs-error{margin:4px 8px;padding:5px 9px;border-radius:8px;font-size:12px;line-height:18px;cursor:pointer;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-state-error-primary,#f85149)}
318
+
319
+ .dbs-chatview{position:absolute;top:0;bottom:0;pointer-events:auto;display:flex;flex-direction:column;background:var(--dsw-alias-bg-base);font-family:var(--dsw-font-family,inherit);z-index:2;--dsh-chat-content-width:748px;--dsh-composer-card-max-width:calc(var(--dsh-chat-content-width) + 32px);--dsh-composer-side-clearance:16px;--dsh-composer-dock-inset:8px;--dsh-composer-text-max-height:336px;min-width:0}
320
+ .dbs-chatbar{flex:none;display:flex;align-items:center;gap:8px;height:44px;padding:0 12px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.08))}
321
+ .dbs-chatbarName{font-size:14px;line-height:20px;font-weight:600;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
322
+ .dbs-scrollBody{scrollbar-gutter:stable;flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden auto}
323
+ .dbs-scroll{min-height:0;padding:16px calc(var(--dsh-composer-side-clearance) + 16px);flex:auto}
324
+ .dbs-column{max-width:var(--dsh-chat-content-width);flex-direction:column;gap:16px;width:100%;margin:0 auto;display:flex}
325
+ .dbs-userRow{flex-direction:column;align-items:flex-end;gap:6px;display:flex}
326
+ .dbs-userStack{flex-direction:column;align-items:flex-end;gap:8px;min-width:0;max-width:min(525px,82%);display:flex}
327
+ .dbs-bubble{background:var(--dsw-specific-bubble);max-width:100%;color:var(--dsw-alias-label-primary);border-radius:22px;padding:10px 16px;font-size:16px;line-height:24px}
328
+ .dbs-botRow{color:var(--dsw-alias-label-primary);flex-direction:column;font-size:16px;line-height:28px;display:flex;align-items:flex-start;gap:4px;width:100%}
329
+ .dbs-author{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);display:flex;align-items:center;gap:6px;font-weight:600;margin-bottom:2px;flex-wrap:wrap}
330
+ .dbs-authorName{font-weight:600;letter-spacing:.2px}
331
+ .dbs-md{min-width:0}
332
+ .dbs-md p{margin:0 0 8px}
333
+ .dbs-md p:last-child{margin-bottom:0}
334
+ .dbs-md h1,.dbs-md h2,.dbs-md h3,.dbs-md h4{margin:12px 0 6px;line-height:1.35;font-weight:600}
335
+ .dbs-md h1{font-size:20px}.dbs-md h2{font-size:18px}.dbs-md h3{font-size:16px}.dbs-md h4{font-size:15px}
336
+ .dbs-md ul,.dbs-md ol{margin:0 0 8px;padding-left:22px}
337
+ .dbs-md li{margin:2px 0}
338
+ .dbs-md code{font-family:var(--dsw-font-family-mono,ui-monospace,monospace);font-size:.9em;background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06));border-radius:6px;padding:1px 5px}
339
+ .dbs-md pre{margin:0 0 8px;background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06));border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));border-radius:10px;padding:10px 12px;overflow-x:auto}
340
+ .dbs-md .dbs-imglink{display:inline-block;max-width:min(320px,100%);border-radius:10px;overflow:hidden;margin:4px 0;box-shadow:0 1px 4px rgba(0,0,0,.25);cursor:zoom-in}
341
+ .dbs-md .dbs-imglink img{display:block;width:100%;height:auto}
342
+ .dbs-md pre code{background:transparent;padding:0;font-size:.9em}
343
+ .dbs-md blockquote{margin:0 0 8px;padding:2px 0 2px 10px;border-left:3px solid var(--dsw-alias-border-l2,rgba(0,0,0,.2));color:var(--dsw-alias-label-secondary)}
344
+ .dbs-md a{color:var(--dsw-alias-state-business-primary,#1a6dff)}
345
+ .dbs-md hr{border:none;border-top:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.15));margin:10px 0}
346
+ .dbs-plain{white-space:pre-wrap;word-break:break-word}
347
+ .dbs-toolCard{border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));background:var(--dsw-specific-bubble);border-radius:12px;padding:8px 12px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);width:100%;box-sizing:border-box}
348
+ .dbs-toolHdr{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}
349
+ .dbs-toolName{font:var(--dsw-font-markdown-code-block-small,inherit);font-size:13px}
350
+ .dbs-toolBody{margin-top:4px;white-space:pre-wrap;word-break:break-word;max-height:190px;overflow:auto;color:var(--dsw-alias-label-tertiary)}
351
+ .dbs-thinking{color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:22px;white-space:pre-wrap;word-break:break-word;border-left:2px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));padding-left:10px}
352
+ .dbs-msgTime{flex:none;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;user-select:none;white-space:nowrap}
353
+ .dbs-userRow .dbs-msgTime,.dbs-botRow .dbs-msgTime{padding:0 4px}
354
+ .dbs-toolTrail{margin-left:auto;flex:none;display:inline-flex;align-items:center;gap:8px}
355
+ .dbs-dayDivider{display:flex;align-items:center;justify-content:center}
356
+ .dbs-dayDivider span{padding:2px 10px;border-radius:999px;background:var(--dsw-specific-bubble);color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;user-select:none}
357
+ .dbs-turnStatus{height:26px;font-size:14px;font-weight:600;white-space:nowrap;background:linear-gradient(90deg,var(--dsw-static-deepseek-500,#4d6bfe) 0%,var(--dsw-static-deepseek-500,#4d6bfe) 40%,var(--dsw-static-deepseek-200,#b6c2ff) 50%,var(--dsw-static-deepseek-500,#4d6bfe) 60%,var(--dsw-static-deepseek-500,#4d6bfe) 100%);color:#0000;-webkit-text-fill-color:transparent;background-position:100% 0;background-size:250% 100%;-webkit-background-clip:text;background-clip:text;flex:none;align-self:flex-start;align-items:center;animation:1.8s linear infinite dbs-turn-status-shimmer;display:inline-flex}
358
+ @keyframes dbs-turn-status-shimmer{to{background-position:0 0}}
359
+ @media (prefers-reduced-motion:reduce){.dbs-turnStatus{background-position:0 0;background-size:100% 100%;animation:none}.dbs-arrow{transition:none}}
360
+ .dbs-composerSeat{flex:none;display:flex;flex-direction:column;z-index:7;background:linear-gradient(180deg,color-mix(in srgb,var(--dsw-alias-bg-base) 0%,transparent) 0px,var(--dsw-alias-bg-base) 36px)}
361
+ .dbs-composer{padding:0 var(--dsh-composer-side-clearance) 8px;flex-direction:column;align-items:center;display:flex}
362
+ .dbs-composerCard{cursor:text;box-sizing:border-box;width:100%;max-width:var(--dsh-composer-card-max-width);border:1px solid var(--dsw-alias-border-l2-darkmode-thin,rgba(0,0,0,.12));background:var(--dsw-specific-input-major);box-shadow:var(--dsw-shadow-lv2);border-radius:22px;flex-direction:column;gap:12px;padding-top:10px;font-size:16px;line-height:24px;display:flex;position:relative}
363
+ .dbs-composerScroll{max-height:var(--dsh-composer-text-max-height);overflow-y:auto}
364
+ .dbs-composerRow{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:12px;min-width:0;padding:2px 8px 6px;display:flex}
365
+ .dbs-composerTrailing{align-items:center;min-width:0;display:flex;flex:none;gap:8px;margin-left:auto}
366
+ .dbs-composerInput{resize:none;width:100%;box-sizing:border-box;border:none;outline:none;background:transparent;font-family:var(--dsw-font-family);font-size:16px;line-height:24px;white-space:pre-wrap;word-break:break-word;padding:4px 12px 0 16px;min-height:52px;color:var(--dsw-alias-label-primary)}
367
+ .dbs-composerInput::placeholder{color:var(--dsw-alias-label-caption);user-select:none}
368
+ .dbs-send{background:var(--dsw-alias-button-info-fill,#1a6dff);color:#fff;cursor:pointer;border:none;border-radius:999px;flex:none;place-items:center;width:34px;height:34px;transition:background-color .1s;display:grid;transform:translateY(-2px)}
369
+ .dbs-send:disabled{opacity:.4;cursor:default}
370
+ .dbs-modalBackdrop{position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;padding:24px;pointer-events:auto}
371
+ .dbs-modalCard{position:relative;width:min(440px,92vw);max-height:88vh;overflow-y:auto;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:14px;padding:18px 20px 22px;box-shadow:0 24px 80px rgba(0,0,0,.3);animation:dbs-modal-in .18s ease-out}
372
+ @keyframes dbs-modal-in{from{opacity:0;transform:translateY(6px) scale(.985)}to{opacity:1;transform:none}}
373
+ .dbs-modalTitleRow{display:flex;align-items:center;gap:8px;margin-bottom:14px}
374
+ .dbs-modalTitle{font-size:16px;line-height:24px;font-weight:600;color:var(--dsw-alias-label-primary);flex:1;min-width:0}
375
+ .dbs-modalBody{display:flex;flex-direction:column;gap:10px}
376
+ .dbs-modalMembers{display:flex;flex-direction:column;gap:2px;max-height:220px;overflow-y:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:6px}
377
+ .dbs-modalFooter{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-top:16px}
378
+ .dbs-rowDel{display:none;border:none;background:transparent;cursor:pointer;color:var(--dsw-alias-label-tertiary);flex:none;justify-content:center;align-items:center;width:24px;height:24px;border-radius:6px;padding:0}
379
+ .dbs-rowDel:hover{color:#f85149;background:rgba(248,81,73,.1)}
380
+ .dbs-srow:hover .dbs-rowDel,.dbs-rowDel:focus-visible{display:inline-flex}
381
+ .dbs-mention{position:absolute;bottom:calc(100% + 6px);left:12px;right:12px;max-height:180px;overflow-y:auto;background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:12px;box-shadow:var(--dsw-shadow-lv2);padding:4px;z-index:3}
382
+ .dbs-mentionRow{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:8px;cursor:pointer;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}
383
+ .dbs-mentionRow[data-active="true"],.dbs-mentionRow:hover{background:var(--dsw-alias-interactive-bg-hover)}
384
+ .dbs-empty{color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:22px;text-align:center;padding:32px 0}
385
+ .dbs-scrollArea{position:relative;flex:1;min-height:0;display:flex;flex-direction:column}
386
+ .dbs-scrollBody::-webkit-scrollbar{width:10px}
387
+ .dbs-scrollBody::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2,rgba(0,0,0,.18));border-radius:5px;border:3px solid transparent;background-clip:content-box}
388
+ .dbs-scrollBody::-webkit-scrollbar-thumb:hover{background:var(--dsw-alias-label-caption,var(--dsw-alias-border-l2,rgba(0,0,0,.25)));border:3px solid transparent;background-clip:content-box}
389
+ .dbs-scrollBody::-webkit-scrollbar-track{background:transparent}
390
+ .dbs-jump{position:absolute;right:20px;bottom:12px;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));background:var(--dsw-specific-input-major,var(--dsw-alias-bg-layer-1,#fff));box-shadow:var(--dsw-shadow-lv2);color:var(--dsw-alias-label-secondary);cursor:pointer;z-index:5;transition:opacity .15s ease,transform .15s ease}
391
+ .dbs-jump:hover{color:var(--dsw-alias-label-primary);transform:translateY(-1px)}
392
+ .dbs-jump[data-show="false"]{opacity:0;pointer-events:none;transform:translateY(4px)}
393
+ .dbs-typing{display:inline-flex;align-items:center;gap:4px;padding:6px 2px}
394
+ .dbs-typing span{width:6px;height:6px;border-radius:999px;background:var(--dsw-alias-label-tertiary);animation:dbsTyping 1.2s ease-in-out infinite}
395
+ .dbs-typing span:nth-child(2){animation-delay:.15s}
396
+ .dbs-typing span:nth-child(3){animation-delay:.3s}
397
+ @keyframes dbsTyping{0%,60%,100%{opacity:.25;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}
398
+ .dbs-mdRow{display:flex;align-items:flex-start;gap:0;min-width:0;width:100%}
399
+ .dbs-caret{flex:none;display:inline-block;width:3px;height:18px;margin-top:5px;border-radius:2px;background:var(--dsw-alias-label-primary);animation:dbsCaret 1s steps(2) infinite}
400
+ @keyframes dbsCaret{0%,49%{opacity:1}50%,100%{opacity:0}}
401
+ .dbs-botRow[data-compact="true"]{padding-left:26px}
402
+ .dbs-compactTime{display:flex;justify-content:flex-end;width:100%}
403
+ .dbs-compactTime .dbs-msgTime{padding:0}
404
+ .dbs-welcome{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:6px;padding:64px 24px 32px;min-height:50%}
405
+ .dbs-welcomeName{font-size:18px;line-height:26px;font-weight:600;color:var(--dsw-alias-label-primary)}
406
+ .dbs-welcomeDesc{font-size:14px;line-height:22px;color:var(--dsw-alias-label-secondary);max-width:420px}
407
+ .dbs-welcomeHint{font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary);margin-top:12px}
408
+ .dbs-skeleton{display:flex;flex-direction:column;gap:18px;padding:24px 0;max-width:560px}
409
+ .dbs-skelRow{height:16px;border-radius:8px;background:linear-gradient(90deg,var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06)) 25%,var(--dsw-alias-border-l1,rgba(0,0,0,.08)) 50%,var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06)) 75%);background-size:200% 100%;animation:dbsShimmer 1.4s ease-in-out infinite}
410
+ @keyframes dbsShimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
411
+ .dbs-composerCard:focus-within{border-color:var(--dsw-alias-state-business-primary,#1a6dff)}
412
+
413
+ .dbs-settings{padding:4px 0 24px;max-width:640px;font-family:var(--dsw-font-family,inherit)}
414
+ .dbs-setcard{border:1px solid var(--dsw-alias-border-l,rgba(0,0,0,.12));border-radius:12px;padding:14px 16px;margin-top:14px}
415
+ .dbs-setrow{display:flex;align-items:center;gap:8px;font-size:13px;line-height:22px;color:var(--dsw-alias-label-secondary);padding:3px 0}
416
+ .dbs-setrow b{color:var(--dsw-alias-label-primary);font-weight:600;word-break:break-all}
417
+ .dbs-sethead{display:flex;align-items:center;gap:8px;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);font-weight:600}
418
+ `;
419
+ // =========================================================
420
+ // Locale.
421
+ //
422
+ // Registered into the shell's own locale runtime under the `bots`
423
+ // namespace, so language follows the app-wide preference instead of
424
+ // stranding this plugin in one language. `zh` is the key-set source of
425
+ // truth; `en` mirrors it exactly. Wire error strings from the gateway
426
+ // pass through untranslated, matching how the shipped packages treat
427
+ // runtime failure text.
428
+ // =========================================================
429
+ const NS = 'bots';
430
+ const zh = {
431
+ 'nav.workspaces': '工作区',
432
+ 'nav.bots': 'Bots',
433
+ 'nav.bots.aria': 'Bots',
434
+ 'nav.bots.unread': 'Bots({n} 条未读)',
435
+ 'delegate.loading': '加载中…',
436
+ 'delegate.unavailable': '工作区视图不可用,请刷新页面。',
437
+ 'delegate.failed': '工作区视图加载失败,Bots 不受影响。',
438
+ 'gateway.online': '网关在线 :{port}',
439
+ 'gateway.offline': '网关未连接',
440
+ 'gateway.offlineHint': '网关未连接,详见设置页。',
441
+ 'bot.new': '新建 Bot',
442
+ 'group.new': '新建群聊',
443
+ 'bot.namePlaceholder': 'Bot 名称',
444
+ 'group.namePlaceholder': '群聊名称',
445
+ 'bot.descPlaceholder': '简介 / 人设(可选)',
446
+ 'action.create': '创建',
447
+ 'action.cancel': '取消',
448
+ 'action.close': '关闭',
449
+ 'action.save': '保存',
450
+ 'action.saving': '保存中…',
451
+ 'action.stop': '停止生成',
452
+ 'chat.stop.noop': '当前没有进行中的生成',
453
+ 'chat.members.manage': '管理成员',
454
+ 'modal.members.title': '管理群成员',
455
+ 'modal.members.hint': '勾选的 Bot 为群成员;保存后立即生效(可随时再改)。',
456
+ 'action.delete': '删除',
457
+ 'delete.title.bot': '删除 Bot',
458
+ 'delete.title.group': '删除群聊',
459
+ 'delete.confirm': '将删除「{name}」及其全部会话记录,此操作不可恢复。',
460
+ 'delete.working': '删除中…',
461
+ 'action.send': '发送',
462
+ 'action.refresh': '刷新',
463
+ 'action.expand': '展开',
464
+ 'action.collapse': '收起',
465
+ 'list.loading': '加载中…',
466
+ 'list.empty': '还没有 Bot,点 + 新建。',
467
+ 'section.groups': '群聊',
468
+ 'section.singles': '单聊',
469
+ 'chat.group': '群聊 · {n} 名成员',
470
+ 'chat.single': '单聊',
471
+ 'chat.loading': '加载中…',
472
+ 'chat.empty.single': '给 {name} 发第一条消息,开始你们的对话',
473
+ 'chat.empty.group': '在群里说点什么,成员们会接龙回复',
474
+ 'chat.jump': '回到底部',
475
+ 'chat.composing': '生成中',
476
+ 'chat.composingHint': '正在生成,请稍候',
477
+ 'chat.placeholder.group': '@名字 可定向,默认全员',
478
+ 'chat.placeholder.single': '给 {name} 发消息…',
479
+ 'chat.charCount': '{n} 字',
480
+ 'time.today': '今天',
481
+ 'time.yesterday': '昨天',
482
+ 'time.date': '{m} 月 {d} 日',
483
+ 'time.dateFull': '{y} 年 {m} 月 {d} 日',
484
+ 'tool.fallbackName': '工具',
485
+ 'settings.summary': '多 Bot 工作台,桥接 sdk-bots 编排网关。',
486
+ 'settings.probing': '检测中…',
487
+ 'settings.address': '地址:',
488
+ 'settings.pid': 'PID:',
489
+ 'settings.busy': '忙',
490
+ 'settings.idle': '闲',
491
+ 'settings.auth': '鉴权:',
492
+ 'settings.auth.token': 'token(自动携带)',
493
+ 'settings.auth.none': '无(loopback 免鉴权)',
494
+ 'settings.reason': '原因:',
495
+ 'settings.reason.unknown': '未知',
496
+ 'settings.dataDir': '数据目录:',
497
+ 'settings.reading': '读取中…',
498
+ 'settings.events': '实时事件:',
499
+ 'settings.events.on': '已连接 · 缓冲 {n} 条',
500
+ 'settings.events.off': '未连接',
501
+ 'settings.entry': '入口:',
502
+ 'settings.entry.value': '左侧边栏「工作区 | Bots」折叠导航',
503
+ 'settings.workspaceRoot': '工作区根目录:',
504
+ 'settings.jail.title': 'Bot 工作区隔离',
505
+ 'settings.jail.summary': '开启后,该 Bot 的每条 Shell 命令被 macOS Seatbelt 包裹:只允许写入自己的工作目录(工作区根/<名字>)与系统临时目录,越界写入被内核拒绝;读取不受限(共享黑板仍可读)。下次对话生效,无需重启。注意:同一名字的多个 Bot 会共享同一目录。',
506
+ 'settings.jail.count': '已隔离:',
507
+ 'settings.jail.on': '开启隔离',
508
+ 'settings.jail.off': '解除',
509
+ 'settings.jail.offConfirm': '确认解除?',
510
+ 'settings.mcp': 'MCP 服务器',
511
+ 'settings.mcp.summary': 'Bot 的工具扩展总线:服务器由 sdk-bots 引擎托管,所有 Bot 共享。',
512
+ 'settings.mcp.empty': '未安装任何 MCP 服务器',
513
+ 'settings.mcp.tools': '可用工具:',
514
+ 'settings.mcp.add': '添加服务器',
515
+ 'settings.mcp.adding': '添加中…',
516
+ 'settings.mcp.namePlaceholder': '名称,例如 github',
517
+ 'settings.mcp.configPlaceholder': '{"command": "npx", "args": ["-y", "server包"]} 或 {"url": "https://…/mcp"}',
518
+ 'settings.mcp.exampleStdio': '填入本地 stdio 示例',
519
+ 'settings.mcp.exampleUrl': '填入远程 URL 示例',
520
+ 'settings.mcp.exampleStdioValue': '{"command": "node", "args": ["/path/to/server.mjs"]}',
521
+ 'settings.mcp.exampleUrlValue': '{"url": "https://example.com/mcp", "headers": {"Authorization": "Bearer <token>"}}',
522
+ 'settings.mcp.remove': '删除',
523
+ 'settings.mcp.removeConfirm': '确认删除?',
524
+ 'settings.mcp.restart': '重启连接',
525
+ 'settings.mcp.restarting': '重启中…',
526
+ 'settings.mcp.failed': '操作失败:',
527
+ 'error.noConnection': '连接服务尚未就绪',
528
+ 'error.callFailed': '调用失败',
529
+ 'error.badResponse': '意外的 RPC 响应',
530
+ };
531
+ const en = {
532
+ 'nav.workspaces': 'Workspaces',
533
+ 'nav.bots': 'Bots',
534
+ 'nav.bots.aria': 'Bots',
535
+ 'nav.bots.unread': 'Bots ({n} unread)',
536
+ 'delegate.loading': 'Loading…',
537
+ 'delegate.unavailable': 'Workspace view unavailable — reload the page.',
538
+ 'delegate.failed': 'Workspace view failed to render; Bots is unaffected.',
539
+ 'gateway.online': 'Gateway online :{port}',
540
+ 'gateway.offline': 'Gateway not connected',
541
+ 'gateway.offlineHint': 'Gateway not connected — see the settings page.',
542
+ 'bot.new': 'New bot',
543
+ 'group.new': 'New group chat',
544
+ 'bot.namePlaceholder': 'Bot name',
545
+ 'group.namePlaceholder': 'Group name',
546
+ 'bot.descPlaceholder': 'Description / persona (optional)',
547
+ 'action.create': 'Create',
548
+ 'action.cancel': 'Cancel',
549
+ 'action.close': 'Close',
550
+ 'action.save': 'Save',
551
+ 'action.saving': 'Saving…',
552
+ 'action.stop': 'Stop generating',
553
+ 'chat.stop.noop': 'No generation in progress',
554
+ 'chat.members.manage': 'Manage members',
555
+ 'modal.members.title': 'Manage group members',
556
+ 'modal.members.hint': 'Checked bots are members; changes apply immediately on save (editable again anytime).',
557
+ 'action.delete': 'Delete',
558
+ 'delete.title.bot': 'Delete bot',
559
+ 'delete.title.group': 'Delete group chat',
560
+ 'delete.confirm': 'This permanently deletes "{name}" and its transcript.',
561
+ 'delete.working': 'Deleting…',
562
+ 'action.send': 'Send',
563
+ 'action.refresh': 'Refresh',
564
+ 'action.expand': 'Expand',
565
+ 'action.collapse': 'Collapse',
566
+ 'list.loading': 'Loading…',
567
+ 'list.empty': 'No bots yet — use + to create one.',
568
+ 'section.groups': 'Groups',
569
+ 'section.singles': 'Direct',
570
+ 'chat.group': 'Group · {n} members',
571
+ 'chat.single': 'Direct',
572
+ 'chat.loading': 'Loading…',
573
+ 'chat.empty.single': 'Send the first message to {name} and start the conversation.',
574
+ 'chat.empty.group': 'Say something in the room — members will pick it up.',
575
+ 'chat.jump': 'Jump to latest',
576
+ 'chat.composing': 'Generating',
577
+ 'chat.composingHint': 'Generating, please wait',
578
+ 'chat.placeholder.group': 'Use @name to direct a turn; everyone by default',
579
+ 'chat.placeholder.single': 'Message {name}…',
580
+ 'chat.charCount': '{n} chars',
581
+ 'time.today': 'Today',
582
+ 'time.yesterday': 'Yesterday',
583
+ 'time.date': '{m}/{d}',
584
+ 'time.dateFull': '{y}/{m}/{d}',
585
+ 'tool.fallbackName': 'Tool',
586
+ 'settings.summary': 'Multi-bot workbench, bridged to the sdk-bots orchestration gateway.',
587
+ 'settings.probing': 'Probing…',
588
+ 'settings.address': 'Address: ',
589
+ 'settings.pid': 'PID: ',
590
+ 'settings.busy': 'busy',
591
+ 'settings.idle': 'idle',
592
+ 'settings.auth': 'Auth: ',
593
+ 'settings.auth.token': 'token (sent automatically)',
594
+ 'settings.auth.none': 'none (loopback, unauthenticated)',
595
+ 'settings.reason': 'Reason: ',
596
+ 'settings.reason.unknown': 'unknown',
597
+ 'settings.dataDir': 'Data directory: ',
598
+ 'settings.reading': 'reading…',
599
+ 'settings.events': 'Live events: ',
600
+ 'settings.events.on': 'connected · {n} buffered',
601
+ 'settings.events.off': 'not connected',
602
+ 'settings.entry': 'Entry point: ',
603
+ 'settings.entry.value': 'Sidebar “Workspaces | Bots” collapsible nav',
604
+ 'settings.workspaceRoot': 'Workspace root: ',
605
+ 'settings.jail.title': 'Bot workspace jail',
606
+ 'settings.jail.summary': 'When enabled, every shell command of that bot is wrapped in a macOS Seatbelt profile: writes are confined to its own workspace directory (workspace root /<name>) and the OS temp dir — out-of-bounds writes are denied by the kernel. Reads stay unrestricted (shared blackboards remain readable). Takes effect on the bot’s next turn, no restart. Note: bots sharing a name share one directory.',
607
+ 'settings.jail.count': 'Jailed: ',
608
+ 'settings.jail.on': 'Enable jail',
609
+ 'settings.jail.off': 'Remove',
610
+ 'settings.jail.offConfirm': 'Confirm removal?',
611
+ 'settings.mcp': 'MCP servers',
612
+ 'settings.mcp.summary': 'The tool-expansion bus for bots: servers are hosted by the sdk-bots engine and shared by every bot.',
613
+ 'settings.mcp.empty': 'No MCP servers installed',
614
+ 'settings.mcp.tools': 'Available tools: ',
615
+ 'settings.mcp.add': 'Add server',
616
+ 'settings.mcp.adding': 'Adding…',
617
+ 'settings.mcp.namePlaceholder': 'Name, e.g. github',
618
+ 'settings.mcp.configPlaceholder': '{"command": "npx", "args": ["-y", "package"]} or {"url": "https://…/mcp"}',
619
+ 'settings.mcp.exampleStdio': 'Fill stdio example',
620
+ 'settings.mcp.exampleUrl': 'Fill URL example',
621
+ 'settings.mcp.exampleStdioValue': '{"command": "node", "args": ["/path/to/server.mjs"]}',
622
+ 'settings.mcp.exampleUrlValue': '{"url": "https://example.com/mcp", "headers": {"Authorization": "Bearer <token>"}}',
623
+ 'settings.mcp.remove': 'Remove',
624
+ 'settings.mcp.removeConfirm': 'Confirm removal?',
625
+ 'settings.mcp.restart': 'Restart connections',
626
+ 'settings.mcp.restarting': 'Restarting…',
627
+ 'settings.mcp.failed': 'Operation failed: ',
628
+ 'error.noConnection': 'Connection service is not ready yet',
629
+ 'error.callFailed': 'Call failed',
630
+ 'error.badResponse': 'Unexpected RPC response',
631
+ };
632
+ /**
633
+ * Namespace-bound translator.
634
+ *
635
+ * `locale.bind` returns a stable function that resolves against whatever
636
+ * locale is active at call time, so this can live at module scope; a
637
+ * switch re-renders through the subscription installed in `apply`.
638
+ * Falls back to the zh dictionary (then the key) if the locale service
639
+ * is unavailable, so no surface ever renders a bare key.
640
+ */
641
+ let boundT = null;
642
+ function t(key, params) {
643
+ if (boundT !== null)
644
+ return boundT(key, params);
645
+ const template = zh[key] ?? key;
646
+ if (params === undefined)
647
+ return template;
648
+ return template.replace(/\{(\w+)\}/g, (m, name) => (name in params ? String(params[name]) : m));
649
+ }
650
+ // =========================================================
651
+ // Module-level services and state.
652
+ // =========================================================
653
+ let ctx = null;
654
+ let connectionSvc = null;
655
+ let slotsSvc = null;
656
+ /** Cadence for draining the host's in-memory SSE ring (no gateway hop). */
657
+ const RING_DRAIN_MS = 1000;
658
+ async function botsCall(method, request) {
659
+ if (connectionSvc === null)
660
+ throw new Error(t('error.noConnection'));
661
+ const envelope = await connectionSvc.rpc.call('/api', 'bots/' + method, {
662
+ args: { request: request === undefined ? null : request },
663
+ });
664
+ if (envelope !== null && typeof envelope === 'object' && envelope.ok === false) {
665
+ throw new Error(envelope.error?.message ?? t('error.callFailed'));
666
+ }
667
+ if (envelope !== null && typeof envelope === 'object' && envelope.ok === true)
668
+ return envelope.value;
669
+ throw new Error(t('error.badResponse'));
670
+ }
671
+ /**
672
+ * The single reader of the host's event ring.
673
+ *
674
+ * The cursor lives here and nowhere else. Components subscribe and get
675
+ * told which channels moved; they never call `eventsSince` themselves,
676
+ * so two consumers can no longer race for the same increment (the old
677
+ * design had a module-global cursor that whoever polled first would
678
+ * advance, silently starving everyone else).
679
+ */
680
+ const ring = (() => {
681
+ let seq = -1;
682
+ let timer = null;
683
+ let live = null;
684
+ /** Latest host-side unread snapshot (see `unread.ts` on the host). */
685
+ let unread = null;
686
+ const subs = new Set();
687
+ async function drain() {
688
+ let r;
689
+ try {
690
+ r = await botsCall('eventsSince', { seq });
691
+ }
692
+ catch {
693
+ return; /* gateway offline; the next tick retries */
694
+ }
695
+ if (r === null || typeof r !== 'object')
696
+ return;
697
+ if (Number.isFinite(r.nextSeq))
698
+ seq = Number(r.nextSeq);
699
+ if (r.state !== undefined)
700
+ live = r.state;
701
+ if (r.unread !== null && typeof r.unread === 'object')
702
+ unread = r.unread;
703
+ const events = Array.isArray(r.events) ? r.events : [];
704
+ if (events.length === 0)
705
+ return;
706
+ const channels = new Set();
707
+ for (const ev of events)
708
+ if (ev !== null && typeof ev?.channel === 'string')
709
+ channels.add(ev.channel);
710
+ for (const fn of [...subs]) {
711
+ try {
712
+ fn(channels, events);
713
+ }
714
+ catch { /* one bad subscriber must not stop the rest */ }
715
+ }
716
+ }
717
+ return {
718
+ subscribe(fn) {
719
+ subs.add(fn);
720
+ if (timer === null) {
721
+ timer = setInterval(() => { void drain(); }, RING_DRAIN_MS);
722
+ void drain();
723
+ }
724
+ return () => {
725
+ subs.delete(fn);
726
+ if (subs.size === 0 && timer !== null) {
727
+ clearInterval(timer);
728
+ timer = null;
729
+ }
730
+ };
731
+ },
732
+ state: () => live,
733
+ unread: () => unread,
734
+ };
735
+ })();
736
+ /** Shared UI state; every mutation goes through `patch`. */
737
+ const state = {
738
+ agents: [],
739
+ agentsLoaded: false,
740
+ info: undefined,
741
+ error: null,
742
+ chatAgentId: null,
743
+ open: { workspaces: true, bots: true },
744
+ /** Host-computed unread counts per agent id (plugin-owned model). */
745
+ unreadCounts: {},
746
+ /** Create dialog: 'bot' | 'group' | null — rendered as a system-style
747
+ * modal from shell.overlay, so the form state lives in the store. */
748
+ create: null,
749
+ createName: '',
750
+ createDesc: '',
751
+ createMembers: {},
752
+ createWorking: false,
753
+ /** Delete confirmation: { id, name, isGroup } | null (system modal). */
754
+ confirmDelete: null,
755
+ /** Manage-members dialog: group agentId | null (system modal). */
756
+ manageMembers: null,
757
+ /** Bumped on a language switch so module-scope `t` output re-renders. */
758
+ localeRev: 0,
759
+ };
760
+ const stateSubs = new Set();
761
+ function patch(next) {
762
+ Object.assign(state, next);
763
+ for (const fn of [...stateSubs])
764
+ fn();
765
+ }
766
+ function useStore() {
767
+ const [, force] = React.useState(0);
768
+ React.useEffect(() => {
769
+ const fn = () => force((n) => n + 1);
770
+ stateSubs.add(fn);
771
+ return () => { stateSubs.delete(fn); };
772
+ }, []);
773
+ return state;
774
+ }
775
+ async function refreshAgents() {
776
+ try {
777
+ const list = await botsCall('list');
778
+ patch({ agents: Array.isArray(list) ? list : [], agentsLoaded: true, error: null });
779
+ }
780
+ catch (err) {
781
+ patch({ agentsLoaded: true, error: String(err?.message ?? err) });
782
+ }
783
+ }
784
+ async function refreshInfo() {
785
+ try {
786
+ patch({ info: await botsCall('gatewayInfo', {}) });
787
+ }
788
+ catch { /* keep the last good reading */ }
789
+ }
790
+ /** Pull the latest host unread snapshot into the store. */
791
+ function syncUnread() {
792
+ const u = ring.unread();
793
+ if (u !== null)
794
+ patch({ unreadCounts: u });
795
+ }
796
+ function openChat(id) {
797
+ patch({ chatAgentId: id, unreadCounts: { ...state.unreadCounts, [id]: 0 } });
798
+ // Clear the badge at the source; the gateway owns unread state.
799
+ void botsCall('markRead', { id, atMs: Date.now() }).then(refreshAgents).catch(() => { });
800
+ }
801
+ function agentById(id) {
802
+ if (id === null)
803
+ return null;
804
+ return state.agents.find((a) => a.id === id) ?? null;
805
+ }
806
+ // =========================================================
807
+ // Delegation of the shipped workspace browser.
808
+ // =========================================================
809
+ /**
810
+ * `useSyncExternalStore` selector bound to one host observable, cached
811
+ * per source. The cache is not an optimization: a fresh hook identity on
812
+ * every render makes React tear down and re-create the child's
813
+ * subscription each pass, which is what the shell's own renderer avoids
814
+ * by caching the same way.
815
+ */
816
+ const warned = new Set();
817
+ function warnOnce(message) {
818
+ if (warned.has(message))
819
+ return;
820
+ warned.add(message);
821
+ // eslint-disable-next-line no-console
822
+ console.warn('[dsh-bots] ' + message);
823
+ reportDiag('warn', { message });
824
+ }
825
+ /**
826
+ * Record a delegation milestone to the host's diagnostics file.
827
+ *
828
+ * Deduplicated by stage+detail, because these fire from render paths.
829
+ * Failure to record is never surfaced: diagnostics must not be able to
830
+ * break the thing they observe.
831
+ */
832
+ const diagSeen = new Set();
833
+ function reportDiag(stage, detail) {
834
+ const key = stage + ':' + JSON.stringify(detail ?? null);
835
+ if (diagSeen.has(key))
836
+ return;
837
+ diagSeen.add(key);
838
+ void botsCall('diag', { stage, detail: detail ?? null }).catch(() => { });
839
+ }
840
+ const hookCache = new WeakMap();
841
+ function observableHook(source) {
842
+ if (source === null || typeof source !== 'object')
843
+ return undefined;
844
+ const hit = hookCache.get(source);
845
+ if (hit !== undefined)
846
+ return hit;
847
+ const subscribe = (fn) => source.subscribe(fn);
848
+ const hook = function useSelector(sel) {
849
+ return React.useSyncExternalStore(subscribe, () => (sel === undefined ? source.getSnapshot() : sel(source.getSnapshot())));
850
+ };
851
+ hookCache.set(source, hook);
852
+ return hook;
853
+ }
854
+ const Boundary = class extends React.Component {
855
+ constructor(props) { super(props); this.state = { failed: false }; }
856
+ static getDerivedStateFromError() { return { failed: true }; }
857
+ componentDidCatch(error) {
858
+ // eslint-disable-next-line no-console
859
+ console.error('[dsh-bots] delegated slot entry failed:', error);
860
+ reportDiag('delegate-crashed', { message: String(error?.message ?? error) });
861
+ }
862
+ render() { return this.state.failed ? this.props.fallback : this.props.children; }
863
+ };
864
+ /** Live entries of a slot that are not ours, most-specific first. */
865
+ function foreignEntries(key) {
866
+ if (slotsSvc === null)
867
+ return [];
868
+ let list = [];
869
+ try {
870
+ list = slotsSvc.entries(key) ?? [];
871
+ }
872
+ catch {
873
+ return [];
874
+ }
875
+ return list.filter((en) => en !== null && en.component !== undefined && en.registrant !== 'dsh-bots');
876
+ }
877
+ /**
878
+ * Render a shadowed entry the way the shell would.
879
+ *
880
+ * The shell's renderer assembles a "standard kit" before handing props
881
+ * to a slot component: global hooks, the entry's store and actions, its
882
+ * locale seat, and — when the registration declares `children` — a bound
883
+ * `renderSlot` so the component can fill its own holes. Taking over a
884
+ * single slot means taking over that assembly too. The previous version
885
+ * stubbed `renderSlot` to `() => null`, which silently blanked the
886
+ * workspace browser's directory-picker flow; here it recurses, so a
887
+ * delegated entry's children render exactly as they would natively.
888
+ */
889
+ function synthesizeProps(entry, ownerProps) {
890
+ const props = {};
891
+ if (slotsSvc === null)
892
+ return { ...props, ...ownerProps };
893
+ const host = slotsSvc.hostFace();
894
+ if (host.sessions !== undefined && host.workspaces !== undefined) {
895
+ props.useSessions = observableHook(host.sessions.list);
896
+ props.useWorkspaces = observableHook(host.workspaces.list);
897
+ }
898
+ let actions;
899
+ if (entry.store !== undefined) {
900
+ try {
901
+ const store = host.storeOf(entry, undefined);
902
+ if (store !== undefined) {
903
+ props.useStore = observableHook(store);
904
+ props.actions = store.actions;
905
+ actions = store.actions;
906
+ }
907
+ }
908
+ catch (err) {
909
+ // The entry still renders, just without its store — better than a
910
+ // blank region, but worth a record since it means degraded props.
911
+ reportDiag('delegate-store-failed', { message: String(err?.message ?? err) });
912
+ }
913
+ }
914
+ if (entry.locale !== undefined && host.locale !== undefined) {
915
+ try {
916
+ const bound = host.locale.bind(entry.locale);
917
+ props.t = (key, params) => bound(key, params);
918
+ }
919
+ catch {
920
+ props.t = (key) => key;
921
+ }
922
+ }
923
+ if (entry.children !== undefined) {
924
+ props.renderSlot = renderChildSlot;
925
+ // Two child-spec flavours need renderer internals we cannot mint from
926
+ // out here (chain composition, the session seat). Neither is used by
927
+ // any slot we delegate today; warn loudly if that ever changes, so it
928
+ // surfaces as a message instead of another silently empty region.
929
+ const specs = Object.values(entry.children);
930
+ if (specs.some((spec) => spec?.kind === 'chain' || spec?.scope === 'session')) {
931
+ warnOnce(`delegated entry '${String(entry.name)}' declares chain/session children that this shadow cannot synthesize`);
932
+ }
933
+ }
934
+ // The shell passes the entry's own actions into `inject`; matching that
935
+ // matters for registrations whose injected callbacks close over them.
936
+ if (typeof entry.inject === 'function') {
937
+ let injected;
938
+ try {
939
+ injected = entry.inject(actions);
940
+ }
941
+ catch {
942
+ injected = undefined;
943
+ }
944
+ if (injected !== null && typeof injected === 'object') {
945
+ for (const key of Object.keys(injected)) {
946
+ if (key === 'hooks')
947
+ continue;
948
+ props[key] = injected[key];
949
+ }
950
+ if (injected.hooks !== null && typeof injected.hooks === 'object') {
951
+ for (const name of Object.keys(injected.hooks)) {
952
+ const source = injected.hooks[name];
953
+ const hook = observableHook(source);
954
+ if (hook !== undefined)
955
+ props['use' + name[0].toUpperCase() + name.slice(1)] = hook;
956
+ }
957
+ }
958
+ }
959
+ }
960
+ // Owner props win, exactly as in the shell's own merge order.
961
+ return { ...props, ...ownerProps };
962
+ }
963
+ /** Bound `renderSlot` handed to delegated entries (recursive). */
964
+ function renderChildSlot(key, ownerProps) {
965
+ const entries = foreignEntries(key);
966
+ if (entries.length === 0)
967
+ return null;
968
+ return entries.map((en, i) => e(Boundary, {
969
+ key: en.id ?? key + ':' + String(i),
970
+ fallback: null,
971
+ children: e(en.component, synthesizeProps(en, ownerProps ?? {})),
972
+ }));
973
+ }
974
+ /**
975
+ * Renders the shipped `sidebar.workspaces` entry underneath our shadow.
976
+ * `wide` is forwarded untouched so the shipped rail branch — search and
977
+ * add-workspace, which the plugin used to replace with two inert icons —
978
+ * keeps working when the sidebar is collapsed.
979
+ */
980
+ function DelegatedBrowser(p) {
981
+ const [entry, setEntry] = React.useState(null);
982
+ const [status, setStatus] = React.useState('loading');
983
+ React.useEffect(() => {
984
+ let alive = true;
985
+ const check = () => {
986
+ const found = foreignEntries('sidebar.workspaces')[0] ?? null;
987
+ if (!alive)
988
+ return;
989
+ setEntry(found);
990
+ setStatus(found === null ? 'missing' : 'ready');
991
+ reportDiag(found === null ? 'delegate-missing' : 'delegate-ready', { slot: 'sidebar.workspaces' });
992
+ };
993
+ check();
994
+ const unsub = slotsSvc === null ? null : slotsSvc.subscribe('sidebar.workspaces', check);
995
+ return () => { alive = false; if (unsub)
996
+ unsub(); };
997
+ }, []);
998
+ const props = React.useMemo(() => (entry === null ? null : synthesizeProps(entry, { wide: p.wide, expandSidebar: p.expandSidebar })), [entry, p.wide, p.expandSidebar]);
999
+ if (status === 'loading')
1000
+ return e('div', { className: 'dbs-navBodyErr' }, t('delegate.loading'));
1001
+ if (entry === null || props === null)
1002
+ return e('div', { className: 'dbs-navBodyErr' }, t('delegate.unavailable'));
1003
+ return e(Boundary, {
1004
+ fallback: e('div', { className: 'dbs-navBodyErr' }, t('delegate.failed')),
1005
+ children: e(entry.component, props),
1006
+ });
1007
+ }
1008
+ // =========================================================
1009
+ // Shared row pieces.
1010
+ // =========================================================
1011
+ /** Stable hue from an id, so an avatar keeps its colour across reloads. */
1012
+ function hueOf(id) {
1013
+ let h = 0;
1014
+ for (let i = 0; i < id.length; i += 1)
1015
+ h = (h * 31 + id.charCodeAt(i)) % 360;
1016
+ return h;
1017
+ }
1018
+ function Avatar(p) {
1019
+ const a = p.agent;
1020
+ const size = p.size ?? 20;
1021
+ const style = { width: size, height: size };
1022
+ if (typeof a.avatarDataUrl === 'string' && a.avatarDataUrl !== '') {
1023
+ return e('span', { className: 'dbs-avatar' + (a.isGroup ? ' dbs-group' : ''), style }, e('img', { src: a.avatarDataUrl, alt: '' }));
1024
+ }
1025
+ style.background = typeof a.avatarColor === 'string' && a.avatarColor !== ''
1026
+ ? a.avatarColor
1027
+ : `hsl(${String(hueOf(a.id))} 52% 46%)`;
1028
+ if (size >= 24)
1029
+ style.fontSize = '13px';
1030
+ const initial = (a.name ?? '').trim().slice(0, 1) || '·';
1031
+ return e('span', {
1032
+ className: 'dbs-avatar' + (a.isGroup ? ' dbs-group' : ''), style, 'aria-hidden': true,
1033
+ }, initial);
1034
+ }
1035
+ /** Chevron matching the shipped project row (fills in if the icon moves). */
1036
+ function Chevron(p) {
1037
+ const cls = 'dbs-arrow' + (p.open ? ' dbs-arrowOpen' : '');
1038
+ const native = NATIVE.IconTriangleRightFill14;
1039
+ return e('span', { className: 'dbs-chevron ' + cls }, native !== undefined
1040
+ ? e(native, {})
1041
+ : e('svg', { width: 14, height: 14, viewBox: '0 0 14 14', 'aria-hidden': true }, e('path', { d: 'M4.25 2.83v8.34c0 .49.59.74.94.39l4.17-4.17a.75.75 0 0 0 0-1.06L5.19 2.16c-.35-.35-.94-.1-.94.39Z', fill: 'currentColor' })));
1042
+ }
1043
+ // =========================================================
1044
+ // Bots nav group.
1045
+ // =========================================================
1046
+ function BotsGroup() {
1047
+ const s = useStore();
1048
+ // Hidden agents are hidden: the gateway owns that flag and the sidebar
1049
+ // has to honour it, same as every other sdk-bots surface.
1050
+ const visible = s.agents
1051
+ .filter((a) => a.isHiddenFromSidebar !== true)
1052
+ .slice()
1053
+ .sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
1054
+ const groups = visible.filter((a) => a.isGroup);
1055
+ const singles = visible.filter((a) => !a.isGroup);
1056
+ const connected = s.info?.ok === true;
1057
+ function row(a) {
1058
+ const busy = a.isComposingMessage === true || a.isRunning === true;
1059
+ // Plugin-owned count first (the gateway's own unreadCount is
1060
+ // desktop-app semantics and never accumulates on a headless host).
1061
+ const unread = Number(s.unreadCounts?.[a.id] ?? a.unreadCount ?? 0);
1062
+ return e('div', {
1063
+ key: a.id,
1064
+ className: 'dbs-srow' + (s.chatAgentId === a.id ? ' dbs-selected' : ''),
1065
+ role: 'treeitem',
1066
+ 'aria-selected': s.chatAgentId === a.id,
1067
+ tabIndex: 0,
1068
+ onClick: () => openChat(a.id),
1069
+ onKeyDown: (ev) => { if (ev.key === 'Enter' || ev.key === ' ') {
1070
+ ev.preventDefault();
1071
+ openChat(a.id);
1072
+ } },
1073
+ title: a.description !== '' ? a.name + ' — ' + a.description : a.name,
1074
+ }, e(Avatar, { agent: a }), e('span', { className: 'dbs-title' }, a.name), busy && StateDot !== null
1075
+ ? e(StateDot, { state: 'ongoing', size: 10 })
1076
+ : a.awaitingUserResponse !== null && a.awaitingUserResponse !== undefined && StateDot !== null
1077
+ ? e(StateDot, { state: 'warning', size: 10 })
1078
+ : unread > 0
1079
+ ? e('span', { className: 'dbs-badge' }, unread > 99 ? '99+' : String(unread))
1080
+ : null, e('button', {
1081
+ type: 'button', className: 'dbs-rowDel',
1082
+ title: t('action.delete'), 'aria-label': t('action.delete') + ' ' + a.name,
1083
+ onClick: (ev) => {
1084
+ ev.stopPropagation();
1085
+ patch({ confirmDelete: { id: a.id, name: a.name, isGroup: a.isGroup === true } });
1086
+ },
1087
+ }, Ico('IconTrashOutline16', { size: 14 })));
1088
+ }
1089
+ /**
1090
+ * Section header + rows. The header always renders (even when the
1091
+ * section is empty) so the far-right + is reachable from where you
1092
+ * are: creating the first bot or group never requires a trip to the
1093
+ * footer. `addAction` opens the same system-style modal as before.
1094
+ */
1095
+ function sectionRows(label, list, addAction) {
1096
+ return e('div', { key: label }, e('div', {
1097
+ className: 'dbs-navBodyErr',
1098
+ // Native sectionHeader outdent: label at x=16 (12 sidebar
1099
+ // padding + 4), aligned with the group headers. The previous
1100
+ // 12px here pushed section labels 8px right of the native
1101
+ // baseline, making both sections read horizontally off.
1102
+ style: { padding: '4px 8px 2px 4px', display: 'flex', alignItems: 'center' },
1103
+ }, e('span', { style: { flex: 1 } }, label), e(Button, {
1104
+ variant: 'ghost', size: 'sm', title: addAction.title, 'aria-label': addAction.title,
1105
+ icon: Ico('IconPlusOutline16', { size: 14 }),
1106
+ onClick: addAction.onClick,
1107
+ })), list.map(row));
1108
+ }
1109
+ // No footer: the gateway status moved to the Bots group-header dot's
1110
+ // hover tooltip (0.2.8) — see SidebarNav's StateDot `title`.
1111
+ return e('div', { className: 'dbs-navBody dbs-botsBody' }, s.error !== null
1112
+ ? e('div', { className: 'dbs-error', onClick: () => patch({ error: null }) }, s.error)
1113
+ : null, !s.agentsLoaded
1114
+ ? e('div', { className: 'dbs-navBodyErr' }, t('list.loading'))
1115
+ : visible.length === 0
1116
+ ? e('div', { className: 'dbs-navBodyErr' }, connected ? t('list.empty') : t('gateway.offlineHint'))
1117
+ : null, s.agentsLoaded
1118
+ ? sectionRows(t('section.groups'), groups, {
1119
+ title: t('group.new'),
1120
+ onClick: () => patch({ create: 'group', createName: '', createMembers: {}, createWorking: false, error: null }),
1121
+ })
1122
+ : null, s.agentsLoaded
1123
+ ? sectionRows(t('section.singles'), singles, {
1124
+ title: t('bot.new'),
1125
+ onClick: () => patch({ create: 'bot', createName: '', createDesc: '', createWorking: false, error: null }),
1126
+ })
1127
+ : null);
1128
+ }
1129
+ // =========================================================
1130
+ // Sidebar nav: 「工作区」 and 「Bots」 as two collapsible groups.
1131
+ // =========================================================
1132
+ function SidebarNav(p) {
1133
+ const wide = p.wide !== false;
1134
+ const s = useStore();
1135
+ // One controller owns the data lifecycle for every Bots surface.
1136
+ React.useEffect(() => {
1137
+ void refreshAgents();
1138
+ void refreshInfo();
1139
+ syncUnread();
1140
+ return ring.subscribe((channels) => {
1141
+ if (channels.has('agents') || channels.has('agent-upserted'))
1142
+ void refreshAgents();
1143
+ if (channels.has('host-settings'))
1144
+ void refreshInfo();
1145
+ if (channels.has('transcript'))
1146
+ syncUnread();
1147
+ });
1148
+ }, []);
1149
+ if (!wide) {
1150
+ // Rail: the shipped browser draws its own icon column (search, add
1151
+ // workspace); we append one Bots control instead of replacing it.
1152
+ const busy = s.agents.some((a) => a.isComposingMessage === true || a.isRunning === true);
1153
+ const unread = s.agents
1154
+ .filter((a) => a.isHiddenFromSidebar !== true)
1155
+ .reduce((n, a) => n + Number(s.unreadCounts?.[a.id] ?? a.unreadCount ?? 0), 0);
1156
+ return e('div', { className: 'dbs-railWrap' }, e(DelegatedBrowser, { wide: false, expandSidebar: p.expandSidebar }), e('div', { className: 'dbs-rail' }, e('button', {
1157
+ type: 'button', className: 'dbs-railBtn', title: unread > 0 ? t('nav.bots.unread', { n: unread }) : t('nav.bots'),
1158
+ 'aria-label': t('nav.bots.aria'), 'data-active': busy || unread > 0,
1159
+ onClick: () => {
1160
+ patch({ open: { ...s.open, bots: true } });
1161
+ if (p.expandSidebar)
1162
+ p.expandSidebar();
1163
+ },
1164
+ }, Ico('IconAgentPresetOutline16', { size: 18 }) ?? '·')));
1165
+ }
1166
+ // Accordion: expanding one nav group collapses the other. The sidebar
1167
+ // is one column of attention, not two stacked browsers — collapsing a
1168
+ // group stays a plain collapse, only the expand is exclusive.
1169
+ function toggleNav(key, isOpen) {
1170
+ if (isOpen)
1171
+ return { ...s.open, [key]: false };
1172
+ const other = key === 'workspaces' ? 'bots' : 'workspaces';
1173
+ return { ...s.open, [key]: true, [other]: false };
1174
+ }
1175
+ function group(key, title, iconName, body, trailing) {
1176
+ const isOpen = s.open[key] !== false;
1177
+ return e('div', { className: 'dbs-navGroup', 'data-open': isOpen }, e('div', {
1178
+ className: 'dbs-prow', role: 'button', tabIndex: 0, 'aria-expanded': isOpen,
1179
+ onClick: () => patch({ open: toggleNav(key, isOpen) }),
1180
+ onKeyDown: (ev) => {
1181
+ if (ev.key === 'Enter' || ev.key === ' ') {
1182
+ ev.preventDefault();
1183
+ patch({ open: toggleNav(key, isOpen) });
1184
+ }
1185
+ },
1186
+ }, e(Chevron, { open: isOpen }), e('span', { className: 'dbs-slot' }, Ico(iconName, { size: 16 })), e('span', { className: 'dbs-title' }, title), trailing ?? null), isOpen ? body : null);
1187
+ }
1188
+ return e('div', { className: 'dbs-nav' }, group('workspaces', t('nav.workspaces'), 'IconFolderClose16',
1189
+ // Clicking into the native browser (a session row, "new chat"…)
1190
+ // is a navigation intent: dismiss the bot chat overlay so the
1191
+ // main window the user asked for is actually visible.
1192
+ e('div', {
1193
+ className: 'dbs-navBody',
1194
+ onClickCapture: () => { if (s.chatAgentId !== null)
1195
+ patch({ chatAgentId: null }); },
1196
+ }, e(DelegatedBrowser, { wide, expandSidebar: p.expandSidebar }))), group('bots', t('nav.bots'), 'IconAgentPresetOutline16', e(BotsGroup, null), StateDot !== null
1197
+ ? e('span', {
1198
+ title: s.info?.ok === true ? t('gateway.online', { port: s.info.port }) : t('gateway.offline'),
1199
+ style: { display: 'inline-flex', alignItems: 'center', flex: 'none' },
1200
+ }, e(StateDot, { state: s.info?.ok === true ? 'done' : 'failed', size: 8 }))
1201
+ : null));
1202
+ }
1203
+ // =========================================================
1204
+ // Chat surface.
1205
+ // =========================================================
1206
+ /**
1207
+ * Pixel offsets of the frame's centre column.
1208
+ *
1209
+ * `shell.overlay` covers the whole AppFrame, so an `inset: 0` panel sits
1210
+ * on top of the sidebar too — which is how the chat used to hide the very
1211
+ * nav it is launched from. The frame is a CSS grid and
1212
+ * `grid-template-columns` resolves to pixels, so the sidebar and details
1213
+ * widths can be read straight off it. `[data-shell-overlay]` is a stable
1214
+ * attribute (not a hashed class), and its parent is the frame.
1215
+ */
1216
+ function useCentreInset() {
1217
+ const [inset, setInset] = React.useState({ left: 0, right: 0 });
1218
+ React.useEffect(() => {
1219
+ const layer = document.querySelector('[data-shell-overlay]');
1220
+ const frame = layer === null ? null : layer.parentElement;
1221
+ if (frame === null)
1222
+ return undefined;
1223
+ const read = () => {
1224
+ const cols = window.getComputedStyle(frame).gridTemplateColumns.split(/\s+/).filter((c) => c !== '');
1225
+ const left = Number.parseFloat(cols[0]) || 0;
1226
+ const right = cols.length >= 3 ? (Number.parseFloat(cols[cols.length - 1]) || 0) : 0;
1227
+ setInset((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
1228
+ };
1229
+ read();
1230
+ const ro = new ResizeObserver(read);
1231
+ ro.observe(frame);
1232
+ // Collapsing a column rewrites the inline grid template without
1233
+ // resizing the frame, so watch the attribute as well.
1234
+ const mo = new MutationObserver(read);
1235
+ mo.observe(frame, { attributes: true, attributeFilter: ['style', 'data-details-collapsed'] });
1236
+ return () => { ro.disconnect(); mo.disconnect(); };
1237
+ }, []);
1238
+ return inset;
1239
+ }
1240
+ // =========================================================
1241
+ // Message clocks.
1242
+ //
1243
+ // Formatted out of our own dictionary rather than `Intl.DateTimeFormat`,
1244
+ // because the language every other string in this plugin follows is the
1245
+ // shell's preference — not the browser's. 24-hour, tabular digits, same
1246
+ // as the shipped surfaces.
1247
+ // =========================================================
1248
+ /** `HH:MM` for the message row. */
1249
+ function clockOf(ms) {
1250
+ const d = new Date(ms);
1251
+ return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
1252
+ }
1253
+ /** Local midnight index, so "same day" ignores the wall clock. */
1254
+ function dayIndexOf(ms) {
1255
+ return Math.floor((ms - new Date(ms).getTimezoneOffset() * 60000) / 86400000);
1256
+ }
1257
+ /** 今天 / 昨天 / 8 月 30 日 / 2025 年 8 月 30 日 — the divider label. */
1258
+ function dayLabelOf(ms) {
1259
+ const today = dayIndexOf(Date.now());
1260
+ const day = dayIndexOf(ms);
1261
+ if (day === today)
1262
+ return t('time.today');
1263
+ if (day === today - 1)
1264
+ return t('time.yesterday');
1265
+ const d = new Date(ms);
1266
+ const params = { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() };
1267
+ return d.getFullYear() === new Date().getFullYear() ? t('time.date', params) : t('time.dateFull', params);
1268
+ }
1269
+ /** Day + clock, for the hover title on a bare `HH:MM`. */
1270
+ function stampOf(ms) {
1271
+ return dayLabelOf(ms) + ' ' + clockOf(ms);
1272
+ }
1273
+ /** Readable timestamp of an entry, or null when the gateway sent none. */
1274
+ function timeOf(entry) {
1275
+ const ms = entry?.timestampMs;
1276
+ return typeof ms === 'number' && Number.isFinite(ms) ? ms : null;
1277
+ }
1278
+ /**
1279
+ * The reply clock under one message. Visible stamp carries the full
1280
+ * date (Y-M-D HH:MM) per product call; the hover title keeps the
1281
+ * locale-friendly "M 月 D 日 HH:MM" form.
1282
+ *
1283
+ * Suppressed while the entry is still streaming: a half-written turn has
1284
+ * no reply time yet, and stamping the tail would make the number twitch
1285
+ * on every chunk.
1286
+ */
1287
+ function MsgTime(p) {
1288
+ const ms = timeOf(p.entry);
1289
+ if (ms === null || p.entry.isStreaming === true)
1290
+ return null;
1291
+ const d = new Date(ms);
1292
+ const p2 = (n) => String(n).padStart(2, '0');
1293
+ return e('span', { className: 'dbs-msgTime', title: stampOf(ms) }, d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()) + ' ' + clockOf(ms));
1294
+ }
1295
+ function ToolCard(p) {
1296
+ const [open, setOpen] = React.useState(false);
1297
+ const en = p.entry;
1298
+ const tone = en.toolStatus === 'error'
1299
+ ? 'var(--dsw-alias-state-error-primary)'
1300
+ : en.toolStatus === 'running'
1301
+ ? 'var(--dsw-alias-state-warn-primary)'
1302
+ : 'var(--dsw-alias-state-success-primary)';
1303
+ return e('div', { className: 'dbs-toolCard' }, e('div', {
1304
+ className: 'dbs-toolHdr', role: 'button', tabIndex: 0,
1305
+ style: { cursor: en.content === '' ? 'default' : 'pointer' },
1306
+ onClick: () => { if (en.content !== '')
1307
+ setOpen(!open); },
1308
+ onKeyDown: (ev) => { if ((ev.key === 'Enter' || ev.key === ' ') && en.content !== '') {
1309
+ ev.preventDefault();
1310
+ setOpen(!open);
1311
+ } },
1312
+ }, StateDot !== null
1313
+ ? e(StateDot, { state: en.toolStatus === 'running' ? 'ongoing' : en.toolStatus === 'error' ? 'error' : 'done', size: 10 })
1314
+ : e('span', { style: { width: 10, height: 10, borderRadius: 999, background: tone, display: 'inline-block' } }), e('span', { className: 'dbs-toolName' }, en.toolName ?? t('tool.fallbackName')), e('span', { className: 'dbs-toolTrail' }, en.content !== '' ? e('span', { className: 'dbs-meta' }, open ? t('action.collapse') : t('action.expand')) : null, e(MsgTime, { entry: en }))), open && en.content !== '' ? e('div', { className: 'dbs-toolBody' }, en.content) : null);
1315
+ }
1316
+ function Entry(p) {
1317
+ const en = p.entry;
1318
+ if (en.display === 'user') {
1319
+ return e('div', { className: 'dbs-userRow' }, e('div', { className: 'dbs-userStack' }, e('div', { className: 'dbs-bubble' }, e(MessageText, { text: en.content }))), e(MsgTime, { entry: en }));
1320
+ }
1321
+ if (en.display === 'tool')
1322
+ return e(ToolCard, { entry: en });
1323
+ if (en.display === 'thinking') {
1324
+ return e('div', { className: 'dbs-thinking' }, en.content);
1325
+ }
1326
+ if (en.display === 'event') {
1327
+ if (en.content === '')
1328
+ return null;
1329
+ return e('div', { className: 'dbs-meta', style: { textAlign: 'center' } }, en.content);
1330
+ }
1331
+ // Compact continuation of the same author's run: the header already
1332
+ // stands above; only the reply clock rides along, right-aligned and
1333
+ // quiet — the way native IMs chain quick follow-ups under one name.
1334
+ if (p.compact === true) {
1335
+ return e('div', { className: 'dbs-botRow', 'data-compact': 'true' }, e('div', { className: 'dbs-mdRow' }, e(MarkdownText, { text: en.content, streaming: en.isStreaming === true }), en.isStreaming === true ? e('span', { className: 'dbs-caret' }) : null), e('div', { className: 'dbs-compactTime' }, e(MsgTime, { entry: en })));
1336
+ }
1337
+ // Bot message: avatar + prominent per-author name + full-datetime in
1338
+ // one header row, so multi-member rooms read at a glance.
1339
+ const authorKnown = typeof en.authorId === 'string' && en.authorId !== '';
1340
+ const memberAgent = authorKnown ? agentById(en.authorId) : null;
1341
+ const avAgent = memberAgent ?? {
1342
+ id: authorKnown ? en.authorId : (p.agent?.id ?? 'bot'),
1343
+ name: en.authorName ?? p.agent?.name ?? '',
1344
+ avatarColor: memberAgent?.avatarColor,
1345
+ avatarDataUrl: memberAgent?.avatarDataUrl,
1346
+ isGroup: false,
1347
+ };
1348
+ const displayName = p.isGroup ? en.authorName : p.agent?.name;
1349
+ const authorColor = 'hsl(' + String(hueOf(avAgent.id)) + ' 55% 45%)';
1350
+ return e('div', { className: 'dbs-botRow' }, e('div', { className: 'dbs-author' }, e(Avatar, { agent: avAgent, size: 18 }), displayName != null && displayName !== ''
1351
+ ? e('span', { className: 'dbs-authorName', style: { color: authorColor } }, displayName)
1352
+ : null, e(MsgTime, { entry: en })), e('div', { className: 'dbs-mdRow' }, e(MarkdownText, { text: en.content, streaming: en.isStreaming === true }), en.isStreaming === true ? e('span', { className: 'dbs-caret' }) : null));
1353
+ }
1354
+ function ChatView(p) {
1355
+ const s = useStore();
1356
+ const [entries, setEntries] = React.useState(null);
1357
+ const [input, setInput] = React.useState('');
1358
+ const [sending, setSending] = React.useState(false);
1359
+ const [stopping, setStopping] = React.useState(false);
1360
+ const [error, setError] = React.useState(null);
1361
+ const [mention, setMention] = React.useState(null); // {query, index} | null
1362
+ const scrollRef = React.useRef(null);
1363
+ const inputRef = React.useRef(null);
1364
+ // True between compositionstart/end: an IME candidate window owns the
1365
+ // keyboard while it is open and must never see our Enter binding.
1366
+ const imeRef = React.useRef(false);
1367
+ const agent = agentById(p.agentId);
1368
+ const isGroup = agent?.isGroup === true;
1369
+ // Authoritative, from the gateway — never a local "I just sent, so it
1370
+ // must be running" guess, which had no path back to false.
1371
+ const composing = agent?.isComposingMessage === true || agent?.isRunning === true;
1372
+ async function loadTranscript() {
1373
+ try {
1374
+ const r = await botsCall('transcriptTail', { id: p.agentId, limit: 80 });
1375
+ setEntries(Array.isArray(r?.entries) ? r.entries : []);
1376
+ }
1377
+ catch (err) {
1378
+ setError(String(err?.message ?? err));
1379
+ }
1380
+ }
1381
+ React.useEffect(() => {
1382
+ setEntries(null);
1383
+ setError(null);
1384
+ setInput('');
1385
+ setMention(null);
1386
+ void loadTranscript();
1387
+ // While this conversation is open it is being read: arrivals for it
1388
+ // must not leave a badge on the row the user is looking at. The
1389
+ // host bump lands first, our debounced markRead cancels it out.
1390
+ let readTimer = null;
1391
+ const scheduleRead = () => {
1392
+ if (readTimer !== null)
1393
+ return;
1394
+ readTimer = setTimeout(() => {
1395
+ readTimer = null;
1396
+ patch({ unreadCounts: { ...state.unreadCounts, [p.agentId]: 0 } });
1397
+ void botsCall('markRead', { id: p.agentId, atMs: Date.now() }).catch(() => { });
1398
+ }, 1200);
1399
+ };
1400
+ const unsub = ring.subscribe((channels, events) => {
1401
+ if (channels.has('transcript') || channels.has('client-side-tool-v2'))
1402
+ void loadTranscript();
1403
+ if (channels.has('transcript') && Array.isArray(events)) {
1404
+ for (const ev of events) {
1405
+ const payload = ev?.data;
1406
+ if (payload?.type !== 'appended' && payload?.type !== 'snapshot')
1407
+ continue;
1408
+ if (String(payload?.agentId ?? payload?.activeAgentId ?? '') === p.agentId) {
1409
+ scheduleRead();
1410
+ break;
1411
+ }
1412
+ }
1413
+ }
1414
+ });
1415
+ return () => {
1416
+ // A pending read timer dies with the view — a message that landed
1417
+ // as the user left stays unread.
1418
+ if (readTimer !== null) {
1419
+ clearTimeout(readTimer);
1420
+ readTimer = null;
1421
+ }
1422
+ unsub();
1423
+ };
1424
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1425
+ }, [p.agentId]);
1426
+ // The composer takes focus when a conversation opens, and keeps its
1427
+ // height in step with programmatic edits (send, mention completion,
1428
+ // agent switch) — user typing is fitted inline, without a frame gap.
1429
+ React.useEffect(() => {
1430
+ const node = inputRef.current;
1431
+ if (node === null || node === undefined)
1432
+ return undefined;
1433
+ node.focus();
1434
+ return undefined;
1435
+ }, [p.agentId]);
1436
+ React.useEffect(() => { fitInput(); }, [input, p.agentId]);
1437
+ // Stick-to-bottom: follow new output only while the reader already
1438
+ // sits at the tail; the moment they scroll up to reread, the thread
1439
+ // stops yanking them down and a jump-to-latest pill appears instead —
1440
+ // the native conversation contract.
1441
+ const stickRef = React.useRef(true);
1442
+ const [showJump, setShowJump] = React.useState(false);
1443
+ function onScrollBody() {
1444
+ const el = scrollRef.current;
1445
+ if (el === null || el === undefined)
1446
+ return;
1447
+ const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
1448
+ stickRef.current = distance < 80;
1449
+ const next = distance > 240;
1450
+ setShowJump((prev) => (prev === next ? prev : next));
1451
+ }
1452
+ function jumpToLatest() {
1453
+ const el = scrollRef.current;
1454
+ if (el !== null && el !== undefined)
1455
+ el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
1456
+ }
1457
+ React.useEffect(() => {
1458
+ const el = scrollRef.current;
1459
+ if (el === null || el === undefined)
1460
+ return;
1461
+ if (stickRef.current)
1462
+ el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
1463
+ }, [entries, composing]);
1464
+ const memberNames = React.useMemo(() => {
1465
+ if (!isGroup)
1466
+ return [];
1467
+ const ids = agent?.memberIds ?? [];
1468
+ return ids
1469
+ .map((id) => state.agents.find((a) => a.id === id))
1470
+ .filter((a) => a !== undefined);
1471
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1472
+ }, [isGroup, agent, s.agents]);
1473
+ /**
1474
+ * Grow the textarea to its content, the way the shipped card does:
1475
+ * one row when empty, taller line by line, and `.dbs-composerScroll`
1476
+ * caps it at `--dsh-composer-text-max-height`. A fixed-height textarea
1477
+ * scrolled its own body instead — the one thing the native card never
1478
+ * does — so a long draft became a 1-line peephole.
1479
+ */
1480
+ function fitInput(node) {
1481
+ const el = node ?? inputRef.current;
1482
+ if (el === null || el === undefined)
1483
+ return;
1484
+ el.style.height = 'auto';
1485
+ el.style.height = String(el.scrollHeight) + 'px';
1486
+ }
1487
+ /**
1488
+ * `@` completion for directed group turns, driven by the caret rather
1489
+ * than by the keystroke: moving the caret back into a half-typed
1490
+ * mention re-opens the menu, and moving out of it closes the menu.
1491
+ */
1492
+ function syncMention(node) {
1493
+ if (!isGroup || node === null || node === undefined) {
1494
+ setMention(null);
1495
+ return;
1496
+ }
1497
+ const value = String(node.value);
1498
+ const upto = value.slice(0, node.selectionStart ?? value.length);
1499
+ const m = /@([^\s@]*)$/.exec(upto);
1500
+ // Identity-stable when nothing changed, so a caret sweep does not
1501
+ // re-render the menu (and does not reset the highlighted row).
1502
+ setMention((prev) => {
1503
+ if (m === null)
1504
+ return null;
1505
+ return prev !== null && prev.query === m[1] ? prev : { query: m[1], index: 0 };
1506
+ });
1507
+ }
1508
+ function onInputChange(node) {
1509
+ setInput(String(node.value));
1510
+ fitInput(node);
1511
+ syncMention(node);
1512
+ }
1513
+ const mentionHits = mention === null
1514
+ ? []
1515
+ : memberNames.filter((a) => a.name.toLowerCase().startsWith(mention.query.toLowerCase())).slice(0, 8);
1516
+ function applyMention(a) {
1517
+ const node = inputRef.current;
1518
+ const caret = node?.selectionStart ?? input.length;
1519
+ const before = input.slice(0, caret).replace(/@([^\s@]*)$/, '@' + a.name + ' ');
1520
+ setInput(before + input.slice(caret));
1521
+ setMention(null);
1522
+ if (node === null || node === undefined)
1523
+ return;
1524
+ node.focus();
1525
+ // React rewrites `value` on the next commit, which parks the caret at
1526
+ // the end of the whole draft; put it back behind the name completed.
1527
+ window.requestAnimationFrame(() => {
1528
+ const live = inputRef.current;
1529
+ if (live === null || live === undefined)
1530
+ return;
1531
+ live.setSelectionRange(before.length, before.length);
1532
+ fitInput(live);
1533
+ });
1534
+ }
1535
+ /** Stop the active run: real gateway interrupt, honest no-op feedback. */
1536
+ async function doStop() {
1537
+ if (stopping)
1538
+ return;
1539
+ setStopping(true);
1540
+ setError(null);
1541
+ try {
1542
+ const r = await botsCall('interrupt', { id: p.agentId });
1543
+ if (r?.hadActiveRun !== true)
1544
+ setError(t('chat.stop.noop'));
1545
+ await refreshAgents();
1546
+ await loadTranscript();
1547
+ }
1548
+ catch (err) {
1549
+ setError(String(err?.message ?? err));
1550
+ }
1551
+ setStopping(false);
1552
+ }
1553
+ async function doSend() {
1554
+ const text = input.trim();
1555
+ if (text === '' || sending || composing)
1556
+ return;
1557
+ setSending(true);
1558
+ setError(null);
1559
+ try {
1560
+ await botsCall('send', { agentId: p.agentId, prompt: text });
1561
+ setInput('');
1562
+ setMention(null);
1563
+ await refreshAgents();
1564
+ await loadTranscript();
1565
+ }
1566
+ catch (err) {
1567
+ setError(String(err?.message ?? err));
1568
+ }
1569
+ setSending(false);
1570
+ // The caret stays in the composer after a send, as in every native
1571
+ // thread: the next turn is typed without reaching for the mouse.
1572
+ const node = inputRef.current;
1573
+ if (node !== null && node !== undefined)
1574
+ node.focus();
1575
+ }
1576
+ const list = (entries ?? []);
1577
+ const inset = useCentreInset();
1578
+ // Day dividers, folded in during render: a bare `HH:MM` turns
1579
+ // ambiguous the moment a transcript crosses midnight, so the date is
1580
+ // stated once and the rows beneath it carry only the clock.
1581
+ // Consecutive same-author bot messages within the window group under
1582
+ // one header — follow-ups go compact, the native IM run pattern.
1583
+ const GROUP_WINDOW_MS = 5 * 60 * 1000;
1584
+ const thread = [];
1585
+ let lastDay = null;
1586
+ let run = null;
1587
+ list.forEach((en, i) => {
1588
+ const ms = timeOf(en);
1589
+ if (ms !== null) {
1590
+ const day = dayIndexOf(ms);
1591
+ if (day !== lastDay) {
1592
+ lastDay = day;
1593
+ run = null;
1594
+ thread.push(e('div', { className: 'dbs-dayDivider', key: 'day:' + String(day) }, e('span', null, dayLabelOf(ms))));
1595
+ }
1596
+ }
1597
+ const authorId = en.display === 'bot' && typeof en.authorId === 'string' ? en.authorId : null;
1598
+ const compact = authorId !== null && run !== null && run.authorId === authorId
1599
+ && ms !== null && run.at !== null && ms - run.at >= 0 && ms - run.at <= GROUP_WINDOW_MS;
1600
+ if (authorId !== null)
1601
+ run = { authorId, at: ms ?? run?.at ?? 0 };
1602
+ else
1603
+ run = null;
1604
+ thread.push(e(Entry, { key: en.id !== '' ? en.id : String(i), entry: en, isGroup, agent, compact }));
1605
+ });
1606
+ return e('div', { className: 'dbs-chatview', style: { left: inset.left, right: inset.right } }, e('div', { className: 'dbs-chatbar' }, e(Button, {
1607
+ variant: 'ghost', size: 'sm', title: t('action.close'), 'aria-label': t('action.close'),
1608
+ icon: Ico('IconCloseOutline16', { size: 16 }),
1609
+ onClick: () => patch({ chatAgentId: null }),
1610
+ }), agent !== null ? e(Avatar, { agent, size: 24 }) : null, e('span', { className: 'dbs-chatbarName' }, agent?.name ?? t('chat.loading')), e('span', { className: 'dbs-meta' }, isGroup ? t('chat.group', { n: memberNames.length }) : t('chat.single')), isGroup
1611
+ ? e(Button, {
1612
+ variant: 'ghost', size: 'sm', title: t('chat.members.manage'), 'aria-label': t('chat.members.manage'),
1613
+ onClick: () => patch({ manageMembers: p.agentId }),
1614
+ }, t('chat.members.manage'))
1615
+ : null, e('span', { style: { flex: 1 } })), error !== null
1616
+ ? e('div', { className: 'dbs-error', onClick: () => setError(null) }, error)
1617
+ : null, e('div', { className: 'dbs-scrollArea' }, e('div', { className: 'dbs-scrollBody', ref: scrollRef, onScroll: onScrollBody }, e('div', { className: 'dbs-scroll' }, e('div', { className: 'dbs-column' }, entries === null
1618
+ ? e('div', { className: 'dbs-skeleton' }, e('div', { className: 'dbs-skelRow', style: { width: '42%' } }), e('div', { className: 'dbs-skelRow', style: { width: '76%' } }), e('div', { className: 'dbs-skelRow', style: { width: '58%' } }))
1619
+ : list.length === 0
1620
+ ? e('div', { className: 'dbs-welcome' }, agent !== null ? e(Avatar, { agent, size: 44 }) : null, e('div', { className: 'dbs-welcomeName' }, agent?.name ?? ''), typeof agent?.description === 'string' && agent.description !== ''
1621
+ ? e('div', { className: 'dbs-welcomeDesc' }, agent.description) : null, e('div', { className: 'dbs-welcomeHint' }, isGroup ? t('chat.empty.group') : t('chat.empty.single', { name: agent?.name ?? '' })))
1622
+ : thread, composing
1623
+ ? e('div', { className: 'dbs-typing', 'aria-label': t('chat.composing'), title: t('chat.composing') }, e('span', null), e('span', null), e('span', null))
1624
+ : null))), e('button', {
1625
+ type: 'button', className: 'dbs-jump', 'data-show': showJump ? 'true' : 'false',
1626
+ 'aria-label': t('chat.jump'), title: t('chat.jump'),
1627
+ onClick: jumpToLatest,
1628
+ }, Ico('IconChevronDownOutline14', { size: 16 }))), e('div', { className: 'dbs-composerSeat' }, e('div', { className: 'dbs-composer' }, e('div', {
1629
+ className: 'dbs-composerCard',
1630
+ // The whole card is the input's hit area natively — clicking
1631
+ // its padding must land the caret, not swallow the click.
1632
+ // Buttons and the mention menu keep their own targets.
1633
+ onMouseDown: (ev) => {
1634
+ const node = inputRef.current;
1635
+ if (node === null || node === undefined || node === ev.target)
1636
+ return;
1637
+ if (typeof ev.target?.closest === 'function'
1638
+ && ev.target.closest('button, textarea, input, .dbs-mention') !== null)
1639
+ return;
1640
+ ev.preventDefault();
1641
+ node.focus();
1642
+ },
1643
+ }, mentionHits.length > 0
1644
+ ? e('div', { className: 'dbs-mention' }, mentionHits.map((a, i) => e('div', {
1645
+ key: a.id, className: 'dbs-mentionRow', 'data-active': i === (mention?.index ?? 0),
1646
+ onMouseDown: (ev) => { ev.preventDefault(); applyMention(a); },
1647
+ }, e(Avatar, { agent: a, size: 18 }), a.name)))
1648
+ : null, e('div', { className: 'dbs-composerScroll' }, e('textarea', {
1649
+ ref: inputRef, className: 'dbs-composerInput', value: input, rows: 1,
1650
+ placeholder: isGroup ? t('chat.placeholder.group') : t('chat.placeholder.single', { name: agent?.name ?? '' }),
1651
+ // Native chrome: no spellcheck squiggles, no autocomplete
1652
+ // dropdown over the card, and a plain multi-line seat.
1653
+ spellCheck: false, autoComplete: 'off', autoCorrect: 'off', autoCapitalize: 'off',
1654
+ onChange: (ev) => onInputChange(ev.target),
1655
+ // Caret moves (click, arrows, Home/End) re-evaluate the
1656
+ // mention token, so the menu tracks the caret, not typing.
1657
+ onSelect: (ev) => syncMention(ev.target),
1658
+ onCompositionStart: () => { imeRef.current = true; },
1659
+ onCompositionEnd: (ev) => {
1660
+ imeRef.current = false;
1661
+ // Firefox/Safari fire this *after* the keydown that
1662
+ // committed the candidate, so the committed value has to
1663
+ // be picked up here rather than in the change handler.
1664
+ onInputChange(ev.target);
1665
+ },
1666
+ onBlur: () => setMention(null),
1667
+ onKeyDown: (ev) => {
1668
+ // While an IME candidate window is open it owns Enter,
1669
+ // the arrows and Escape. `isComposing` is the standard
1670
+ // signal; keyCode 229 covers the engines that omit it.
1671
+ if (imeRef.current || ev.nativeEvent?.isComposing === true || ev.keyCode === 229)
1672
+ return;
1673
+ if (mentionHits.length > 0 && (ev.key === 'Enter' || ev.key === 'Tab')) {
1674
+ ev.preventDefault();
1675
+ applyMention(mentionHits[mention?.index ?? 0]);
1676
+ return;
1677
+ }
1678
+ if (mentionHits.length > 0 && (ev.key === 'ArrowDown' || ev.key === 'ArrowUp')) {
1679
+ ev.preventDefault();
1680
+ const d = ev.key === 'ArrowDown' ? 1 : -1;
1681
+ const n = mentionHits.length;
1682
+ setMention({ ...mention, index: (((mention?.index ?? 0) + d) % n + n) % n });
1683
+ return;
1684
+ }
1685
+ if (ev.key === 'Escape' && mention !== null) {
1686
+ // Dismissing the menu must not also reach the overlay's
1687
+ // window-level Escape, which would close the chat.
1688
+ ev.preventDefault();
1689
+ ev.stopPropagation();
1690
+ setMention(null);
1691
+ return;
1692
+ }
1693
+ if (ev.key === 'Enter' && !ev.shiftKey) {
1694
+ ev.preventDefault();
1695
+ void doSend();
1696
+ }
1697
+ },
1698
+ })), e('div', { className: 'dbs-composerRow' }, e('span', { className: 'dbs-meta' }, composing ? t('chat.composingHint') : input.trim() !== '' ? t('chat.charCount', { n: input.trim().length }) : ''), e('div', { className: 'dbs-composerTrailing' }, composing
1699
+ ? e('button', {
1700
+ type: 'button', className: 'dbs-send',
1701
+ // Native contract (§12-29): the send button morphs
1702
+ // into a stop square while a run is active. The
1703
+ // gateway now exposes interruptAgent (engine patch,
1704
+ // e2e-verified), so the stop is real — `hadActiveRun`
1705
+ // tells "stopped" from "nothing to stop".
1706
+ disabled: stopping,
1707
+ title: t('action.stop'), 'aria-label': t('action.stop'),
1708
+ onClick: () => void doStop(),
1709
+ }, e(StopSquareIcon, null))
1710
+ : e('button', {
1711
+ type: 'button', className: 'dbs-send',
1712
+ disabled: sending || input.trim() === '',
1713
+ title: t('action.send'),
1714
+ 'aria-label': t('action.send'),
1715
+ onClick: () => void doSend(),
1716
+ }, e(SendUpIcon, null))))))));
1717
+ }
1718
+ // =========================================================
1719
+ // Settings section.
1720
+ // =========================================================
1721
+ /**
1722
+ * MCP servers card (DEVELOPMENT.md §13): the engine hosts the MCP
1723
+ * stack, so this surface is a thin management view — list with live
1724
+ * status, add (stdio or URL config JSON), remove (two-click confirm),
1725
+ * restart. Tool try-run lives in the workbench, not here.
1726
+ */
1727
+ function McpCard() {
1728
+ const [servers, setServers] = React.useState(null);
1729
+ const [toolCount, setToolCount] = React.useState(null);
1730
+ const [err, setErr] = React.useState(null);
1731
+ const [busy, setBusy] = React.useState(false);
1732
+ const [confirmId, setConfirmId] = React.useState(null);
1733
+ const [adding, setAdding] = React.useState(false);
1734
+ const [name, setName] = React.useState('');
1735
+ const [configJson, setConfigJson] = React.useState('');
1736
+ async function reload() {
1737
+ setErr(null);
1738
+ try {
1739
+ const s = await botsCall('mcpServers', {});
1740
+ setServers((s?.servers ?? []));
1741
+ const tl = await botsCall('mcpTools', {});
1742
+ setToolCount((tl?.tools ?? []).length);
1743
+ }
1744
+ catch (e2) {
1745
+ setErr(String(e2?.message ?? e2));
1746
+ }
1747
+ }
1748
+ React.useEffect(() => { void reload(); }, []);
1749
+ async function run(fn) {
1750
+ setBusy(true);
1751
+ setErr(null);
1752
+ try {
1753
+ await fn();
1754
+ await reload();
1755
+ }
1756
+ catch (e2) {
1757
+ setErr(String(e2?.message ?? e2));
1758
+ }
1759
+ finally {
1760
+ setBusy(false);
1761
+ }
1762
+ }
1763
+ function dotState(status) {
1764
+ if (status === 'connected')
1765
+ return 'done';
1766
+ if (status === 'needsAuth')
1767
+ return 'warning';
1768
+ if (status === 'error')
1769
+ return 'failed';
1770
+ return 'ongoing';
1771
+ }
1772
+ async function submitAdd() {
1773
+ if (busy || name.trim() === '' || configJson.trim() === '')
1774
+ return;
1775
+ await run(async () => {
1776
+ await botsCall('mcpAdd', { name: name.trim(), configJson: configJson.trim() });
1777
+ setAdding(false);
1778
+ setName('');
1779
+ setConfigJson('');
1780
+ });
1781
+ }
1782
+ return e('div', { className: 'dbs-setcard' }, e('div', { className: 'dbs-sethead' }, e('span', { className: 'dbs-title' }, t('settings.mcp')), e('span', { style: { flex: 1 } }), e(Button, { variant: 'ghost', size: 'sm', disabled: busy, onClick: () => void reload() }, t('action.refresh')), e(Button, {
1783
+ variant: 'ghost', size: 'sm', disabled: busy,
1784
+ onClick: () => void run(() => botsCall('mcpRefresh', {})),
1785
+ }, busy ? t('settings.mcp.restarting') : t('settings.mcp.restart')), e(Button, { variant: 'outline', size: 'sm', disabled: busy, onClick: () => setAdding(!adding) }, t('settings.mcp.add'))), e('div', { className: 'dbs-setrow' }, t('settings.mcp.summary')), toolCount !== null
1786
+ ? e('div', { className: 'dbs-setrow' }, t('settings.mcp.tools'), e('b', null, String(toolCount)))
1787
+ : null, adding ? e('div', { className: 'dbs-setcard', style: { margin: '6px 0' } }, e(Input, {
1788
+ placeholder: t('settings.mcp.namePlaceholder'), value: name, disabled: busy,
1789
+ onChange: (ev) => setName(ev.target.value),
1790
+ }), e('textarea', {
1791
+ placeholder: t('settings.mcp.configPlaceholder'), value: configJson, disabled: busy,
1792
+ rows: 3, onChange: (ev) => setConfigJson(ev.target.value),
1793
+ style: {
1794
+ width: '100%', marginTop: 6, padding: '6px 8px', fontSize: 12, lineHeight: '18px',
1795
+ borderRadius: 8, border: '1px solid var(--dsw-alias-border-l, #ccc)', resize: 'vertical',
1796
+ background: 'transparent', color: 'var(--dsw-alias-label-primary, inherit)', fontFamily: 'monospace',
1797
+ },
1798
+ }), e('div', { className: 'dbs-setrow', style: { marginTop: 6 } }, e(Button, {
1799
+ variant: 'ghost', size: 'sm', disabled: busy,
1800
+ onClick: () => setConfigJson(t('settings.mcp.exampleStdioValue')),
1801
+ }, t('settings.mcp.exampleStdio')), e(Button, {
1802
+ variant: 'ghost', size: 'sm', disabled: busy,
1803
+ onClick: () => setConfigJson(t('settings.mcp.exampleUrlValue')),
1804
+ }, t('settings.mcp.exampleUrl')), e('span', { style: { flex: 1 } }), e(Button, {
1805
+ variant: 'primary', size: 'sm', disabled: busy || name.trim() === '' || configJson.trim() === '',
1806
+ onClick: () => void submitAdd(),
1807
+ }, busy ? t('settings.mcp.adding') : t('settings.mcp.add')))) : null, servers === null
1808
+ ? e('div', { className: 'dbs-setrow' }, t('settings.reading'))
1809
+ : servers.length === 0
1810
+ ? e('div', { className: 'dbs-setrow' }, t('settings.mcp.empty'))
1811
+ : servers.map((sv) => e('div', {
1812
+ key: sv.id || sv.serverIdentifier, className: 'dbs-setrow',
1813
+ style: { alignItems: 'center' },
1814
+ }, StateDot !== null ? e(StateDot, { state: dotState(sv.status), size: 8 }) : null, e('b', null, sv.name || sv.serverIdentifier), e('span', { className: 'dbs-meta' }, ` ${sv.status}${sv.transport !== '' ? ' · ' + sv.transport : ''} · ${sv.toolCount} tools`), sv.statusDetail ? e('span', { className: 'dbs-meta' }, ` · ${sv.statusDetail}`) : null, e('span', { style: { flex: 1 } }), e(Button, {
1815
+ variant: 'ghost', size: 'sm', disabled: busy,
1816
+ onClick: () => {
1817
+ if (confirmId !== sv.id) {
1818
+ setConfirmId(sv.id);
1819
+ return;
1820
+ }
1821
+ setConfirmId(null);
1822
+ void run(() => botsCall('mcpRemove', { serverId: sv.id }));
1823
+ },
1824
+ }, confirmId === sv.id ? t('settings.mcp.removeConfirm') : t('settings.mcp.remove')))), err !== null ? e('div', { className: 'dbs-setrow' }, t('settings.mcp.failed'), err) : null);
1825
+ }
1826
+ /**
1827
+ * Workspace card: per-agent Seatbelt jail management (the engine owns
1828
+ * the isolation; this surface edits each agent's settings.json jail
1829
+ * keys — see DEVELOPMENT.md §14). Toggling writes the config; the jail
1830
+ * arms on the bot's NEXT turn, no restart.
1831
+ */
1832
+ function WorkspaceCard() {
1833
+ const [rows, setRows] = React.useState(null);
1834
+ const [names, setNames] = React.useState({});
1835
+ const [err, setErr] = React.useState(null);
1836
+ const [busy, setBusy] = React.useState(false);
1837
+ const [confirmId, setConfirmId] = React.useState(null);
1838
+ async function reload() {
1839
+ setErr(null);
1840
+ try {
1841
+ const ws = await botsCall('workspaceList', {});
1842
+ setRows((ws?.workspaces ?? []));
1843
+ const agents = await botsCall('list', {});
1844
+ const map = {};
1845
+ for (const a of agents?.agents ?? [])
1846
+ map[a.id] = a.name;
1847
+ setNames(map);
1848
+ }
1849
+ catch (e2) {
1850
+ setErr(String(e2?.message ?? e2));
1851
+ }
1852
+ }
1853
+ React.useEffect(() => { void reload(); }, []);
1854
+ async function toggle(row) {
1855
+ if (busy)
1856
+ return;
1857
+ setBusy(true);
1858
+ setErr(null);
1859
+ try {
1860
+ if (row.workspaceRoot === null) {
1861
+ const slug = (names[row.agentId] ?? row.agentId).trim();
1862
+ await botsCall('workspaceSet', { agentId: row.agentId, workspaceRoot: '/workspace/' + slug });
1863
+ }
1864
+ else {
1865
+ await botsCall('workspaceSet', { agentId: row.agentId, workspaceRoot: null });
1866
+ }
1867
+ await reload();
1868
+ }
1869
+ catch (e2) {
1870
+ setErr(String(e2?.message ?? e2));
1871
+ }
1872
+ finally {
1873
+ setBusy(false);
1874
+ }
1875
+ }
1876
+ const jailed = (rows ?? []).filter((r) => r.workspaceRoot !== null).length;
1877
+ return e('div', { className: 'dbs-setcard' }, e('div', { className: 'dbs-sethead' }, e('span', { className: 'dbs-title' }, t('settings.jail.title')), e('span', { style: { flex: 1 } }), e(Button, { variant: 'ghost', size: 'sm', disabled: busy, onClick: () => void reload() }, t('action.refresh'))), e('div', { className: 'dbs-setrow' }, t('settings.jail.summary')), rows !== null ? e('div', { className: 'dbs-setrow' }, t('settings.jail.count'), e('b', null, `${jailed} / ${rows.length}`)) : null, rows === null
1878
+ ? e('div', { className: 'dbs-setrow' }, t('settings.reading'))
1879
+ : rows.map((row) => e('div', {
1880
+ key: row.agentId, className: 'dbs-setrow', style: { alignItems: 'center' },
1881
+ }, row.workspaceRoot !== null && StateDot !== null ? e(StateDot, { state: 'done', size: 8 }) : null, e('b', null, names[row.agentId] ?? row.agentId), row.workspaceRoot !== null
1882
+ ? e('span', { className: 'dbs-meta' }, ` ${row.workspaceRoot}${row.allowPaths.length > 0 ? ` · +${row.allowPaths.length}` : ''}`)
1883
+ : null, e('span', { style: { flex: 1 } }), e(Button, {
1884
+ variant: row.workspaceRoot !== null ? 'ghost' : 'outline', size: 'sm', disabled: busy,
1885
+ onClick: () => {
1886
+ if (row.workspaceRoot !== null && confirmId !== row.agentId) {
1887
+ setConfirmId(row.agentId);
1888
+ return;
1889
+ }
1890
+ setConfirmId(null);
1891
+ void toggle(row);
1892
+ },
1893
+ }, row.workspaceRoot !== null
1894
+ ? (confirmId === row.agentId ? t('settings.jail.offConfirm') : t('settings.jail.off'))
1895
+ : t('settings.jail.on')))), err !== null ? e('div', { className: 'dbs-setrow' }, t('settings.mcp.failed'), err) : null);
1896
+ }
1897
+ function BotsSettings() {
1898
+ const [info, setInfo] = React.useState(undefined);
1899
+ const [sse, setSse] = React.useState(null);
1900
+ const [err, setErr] = React.useState(null);
1901
+ async function refresh() {
1902
+ setInfo(undefined);
1903
+ setErr(null);
1904
+ try {
1905
+ setInfo(await botsCall('gatewayInfo', {}));
1906
+ setSse(await botsCall('sseState'));
1907
+ }
1908
+ catch (e2) {
1909
+ setErr(String(e2?.message ?? e2));
1910
+ }
1911
+ }
1912
+ React.useEffect(() => { void refresh(); }, []);
1913
+ const ok = info?.ok === true;
1914
+ return e('div', { className: 'dbs-settings' }, e('div', { className: 'dbs-setrow' }, t('settings.summary')), e('div', { className: 'dbs-setcard' }, e('div', { className: 'dbs-sethead' }, StateDot !== null ? e(StateDot, { state: ok ? 'done' : 'failed', size: 10 }) : null, info === undefined ? t('settings.probing') : ok ? t('gateway.online', { port: info.port }) : t('gateway.offline'), e('span', { style: { flex: 1 } }), e(Button, { variant: 'outline', size: 'sm', onClick: () => void refresh() }, t('action.refresh'))), ok ? e('div', null, e('div', { className: 'dbs-setrow' }, t('settings.address'), e('b', null, info.baseUrl)), e('div', { className: 'dbs-setrow' }, t('settings.pid'), e('b', null, String(info.pid)), ' · ' + (info.health?.isBusy === true ? t('settings.busy') : t('settings.idle'))), e('div', { className: 'dbs-setrow' }, t('settings.auth'), e('b', null, info.hasToken === true ? t('settings.auth.token') : t('settings.auth.none')))) : null, info !== undefined && !ok ? e('div', { className: 'dbs-setrow' }, t('settings.reason'), e('b', null, info.reason ?? t('settings.reason.unknown'))) : null, err !== null ? e('div', { className: 'dbs-setrow' }, err) : null), e('div', { className: 'dbs-setcard' }, e('div', { className: 'dbs-setrow' }, t('settings.dataDir'), e('b', null, info?.dataDir ?? t('settings.reading'))), info?.workspaceRoot
1915
+ ? e('div', { className: 'dbs-setrow' }, t('settings.workspaceRoot'), e('b', null, info.workspaceRoot))
1916
+ : null, e('div', { className: 'dbs-setrow' }, t('settings.events'), e('b', null, sse?.running === true ? t('settings.events.on', { n: sse.buffered ?? 0 }) : t('settings.events.off')), sse?.lastError ? ' · ' + String(sse.lastError) : ''), e('div', { className: 'dbs-setrow' }, t('settings.entry'), e('b', null, t('settings.entry.value')))), info?.ok === true ? e(WorkspaceCard) : null, info?.ok === true ? e(McpCard) : null);
1917
+ }
1918
+ // =========================================================
1919
+ // Overlay root: hosts the chat surface only.
1920
+ // =========================================================
1921
+ /**
1922
+ * Create dialog ("新建 Bot" / "新建群聊") as a system-style modal:
1923
+ * fixed backdrop + centred card rendered from shell.overlay, matching
1924
+ * the wfx main-window modal parameters (rgba(0,0,0,.45), bg-layer-1,
1925
+ * 14px radius, entrance animation). ESC and backdrop clicks dismiss.
1926
+ */
1927
+ function CreateModal() {
1928
+ const s = useStore();
1929
+ const [err, setErr] = React.useState(null);
1930
+ const isGroup = s.create === 'group';
1931
+ const singles = s.agents.filter((a) => !a.isGroup && a.isHiddenFromSidebar !== true);
1932
+ const picked = Object.keys(s.createMembers).filter((k) => s.createMembers[k]);
1933
+ const nameOk = s.createName.trim() !== '';
1934
+ const valid = nameOk && (!isGroup || picked.length > 0);
1935
+ async function submit() {
1936
+ if (!valid || s.createWorking)
1937
+ return;
1938
+ patch({ createWorking: true, error: null });
1939
+ setErr(null);
1940
+ try {
1941
+ if (isGroup)
1942
+ await botsCall('createGroup', { name: s.createName.trim(), memberIds: picked });
1943
+ else
1944
+ await botsCall('create', { name: s.createName.trim(), description: s.createDesc.trim() });
1945
+ patch({ create: null, createName: '', createDesc: '', createMembers: {}, createWorking: false });
1946
+ await refreshAgents();
1947
+ return;
1948
+ }
1949
+ catch (e2) {
1950
+ setErr(String(e2?.message ?? e2));
1951
+ }
1952
+ patch({ createWorking: false });
1953
+ }
1954
+ return e('div', {
1955
+ className: 'dbs-modalBackdrop',
1956
+ onClick: () => { if (!s.createWorking)
1957
+ patch({ create: null }); },
1958
+ }, e('div', {
1959
+ className: 'dbs-modalCard', role: 'dialog', 'aria-modal': true,
1960
+ 'aria-label': isGroup ? t('group.new') : t('bot.new'),
1961
+ onClick: (ev) => { ev.stopPropagation(); },
1962
+ }, e('div', { className: 'dbs-modalTitleRow' }, e('span', { className: 'dbs-modalTitle' }, isGroup ? t('group.new') : t('bot.new')), e(Button, {
1963
+ variant: 'ghost', size: 'sm', title: t('action.close'), 'aria-label': t('action.close'),
1964
+ disabled: s.createWorking,
1965
+ icon: Ico('IconCloseOutline16', { size: 16 }),
1966
+ onClick: () => patch({ create: null }),
1967
+ })), e('div', { className: 'dbs-modalBody' }, e(Input, {
1968
+ placeholder: isGroup ? t('group.namePlaceholder') : t('bot.namePlaceholder'),
1969
+ value: s.createName, autoFocus: true, disabled: s.createWorking,
1970
+ onChange: (ev) => patch({ createName: ev.target.value }),
1971
+ onKeyDown: (ev) => { if (ev.key === 'Enter') {
1972
+ ev.preventDefault();
1973
+ void submit();
1974
+ } },
1975
+ }), !isGroup
1976
+ ? e(Input, {
1977
+ placeholder: t('bot.descPlaceholder'), value: s.createDesc, disabled: s.createWorking,
1978
+ onChange: (ev) => patch({ createDesc: ev.target.value }),
1979
+ })
1980
+ : e('div', { className: 'dbs-modalMembers' }, singles.length === 0
1981
+ ? e('div', { className: 'dbs-meta', style: { padding: '4px 6px' } }, t('list.loading'))
1982
+ : singles.map((m) => e('div', {
1983
+ key: m.id,
1984
+ className: 'dbs-member' + (s.createMembers[m.id] ? ' checked' : ''),
1985
+ style: { cursor: 'pointer', padding: '4px 6px', borderRadius: 8 },
1986
+ onClick: () => patch({ createMembers: { ...s.createMembers, [m.id]: !s.createMembers[m.id] } }),
1987
+ }, e('input', { type: 'checkbox', checked: Boolean(s.createMembers[m.id]), readOnly: true }), e(Avatar, { agent: m, size: 18 }), e('span', { className: 'dbs-title' }, m.name))), isGroup && singles.length > 0
1988
+ ? e('div', { className: 'dbs-meta', style: { padding: '6px 6px 0' } }, t('chat.group', { n: picked.length }))
1989
+ : null), err !== null
1990
+ ? e('div', { className: 'dbs-error', onClick: () => { setErr(null); } }, err)
1991
+ : null), e('div', { className: 'dbs-modalFooter' }, e(Button, {
1992
+ variant: 'ghost', size: 'sm', disabled: s.createWorking,
1993
+ onClick: () => patch({ create: null }),
1994
+ }, t('action.cancel')), e(Button, {
1995
+ variant: 'primary', size: 'sm', disabled: !valid || s.createWorking,
1996
+ onClick: () => void submit(),
1997
+ }, t('action.create')))));
1998
+ }
1999
+ /**
2000
+ * Manage group members: full-set checkbox editor over the group's
2001
+ * current memberIds — add and remove are the same save (the gateway
2002
+ * command is a member-list put, not a delta). Same dialog chrome and
2003
+ * member-row markup as CreateModal.
2004
+ */
2005
+ function ManageMembersModal() {
2006
+ const s = useStore();
2007
+ const group = agentById(s.manageMembers);
2008
+ const [picked, setPicked] = React.useState(null);
2009
+ const [err, setErr] = React.useState(null);
2010
+ const [working, setWorking] = React.useState(false);
2011
+ // Seed once per opened group; a group vanishing mid-edit (deleted by
2012
+ // the swarm, say) just renders the modal inert until closed.
2013
+ React.useEffect(() => {
2014
+ if (group === null || group === undefined)
2015
+ return;
2016
+ const init = {};
2017
+ for (const id of group.memberIds ?? [])
2018
+ init[id] = true;
2019
+ setPicked(init);
2020
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2021
+ }, [s.manageMembers]);
2022
+ if (group === null || group === undefined)
2023
+ return null;
2024
+ const singles = s.agents.filter((a) => !a.isGroup && a.isHiddenFromSidebar !== true);
2025
+ const pickedIds = picked === null ? [] : Object.keys(picked).filter((k) => picked[k]);
2026
+ async function submit() {
2027
+ if (working || picked === null)
2028
+ return;
2029
+ setWorking(true);
2030
+ setErr(null);
2031
+ try {
2032
+ await botsCall('setGroupMembers', { id: group.id, memberIds: pickedIds });
2033
+ patch({ manageMembers: null });
2034
+ await refreshAgents();
2035
+ return;
2036
+ }
2037
+ catch (e2) {
2038
+ setErr(String(e2?.message ?? e2));
2039
+ }
2040
+ setWorking(false);
2041
+ }
2042
+ return e('div', {
2043
+ className: 'dbs-modalBackdrop',
2044
+ onClick: () => { if (!working)
2045
+ patch({ manageMembers: null }); },
2046
+ }, e('div', {
2047
+ className: 'dbs-modalCard', role: 'dialog', 'aria-modal': true,
2048
+ 'aria-label': t('modal.members.title'),
2049
+ onClick: (ev) => { ev.stopPropagation(); },
2050
+ }, e('div', { className: 'dbs-modalTitleRow' }, e('span', { className: 'dbs-modalTitle' }, t('modal.members.title')), e(Button, {
2051
+ variant: 'ghost', size: 'sm', title: t('action.close'), 'aria-label': t('action.close'),
2052
+ disabled: working,
2053
+ icon: Ico('IconCloseOutline16', { size: 16 }),
2054
+ onClick: () => patch({ manageMembers: null }),
2055
+ })), e('div', { className: 'dbs-modalBody' }, e('div', { className: 'dbs-meta', style: { padding: '0 2px 6px' } }, t('modal.members.hint')), picked === null
2056
+ ? e('div', { className: 'dbs-meta', style: { padding: '4px 6px' } }, t('list.loading'))
2057
+ : e('div', { className: 'dbs-modalMembers' }, singles.length === 0
2058
+ ? e('div', { className: 'dbs-meta', style: { padding: '4px 6px' } }, t('list.empty'))
2059
+ : singles.map((m) => e('div', {
2060
+ key: m.id,
2061
+ className: 'dbs-member' + (picked[m.id] ? ' checked' : ''),
2062
+ style: { cursor: 'pointer', padding: '4px 6px', borderRadius: 8 },
2063
+ onClick: () => setPicked({ ...picked, [m.id]: !picked[m.id] }),
2064
+ }, e('input', { type: 'checkbox', checked: Boolean(picked[m.id]), readOnly: true }), e(Avatar, { agent: m, size: 18 }), e('span', { className: 'dbs-title' }, m.name))), singles.length > 0
2065
+ ? e('div', { className: 'dbs-meta', style: { padding: '6px 6px 0' } }, t('chat.group', { n: pickedIds.length }))
2066
+ : null), err !== null
2067
+ ? e('div', { className: 'dbs-error', onClick: () => { setErr(null); } }, err)
2068
+ : null), e('div', { className: 'dbs-modalFooter' }, e(Button, {
2069
+ variant: 'ghost', size: 'sm', disabled: working,
2070
+ onClick: () => patch({ manageMembers: null }),
2071
+ }, t('action.cancel')), e(Button, {
2072
+ variant: 'primary', size: 'sm', disabled: picked === null || working,
2073
+ onClick: () => void submit(),
2074
+ }, working ? t('action.saving') : t('action.save')))));
2075
+ }
2076
+ /** Delete confirmation modal: same system-dialog chrome as CreateModal. */
2077
+ function ConfirmDeleteModal() {
2078
+ const s = useStore();
2079
+ const target = s.confirmDelete;
2080
+ const [err, setErr] = React.useState(null);
2081
+ const [working, setWorking] = React.useState(false);
2082
+ const isGroup = target?.isGroup === true;
2083
+ async function doDelete() {
2084
+ if (target === null || target === undefined || working)
2085
+ return;
2086
+ setWorking(true);
2087
+ setErr(null);
2088
+ try {
2089
+ await botsCall('remove', { id: target.id });
2090
+ // The chat for a deleted bot must not survive its bot.
2091
+ const closeChat = state.chatAgentId === target.id;
2092
+ patch({ confirmDelete: null });
2093
+ if (closeChat)
2094
+ patch({ chatAgentId: null });
2095
+ await refreshAgents();
2096
+ return;
2097
+ }
2098
+ catch (e2) {
2099
+ setErr(String(e2?.message ?? e2));
2100
+ }
2101
+ setWorking(false);
2102
+ }
2103
+ return e('div', {
2104
+ className: 'dbs-modalBackdrop',
2105
+ onClick: () => { if (!working)
2106
+ patch({ confirmDelete: null }); },
2107
+ }, e('div', {
2108
+ className: 'dbs-modalCard', role: 'dialog', 'aria-modal': true,
2109
+ 'aria-label': isGroup ? t('delete.title.group') : t('delete.title.bot'),
2110
+ onClick: (ev) => { ev.stopPropagation(); },
2111
+ }, e('div', { className: 'dbs-modalTitleRow' }, e('span', { className: 'dbs-modalTitle' }, isGroup ? t('delete.title.group') : t('delete.title.bot')), e(Button, {
2112
+ variant: 'ghost', size: 'sm', title: t('action.close'), 'aria-label': t('action.close'),
2113
+ disabled: working,
2114
+ icon: Ico('IconCloseOutline16', { size: 16 }),
2115
+ onClick: () => patch({ confirmDelete: null }),
2116
+ })), e('div', { className: 'dbs-modalBody' }, e('span', { className: 'dbs-meta', style: { fontSize: 14, lineHeight: 22 } }, t('delete.confirm', { name: target?.name ?? '' })), err !== null
2117
+ ? e('div', { className: 'dbs-error', onClick: () => { setErr(null); } }, err)
2118
+ : null), e('div', { className: 'dbs-modalFooter' }, e(Button, {
2119
+ variant: 'ghost', size: 'sm', disabled: working,
2120
+ onClick: () => patch({ confirmDelete: null }),
2121
+ }, t('action.cancel')), e(Button, {
2122
+ variant: 'primary', size: 'sm', disabled: working,
2123
+ onClick: () => void doDelete(),
2124
+ }, working ? t('delete.working') : t('action.delete')))));
2125
+ }
2126
+ function BotsLayer() {
2127
+ const s = useStore();
2128
+ React.useEffect(() => {
2129
+ function onKey(ev) {
2130
+ if (ev.key !== 'Escape')
2131
+ return;
2132
+ // Topmost dialog first: delete confirm, then create, then chat.
2133
+ if (state.confirmDelete !== null) {
2134
+ patch({ confirmDelete: null });
2135
+ return;
2136
+ }
2137
+ if (state.create !== null) {
2138
+ patch({ create: null });
2139
+ return;
2140
+ }
2141
+ if (state.chatAgentId !== null)
2142
+ patch({ chatAgentId: null });
2143
+ }
2144
+ window.addEventListener('keydown', onKey);
2145
+ return () => { window.removeEventListener('keydown', onKey); };
2146
+ }, []);
2147
+ const layers = [];
2148
+ if (s.chatAgentId !== null)
2149
+ layers.push(e(ChatView, { agentId: s.chatAgentId, key: s.chatAgentId }));
2150
+ if (s.create !== null)
2151
+ layers.push(e(CreateModal, { key: 'create' }));
2152
+ if (s.manageMembers !== null)
2153
+ layers.push(e(ManageMembersModal, { key: 'manage-members' }));
2154
+ if (s.confirmDelete !== null)
2155
+ layers.push(e(ConfirmDeleteModal, { key: 'confirm-delete' }));
2156
+ return layers.length === 0 ? null : e('div', null, layers);
2157
+ }
2158
+ // =========================================================
2159
+ // Slot registration (formal runtime contract).
2160
+ // =========================================================
2161
+ const inject = ['connection', 'slots', 'locale'];
2162
+ function apply(c) {
2163
+ ctx = c;
2164
+ connectionSvc = c.get('connection');
2165
+ slotsSvc = c.get('slots');
2166
+ const slots = slotsSvc;
2167
+ if (slots === undefined || slots === null)
2168
+ return;
2169
+ c.effect(() => {
2170
+ const styleEl = document.createElement('style');
2171
+ styleEl.setAttribute('data-dsh-plugin', 'dsh-bots');
2172
+ styleEl.textContent = CSS;
2173
+ document.head.appendChild(styleEl);
2174
+ return () => { styleEl.remove(); };
2175
+ }, 'dsh-bots: styles');
2176
+ // Dictionaries first: a slot may render on the same tick it registers.
2177
+ const locale = c.get('locale');
2178
+ if (locale !== undefined && locale !== null) {
2179
+ c.effect(() => locale.register(NS, { zh, en }), 'dsh-bots: dictionaries');
2180
+ boundT = locale.bind(NS);
2181
+ // Our surfaces read `t` from module scope rather than from the prop
2182
+ // the renderer hands the slot root, because the strings live six
2183
+ // components deep. That means a language switch has to be pushed
2184
+ // into our own store to re-render them.
2185
+ c.effect(() => locale.subscribe(() => { patch({ localeRev: state.localeRev + 1 }); }), 'dsh-bots: locale refresh');
2186
+ }
2187
+ c.effect(() => slots.inject('shell.overlay', () => slots.register({ name: 'shell.overlay', id: 'dsh-bots.chat', order: 20, registrant: 'dsh-bots', locale: NS }, () => e(BotsLayer))), 'dsh-bots: chat overlay');
2188
+ // Shadow the single workspace slot at a lower priority (lowest renders);
2189
+ // the shipped entry stays registered and is delegated to by name.
2190
+ c.effect(() => slots.inject('sidebar.workspaces', () => slots.register({ name: 'sidebar.workspaces', priority: -100, registrant: 'dsh-bots', locale: NS }, (props) => e(SidebarNav, { wide: props.wide, expandSidebar: props.expandSidebar }))), 'dsh-bots: sidebar workspaces shadow');
2191
+ c.effect(() => slots.inject('settings.section', () => slots.register({ name: 'settings.section', id: 'bots', order: 40, label: () => t('nav.bots'), registrant: 'dsh-bots', locale: NS }, () => e(BotsSettings))), 'dsh-bots: settings section');
2192
+ reportDiag('apply', { slots: ['shell.overlay', 'sidebar.workspaces', 'settings.section'] });
2193
+ }
2194
+ exports.apply = apply;
2195
+ exports.inject = inject;
2196
+ return module.exports;
2197
+ },
2198
+ });
2199
+ })();