@twinsearth/w2m-dsh-plugin 1.1.18 → 1.1.20

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 CHANGED
@@ -1,640 +1,850 @@
1
- /**
2
- * DSH Web client half of `@twinsearth/w2m-dsh-plugin`: the composer's target-machine control.
3
- *
4
- * # What this file is
5
- *
6
- * This is the *shipped* client bundle, not compiled output: the package has no build step and no
7
- * dependencies, so the half the browser loads is written here in the exact module format the DSH
8
- * web shell's loader consumes (`window.__ModuleLoader__.load({ id, factory })`, a lazy CJS table
9
- * whose factory runs at materialization). `package.json` declares it with
10
- * `dsh.client: { platform: 'web' }` and `exports['./client'] = './lib/client.js'`, which is what the
11
- * host scans for. There is no second copy to keep in sync: this file **is** the source, and
12
- * `test/client-bundle.test.mjs` is where its entry shape is read.
13
- *
14
- * # A failed client entry is fatal, and silence is not a neutral failure
15
- *
16
- * Measured twice in production: when this entry's `apply` throws, the page does not degrade — the
17
- * shell audits every entry after activation and refuses to boot with
18
- * `web boot: 1 entry did not activate / @twinsearth/w2m-dsh-plugin: failed`, which in the desktop
19
- * app is forwarded to the main process and written as a `web-boot` crash: **the app does not start**.
20
- * The audit prints the entry's *name* and its fiber state and nothing else, so the cause has to be
21
- * carried by the failing code itself; {@link apply} therefore reports its own error to
22
- * `console.error`, which is the error-level renderer console the crash log already captures.
23
- *
24
- * The failure that cost those two outages was a single property read. Cordis resolves `ctx.<name>`
25
- * through the fiber's `inject` list: a service the fiber never declared **throws**
26
- * (`cannot get property "locale" without inject`) whether or not some plugin provides it, and every
27
- * name that *is* declared becomes a required dependency the entry waits for. Both halves of that rule
28
- * are load-bearing here — see {@link inject} for what this bundle declares, and how the optional
29
- * locale carrier is read instead.
30
- *
31
- * Two consequences, both deliberate:
32
- *
33
- * * **No JSX and no bundler.** `React.createElement` keeps the file plain JavaScript a reviewer
34
- * can read, and the module system's platform seed table already holds React (`react`,
35
- * `react/jsx-runtime`, `react-dom`, Cordis and the shell's static UI libraries are frozen seeds
36
- * for every dynamic bundle), so `require('react')` resolves without this package shipping or
37
- * declaring anything.
38
- * * **Only React is required.** `@deepseek-ai/dsh-client-ui-primitives` is *available* to a
39
- * dynamic bundle — the shipped permission control next to this one uses its `Menu` — but a
40
- * package that must stay dependency-free should not take a rendering dependency for one
41
- * dropdown. The control is styled with the shell's own theme tokens instead, which is what
42
- * makes it correct in both themes rather than "similar in one".
43
- *
44
- * # Where it sits
45
- *
46
- * `conversation.input.left` — `kind: list`, `scope: session`, `replaceRisk: none`. The shipped
47
- * permission control owns `conversation.input.permission` (`single`, `replaceRisk:
48
- * shadows-shipped-ui`) and is **not** touched: this entry is appended to the composer's left group,
49
- * so it renders to the right of the permission control and never competes with it for a seat.
50
- *
51
- * # What it shows
52
- *
53
- * The machine this session's work will be dispatched to, and — on click — the fleet the relay
54
- * currently holds. Liveness comes from the host plugin, never from this file: a machine is
55
- * selectable because the relay lists it as online, and one that is not listed that way is rendered
56
- * with its state and cannot be chosen. A selection is written to the host process, so it survives a
57
- * reload and is visible to `w2m_run`, which is where it actually means something.
58
- */
59
-
60
- window.__ModuleLoader__.load({
61
- id: '@twinsearth/w2m-dsh-plugin',
62
- factory: (require) => {
63
- var module = { exports: {} };
64
- var exports = module.exports;
65
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
66
-
67
- const React = require('react');
68
-
69
- // -------------------------------------------------------------------------------------------
70
- // Wire
71
- // -------------------------------------------------------------------------------------------
72
- //
73
- // Mirrors TARGET_ROUTE_PATH in src/plugin/fleet-target.mjs, `/api` prefix included: the host's
74
- // Connection service mounts its exact-fetch-route registry under that channel and refuses any
75
- // path that does not start with `/api/`. Plain same-origin `fetch` rather than a Remote method:
76
- // the route is mounted inside the host's authentication fence, so the page's own cookie is the
77
- // credential and there is no descriptor to keep in sync.
78
- const ROUTE = '/api/w2m/target';
79
-
80
- /**
81
- * The locale namespace and its two dictionaries.
82
- *
83
- * Registered through `ctx.locale` when the composition provides it, exactly as the shipped
84
- * permission control beside this one does; the Chinese dictionary is the fallback when it does
85
- * not, because a missing localisation service is not a reason for an operator to lose the
86
- * control. Only the *visible* strings are here. A refusal from the host is reported in the
87
- * host's own words — it is a diagnostic naming a machine, a relay or an HTTP status, and
88
- * translating it would put a second, drifting copy of that vocabulary in the browser.
89
- */
90
- const NS = 'w2m.target';
91
- const DICT = {
92
- zh: {
93
- all: '全部机器',
94
- machine: '机器',
95
- local: '本机',
96
- online: '在线',
97
- noRoster: '无列表',
98
- empty: '机器列表里没有机器',
99
- costAll: '本次会话的派发会送到所有在线机器',
100
- costOne: '本次会话的派发会送到 {name}',
101
- unconfirmed: '(未确认在线)',
102
- targetLabel: '目标机器:{name}',
103
- unreachable: '无法连接插件',
104
- routeMissing: '这个 Harness 没有提供插件目标路由',
105
- fleetUnreadable: '机器列表读取失败',
106
- },
107
- en: {
108
- all: 'All machines',
109
- machine: 'Machine',
110
- local: 'This machine',
111
- online: 'online',
112
- noRoster: 'no roster',
113
- empty: 'the fleet lists no machines',
114
- costAll: 'Work from this session goes to every online machine',
115
- costOne: 'Work from this session goes to {name}',
116
- unconfirmed: ' (not confirmed online)',
117
- targetLabel: 'target machine: {name}',
118
- unreachable: 'the plugin could not be reached',
119
- routeMissing: 'this host does not serve the W2M target route',
120
- fleetUnreadable: 'the fleet could not be read',
121
- },
122
- };
123
-
124
- /**
125
- * A translator over {@link DICT}, preferring the shell's locale service.
126
- *
127
- * `{name}` substitution is deliberately the only templating: these strings have one variable
128
- * each, and a message format would be a dependency this package does not have.
129
- *
130
- * @param localeOf - the shell's locale service, or a function returning it (read at lookup time).
131
- * Passed in rather than read from `ctx` here: the service is optional, and reading an optional
132
- * service off the context is the one operation in this file that can kill the page (see
133
- * {@link readOptionalService}). A function is accepted because the service may arrive *after*
134
- * this entry activates — the shell's `bind()` is live, so a deferred read still localises.
135
- * @returns {(key: string, values?: object) => string}
136
- */
137
- function makeTranslator(localeOf) {
138
- const resolveLocale = () => (typeof localeOf === 'function' ? localeOf() : localeOf);
139
- return (key, values) => {
140
- const locale = resolveLocale();
141
- const bound = typeof locale?.bind === 'function' ? locale.bind(NS) : null;
142
- const template = bound === null
143
- ? (DICT.zh[key] ?? DICT.en[key] ?? key)
144
- : bound(key, values);
145
- const text = typeof template === 'string' && template !== '' ? template : (DICT.en[key] ?? key);
146
- if (values === undefined) return text;
147
- return text.replace(/\{(\w+)\}/g, (match, name) => (values[name] === undefined ? match : String(values[name])));
148
- };
149
- }
150
-
151
- /**
152
- * Read a service the composition may or may not provide, without the inject requirement.
153
- *
154
- * `ctx.get(name)` is Cordis's inject-free lookup, and it is the only correct way to read an
155
- * *optional* service from a plugin: `ctx[name]` throws for anything the fiber did not declare as
156
- * a dependency, and declaring it would make the service *required* — an entry whose declared
157
- * dependency never arrives never activates either, which is fatal in exactly the same way. So a
158
- * carrier that must not be able to block the control (`locale`) is read here, and a service the
159
- * control cannot work without (`slots`) is declared in {@link inject}.
160
- *
161
- * @param ctx - the client plugin context.
162
- * @param name - the service name.
163
- * @returns the service, or undefined when the composition has none (or this is not a Cordis
164
- * context at all, which is how `test/client-bundle.test.mjs` applies this bundle).
165
- */
166
- function readOptionalService(ctx, name) {
167
- return typeof ctx?.get === 'function' ? ctx.get(name) : undefined;
168
- }
169
-
170
- /**
171
- * Register {@link DICT} with the shell's locale service whenever that service exists.
172
- *
173
- * Two paths, one outcome. When the service is already up, the registration is an effect of this
174
- * entry and goes away with it. When it is not up yet, the registration rides a **child fiber**
175
- * (`ctx.inject`) that waits for `locale` without holding this entry back: declaring `locale` in
176
- * {@link inject} instead would make it required, and a composition without a locale service
177
- * would then report this entry as `pending` — which the shell's audit treats exactly like
178
- * `failed`, i.e. the page does not boot. A missing localisation service must never cost an
179
- * operator the control, and must never cost them the app.
180
- *
181
- * @param ctx - the client plugin context.
182
- * @param locale - the shell's locale service, or undefined when the composition has none.
183
- */
184
- function registerDictionaries(ctx, locale) {
185
- if (typeof locale?.register === 'function') {
186
- ctx.effect(() => locale.register(NS, DICT), 'w2m: composer target control dictionaries');
187
- return;
188
- }
189
- if (typeof ctx?.inject !== 'function') return;
190
- ctx.inject(['locale'], (localeCtx) => {
191
- localeCtx.effect(() => localeCtx.locale.register(NS, DICT), 'w2m: composer target control dictionaries');
192
- });
193
- }
194
-
195
- /** One JSON call to the host half, folded into a value: a refusal is never an exception here. */
196
- async function callHost(options) {
197
- let response;
198
- try {
199
- response = await fetch(options.url, options.init);
200
- } catch (error) {
201
- return { ok: false, error: `the plugin could not be reached: ${error instanceof Error ? error.message : String(error)}`, status: 0 };
202
- }
203
- if (response.status === 404) {
204
- return { ok: false, status: 404, error: 'this host does not serve the W2M target route' };
205
- }
206
- let body = null;
207
- try {
208
- body = await response.json();
209
- } catch {
210
- return { ok: false, status: response.status, error: `the plugin answered HTTP ${response.status} with a body that is not JSON` };
211
- }
212
- if (response.ok !== true) {
213
- return { ok: false, status: response.status, error: typeof body?.error === 'string' ? body.error : `HTTP ${response.status}` };
214
- }
215
- return { ok: true, status: response.status, state: body };
216
- }
217
-
218
- const readState = (sessionId, signal) => callHost({
219
- url: `${ROUTE}?session_id=${encodeURIComponent(sessionId)}`,
220
- init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal },
221
- });
222
-
223
- const writeSelection = (sessionId, machineId, machineName) => callHost({
224
- url: ROUTE,
225
- init: {
226
- method: 'POST',
227
- credentials: 'same-origin',
228
- headers: { 'content-type': 'application/json', accept: 'application/json' },
229
- body: JSON.stringify({ session_id: sessionId, machine_id: machineId, machine_name: machineName }),
230
- },
231
- });
232
-
233
- const labelOf = (state, t) => state?.selected?.machine_id != null
234
- ? (state.selected.name ?? state.selected.machine_id)
235
- : t('all');
236
-
237
- /** The one-line reason the list cannot be trusted, or null when it can. */
238
- function fleetProblem(state, t) {
239
- if (state === null || state === undefined) return null;
240
- if (state.host?.detail_unavailable === true) return t('routeMissing');
241
- if (state.fleet?.ok === true) return null;
242
- return state.fleet?.error ?? t('fleetUnreadable');
243
- }
244
-
245
- // -------------------------------------------------------------------------------------------
246
- // Styles
247
- // -------------------------------------------------------------------------------------------
248
- //
249
- // Token colours only: `--dsw-*` is the shell's own theme surface, so the control follows the
250
- // light/dark switch instead of inventing a palette that is wrong in one of them. The geometry
251
- // (28px height, `--dsw-radius-sm`, 13px/500 label, 4px gap, 8px leading pad) is copied from the
252
- // shipped permission trigger in the same row, and the status dot uses the theme's state colours:
253
- // business blue for this machine, success green for a reachable peer, warn/error for the two
254
- // states that are not "ready".
255
- const TAG_ID = '@twinsearth/w2m-dsh-plugin/TargetSelect.css';
256
- const CSS = [
257
- '.w2mTarget_trigger{display:inline-flex;align-items:center;gap:4px;height:28px;max-width:220px;min-width:0;',
258
- 'padding:0 4px 0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;',
259
- 'color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;cursor:pointer;outline:none}',
260
- '.w2mTarget_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
261
- '.w2mTarget_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}',
262
- '.w2mTarget_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
263
- '.w2mTarget_dot{flex:none;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-state-idle-primary);',
264
- 'box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2)}',
265
- '.w2mTarget_dot[data-state="local"]{background:var(--dsw-alias-state-business-primary)}',
266
- '.w2mTarget_dot[data-state="online"]{background:var(--dsw-alias-state-success-primary)}',
267
- '.w2mTarget_dot[data-state="stale"]{background:transparent;border:1px solid var(--dsw-alias-state-warn-primary)}',
268
- '.w2mTarget_dot[data-state="none"]{background:transparent;border:1px solid var(--dsw-alias-label-caption)}',
269
- '.w2mTarget_label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
270
- '.w2mTarget_chevron{flex:none;display:inline-flex;color:var(--dsw-alias-label-caption);transition:transform .12s}',
271
- '.w2mTarget_chevron[data-open="true"]{transform:rotate(180deg)}',
272
- '.w2mTarget_menu{position:fixed;z-index:60;box-sizing:border-box;min-width:200px;max-width:300px;',
273
- 'padding:4px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);',
274
- 'background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);',
275
- 'font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
276
- '.w2mTarget_note{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-weight:400}',
277
- '.w2mTarget_option{display:block;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:var(--dsw-radius-sm);',
278
- 'background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
279
- '.w2mTarget_option:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
280
- '.w2mTarget_option:disabled{cursor:default;color:var(--dsw-alias-label-dimmed)}',
281
- '.w2mTarget_option[data-active="true"]{color:var(--dsw-alias-state-business-primary)}',
282
- '.w2mTarget_row{display:flex;align-items:center;gap:8px}',
283
- '.w2mTarget_name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
284
- '.w2mTarget_detail{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:400}',
285
- '.w2mTarget_option:disabled .w2mTarget_detail{color:var(--dsw-alias-label-caption)}',
286
- ].join('');
287
-
288
- /**
289
- * Install the stylesheet once per document, and hand back a disposer for the plugin's lifetime.
290
- *
291
- * Idempotent by the tag id, not by a module flag: two plugin instances in one page (a reload
292
- * that did not evict the old one, two compositions) must not produce two stylesheets, and an
293
- * unload must take exactly the one this call created. When a tag is already present this is a
294
- * no-op that returns nothing, because removing a tag this call did not add would be a
295
- * disposer that breaks whichever instance owns it.
296
- *
297
- * @returns {(() => void)|null} Disposer, or null when the stylesheet was already installed.
298
- */
299
- function injectStyle(hostDocument) {
300
- if (hostDocument === undefined || hostDocument === null) return null;
301
- if (hostDocument.querySelector(`style[data-plugin-css="${TAG_ID}"]`) !== null) return null;
302
- const tag = hostDocument.createElement('style');
303
- tag.dataset.plugin = '@twinsearth/w2m-dsh-plugin';
304
- tag.dataset.pluginCss = TAG_ID;
305
- tag.textContent = CSS;
306
- hostDocument.head.appendChild(tag);
307
- return () => {
308
- tag.remove();
309
- };
310
- }
311
-
312
- const cls = (name) => `w2mTarget_${name}`;
313
- const h = React.createElement;
314
-
315
- /** The status dot: the colour is the machine's state, and the title says it in words. */
316
- function StatusDot({ kind, title }) {
317
- return h('span', { className: cls('dot'), 'data-state': kind, title, 'aria-hidden': 'true' });
318
- }
319
-
320
- /**
321
- * This machine is `local` — the one whose composer this is — and the blue dot marks it; a
322
- * reachable peer is green; anything else is drawn as an outline in the state's own colour so an
323
- * offline row can never be mistaken for a live one.
324
- */
325
- function dotKind(machine) {
326
- if (machine.online !== true) return machine.state === 'expired' ? 'none' : 'stale';
327
- return machine.local === true ? 'local' : 'online';
328
- }
329
-
330
- /** The label the trigger shows before the host has answered: the configured fleet name. */
331
- const provisionalLabel = (state) => state?.host?.local_machine_name ?? null;
332
-
333
- /**
334
- * `locked` is accepted for parity with the seat's other occupants and deliberately unused: the
335
- * target is a choice about the *next* dispatch, which is legitimate to change while a turn runs.
336
- * `t` arrives through the entry's inject face (see {@link apply}).
337
- */
338
- function TargetSelect({ sessionId, locked: _locked, t }) {
339
- const [open, setOpen] = React.useState(false);
340
- const [reloadSeq, setReloadSeq] = React.useState(0);
341
- const [state, setState] = React.useState(null);
342
- const [error, setError] = React.useState(null);
343
- const [busy, setBusy] = React.useState(false);
344
- const anchorRef = React.useRef(null);
345
- const menuRef = React.useRef(null);
346
- const [placement, setPlacement] = React.useState(null);
347
-
348
- // The host half is the source of truth, so every read replaces the mirror rather than merging
349
- // into it: a stale "online" must not outlive the answer that disproved it.
350
- React.useEffect(() => {
351
- if (typeof sessionId !== 'string' || sessionId === '') return undefined;
352
- const controller = new AbortController();
353
- readState(sessionId, controller.signal).then((result) => {
354
- if (controller.signal.aborted) return;
355
- if (result.ok) {
356
- setState(result.state);
357
- setError(null);
358
- } else {
359
- setError(result.error);
360
- }
361
- });
362
- return () => controller.abort();
363
- }, [sessionId, reloadSeq]);
364
-
365
- // Opening the menu is a read: the roster and the selection are re-asked for every time, so a
366
- // machine that went away while the menu was closed cannot be clicked as if it were live. The
367
- // host caches its own relay read for a few seconds, so this is not a request storm.
368
- React.useEffect(() => {
369
- if (open) setReloadSeq((seq) => seq + 1);
370
- }, [open]);
371
-
372
- React.useEffect(() => {
373
- if (!open) return undefined;
374
- const reposition = () => {
375
- const anchor = anchorRef.current;
376
- if (anchor === null) return;
377
- const rect = anchor.getBoundingClientRect();
378
- const width = Math.min(300, Math.max(200, Math.round(rect.width) + 96));
379
- const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
380
- // Above the composer by default — it is at the bottom of the window — and below it only
381
- // when the space above is genuinely unusable.
382
- const roomAbove = rect.top;
383
- const maxHeight = Math.max(160, Math.min(320, roomAbove > 240 ? roomAbove - 16 : window.innerHeight - rect.bottom - 24));
384
- setPlacement(roomAbove > 240
385
- ? { left, width, maxHeight, bottom: window.innerHeight - rect.top + 6, top: 'auto' }
386
- : { left, width, maxHeight, top: rect.bottom + 6, bottom: 'auto' });
387
- };
388
- const onKeyDown = (event) => {
389
- if (event.key === 'Escape') {
390
- setOpen(false);
391
- anchorRef.current?.focus?.();
392
- }
393
- };
394
- const onPointerDown = (event) => {
395
- const target = event.target;
396
- if (anchorRef.current?.contains(target) === true) return;
397
- if (menuRef.current?.contains(target) === true) return;
398
- setOpen(false);
399
- };
400
- reposition();
401
- // `capture` matters: the composer's own editor listens on the document too, and Escape has
402
- // to close this menu before anything else reads it.
403
- document.addEventListener('keydown', onKeyDown, true);
404
- document.addEventListener('pointerdown', onPointerDown, true);
405
- window.addEventListener('resize', reposition);
406
- window.addEventListener('scroll', reposition, true);
407
- return () => {
408
- document.removeEventListener('keydown', onKeyDown, true);
409
- document.removeEventListener('pointerdown', onPointerDown, true);
410
- window.removeEventListener('resize', reposition);
411
- window.removeEventListener('scroll', reposition, true);
412
- };
413
- }, [open]);
414
-
415
- const chosen = state?.selected?.machine_id ?? null;
416
- const machines = Array.isArray(state?.machines) ? state.machines : [];
417
- const problem = error ?? fleetProblem(state, t);
418
- const disabled = typeof sessionId !== 'string' || sessionId === '';
419
- const label = state === null ? (provisionalLabel(state) ?? t('machine')) : labelOf(state, t);
420
- const selectedMachine = machines.find((machine) => machine.machine_id === chosen) ?? null;
421
- const selectedOnline = state === null
422
- ? null
423
- : (selectedMachine !== null ? selectedMachine.online === true : (chosen === null ? null : state?.selected?.online ?? null));
424
- const dot = chosen === null
425
- ? 'none'
426
- : (selectedOnline === true ? (selectedMachine?.local === true ? 'local' : 'online') : 'stale');
427
- const title = problem ?? (chosen === null
428
- ? t('costAll')
429
- : `${t('costOne', { name: label })}${selectedOnline === true ? '' : t('unconfirmed')}`);
430
-
431
- const choose = (machineId, machineName) => {
432
- setOpen(false);
433
- if (machineId === chosen) return;
434
- setBusy(true);
435
- writeSelection(sessionId, machineId, machineName).then((result) => {
436
- setBusy(false);
437
- if (result.ok) {
438
- setState(result.state);
439
- setError(null);
440
- return;
441
- }
442
- // The write is refused for a reason the operator can act on (the machine went away, the
443
- // fleet could not be read), so it is shown where they are looking rather than swallowed.
444
- setError(result.error);
445
- setReloadSeq((seq) => seq + 1);
446
- });
447
- };
448
-
449
- const menu = open && placement !== null
450
- ? h('div', {
451
- className: cls('menu'),
452
- role: 'listbox',
453
- 'aria-label': t('targetLabel', { name: label }),
454
- ref: menuRef,
455
- style: {
456
- left: `${placement.left}px`,
457
- top: placement.top === 'auto' ? 'auto' : `${placement.top}px`,
458
- bottom: placement.bottom === undefined || placement.bottom === 'auto' ? 'auto' : `${placement.bottom}px`,
459
- width: `${placement.width}px`,
460
- maxHeight: `${placement.maxHeight}px`,
461
- overflowY: 'auto',
462
- },
463
- }, [
464
- h('button', {
465
- key: '__all__',
466
- type: 'button',
467
- role: 'option',
468
- className: cls('option'),
469
- 'data-active': chosen === null ? 'true' : 'false',
470
- 'aria-selected': chosen === null,
471
- onClick: () => choose(null, null),
472
- title: t('costAll'),
473
- }, h('span', { className: cls('row') }, [
474
- h(StatusDot, { key: 'dot', kind: 'none', title: t('all') }),
475
- h('span', { key: 'name', className: cls('name') }, t('all')),
476
- h('span', { key: 'detail', className: cls('detail') }, state?.fleet?.ok === true ? `${state.fleet.online} ${t('online')}` : '—'),
477
- ])),
478
- problem === null ? null : h('div', { key: '__problem__', className: cls('note'), role: 'status' }, problem),
479
- machines.length === 0
480
- ? h('div', { key: '__empty__', className: cls('note') }, problem === null ? t('empty') : t('noRoster'))
481
- : machines.map((machine) => h('button', {
482
- key: machine.machine_id ?? machine.name ?? Math.random().toString(36).slice(2),
483
- type: 'button',
484
- role: 'option',
485
- className: cls('option'),
486
- 'data-active': machine.machine_id !== null && machine.machine_id === chosen ? 'true' : 'false',
487
- 'aria-selected': machine.machine_id !== null && machine.machine_id === chosen,
488
- disabled: machine.online !== true,
489
- title: machine.online === true
490
- ? `${machine.name ?? machine.machine_id} — ${machine.local === true ? t('local') : t('online')}`
491
- : `${machine.name ?? machine.machine_id} — ${machine.state}`,
492
- onClick: () => choose(machine.machine_id, machine.name),
493
- }, h('span', { className: cls('row') }, [
494
- h(StatusDot, { key: 'dot', kind: dotKind(machine), title: machine.state }),
495
- h('span', { key: 'name', className: cls('name') }, machine.name ?? machine.machine_id ?? 'unnamed'),
496
- h('span', { key: 'detail', className: cls('detail') }, machine.local === true
497
- ? t('local')
498
- : (machine.online === true ? (machine.os ?? t('online')) : machine.state)),
499
- ]))),
500
- ])
501
- : null;
502
-
503
- return h(React.Fragment, null, [
504
- h('button', {
505
- key: 'trigger',
506
- type: 'button',
507
- ref: anchorRef,
508
- className: cls('trigger'),
509
- 'aria-label': t('targetLabel', { name: label }),
510
- 'aria-haspopup': 'listbox',
511
- 'aria-expanded': open,
512
- 'aria-busy': busy,
513
- title,
514
- disabled,
515
- onClick: () => setOpen((wasOpen) => !wasOpen),
516
- }, [
517
- h(StatusDot, { key: 'dot', kind: dot, title: title ?? '' }),
518
- h('span', { key: 'label', className: cls('label') }, label),
519
- h('span', { key: 'chevron', className: cls('chevron'), 'data-open': open ? 'true' : 'false', 'aria-hidden': 'true' },
520
- h('svg', { width: 12, height: 12, viewBox: '0 0 12 12', fill: 'none' },
521
- h('path', {
522
- d: 'M3 4.5L6 7.5L9 4.5',
523
- stroke: 'currentColor',
524
- strokeWidth: 1.5,
525
- strokeLinecap: 'round',
526
- strokeLinejoin: 'round',
527
- }))),
528
- ]),
529
- menu,
530
- ]);
531
- }
532
-
533
- /**
534
- * Client plugin body: install the stylesheet, register the dictionaries, take the seat.
535
- *
536
- * @param ctx - the client root context.
537
- */
538
- function apply(ctx) {
539
- try {
540
- mount(ctx);
541
- } catch (error) {
542
- // The audit that decides whether the app boots reports the entry's name and the word
543
- // "failed" and nothing else, so this is where the cause has to be said out loud. The
544
- // renderer console at error level is the channel the desktop crash log already carries
545
- // (`--- renderer console (error level, oldest first) ---`), which makes this the one place a
546
- // browser half can put its own diagnosis next to the failure it caused.
547
- console.error(
548
- '@twinsearth/w2m-dsh-plugin: the client entry failed to activate, so this page will not boot: ' +
549
- `${error instanceof Error ? error.message : String(error)}`,
550
- error,
551
- );
552
- // Deliberately rethrown. A swallowed failure would boot a page that looks healthy with no
553
- // target control in it — the silent-half-arrival failure this package has already shipped
554
- // once (v0.5.4) and refuses to ship again.
555
- throw error;
556
- }
557
- }
558
-
559
- /**
560
- * The body of {@link apply}, wrapped so no failure can escape without a diagnostic.
561
- *
562
- * `slots.inject` (not a bare `register`) is what the slot's declaration requires of an entry
563
- * from a package that does not own the seat: the parent declared `conversation.input.left`, and
564
- * an entry may only register into a key it has announced. The returned `inject` factory is
565
- * called per render position with that session's id, and its keys become this component's props
566
- * — which is how the control knows which session's selection it is showing.
567
- *
568
- * The stylesheet is installed here rather than at module scope, so its lifetime is the plugin's:
569
- * `ctx.effect` disposes it on unload, and nothing a bundle factory does at materialization can
570
- * outlive the entry that materialized it.
571
- *
572
- * @param ctx - the client root context.
573
- */
574
- function mount(ctx) {
575
- // `ctx.slots` is legal to read because {@link inject} declares it — and declaring it is also
576
- // what makes this entry wait for the seat instead of racing it. The shape check is for callers
577
- // that are not a Cordis fiber at all (`test/client-bundle.test.mjs`): a missing or foreign
578
- // registry is a control that cannot be mounted, and a control that cannot be mounted must not
579
- // be allowed to take the whole page down with it.
580
- const slots = ctx?.slots;
581
- if (typeof slots?.inject !== 'function' || typeof slots?.register !== 'function') {
582
- console.error(
583
- '@twinsearth/w2m-dsh-plugin: the client half found no slot registry on this context, so the ' +
584
- 'dispatch-target control is not mounted; the rest of the page is unaffected',
585
- );
586
- return;
587
- }
588
- const styleDisposer = injectStyle(typeof document === 'undefined' ? null : document);
589
- if (styleDisposer !== null && typeof ctx.effect === 'function') {
590
- ctx.effect(() => styleDisposer, 'w2m: composer target control styles');
591
- }
592
- // Dictionaries as early as the composition allows: a namespace registered after the UI has
593
- // mounted is still picked up, and the service is optional, so nothing here may block.
594
- const locale = readOptionalService(ctx, 'locale');
595
- registerDictionaries(ctx, locale);
596
- const t = makeTranslator(() => readOptionalService(ctx, 'locale'));
597
- slots.inject('conversation.input.left', () => slots.register({
598
- name: 'conversation.input.left',
599
- id: 'w2m-target',
600
- // After the shipped permission control and the plan control, which sit earlier in the
601
- // composer row: the target is a per-session choice like them, and the row reads left to
602
- // right as "attach, permissions, plan, where it runs".
603
- order: 40,
604
- label: 'dispatch target',
605
- inject: (sessionId) => ({
606
- sessionId,
607
- t,
608
- }),
609
- }, TargetSelect));
610
- }
611
-
612
- /**
613
- * Cordis services this entry depends on, read by the fiber that mounts this bundle.
614
- *
615
- * `slots` is the seat: the control is registered into `conversation.input.left` through the
616
- * slot registry, and a fiber that cannot see `slots` has nowhere to register. Declaring it is
617
- * not bookkeeping. Cordis resolves `ctx.<name>` against this list, so reading an undeclared
618
- * service **throws** (`cannot get property "slots" without inject`) even when the service is
619
- * provided — measured in production: this bundle shipped with `inject = []` while reading
620
- * `ctx.locale` and `ctx.slots`, the first read threw, and the app refused to boot.
621
- *
622
- * `locale` is deliberately absent, and that is the other half of the same rule: **every**
623
- * declared name is a required dependency, so an entry that declares a service the composition
624
- * does not provide never reaches `active` either — the audit then reports it as
625
- * `pending (waiting for service: locale)`, which is just as fatal. Optional carriers are read
626
- * with {@link readOptionalService} instead, and waited for by a child fiber when waiting is
627
- * useful ({@link registerDictionaries}). Keep those two decisions in step with the reads in
628
- * {@link mount}: `test/client-bundle.test.mjs` mounts this bundle behind a context that throws
629
- * on any undeclared service read, exactly as Cordis does.
630
- */
631
- const inject = ['slots'];
632
-
633
- exports.apply = apply;
634
- exports.inject = inject;
635
- exports.TargetSelect = TargetSelect;
636
- exports.injectStyle = injectStyle;
637
- exports.makeTranslator = makeTranslator;
638
- return module.exports;
639
- },
640
- });
1
+ /**
2
+ * DSH Web client half of `@twinsearth/w2m-dsh-plugin`: the composer's target-machine control.
3
+ *
4
+ * # What this file is
5
+ *
6
+ * This is the *shipped* client bundle, not compiled output: the package has no build step and no
7
+ * dependencies, so the half the browser loads is written here in the exact module format the DSH
8
+ * web shell's loader consumes (`window.__ModuleLoader__.load({ id, factory })`, a lazy CJS table
9
+ * whose factory runs at materialization). `package.json` declares it with
10
+ * `dsh.client: { platform: 'web' }` and `exports['./client'] = './lib/client.js'`, which is what the
11
+ * host scans for. There is no second copy to keep in sync: this file **is** the source, and
12
+ * `test/client-bundle.test.mjs` is where its entry shape is read.
13
+ *
14
+ * # A failed client entry is fatal, and silence is not a neutral failure
15
+ *
16
+ * Measured twice in production: when this entry's `apply` throws, the page does not degrade — the
17
+ * shell audits every entry after activation and refuses to boot with
18
+ * `web boot: 1 entry did not activate / @twinsearth/w2m-dsh-plugin: failed`, which in the desktop
19
+ * app is forwarded to the main process and written as a `web-boot` crash: **the app does not start**.
20
+ * The audit prints the entry's *name* and its fiber state and nothing else, so the cause has to be
21
+ * carried by the failing code itself; {@link apply} therefore reports its own error to
22
+ * `console.error`, which is the error-level renderer console the crash log already captures.
23
+ *
24
+ * The failure that cost those two outages was a single property read. Cordis resolves `ctx.<name>`
25
+ * through the fiber's `inject` list: a service the fiber never declared **throws**
26
+ * (`cannot get property "locale" without inject`) whether or not some plugin provides it, and every
27
+ * name that *is* declared becomes a required dependency the entry waits for. Both halves of that rule
28
+ * are load-bearing here — see {@link inject} for what this bundle declares, and how the optional
29
+ * locale carrier is read instead.
30
+ *
31
+ * Two consequences, both deliberate:
32
+ *
33
+ * * **No JSX and no bundler.** `React.createElement` keeps the file plain JavaScript a reviewer
34
+ * can read, and the module system's platform seed table already holds React (`react`,
35
+ * `react/jsx-runtime`, `react-dom`, Cordis and the shell's static UI libraries are frozen seeds
36
+ * for every dynamic bundle), so `require('react')` resolves without this package shipping or
37
+ * declaring anything.
38
+ * * **Only React is required.** `@deepseek-ai/dsh-client-ui-primitives` is *available* to a
39
+ * dynamic bundle — the shipped permission control next to this one uses its `Menu` — but a
40
+ * package that must stay dependency-free should not take a rendering dependency for one
41
+ * dropdown. The control is styled with the shell's own theme tokens instead, which is what
42
+ * makes it correct in both themes rather than "similar in one".
43
+ *
44
+ * # Where it sits
45
+ *
46
+ * `conversation.input.left` — `kind: list`, `scope: session`, `replaceRisk: none`. The shipped
47
+ * permission control owns `conversation.input.permission` (`single`, `replaceRisk:
48
+ * shadows-shipped-ui`) and is **not** touched: this entry is appended to the composer's left group,
49
+ * so it renders to the right of the permission control and never competes with it for a seat.
50
+ *
51
+ * # What it shows
52
+ *
53
+ * The machine this session's work will be dispatched to, and — on click — the fleet the relay
54
+ * currently holds. Liveness comes from the host plugin, never from this file: a machine is
55
+ * selectable because the relay lists it as online, and one that is not listed that way is rendered
56
+ * with its state and cannot be chosen. A selection is written to the host process, so it survives a
57
+ * reload and is visible to `w2m_run`, which is where it actually means something.
58
+ */
59
+
60
+ window.__ModuleLoader__.load({
61
+ id: '@twinsearth/w2m-dsh-plugin',
62
+ factory: (require) => {
63
+ var module = { exports: {} };
64
+ var exports = module.exports;
65
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
66
+
67
+ const React = require('react');
68
+
69
+ // -------------------------------------------------------------------------------------------
70
+ // Wire
71
+ // -------------------------------------------------------------------------------------------
72
+ //
73
+ // Mirrors TARGET_ROUTE_PATH in src/plugin/fleet-target.mjs, `/api` prefix included: the host's
74
+ // Connection service mounts its exact-fetch-route registry under that channel and refuses any
75
+ // path that does not start with `/api/`. Plain same-origin `fetch` rather than a Remote method:
76
+ // the route is mounted inside the host's authentication fence, so the page's own cookie is the
77
+ // credential and there is no descriptor to keep in sync.
78
+ const ROUTE = '/api/w2m/target';
79
+
80
+ /**
81
+ * The locale namespace and its two dictionaries.
82
+ *
83
+ * Registered through `ctx.locale` when the composition provides it, exactly as the shipped
84
+ * permission control beside this one does; the Chinese dictionary is the fallback when it does
85
+ * not, because a missing localisation service is not a reason for an operator to lose the
86
+ * control. Only the *visible* strings are here. A refusal from the host is reported in the
87
+ * host's own words — it is a diagnostic naming a machine, a relay or an HTTP status, and
88
+ * translating it would put a second, drifting copy of that vocabulary in the browser.
89
+ */
90
+ const NS = 'w2m.target';
91
+ const DICT = {
92
+ zh: {
93
+ all: '全部机器',
94
+ machine: '机器',
95
+ local: '本机',
96
+ online: '在线',
97
+ noRoster: '无列表',
98
+ empty: '机器列表里没有机器',
99
+ costAll: '本次会话的派发会送到所有在线机器',
100
+ costOne: '本次会话的派发会送到 {name}',
101
+ unconfirmed: '(未确认在线)',
102
+ targetLabel: '目标机器:{name}',
103
+ unreachable: '无法连接插件',
104
+ routeMissing: '这个 Harness 没有提供插件目标路由',
105
+ fleetUnreadable: '机器列表读取失败',
106
+ },
107
+ en: {
108
+ all: 'All machines',
109
+ machine: 'Machine',
110
+ local: 'This machine',
111
+ online: 'online',
112
+ noRoster: 'no roster',
113
+ empty: 'the fleet lists no machines',
114
+ costAll: 'Work from this session goes to every online machine',
115
+ costOne: 'Work from this session goes to {name}',
116
+ unconfirmed: ' (not confirmed online)',
117
+ targetLabel: 'target machine: {name}',
118
+ unreachable: 'the plugin could not be reached',
119
+ routeMissing: 'this host does not serve the W2M target route',
120
+ fleetUnreadable: 'the fleet could not be read',
121
+ },
122
+ };
123
+
124
+ /**
125
+ * A translator over {@link DICT}, preferring the shell's locale service.
126
+ *
127
+ * `{name}` substitution is deliberately the only templating: these strings have one variable
128
+ * each, and a message format would be a dependency this package does not have.
129
+ *
130
+ * @param localeOf - the shell's locale service, or a function returning it (read at lookup time).
131
+ * Passed in rather than read from `ctx` here: the service is optional, and reading an optional
132
+ * service off the context is the one operation in this file that can kill the page (see
133
+ * {@link readOptionalService}). A function is accepted because the service may arrive *after*
134
+ * this entry activates — the shell's `bind()` is live, so a deferred read still localises.
135
+ * @returns {(key: string, values?: object) => string}
136
+ */
137
+ function makeTranslator(localeOf) {
138
+ const resolveLocale = () => (typeof localeOf === 'function' ? localeOf() : localeOf);
139
+ return (key, values) => {
140
+ const locale = resolveLocale();
141
+ const bound = typeof locale?.bind === 'function' ? locale.bind(NS) : null;
142
+ const template = bound === null
143
+ ? (DICT.zh[key] ?? DICT.en[key] ?? key)
144
+ : bound(key, values);
145
+ const text = typeof template === 'string' && template !== '' ? template : (DICT.en[key] ?? key);
146
+ if (values === undefined) return text;
147
+ return text.replace(/\{(\w+)\}/g, (match, name) => (values[name] === undefined ? match : String(values[name])));
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Read a service the composition may or may not provide, without the inject requirement.
153
+ *
154
+ * `ctx.get(name)` is Cordis's inject-free lookup, and it is the only correct way to read an
155
+ * *optional* service from a plugin: `ctx[name]` throws for anything the fiber did not declare as
156
+ * a dependency, and declaring it would make the service *required* — an entry whose declared
157
+ * dependency never arrives never activates either, which is fatal in exactly the same way. So a
158
+ * carrier that must not be able to block the control (`locale`) is read here, and a service the
159
+ * control cannot work without (`slots`) is declared in {@link inject}.
160
+ *
161
+ * @param ctx - the client plugin context.
162
+ * @param name - the service name.
163
+ * @returns the service, or undefined when the composition has none (or this is not a Cordis
164
+ * context at all, which is how `test/client-bundle.test.mjs` applies this bundle).
165
+ */
166
+ function readOptionalService(ctx, name) {
167
+ return typeof ctx?.get === 'function' ? ctx.get(name) : undefined;
168
+ }
169
+
170
+ /**
171
+ * Register {@link DICT} with the shell's locale service whenever that service exists.
172
+ *
173
+ * Two paths, one outcome. When the service is already up, the registration is an effect of this
174
+ * entry and goes away with it. When it is not up yet, the registration rides a **child fiber**
175
+ * (`ctx.inject`) that waits for `locale` without holding this entry back: declaring `locale` in
176
+ * {@link inject} instead would make it required, and a composition without a locale service
177
+ * would then report this entry as `pending` — which the shell's audit treats exactly like
178
+ * `failed`, i.e. the page does not boot. A missing localisation service must never cost an
179
+ * operator the control, and must never cost them the app.
180
+ *
181
+ * @param ctx - the client plugin context.
182
+ * @param locale - the shell's locale service, or undefined when the composition has none.
183
+ */
184
+ function registerDictionaries(ctx, locale) {
185
+ if (typeof locale?.register === 'function') {
186
+ ctx.effect(() => locale.register(NS, DICT), 'w2m: composer target control dictionaries');
187
+ return;
188
+ }
189
+ if (typeof ctx?.inject !== 'function') return;
190
+ ctx.inject(['locale'], (localeCtx) => {
191
+ localeCtx.effect(() => localeCtx.locale.register(NS, DICT), 'w2m: composer target control dictionaries');
192
+ });
193
+ }
194
+
195
+ /** One JSON call to the host half, folded into a value: a refusal is never an exception here. */
196
+ async function callHost(options) {
197
+ let response;
198
+ try {
199
+ response = await fetch(options.url, options.init);
200
+ } catch (error) {
201
+ return { ok: false, error: `the plugin could not be reached: ${error instanceof Error ? error.message : String(error)}`, status: 0 };
202
+ }
203
+ if (response.status === 404) {
204
+ return { ok: false, status: 404, error: 'this host does not serve the W2M target route' };
205
+ }
206
+ let body = null;
207
+ try {
208
+ body = await response.json();
209
+ } catch {
210
+ return { ok: false, status: response.status, error: `the plugin answered HTTP ${response.status} with a body that is not JSON` };
211
+ }
212
+ if (response.ok !== true) {
213
+ return { ok: false, status: response.status, error: typeof body?.error === 'string' ? body.error : `HTTP ${response.status}` };
214
+ }
215
+ return { ok: true, status: response.status, state: body };
216
+ }
217
+
218
+ const readState = (sessionId, signal) => callHost({
219
+ url: `${ROUTE}?session_id=${encodeURIComponent(sessionId)}`,
220
+ init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal },
221
+ });
222
+
223
+ const writeSelection = (sessionId, machineId, machineName) => callHost({
224
+ url: ROUTE,
225
+ init: {
226
+ method: 'POST',
227
+ credentials: 'same-origin',
228
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
229
+ body: JSON.stringify({ session_id: sessionId, machine_id: machineId, machine_name: machineName }),
230
+ },
231
+ });
232
+
233
+ const labelOf = (state, t) => state?.selected?.machine_id != null
234
+ ? (state.selected.name ?? state.selected.machine_id)
235
+ : t('all');
236
+
237
+ /** The one-line reason the list cannot be trusted, or null when it can. */
238
+ function fleetProblem(state, t) {
239
+ if (state === null || state === undefined) return null;
240
+ if (state.host?.detail_unavailable === true) return t('routeMissing');
241
+ if (state.fleet?.ok === true) return null;
242
+ return state.fleet?.error ?? t('fleetUnreadable');
243
+ }
244
+
245
+ // -------------------------------------------------------------------------------------------
246
+ // Styles
247
+ // -------------------------------------------------------------------------------------------
248
+ //
249
+ // Token colours only: `--dsw-*` is the shell's own theme surface, so the control follows the
250
+ // light/dark switch instead of inventing a palette that is wrong in one of them. The geometry
251
+ // (28px height, `--dsw-radius-sm`, 13px/500 label, 4px gap, 8px leading pad) is copied from the
252
+ // shipped permission trigger in the same row, and the status dot uses the theme's state colours:
253
+ // business blue for this machine, success green for a reachable peer, warn/error for the two
254
+ // states that are not "ready".
255
+ const TAG_ID = '@twinsearth/w2m-dsh-plugin/TargetSelect.css';
256
+ const CSS = [
257
+ '.w2mTarget_trigger{display:inline-flex;align-items:center;gap:4px;height:28px;max-width:220px;min-width:0;',
258
+ 'padding:0 4px 0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;',
259
+ 'color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;cursor:pointer;outline:none}',
260
+ '.w2mTarget_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
261
+ '.w2mTarget_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}',
262
+ '.w2mTarget_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
263
+ '.w2mTarget_dot{flex:none;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-state-idle-primary);',
264
+ 'box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2)}',
265
+ '.w2mTarget_dot[data-state="local"]{background:var(--dsw-alias-state-business-primary)}',
266
+ '.w2mTarget_dot[data-state="online"]{background:var(--dsw-alias-state-success-primary)}',
267
+ '.w2mTarget_dot[data-state="stale"]{background:transparent;border:1px solid var(--dsw-alias-state-warn-primary)}',
268
+ '.w2mTarget_dot[data-state="none"]{background:transparent;border:1px solid var(--dsw-alias-label-caption)}',
269
+ '.w2mTarget_label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
270
+ '.w2mTarget_chevron{flex:none;display:inline-flex;color:var(--dsw-alias-label-caption);transition:transform .12s}',
271
+ '.w2mTarget_chevron[data-open="true"]{transform:rotate(180deg)}',
272
+ '.w2mTarget_menu{position:fixed;z-index:60;box-sizing:border-box;min-width:200px;max-width:300px;',
273
+ 'padding:4px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);',
274
+ 'background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);',
275
+ 'font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
276
+ '.w2mTarget_note{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-weight:400}',
277
+ '.w2mTarget_option{display:block;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:var(--dsw-radius-sm);',
278
+ 'background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
279
+ '.w2mTarget_option:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
280
+ '.w2mTarget_option:disabled{cursor:default;color:var(--dsw-alias-label-dimmed)}',
281
+ '.w2mTarget_option[data-active="true"]{color:var(--dsw-alias-state-business-primary)}',
282
+ '.w2mTarget_row{display:flex;align-items:center;gap:8px}',
283
+ '.w2mTarget_name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
284
+ '.w2mTarget_detail{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:400}',
285
+ '.w2mTarget_option:disabled .w2mTarget_detail{color:var(--dsw-alias-label-caption)}',
286
+ '.w2mChat{position:relative;display:inline-flex;align-items:center}',
287
+ '.w2mChat_trigger{display:inline-flex;align-items:center;gap:4px;height:28px;padding:0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;cursor:pointer;outline:none}',
288
+ '.w2mChat_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
289
+ '.w2mChat_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
290
+ '.w2mChat_panel{position:fixed;right:16px;bottom:88px;z-index:60;display:flex;flex-direction:column;width:340px;max-height:420px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);overflow:hidden}',
291
+ '.w2mChat_head{display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:.5px solid var(--dsw-alias-border-l2);font-weight:600}',
292
+ '.w2mChat_usage{margin-left:auto;font-weight:400;color:var(--dsw-alias-label-tertiary);font-size:11px}',
293
+ '.w2mChat_close{border:none;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:16px;line-height:16px;padding:0 2px}',
294
+ '.w2mChat_list{flex:1;overflow-y:auto;padding:8px 10px;display:flex;flex-direction:column;gap:8px;min-height:120px}',
295
+ '.w2mChat_empty{color:var(--dsw-alias-label-tertiary)}',
296
+ '.w2mChat_msg{display:flex;flex-direction:column;gap:2px}',
297
+ '.w2mChat_meta{color:var(--dsw-alias-label-tertiary);font-size:11px}',
298
+ '.w2mChat_text{white-space:pre-wrap;word-break:break-word}',
299
+ '.w2mChat_msg.is-session .w2mChat_text{color:var(--dsw-danger)}', '.w2mChat_msg.is-chat .w2mChat_text{color:var(--dsw-success)}', '.w2mChat_msg.is-remote .w2mChat_text{color:var(--dsw-text)}', '.w2mChat_avatar{display:inline-flex;width:16px;height:16px;margin-right:5px;vertical-align:-3px}',
300
+ '.w2mChat_compose{display:flex;gap:8px;padding:8px 10px;border-top:.5px solid var(--dsw-alias-border-l2);align-items:flex-end}',
301
+ '.w2mChat_input{flex:1;resize:none;box-sizing:border-box;font:inherit;color:inherit;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);padding:6px 8px;outline:none;min-height:38px;max-height:96px}',
302
+ '.w2mChat_send{flex:none;height:28px;padding:0 10px;border:none;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-text-on-business);font:inherit;font-weight:500;cursor:pointer}',
303
+ '.w2mChat_send:disabled{opacity:.5;cursor:default}',
304
+ '.w2mChat_error{padding:0 10px 8px;color:var(--dsw-alias-state-error-primary);font-size:11px}',
305
+ ].join('');
306
+
307
+ /**
308
+ * Install the stylesheet once per document, and hand back a disposer for the plugin's lifetime.
309
+ *
310
+ * Idempotent by the tag id, not by a module flag: two plugin instances in one page (a reload
311
+ * that did not evict the old one, two compositions) must not produce two stylesheets, and an
312
+ * unload must take exactly the one this call created. When a tag is already present this is a
313
+ * no-op that returns nothing, because removing a tag this call did not add would be a
314
+ * disposer that breaks whichever instance owns it.
315
+ *
316
+ * @returns {(() => void)|null} Disposer, or null when the stylesheet was already installed.
317
+ */
318
+ function injectStyle(hostDocument) {
319
+ if (hostDocument === undefined || hostDocument === null) return null;
320
+ if (hostDocument.querySelector(`style[data-plugin-css="${TAG_ID}"]`) !== null) return null;
321
+ const tag = hostDocument.createElement('style');
322
+ tag.dataset.plugin = '@twinsearth/w2m-dsh-plugin';
323
+ tag.dataset.pluginCss = TAG_ID;
324
+ tag.textContent = CSS;
325
+ hostDocument.head.appendChild(tag);
326
+ return () => {
327
+ tag.remove();
328
+ };
329
+ }
330
+
331
+ const cls = (name) => `w2mTarget_${name}`;
332
+ const h = React.createElement;
333
+
334
+ /** The status dot: the colour is the machine's state, and the title says it in words. */
335
+ function StatusDot({ kind, title }) {
336
+ return h('span', { className: cls('dot'), 'data-state': kind, title, 'aria-hidden': 'true' });
337
+ }
338
+
339
+ /**
340
+ * This machine is `local` — the one whose composer this is — and the blue dot marks it; a
341
+ * reachable peer is green; anything else is drawn as an outline in the state's own colour so an
342
+ * offline row can never be mistaken for a live one.
343
+ */
344
+ function dotKind(machine) {
345
+ if (machine.online !== true) return machine.state === 'expired' ? 'none' : 'stale';
346
+ return machine.local === true ? 'local' : 'online';
347
+ }
348
+
349
+ /** The label the trigger shows before the host has answered: the configured fleet name. */
350
+ const provisionalLabel = (state) => state?.host?.local_machine_name ?? null;
351
+
352
+ /**
353
+ * `locked` is accepted for parity with the seat's other occupants and deliberately unused: the
354
+ * target is a choice about the *next* dispatch, which is legitimate to change while a turn runs.
355
+ * `t` arrives through the entry's inject face (see {@link apply}).
356
+ */
357
+ function TargetSelect({ sessionId, locked: _locked, t }) {
358
+ const [open, setOpen] = React.useState(false);
359
+ const [reloadSeq, setReloadSeq] = React.useState(0);
360
+ const [state, setState] = React.useState(null);
361
+ const [error, setError] = React.useState(null);
362
+ const [busy, setBusy] = React.useState(false);
363
+ const anchorRef = React.useRef(null);
364
+ const menuRef = React.useRef(null);
365
+ const [placement, setPlacement] = React.useState(null);
366
+
367
+ // The host half is the source of truth, so every read replaces the mirror rather than merging
368
+ // into it: a stale "online" must not outlive the answer that disproved it.
369
+ React.useEffect(() => {
370
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined;
371
+ const controller = new AbortController();
372
+ readState(sessionId, controller.signal).then((result) => {
373
+ if (controller.signal.aborted) return;
374
+ if (result.ok) {
375
+ setState(result.state);
376
+ setError(null);
377
+ } else {
378
+ setError(result.error);
379
+ }
380
+ });
381
+ return () => controller.abort();
382
+ }, [sessionId, reloadSeq]);
383
+
384
+ // Opening the menu is a read: the roster and the selection are re-asked for every time, so a
385
+ // machine that went away while the menu was closed cannot be clicked as if it were live. The
386
+ // host caches its own relay read for a few seconds, so this is not a request storm.
387
+ React.useEffect(() => {
388
+ if (open) setReloadSeq((seq) => seq + 1);
389
+ }, [open]);
390
+
391
+ React.useEffect(() => {
392
+ if (!open) return undefined;
393
+ const reposition = () => {
394
+ const anchor = anchorRef.current;
395
+ if (anchor === null) return;
396
+ const rect = anchor.getBoundingClientRect();
397
+ const width = Math.min(300, Math.max(200, Math.round(rect.width) + 96));
398
+ const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
399
+ // Above the composer by default — it is at the bottom of the window — and below it only
400
+ // when the space above is genuinely unusable.
401
+ const roomAbove = rect.top;
402
+ const maxHeight = Math.max(160, Math.min(320, roomAbove > 240 ? roomAbove - 16 : window.innerHeight - rect.bottom - 24));
403
+ setPlacement(roomAbove > 240
404
+ ? { left, width, maxHeight, bottom: window.innerHeight - rect.top + 6, top: 'auto' }
405
+ : { left, width, maxHeight, top: rect.bottom + 6, bottom: 'auto' });
406
+ };
407
+ const onKeyDown = (event) => {
408
+ if (event.key === 'Escape') {
409
+ setOpen(false);
410
+ anchorRef.current?.focus?.();
411
+ }
412
+ };
413
+ const onPointerDown = (event) => {
414
+ const target = event.target;
415
+ if (anchorRef.current?.contains(target) === true) return;
416
+ if (menuRef.current?.contains(target) === true) return;
417
+ setOpen(false);
418
+ };
419
+ reposition();
420
+ // `capture` matters: the composer's own editor listens on the document too, and Escape has
421
+ // to close this menu before anything else reads it.
422
+ document.addEventListener('keydown', onKeyDown, true);
423
+ document.addEventListener('pointerdown', onPointerDown, true);
424
+ window.addEventListener('resize', reposition);
425
+ window.addEventListener('scroll', reposition, true);
426
+ return () => {
427
+ document.removeEventListener('keydown', onKeyDown, true);
428
+ document.removeEventListener('pointerdown', onPointerDown, true);
429
+ window.removeEventListener('resize', reposition);
430
+ window.removeEventListener('scroll', reposition, true);
431
+ };
432
+ }, [open]);
433
+
434
+ const chosen = state?.selected?.machine_id ?? null;
435
+ const machines = Array.isArray(state?.machines) ? state.machines : [];
436
+ const problem = error ?? fleetProblem(state, t);
437
+ const disabled = typeof sessionId !== 'string' || sessionId === '';
438
+ const label = state === null ? (provisionalLabel(state) ?? t('machine')) : labelOf(state, t);
439
+ const selectedMachine = machines.find((machine) => machine.machine_id === chosen) ?? null;
440
+ const selectedOnline = state === null
441
+ ? null
442
+ : (selectedMachine !== null ? selectedMachine.online === true : (chosen === null ? null : state?.selected?.online ?? null));
443
+ const dot = chosen === null
444
+ ? 'none'
445
+ : (selectedOnline === true ? (selectedMachine?.local === true ? 'local' : 'online') : 'stale');
446
+ const title = problem ?? (chosen === null
447
+ ? t('costAll')
448
+ : `${t('costOne', { name: label })}${selectedOnline === true ? '' : t('unconfirmed')}`);
449
+
450
+ const choose = (machineId, machineName) => {
451
+ setOpen(false);
452
+ if (machineId === chosen) return;
453
+ setBusy(true);
454
+ writeSelection(sessionId, machineId, machineName).then((result) => {
455
+ setBusy(false);
456
+ if (result.ok) {
457
+ setState(result.state);
458
+ setError(null);
459
+ return;
460
+ }
461
+ // The write is refused for a reason the operator can act on (the machine went away, the
462
+ // fleet could not be read), so it is shown where they are looking rather than swallowed.
463
+ setError(result.error);
464
+ setReloadSeq((seq) => seq + 1);
465
+ });
466
+ };
467
+
468
+ const menu = open && placement !== null
469
+ ? h('div', {
470
+ className: cls('menu'),
471
+ role: 'listbox',
472
+ 'aria-label': t('targetLabel', { name: label }),
473
+ ref: menuRef,
474
+ style: {
475
+ left: `${placement.left}px`,
476
+ top: placement.top === 'auto' ? 'auto' : `${placement.top}px`,
477
+ bottom: placement.bottom === undefined || placement.bottom === 'auto' ? 'auto' : `${placement.bottom}px`,
478
+ width: `${placement.width}px`,
479
+ maxHeight: `${placement.maxHeight}px`,
480
+ overflowY: 'auto',
481
+ },
482
+ }, [
483
+ h('button', {
484
+ key: '__all__',
485
+ type: 'button',
486
+ role: 'option',
487
+ className: cls('option'),
488
+ 'data-active': chosen === null ? 'true' : 'false',
489
+ 'aria-selected': chosen === null,
490
+ onClick: () => choose(null, null),
491
+ title: t('costAll'),
492
+ }, h('span', { className: cls('row') }, [
493
+ h(StatusDot, { key: 'dot', kind: 'none', title: t('all') }),
494
+ h('span', { key: 'name', className: cls('name') }, t('all')),
495
+ h('span', { key: 'detail', className: cls('detail') }, state?.fleet?.ok === true ? `${state.fleet.online} ${t('online')}` : '—'),
496
+ ])),
497
+ problem === null ? null : h('div', { key: '__problem__', className: cls('note'), role: 'status' }, problem),
498
+ machines.length === 0
499
+ ? h('div', { key: '__empty__', className: cls('note') }, problem === null ? t('empty') : t('noRoster'))
500
+ : machines.map((machine) => h('button', {
501
+ key: machine.machine_id ?? machine.name ?? Math.random().toString(36).slice(2),
502
+ type: 'button',
503
+ role: 'option',
504
+ className: cls('option'),
505
+ 'data-active': machine.machine_id !== null && machine.machine_id === chosen ? 'true' : 'false',
506
+ 'aria-selected': machine.machine_id !== null && machine.machine_id === chosen,
507
+ disabled: machine.online !== true,
508
+ title: machine.online === true
509
+ ? `${machine.name ?? machine.machine_id} — ${machine.local === true ? t('local') : t('online')}`
510
+ : `${machine.name ?? machine.machine_id} — ${machine.state}`,
511
+ onClick: () => choose(machine.machine_id, machine.name),
512
+ }, h('span', { className: cls('row') }, [
513
+ h(StatusDot, { key: 'dot', kind: dotKind(machine), title: machine.state }),
514
+ h('span', { key: 'name', className: cls('name') }, machine.name ?? machine.machine_id ?? 'unnamed'),
515
+ h('span', { key: 'detail', className: cls('detail') }, machine.local === true
516
+ ? t('local')
517
+ : (machine.online === true ? (machine.os ?? t('online')) : machine.state)),
518
+ ]))),
519
+ ])
520
+ : null;
521
+
522
+ return h(React.Fragment, null, [
523
+ h('button', {
524
+ key: 'trigger',
525
+ type: 'button',
526
+ ref: anchorRef,
527
+ className: cls('trigger'),
528
+ 'aria-label': t('targetLabel', { name: label }),
529
+ 'aria-haspopup': 'listbox',
530
+ 'aria-expanded': open,
531
+ 'aria-busy': busy,
532
+ title,
533
+ disabled,
534
+ onClick: () => setOpen((wasOpen) => !wasOpen),
535
+ }, [
536
+ h(StatusDot, { key: 'dot', kind: dot, title: title ?? '' }),
537
+ h('span', { key: 'label', className: cls('label') }, label),
538
+ h('span', { key: 'chevron', className: cls('chevron'), 'data-open': open ? 'true' : 'false', 'aria-hidden': 'true' },
539
+ h('svg', { width: 12, height: 12, viewBox: '0 0 12 12', fill: 'none' },
540
+ h('path', {
541
+ d: 'M3 4.5L6 7.5L9 4.5',
542
+ stroke: 'currentColor',
543
+ strokeWidth: 1.5,
544
+ strokeLinecap: 'round',
545
+ strokeLinejoin: 'round',
546
+ }))),
547
+ ]),
548
+ menu,
549
+ ]);
550
+ }
551
+
552
+ // -------------------------------------------------------------------------------------------
553
+ // Fleet chat panel (v1.1.18 addition: the missing browser half of `w2m_chat`)
554
+ // -------------------------------------------------------------------------------------------
555
+ //
556
+ // v1.1.17 shipped fleet chat as a tool only — its own CHANGELOG records "no client UI panel
557
+ // beyond the generic tool card". This is that panel: a session-scoped broadcast board the
558
+ // operator and the agent share. Every read and write goes through the host route
559
+ // `/api/w2m/chat` (host-side `fleet-chat-route.mjs`), so the device token stays in the host
560
+ // process and this file never holds a credential.
561
+ const CHAT_ROUTE = '/api/w2m/chat';
562
+
563
+ /** One JSON call to the host chat route; a refusal is a value, not an exception. */
564
+ async function chatCall(options) {
565
+ let response;
566
+ try {
567
+ response = await fetch(options.url, options.init);
568
+ } catch (error) {
569
+ return { ok: false, error: `the plugin could not be reached: ${error instanceof Error ? error.message : String(error)}` };
570
+ }
571
+ let body = null;
572
+ try {
573
+ body = await response.json();
574
+ } catch {
575
+ return { ok: false, error: `the plugin answered HTTP ${response.status} with a body that is not JSON` };
576
+ }
577
+ if (response.status === 404) return { ok: false, error: 'this host does not serve the W2M chat route' };
578
+ if (response.ok !== true || body?.ok === false) {
579
+ return { ok: false, error: typeof body?.error === 'string' ? body.error : `HTTP ${response.status}` };
580
+ }
581
+ return { ok: true, state: body };
582
+ }
583
+
584
+ /**
585
+ * The fleet-chat control: opens a panel with the session's messages, a compose box and the
586
+ * relay's current usage. The text stays on the relay; this component keeps only the last view.
587
+ */
588
+ const AVATAR_PALETTE = ['#4c6ef5', '#12b886', '#f59f00', '#e8590c', '#ae3ec9', '#1098ad', '#d6336c', '#5c940d'];
589
+
590
+ /** A machine keeps its colour: the index comes from its id, not from the order of the list. */
591
+ function avatarColor(machineId) {
592
+ var h = 0;
593
+ var s = String(machineId == null ? '' : machineId);
594
+ for (var i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) >>> 0;
595
+ return AVATAR_PALETTE[h % AVATAR_PALETTE.length];
596
+ }
597
+
598
+ /** The DeepSeek whale, filled with the machine colour. */
599
+ function WhaleAvatar({ machineId }) {
600
+ return h('span', { className: 'w2mChat_avatar', style: { color: avatarColor(machineId) }, 'aria-hidden': 'true' },
601
+ h('svg', { viewBox: '0 0 24 24', width: 16, height: 16 },
602
+ h('path', { fill: 'currentColor', d: 'M2 13c2.2-3.4 5.4-5 9-5 3.1 0 5.6 1.2 7.2 3.2h2.3l-1.6-2.4 1.6.9L22 6.9l.6 3.4 1.4.8-1.4.8-.6 3.4-1.5-2.8-1.6.9 1.6-2.4h-2.2C16.6 8.9 13.9 7.6 11 7.6 6.6 7.6 3.3 9.6 1 13h1Z' })));
603
+ }
604
+
605
+ /** session = this machine typed it into DSH; chat = typed here; remote = another machine. */
606
+ function messageKind(message, localMachineId) {
607
+ if (localMachineId == null || message == null || message.machine_id !== localMachineId) return 'remote';
608
+ return message.origin === 'session' ? 'session' : 'chat';
609
+ }
610
+
611
+ function FleetChat({ sessionId }) {
612
+ const [open, setOpen] = React.useState(false);
613
+ const [messages, setMessages] = React.useState([]);
614
+ const [usage, setUsage] = React.useState(null);
615
+ const [text, setText] = React.useState('');
616
+ const [error, setError] = React.useState(null);
617
+ const [busy, setBusy] = React.useState(false);
618
+ const [localMachineId, setLocalMachineId] = React.useState(null);
619
+
620
+ const load = React.useCallback(async (signal) => {
621
+ if (typeof sessionId !== 'string' || sessionId === '') return;
622
+ const result = await chatCall({
623
+ url: `${CHAT_ROUTE}?session_id=${encodeURIComponent(sessionId)}&limit=50`,
624
+ init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal },
625
+ });
626
+ if (result.ok) {
627
+ setMessages(Array.isArray(result.state.messages) ? result.state.messages : []);
628
+ setLocalMachineId(typeof result.state.local_machine_id === 'string' ? result.state.local_machine_id : null);
629
+ setUsage(result.state.usage ?? null);
630
+ setError(null);
631
+ } else {
632
+ setError(result.error);
633
+ }
634
+ }, [sessionId]);
635
+
636
+ React.useEffect(() => {
637
+ if (!open) return undefined;
638
+ const controller = new AbortController();
639
+ load(controller.signal);
640
+ const timer = setInterval(() => load(controller.signal), 3000);
641
+ return () => {
642
+ controller.abort();
643
+ clearInterval(timer);
644
+ };
645
+ }, [open, load]);
646
+
647
+ const send = React.useCallback(async () => {
648
+ const value = text.trim();
649
+ if (value === '' || busy) return undefined;
650
+ setBusy(true);
651
+ const result = await chatCall({
652
+ url: CHAT_ROUTE,
653
+ init: {
654
+ method: 'POST',
655
+ credentials: 'same-origin',
656
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
657
+ body: JSON.stringify({ session_id: sessionId, text: value }),
658
+ },
659
+ });
660
+ setBusy(false);
661
+ if (result.ok) {
662
+ setText('');
663
+ await load();
664
+ } else {
665
+ setError(result.error);
666
+ }
667
+ return undefined;
668
+ }, [busy, load, sessionId, text]);
669
+
670
+ const disabled = typeof sessionId !== 'string' || sessionId === '';
671
+ const usageText = usage === null ? null : `${Math.round((usage.used_24h_bytes ?? 0) / 1024)} KiB/24h · ${usage.messages ?? 0} 条`;
672
+ const machineLabel = (message) => {
673
+ const id = typeof message?.machine_id === 'string' ? message.machine_id : '?';
674
+ return id.length > 12 ? `${id.slice(0, 8)}…` : id;
675
+ };
676
+ const timeLabel = (message) => (Number.isFinite(message?.at_ms) ? new Date(message.at_ms).toLocaleTimeString() : '');
677
+
678
+ return h('span', { className: 'w2mChat' },
679
+ h('button', {
680
+ type: 'button',
681
+ className: 'w2mChat_trigger',
682
+ title: '编队聊天(本会话广播)',
683
+ 'aria-expanded': open,
684
+ disabled,
685
+ onClick: () => setOpen((value) => !value),
686
+ }, '💬', h('span', { className: 'w2mChat_label' }, '编队聊天')),
687
+ open
688
+ ? h('div', { className: 'w2mChat_panel', role: 'dialog', 'aria-label': '编队聊天' },
689
+ h('div', { className: 'w2mChat_head' },
690
+ h('span', null, '编队聊天'),
691
+ usageText === null ? null : h('span', { className: 'w2mChat_usage' }, usageText),
692
+ h('button', { type: 'button', className: 'w2mChat_close', title: '关闭', onClick: () => setOpen(false) }, '×')),
693
+ h('div', { className: 'w2mChat_list' },
694
+ error !== null ? h('div', { className: 'w2mChat_error' }, error) : null,
695
+ messages.length === 0
696
+ ? (error === null ? h('div', { className: 'w2mChat_empty' }, '本会话还没有消息') : null)
697
+ : messages.map((message) => {
698
+ var kind = messageKind(message, localMachineId);
699
+ return h('div', { key: message?.message_id ?? `${message?.machine_id}:${message?.at_ms}`, className: 'w2mChat_msg is-' + kind },
700
+ h('div', { className: 'w2mChat_meta' }, h(WhaleAvatar, { machineId: message?.machine_id }), `${machineLabel(message)} · ${timeLabel(message)}`, kind === 'session' ? ' · 来自会话' : ''),
701
+ h('div', { className: 'w2mChat_text' }, message?.text ?? ''));
702
+ })),
703
+
704
+
705
+ h('div', { className: 'w2mChat_compose' },
706
+ h('textarea', {
707
+ className: 'w2mChat_input',
708
+ value: text,
709
+ rows: 2,
710
+ placeholder: '说点什么,发给本会话的机器…',
711
+ disabled: busy,
712
+ onChange: (event) => setText(event.target.value),
713
+ onKeyDown: (event) => {
714
+ if (event.key === 'Enter' && event.shiftKey !== true) {
715
+ event.preventDefault();
716
+ send();
717
+ }
718
+ },
719
+ }),
720
+ h('button', { type: 'button', className: 'w2mChat_send', disabled: busy || text.trim() === '', onClick: send }, busy ? '发送中…' : '发送')))
721
+ : null);
722
+ }
723
+
724
+ /**
725
+ * Client plugin body: install the stylesheet, register the dictionaries, take the seat.
726
+ *
727
+ * @param ctx - the client root context.
728
+ */
729
+ function apply(ctx) {
730
+ try {
731
+ mount(ctx);
732
+ } catch (error) {
733
+ // The audit that decides whether the app boots reports the entry's name and the word
734
+ // "failed" and nothing else, so this is where the cause has to be said out loud. The
735
+ // renderer console at error level is the channel the desktop crash log already carries
736
+ // (`--- renderer console (error level, oldest first) ---`), which makes this the one place a
737
+ // browser half can put its own diagnosis next to the failure it caused.
738
+ console.error(
739
+ '@twinsearth/w2m-dsh-plugin: the client entry failed to activate, so this page will not boot: ' +
740
+ `${error instanceof Error ? error.message : String(error)}`,
741
+ error,
742
+ );
743
+ // Deliberately rethrown. A swallowed failure would boot a page that looks healthy with no
744
+ // target control in it — the silent-half-arrival failure this package has already shipped
745
+ // once (v0.5.4) and refuses to ship again.
746
+ throw error;
747
+ }
748
+ }
749
+
750
+ /**
751
+ * The body of {@link apply}, wrapped so no failure can escape without a diagnostic.
752
+ *
753
+ * `slots.inject` (not a bare `register`) is what the slot's declaration requires of an entry
754
+ * from a package that does not own the seat: the parent declared `conversation.input.left`, and
755
+ * an entry may only register into a key it has announced. The returned `inject` factory is
756
+ * called per render position with that session's id, and its keys become this component's props
757
+ * — which is how the control knows which session's selection it is showing.
758
+ *
759
+ * The stylesheet is installed here rather than at module scope, so its lifetime is the plugin's:
760
+ * `ctx.effect` disposes it on unload, and nothing a bundle factory does at materialization can
761
+ * outlive the entry that materialized it.
762
+ *
763
+ * @param ctx - the client root context.
764
+ */
765
+ function mount(ctx) {
766
+ // `ctx.slots` is legal to read because {@link inject} declares it — and declaring it is also
767
+ // what makes this entry wait for the seat instead of racing it. The shape check is for callers
768
+ // that are not a Cordis fiber at all (`test/client-bundle.test.mjs`): a missing or foreign
769
+ // registry is a control that cannot be mounted, and a control that cannot be mounted must not
770
+ // be allowed to take the whole page down with it.
771
+ const slots = ctx?.slots;
772
+ if (typeof slots?.inject !== 'function' || typeof slots?.register !== 'function') {
773
+ console.error(
774
+ '@twinsearth/w2m-dsh-plugin: the client half found no slot registry on this context, so the ' +
775
+ 'dispatch-target control is not mounted; the rest of the page is unaffected',
776
+ );
777
+ return;
778
+ }
779
+ const styleDisposer = injectStyle(typeof document === 'undefined' ? null : document);
780
+ if (styleDisposer !== null && typeof ctx.effect === 'function') {
781
+ ctx.effect(() => styleDisposer, 'w2m: composer target control styles');
782
+ }
783
+ // Dictionaries as early as the composition allows: a namespace registered after the UI has
784
+ // mounted is still picked up, and the service is optional, so nothing here may block.
785
+ const locale = readOptionalService(ctx, 'locale');
786
+ registerDictionaries(ctx, locale);
787
+ const t = makeTranslator(() => readOptionalService(ctx, 'locale'));
788
+ slots.inject('conversation.input.left', () => slots.register({
789
+ name: 'conversation.input.left',
790
+ id: 'w2m-target',
791
+ // After the shipped permission control and the plan control, which sit earlier in the
792
+ // composer row: the target is a per-session choice like them, and the row reads left to
793
+ // right as "attach, permissions, plan, where it runs".
794
+ order: 40,
795
+ label: 'dispatch target',
796
+ inject: (sessionId) => ({
797
+ sessionId,
798
+ t,
799
+ }),
800
+ }, TargetSelect));
801
+
802
+ // The fleet-chat panel: the same session-scoped seat, one slot after the target control.
803
+ // Wrapped in its own try/catch on purpose — the target control is the primary half of this
804
+ // bundle, and a chat panel that cannot mount must not take the page (or the app) down with it.
805
+ try {
806
+ slots.inject('conversation.input.left', () => slots.register({
807
+ name: 'conversation.input.left',
808
+ id: 'w2m-chat',
809
+ order: 41,
810
+ label: 'fleet chat',
811
+ inject: (sessionId) => ({ sessionId }),
812
+ }, FleetChat));
813
+ } catch (error) {
814
+ console.error(
815
+ '@twinsearth/w2m-dsh-plugin: the fleet chat panel was not mounted; the rest of the page is unaffected',
816
+ error,
817
+ );
818
+ }
819
+ }
820
+
821
+ /**
822
+ * Cordis services this entry depends on, read by the fiber that mounts this bundle.
823
+ *
824
+ * `slots` is the seat: the control is registered into `conversation.input.left` through the
825
+ * slot registry, and a fiber that cannot see `slots` has nowhere to register. Declaring it is
826
+ * not bookkeeping. Cordis resolves `ctx.<name>` against this list, so reading an undeclared
827
+ * service **throws** (`cannot get property "slots" without inject`) even when the service is
828
+ * provided — measured in production: this bundle shipped with `inject = []` while reading
829
+ * `ctx.locale` and `ctx.slots`, the first read threw, and the app refused to boot.
830
+ *
831
+ * `locale` is deliberately absent, and that is the other half of the same rule: **every**
832
+ * declared name is a required dependency, so an entry that declares a service the composition
833
+ * does not provide never reaches `active` either — the audit then reports it as
834
+ * `pending (waiting for service: locale)`, which is just as fatal. Optional carriers are read
835
+ * with {@link readOptionalService} instead, and waited for by a child fiber when waiting is
836
+ * useful ({@link registerDictionaries}). Keep those two decisions in step with the reads in
837
+ * {@link mount}: `test/client-bundle.test.mjs` mounts this bundle behind a context that throws
838
+ * on any undeclared service read, exactly as Cordis does.
839
+ */
840
+ const inject = ['slots'];
841
+
842
+ exports.apply = apply;
843
+ exports.inject = inject;
844
+ exports.TargetSelect = TargetSelect;
845
+ exports.FleetChat = FleetChat;
846
+ exports.injectStyle = injectStyle;
847
+ exports.makeTranslator = makeTranslator;
848
+ return module.exports;
849
+ },
850
+ });