@twinsearth/w2m-dsh-plugin 1.2.7 → 1.3.1
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/.claude-plugin/marketplace.json +30 -29
- package/.claude-plugin/plugin.json +29 -29
- package/CHANGELOG.md +149 -0
- package/README.md +3 -3
- package/codex/mcp-servers.toml +1 -1
- package/docs/DATA-PLANE-COLLABORATION.md +82 -0
- package/lib/client.js +942 -941
- package/package.json +236 -235
- package/plugin.json +14 -14
- package/src/agent/agent.mjs +55 -1
- package/src/plugin/merge-hint.mjs +35 -0
- package/src/plugin/tools.mjs +73 -2
- package/src/relay/fleet-messages.mjs +258 -247
- package/src/relay/server.mjs +5 -1
- package/src/relay/state.mjs +33 -0
- package/src/util/reduce-suggestion.mjs +107 -0
package/lib/client.js
CHANGED
|
@@ -1,687 +1,687 @@
|
|
|
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_to{font:inherit;font-size:11px;max-width:120px;border-radius:6px;border:.5px solid var(--dsw-alias-border-default);background:var(--dsw-alias-surface-default);color:var(--dsw-alias-label-primary)}',
|
|
299
|
-
'.w2mChat_reply{display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:11px;color:var(--dsw-alias-label-secondary);border-top:.5px solid var(--dsw-alias-border-default)}',
|
|
300
|
-
'.w2mChat_replyCancel{margin-left:auto;border:none;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer}',
|
|
301
|
-
'.w2mChat_msg{cursor:pointer}',
|
|
302
|
-
'.w2mChat_thread{margin-left:6px;opacity:.7}',
|
|
303
|
-
'.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
|
|
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_to{font:inherit;font-size:11px;max-width:120px;border-radius:6px;border:.5px solid var(--dsw-alias-border-default);background:var(--dsw-alias-surface-default);color:var(--dsw-alias-label-primary)}',
|
|
299
|
+
'.w2mChat_reply{display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:11px;color:var(--dsw-alias-label-secondary);border-top:.5px solid var(--dsw-alias-border-default)}',
|
|
300
|
+
'.w2mChat_replyCancel{margin-left:auto;border:none;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer}',
|
|
301
|
+
'.w2mChat_msg{cursor:pointer}',
|
|
302
|
+
'.w2mChat_thread{margin-left:6px;opacity:.7}',
|
|
303
|
+
'.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
|
|
304
304
|
'.w2mChat_board{display:flex;flex-direction:column;gap:2px;padding:6px 10px;border-bottom:.5px solid var(--dsw-alias-border-default)}',
|
|
305
305
|
'.w2mChat_boardTitle{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
306
306
|
'.w2mChat_boardRow{font-size:11px;color:var(--dsw-alias-label-secondary);font-family:ui-monospace,monospace}',
|
|
307
|
-
'.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
|
|
308
|
-
'.w2mChat_unseen{margin-left:6px;min-width:16px;padding:0 5px;border-radius:8px;background:var(--dsw-alias-accent-default);color:var(--dsw-alias-label-on-accent);font-size:10px;line-height:16px;text-align:center}',
|
|
309
|
-
'.w2mChat_text{white-space:pre-wrap;word-break:break-word}',
|
|
310
|
-
'.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}',
|
|
311
|
-
'.w2mChat_compose{display:flex;gap:8px;padding:8px 10px;border-top:.5px solid var(--dsw-alias-border-l2);align-items:flex-end}',
|
|
312
|
-
'.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}',
|
|
313
|
-
'.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}',
|
|
314
|
-
'.w2mChat_send:disabled{opacity:.5;cursor:default}',
|
|
315
|
-
'.w2mChat_error{padding:0 10px 8px;color:var(--dsw-alias-state-error-primary);font-size:11px}',
|
|
316
|
-
].join('');
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Install the stylesheet once per document, and hand back a disposer for the plugin's lifetime.
|
|
320
|
-
*
|
|
321
|
-
* Idempotent by the tag id, not by a module flag: two plugin instances in one page (a reload
|
|
322
|
-
* that did not evict the old one, two compositions) must not produce two stylesheets, and an
|
|
323
|
-
* unload must take exactly the one this call created. When a tag is already present this is a
|
|
324
|
-
* no-op that returns nothing, because removing a tag this call did not add would be a
|
|
325
|
-
* disposer that breaks whichever instance owns it.
|
|
326
|
-
*
|
|
327
|
-
* @returns {(() => void)|null} Disposer, or null when the stylesheet was already installed.
|
|
328
|
-
*/
|
|
329
|
-
function injectStyle(hostDocument) {
|
|
330
|
-
if (hostDocument === undefined || hostDocument === null) return null;
|
|
331
|
-
if (hostDocument.querySelector(`style[data-plugin-css="${TAG_ID}"]`) !== null) return null;
|
|
332
|
-
const tag = hostDocument.createElement('style');
|
|
333
|
-
tag.dataset.plugin = '@twinsearth/w2m-dsh-plugin';
|
|
334
|
-
tag.dataset.pluginCss = TAG_ID;
|
|
335
|
-
tag.textContent = CSS;
|
|
336
|
-
hostDocument.head.appendChild(tag);
|
|
337
|
-
return () => {
|
|
338
|
-
tag.remove();
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const cls = (name) => `w2mTarget_${name}`;
|
|
343
|
-
const h = React.createElement;
|
|
344
|
-
|
|
345
|
-
/** The status dot: the colour is the machine's state, and the title says it in words. */
|
|
346
|
-
function StatusDot({ kind, title }) {
|
|
347
|
-
return h('span', { className: cls('dot'), 'data-state': kind, title, 'aria-hidden': 'true' });
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* This machine is `local` — the one whose composer this is — and the blue dot marks it; a
|
|
352
|
-
* reachable peer is green; anything else is drawn as an outline in the state's own colour so an
|
|
353
|
-
* offline row can never be mistaken for a live one.
|
|
354
|
-
*/
|
|
355
|
-
function dotKind(machine) {
|
|
356
|
-
if (machine.online !== true) return machine.state === 'expired' ? 'none' : 'stale';
|
|
357
|
-
return machine.local === true ? 'local' : 'online';
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** The label the trigger shows before the host has answered: the configured fleet name. */
|
|
361
|
-
const provisionalLabel = (state) => state?.host?.local_machine_name ?? null;
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* `locked` is accepted for parity with the seat's other occupants and deliberately unused: the
|
|
365
|
-
* target is a choice about the *next* dispatch, which is legitimate to change while a turn runs.
|
|
366
|
-
* `t` arrives through the entry's inject face (see {@link apply}).
|
|
367
|
-
*/
|
|
368
|
-
function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
369
|
-
const [open, setOpen] = React.useState(false);
|
|
370
|
-
const [reloadSeq, setReloadSeq] = React.useState(0);
|
|
371
|
-
const [state, setState] = React.useState(null);
|
|
372
|
-
const [error, setError] = React.useState(null);
|
|
373
|
-
const [busy, setBusy] = React.useState(false);
|
|
374
|
-
const anchorRef = React.useRef(null);
|
|
375
|
-
const menuRef = React.useRef(null);
|
|
376
|
-
const [placement, setPlacement] = React.useState(null);
|
|
377
|
-
|
|
378
|
-
// The host half is the source of truth, so every read replaces the mirror rather than merging
|
|
379
|
-
// into it: a stale "online" must not outlive the answer that disproved it.
|
|
380
|
-
React.useEffect(() => {
|
|
381
|
-
if (typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
382
|
-
const controller = new AbortController();
|
|
383
|
-
readState(sessionId, controller.signal).then((result) => {
|
|
384
|
-
if (controller.signal.aborted) return;
|
|
385
|
-
if (result.ok) {
|
|
386
|
-
setState(result.state);
|
|
387
|
-
setError(null);
|
|
388
|
-
} else {
|
|
389
|
-
setError(result.error);
|
|
390
|
-
}
|
|
391
|
-
});
|
|
392
|
-
return () => controller.abort();
|
|
393
|
-
}, [sessionId, reloadSeq]);
|
|
394
|
-
|
|
395
|
-
// Opening the menu is a read: the roster and the selection are re-asked for every time, so a
|
|
396
|
-
// machine that went away while the menu was closed cannot be clicked as if it were live. The
|
|
397
|
-
// host caches its own relay read for a few seconds, so this is not a request storm.
|
|
398
|
-
React.useEffect(() => {
|
|
399
|
-
if (open) setReloadSeq((seq) => seq + 1);
|
|
400
|
-
}, [open]);
|
|
401
|
-
|
|
402
|
-
React.useEffect(() => {
|
|
403
|
-
if (!open) return undefined;
|
|
404
|
-
const reposition = () => {
|
|
405
|
-
const anchor = anchorRef.current;
|
|
406
|
-
if (anchor === null) return;
|
|
407
|
-
const rect = anchor.getBoundingClientRect();
|
|
408
|
-
const width = Math.min(300, Math.max(200, Math.round(rect.width) + 96));
|
|
409
|
-
const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
|
|
410
|
-
// Above the composer by default — it is at the bottom of the window — and below it only
|
|
411
|
-
// when the space above is genuinely unusable.
|
|
412
|
-
const roomAbove = rect.top;
|
|
413
|
-
const maxHeight = Math.max(160, Math.min(320, roomAbove > 240 ? roomAbove - 16 : window.innerHeight - rect.bottom - 24));
|
|
414
|
-
setPlacement(roomAbove > 240
|
|
415
|
-
? { left, width, maxHeight, bottom: window.innerHeight - rect.top + 6, top: 'auto' }
|
|
416
|
-
: { left, width, maxHeight, top: rect.bottom + 6, bottom: 'auto' });
|
|
417
|
-
};
|
|
418
|
-
const onKeyDown = (event) => {
|
|
419
|
-
if (event.key === 'Escape') {
|
|
420
|
-
setOpen(false);
|
|
421
|
-
anchorRef.current?.focus?.();
|
|
422
|
-
}
|
|
423
|
-
};
|
|
424
|
-
const onPointerDown = (event) => {
|
|
425
|
-
const target = event.target;
|
|
426
|
-
if (anchorRef.current?.contains(target) === true) return;
|
|
427
|
-
if (menuRef.current?.contains(target) === true) return;
|
|
428
|
-
setOpen(false);
|
|
429
|
-
};
|
|
430
|
-
reposition();
|
|
431
|
-
// `capture` matters: the composer's own editor listens on the document too, and Escape has
|
|
432
|
-
// to close this menu before anything else reads it.
|
|
433
|
-
document.addEventListener('keydown', onKeyDown, true);
|
|
434
|
-
document.addEventListener('pointerdown', onPointerDown, true);
|
|
435
|
-
window.addEventListener('resize', reposition);
|
|
436
|
-
window.addEventListener('scroll', reposition, true);
|
|
437
|
-
return () => {
|
|
438
|
-
document.removeEventListener('keydown', onKeyDown, true);
|
|
439
|
-
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
440
|
-
window.removeEventListener('resize', reposition);
|
|
441
|
-
window.removeEventListener('scroll', reposition, true);
|
|
442
|
-
};
|
|
443
|
-
}, [open]);
|
|
444
|
-
|
|
445
|
-
const chosen = state?.selected?.machine_id ?? null;
|
|
446
|
-
const machines = Array.isArray(state?.machines) ? state.machines : [];
|
|
447
|
-
const problem = error ?? fleetProblem(state, t);
|
|
448
|
-
const disabled = typeof sessionId !== 'string' || sessionId === '';
|
|
449
|
-
const label = state === null ? (provisionalLabel(state) ?? t('machine')) : labelOf(state, t);
|
|
450
|
-
const selectedMachine = machines.find((machine) => machine.machine_id === chosen) ?? null;
|
|
451
|
-
const selectedOnline = state === null
|
|
452
|
-
? null
|
|
453
|
-
: (selectedMachine !== null ? selectedMachine.online === true : (chosen === null ? null : state?.selected?.online ?? null));
|
|
454
|
-
const dot = chosen === null
|
|
455
|
-
? 'none'
|
|
456
|
-
: (selectedOnline === true ? (selectedMachine?.local === true ? 'local' : 'online') : 'stale');
|
|
457
|
-
const title = problem ?? (chosen === null
|
|
458
|
-
? t('costAll')
|
|
459
|
-
: `${t('costOne', { name: label })}${selectedOnline === true ? '' : t('unconfirmed')}`);
|
|
460
|
-
|
|
461
|
-
const choose = (machineId, machineName) => {
|
|
462
|
-
setOpen(false);
|
|
463
|
-
if (machineId === chosen) return;
|
|
464
|
-
setBusy(true);
|
|
465
|
-
writeSelection(sessionId, machineId, machineName).then((result) => {
|
|
466
|
-
setBusy(false);
|
|
467
|
-
if (result.ok) {
|
|
468
|
-
setState(result.state);
|
|
469
|
-
setError(null);
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
// The write is refused for a reason the operator can act on (the machine went away, the
|
|
473
|
-
// fleet could not be read), so it is shown where they are looking rather than swallowed.
|
|
474
|
-
setError(result.error);
|
|
475
|
-
setReloadSeq((seq) => seq + 1);
|
|
476
|
-
});
|
|
477
|
-
};
|
|
478
|
-
|
|
479
|
-
const menu = open && placement !== null
|
|
480
|
-
? h('div', {
|
|
481
|
-
className: cls('menu'),
|
|
482
|
-
role: 'listbox',
|
|
483
|
-
'aria-label': t('targetLabel', { name: label }),
|
|
484
|
-
ref: menuRef,
|
|
485
|
-
style: {
|
|
486
|
-
left: `${placement.left}px`,
|
|
487
|
-
top: placement.top === 'auto' ? 'auto' : `${placement.top}px`,
|
|
488
|
-
bottom: placement.bottom === undefined || placement.bottom === 'auto' ? 'auto' : `${placement.bottom}px`,
|
|
489
|
-
width: `${placement.width}px`,
|
|
490
|
-
maxHeight: `${placement.maxHeight}px`,
|
|
491
|
-
overflowY: 'auto',
|
|
492
|
-
},
|
|
493
|
-
}, [
|
|
494
|
-
h('button', {
|
|
495
|
-
key: '__all__',
|
|
496
|
-
type: 'button',
|
|
497
|
-
role: 'option',
|
|
498
|
-
className: cls('option'),
|
|
499
|
-
'data-active': chosen === null ? 'true' : 'false',
|
|
500
|
-
'aria-selected': chosen === null,
|
|
501
|
-
onClick: () => choose(null, null),
|
|
502
|
-
title: t('costAll'),
|
|
503
|
-
}, h('span', { className: cls('row') }, [
|
|
504
|
-
h(StatusDot, { key: 'dot', kind: 'none', title: t('all') }),
|
|
505
|
-
h('span', { key: 'name', className: cls('name') }, t('all')),
|
|
506
|
-
h('span', { key: 'detail', className: cls('detail') }, state?.fleet?.ok === true ? `${state.fleet.online} ${t('online')}` : '—'),
|
|
507
|
-
])),
|
|
508
|
-
problem === null ? null : h('div', { key: '__problem__', className: cls('note'), role: 'status' }, problem),
|
|
509
|
-
machines.length === 0
|
|
510
|
-
? h('div', { key: '__empty__', className: cls('note') }, problem === null ? t('empty') : t('noRoster'))
|
|
511
|
-
: machines.map((machine) => h('button', {
|
|
512
|
-
key: machine.machine_id ?? machine.name ?? Math.random().toString(36).slice(2),
|
|
513
|
-
type: 'button',
|
|
514
|
-
role: 'option',
|
|
515
|
-
className: cls('option'),
|
|
516
|
-
'data-active': machine.machine_id !== null && machine.machine_id === chosen ? 'true' : 'false',
|
|
517
|
-
'aria-selected': machine.machine_id !== null && machine.machine_id === chosen,
|
|
518
|
-
disabled: machine.online !== true,
|
|
519
|
-
title: machine.online === true
|
|
520
|
-
? `${machine.name ?? machine.machine_id} — ${machine.local === true ? t('local') : t('online')}`
|
|
521
|
-
: `${machine.name ?? machine.machine_id} — ${machine.state}`,
|
|
522
|
-
onClick: () => choose(machine.machine_id, machine.name),
|
|
523
|
-
}, h('span', { className: cls('row') }, [
|
|
524
|
-
h(StatusDot, { key: 'dot', kind: dotKind(machine), title: machine.state }),
|
|
525
|
-
h('span', { key: 'name', className: cls('name') }, machine.name ?? machine.machine_id ?? 'unnamed'),
|
|
526
|
-
h('span', { key: 'detail', className: cls('detail') }, machine.local === true
|
|
527
|
-
? t('local')
|
|
528
|
-
: (machine.online === true ? (machine.os ?? t('online')) : machine.state)),
|
|
529
|
-
]))),
|
|
530
|
-
])
|
|
531
|
-
: null;
|
|
532
|
-
|
|
533
|
-
return h(React.Fragment, null, [
|
|
534
|
-
h('button', {
|
|
535
|
-
key: 'trigger',
|
|
536
|
-
type: 'button',
|
|
537
|
-
ref: anchorRef,
|
|
538
|
-
className: cls('trigger'),
|
|
539
|
-
'aria-label': t('targetLabel', { name: label }),
|
|
540
|
-
'aria-haspopup': 'listbox',
|
|
541
|
-
'aria-expanded': open,
|
|
542
|
-
'aria-busy': busy,
|
|
543
|
-
title,
|
|
544
|
-
disabled,
|
|
545
|
-
onClick: () => setOpen((wasOpen) => !wasOpen),
|
|
546
|
-
}, [
|
|
547
|
-
h(StatusDot, { key: 'dot', kind: dot, title: title ?? '' }),
|
|
548
|
-
h('span', { key: 'label', className: cls('label') }, label),
|
|
549
|
-
h('span', { key: 'chevron', className: cls('chevron'), 'data-open': open ? 'true' : 'false', 'aria-hidden': 'true' },
|
|
550
|
-
h('svg', { width: 12, height: 12, viewBox: '0 0 12 12', fill: 'none' },
|
|
551
|
-
h('path', {
|
|
552
|
-
d: 'M3 4.5L6 7.5L9 4.5',
|
|
553
|
-
stroke: 'currentColor',
|
|
554
|
-
strokeWidth: 1.5,
|
|
555
|
-
strokeLinecap: 'round',
|
|
556
|
-
strokeLinejoin: 'round',
|
|
557
|
-
}))),
|
|
558
|
-
]),
|
|
559
|
-
menu,
|
|
560
|
-
]);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// -------------------------------------------------------------------------------------------
|
|
564
|
-
// Fleet chat panel (v1.1.18 addition: the missing browser half of `w2m_chat`)
|
|
565
|
-
// -------------------------------------------------------------------------------------------
|
|
566
|
-
//
|
|
567
|
-
// v1.1.17 shipped fleet chat as a tool only — its own CHANGELOG records "no client UI panel
|
|
568
|
-
// beyond the generic tool card". This is that panel: a session-scoped broadcast board the
|
|
569
|
-
// operator and the agent share. Every read and write goes through the host route
|
|
570
|
-
// `/api/w2m/chat` (host-side `fleet-chat-route.mjs`), so the device token stays in the host
|
|
571
|
-
// process and this file never holds a credential.
|
|
572
|
-
const CHAT_ROUTE = '/api/w2m/chat';
|
|
573
|
-
|
|
574
|
-
/** One JSON call to the host chat route; a refusal is a value, not an exception. */
|
|
575
|
-
async function chatCall(options) {
|
|
576
|
-
let response;
|
|
577
|
-
try {
|
|
578
|
-
response = await fetch(options.url, options.init);
|
|
579
|
-
} catch (error) {
|
|
580
|
-
return { ok: false, error: `the plugin could not be reached: ${error instanceof Error ? error.message : String(error)}` };
|
|
581
|
-
}
|
|
582
|
-
let body = null;
|
|
583
|
-
try {
|
|
584
|
-
body = await response.json();
|
|
585
|
-
} catch {
|
|
586
|
-
return { ok: false, error: `the plugin answered HTTP ${response.status} with a body that is not JSON` };
|
|
587
|
-
}
|
|
588
|
-
if (response.status === 404) return { ok: false, error: 'this host does not serve the W2M chat route' };
|
|
589
|
-
if (response.ok !== true || body?.ok === false) {
|
|
590
|
-
return { ok: false, error: typeof body?.error === 'string' ? body.error : `HTTP ${response.status}` };
|
|
591
|
-
}
|
|
592
|
-
return { ok: true, state: body };
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/**
|
|
596
|
-
* The fleet-chat control: opens a panel with the session's messages, a compose box and the
|
|
597
|
-
* relay's current usage. The text stays on the relay; this component keeps only the last view.
|
|
598
|
-
*/
|
|
599
|
-
const AVATAR_PALETTE = ['#4c6ef5', '#12b886', '#f59f00', '#e8590c', '#ae3ec9', '#1098ad', '#d6336c', '#5c940d'];
|
|
600
|
-
|
|
601
|
-
/** A machine keeps its colour: the index comes from its id, not from the order of the list. */
|
|
602
|
-
function avatarColor(machineId) {
|
|
603
|
-
var h = 0;
|
|
604
|
-
var s = String(machineId == null ? '' : machineId);
|
|
605
|
-
for (var i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
|
606
|
-
return AVATAR_PALETTE[h % AVATAR_PALETTE.length];
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/** The DeepSeek whale, filled with the machine colour. */
|
|
610
|
-
function WhaleAvatar({ machineId }) {
|
|
611
|
-
return h('span', { className: 'w2mChat_avatar', style: { color: avatarColor(machineId) }, 'aria-hidden': 'true' },
|
|
612
|
-
h('svg', { viewBox: '0 0 24 24', width: 16, height: 16 },
|
|
613
|
-
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' })));
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
/** session = this machine typed it into DSH; chat = typed here; remote = another machine. */
|
|
617
|
-
function messageKind(message, localMachineId) {
|
|
618
|
-
if (localMachineId == null || message == null || message.machine_id !== localMachineId) return 'remote';
|
|
619
|
-
return message.origin === 'session' ? 'session' : 'chat';
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
function FleetChat({ sessionId }) {
|
|
623
|
-
const [open, setOpen] = React.useState(false);
|
|
624
|
-
const [messages, setMessages] = React.useState([]);
|
|
625
|
-
const [usage, setUsage] = React.useState(null);
|
|
626
|
-
const [localMachineName, setLocalMachineName] = React.useState(null);
|
|
627
|
-
const [roster, setRoster] = React.useState(null);
|
|
628
|
-
const [toRecipient, setToRecipient] = React.useState('all');
|
|
629
|
-
const [replyTo, setReplyTo] = React.useState(null);
|
|
307
|
+
'.w2mChat_threadHead{font-size:11px;color:var(--dsw-alias-label-tertiary);padding:4px 10px 0}',
|
|
308
|
+
'.w2mChat_unseen{margin-left:6px;min-width:16px;padding:0 5px;border-radius:8px;background:var(--dsw-alias-accent-default);color:var(--dsw-alias-label-on-accent);font-size:10px;line-height:16px;text-align:center}',
|
|
309
|
+
'.w2mChat_text{white-space:pre-wrap;word-break:break-word}',
|
|
310
|
+
'.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}',
|
|
311
|
+
'.w2mChat_compose{display:flex;gap:8px;padding:8px 10px;border-top:.5px solid var(--dsw-alias-border-l2);align-items:flex-end}',
|
|
312
|
+
'.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}',
|
|
313
|
+
'.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}',
|
|
314
|
+
'.w2mChat_send:disabled{opacity:.5;cursor:default}',
|
|
315
|
+
'.w2mChat_error{padding:0 10px 8px;color:var(--dsw-alias-state-error-primary);font-size:11px}',
|
|
316
|
+
].join('');
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Install the stylesheet once per document, and hand back a disposer for the plugin's lifetime.
|
|
320
|
+
*
|
|
321
|
+
* Idempotent by the tag id, not by a module flag: two plugin instances in one page (a reload
|
|
322
|
+
* that did not evict the old one, two compositions) must not produce two stylesheets, and an
|
|
323
|
+
* unload must take exactly the one this call created. When a tag is already present this is a
|
|
324
|
+
* no-op that returns nothing, because removing a tag this call did not add would be a
|
|
325
|
+
* disposer that breaks whichever instance owns it.
|
|
326
|
+
*
|
|
327
|
+
* @returns {(() => void)|null} Disposer, or null when the stylesheet was already installed.
|
|
328
|
+
*/
|
|
329
|
+
function injectStyle(hostDocument) {
|
|
330
|
+
if (hostDocument === undefined || hostDocument === null) return null;
|
|
331
|
+
if (hostDocument.querySelector(`style[data-plugin-css="${TAG_ID}"]`) !== null) return null;
|
|
332
|
+
const tag = hostDocument.createElement('style');
|
|
333
|
+
tag.dataset.plugin = '@twinsearth/w2m-dsh-plugin';
|
|
334
|
+
tag.dataset.pluginCss = TAG_ID;
|
|
335
|
+
tag.textContent = CSS;
|
|
336
|
+
hostDocument.head.appendChild(tag);
|
|
337
|
+
return () => {
|
|
338
|
+
tag.remove();
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const cls = (name) => `w2mTarget_${name}`;
|
|
343
|
+
const h = React.createElement;
|
|
344
|
+
|
|
345
|
+
/** The status dot: the colour is the machine's state, and the title says it in words. */
|
|
346
|
+
function StatusDot({ kind, title }) {
|
|
347
|
+
return h('span', { className: cls('dot'), 'data-state': kind, title, 'aria-hidden': 'true' });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* This machine is `local` — the one whose composer this is — and the blue dot marks it; a
|
|
352
|
+
* reachable peer is green; anything else is drawn as an outline in the state's own colour so an
|
|
353
|
+
* offline row can never be mistaken for a live one.
|
|
354
|
+
*/
|
|
355
|
+
function dotKind(machine) {
|
|
356
|
+
if (machine.online !== true) return machine.state === 'expired' ? 'none' : 'stale';
|
|
357
|
+
return machine.local === true ? 'local' : 'online';
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The label the trigger shows before the host has answered: the configured fleet name. */
|
|
361
|
+
const provisionalLabel = (state) => state?.host?.local_machine_name ?? null;
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* `locked` is accepted for parity with the seat's other occupants and deliberately unused: the
|
|
365
|
+
* target is a choice about the *next* dispatch, which is legitimate to change while a turn runs.
|
|
366
|
+
* `t` arrives through the entry's inject face (see {@link apply}).
|
|
367
|
+
*/
|
|
368
|
+
function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
369
|
+
const [open, setOpen] = React.useState(false);
|
|
370
|
+
const [reloadSeq, setReloadSeq] = React.useState(0);
|
|
371
|
+
const [state, setState] = React.useState(null);
|
|
372
|
+
const [error, setError] = React.useState(null);
|
|
373
|
+
const [busy, setBusy] = React.useState(false);
|
|
374
|
+
const anchorRef = React.useRef(null);
|
|
375
|
+
const menuRef = React.useRef(null);
|
|
376
|
+
const [placement, setPlacement] = React.useState(null);
|
|
377
|
+
|
|
378
|
+
// The host half is the source of truth, so every read replaces the mirror rather than merging
|
|
379
|
+
// into it: a stale "online" must not outlive the answer that disproved it.
|
|
380
|
+
React.useEffect(() => {
|
|
381
|
+
if (typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
382
|
+
const controller = new AbortController();
|
|
383
|
+
readState(sessionId, controller.signal).then((result) => {
|
|
384
|
+
if (controller.signal.aborted) return;
|
|
385
|
+
if (result.ok) {
|
|
386
|
+
setState(result.state);
|
|
387
|
+
setError(null);
|
|
388
|
+
} else {
|
|
389
|
+
setError(result.error);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
return () => controller.abort();
|
|
393
|
+
}, [sessionId, reloadSeq]);
|
|
394
|
+
|
|
395
|
+
// Opening the menu is a read: the roster and the selection are re-asked for every time, so a
|
|
396
|
+
// machine that went away while the menu was closed cannot be clicked as if it were live. The
|
|
397
|
+
// host caches its own relay read for a few seconds, so this is not a request storm.
|
|
398
|
+
React.useEffect(() => {
|
|
399
|
+
if (open) setReloadSeq((seq) => seq + 1);
|
|
400
|
+
}, [open]);
|
|
401
|
+
|
|
402
|
+
React.useEffect(() => {
|
|
403
|
+
if (!open) return undefined;
|
|
404
|
+
const reposition = () => {
|
|
405
|
+
const anchor = anchorRef.current;
|
|
406
|
+
if (anchor === null) return;
|
|
407
|
+
const rect = anchor.getBoundingClientRect();
|
|
408
|
+
const width = Math.min(300, Math.max(200, Math.round(rect.width) + 96));
|
|
409
|
+
const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
|
|
410
|
+
// Above the composer by default — it is at the bottom of the window — and below it only
|
|
411
|
+
// when the space above is genuinely unusable.
|
|
412
|
+
const roomAbove = rect.top;
|
|
413
|
+
const maxHeight = Math.max(160, Math.min(320, roomAbove > 240 ? roomAbove - 16 : window.innerHeight - rect.bottom - 24));
|
|
414
|
+
setPlacement(roomAbove > 240
|
|
415
|
+
? { left, width, maxHeight, bottom: window.innerHeight - rect.top + 6, top: 'auto' }
|
|
416
|
+
: { left, width, maxHeight, top: rect.bottom + 6, bottom: 'auto' });
|
|
417
|
+
};
|
|
418
|
+
const onKeyDown = (event) => {
|
|
419
|
+
if (event.key === 'Escape') {
|
|
420
|
+
setOpen(false);
|
|
421
|
+
anchorRef.current?.focus?.();
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
const onPointerDown = (event) => {
|
|
425
|
+
const target = event.target;
|
|
426
|
+
if (anchorRef.current?.contains(target) === true) return;
|
|
427
|
+
if (menuRef.current?.contains(target) === true) return;
|
|
428
|
+
setOpen(false);
|
|
429
|
+
};
|
|
430
|
+
reposition();
|
|
431
|
+
// `capture` matters: the composer's own editor listens on the document too, and Escape has
|
|
432
|
+
// to close this menu before anything else reads it.
|
|
433
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
434
|
+
document.addEventListener('pointerdown', onPointerDown, true);
|
|
435
|
+
window.addEventListener('resize', reposition);
|
|
436
|
+
window.addEventListener('scroll', reposition, true);
|
|
437
|
+
return () => {
|
|
438
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
439
|
+
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
440
|
+
window.removeEventListener('resize', reposition);
|
|
441
|
+
window.removeEventListener('scroll', reposition, true);
|
|
442
|
+
};
|
|
443
|
+
}, [open]);
|
|
444
|
+
|
|
445
|
+
const chosen = state?.selected?.machine_id ?? null;
|
|
446
|
+
const machines = Array.isArray(state?.machines) ? state.machines : [];
|
|
447
|
+
const problem = error ?? fleetProblem(state, t);
|
|
448
|
+
const disabled = typeof sessionId !== 'string' || sessionId === '';
|
|
449
|
+
const label = state === null ? (provisionalLabel(state) ?? t('machine')) : labelOf(state, t);
|
|
450
|
+
const selectedMachine = machines.find((machine) => machine.machine_id === chosen) ?? null;
|
|
451
|
+
const selectedOnline = state === null
|
|
452
|
+
? null
|
|
453
|
+
: (selectedMachine !== null ? selectedMachine.online === true : (chosen === null ? null : state?.selected?.online ?? null));
|
|
454
|
+
const dot = chosen === null
|
|
455
|
+
? 'none'
|
|
456
|
+
: (selectedOnline === true ? (selectedMachine?.local === true ? 'local' : 'online') : 'stale');
|
|
457
|
+
const title = problem ?? (chosen === null
|
|
458
|
+
? t('costAll')
|
|
459
|
+
: `${t('costOne', { name: label })}${selectedOnline === true ? '' : t('unconfirmed')}`);
|
|
460
|
+
|
|
461
|
+
const choose = (machineId, machineName) => {
|
|
462
|
+
setOpen(false);
|
|
463
|
+
if (machineId === chosen) return;
|
|
464
|
+
setBusy(true);
|
|
465
|
+
writeSelection(sessionId, machineId, machineName).then((result) => {
|
|
466
|
+
setBusy(false);
|
|
467
|
+
if (result.ok) {
|
|
468
|
+
setState(result.state);
|
|
469
|
+
setError(null);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
// The write is refused for a reason the operator can act on (the machine went away, the
|
|
473
|
+
// fleet could not be read), so it is shown where they are looking rather than swallowed.
|
|
474
|
+
setError(result.error);
|
|
475
|
+
setReloadSeq((seq) => seq + 1);
|
|
476
|
+
});
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const menu = open && placement !== null
|
|
480
|
+
? h('div', {
|
|
481
|
+
className: cls('menu'),
|
|
482
|
+
role: 'listbox',
|
|
483
|
+
'aria-label': t('targetLabel', { name: label }),
|
|
484
|
+
ref: menuRef,
|
|
485
|
+
style: {
|
|
486
|
+
left: `${placement.left}px`,
|
|
487
|
+
top: placement.top === 'auto' ? 'auto' : `${placement.top}px`,
|
|
488
|
+
bottom: placement.bottom === undefined || placement.bottom === 'auto' ? 'auto' : `${placement.bottom}px`,
|
|
489
|
+
width: `${placement.width}px`,
|
|
490
|
+
maxHeight: `${placement.maxHeight}px`,
|
|
491
|
+
overflowY: 'auto',
|
|
492
|
+
},
|
|
493
|
+
}, [
|
|
494
|
+
h('button', {
|
|
495
|
+
key: '__all__',
|
|
496
|
+
type: 'button',
|
|
497
|
+
role: 'option',
|
|
498
|
+
className: cls('option'),
|
|
499
|
+
'data-active': chosen === null ? 'true' : 'false',
|
|
500
|
+
'aria-selected': chosen === null,
|
|
501
|
+
onClick: () => choose(null, null),
|
|
502
|
+
title: t('costAll'),
|
|
503
|
+
}, h('span', { className: cls('row') }, [
|
|
504
|
+
h(StatusDot, { key: 'dot', kind: 'none', title: t('all') }),
|
|
505
|
+
h('span', { key: 'name', className: cls('name') }, t('all')),
|
|
506
|
+
h('span', { key: 'detail', className: cls('detail') }, state?.fleet?.ok === true ? `${state.fleet.online} ${t('online')}` : '—'),
|
|
507
|
+
])),
|
|
508
|
+
problem === null ? null : h('div', { key: '__problem__', className: cls('note'), role: 'status' }, problem),
|
|
509
|
+
machines.length === 0
|
|
510
|
+
? h('div', { key: '__empty__', className: cls('note') }, problem === null ? t('empty') : t('noRoster'))
|
|
511
|
+
: machines.map((machine) => h('button', {
|
|
512
|
+
key: machine.machine_id ?? machine.name ?? Math.random().toString(36).slice(2),
|
|
513
|
+
type: 'button',
|
|
514
|
+
role: 'option',
|
|
515
|
+
className: cls('option'),
|
|
516
|
+
'data-active': machine.machine_id !== null && machine.machine_id === chosen ? 'true' : 'false',
|
|
517
|
+
'aria-selected': machine.machine_id !== null && machine.machine_id === chosen,
|
|
518
|
+
disabled: machine.online !== true,
|
|
519
|
+
title: machine.online === true
|
|
520
|
+
? `${machine.name ?? machine.machine_id} — ${machine.local === true ? t('local') : t('online')}`
|
|
521
|
+
: `${machine.name ?? machine.machine_id} — ${machine.state}`,
|
|
522
|
+
onClick: () => choose(machine.machine_id, machine.name),
|
|
523
|
+
}, h('span', { className: cls('row') }, [
|
|
524
|
+
h(StatusDot, { key: 'dot', kind: dotKind(machine), title: machine.state }),
|
|
525
|
+
h('span', { key: 'name', className: cls('name') }, machine.name ?? machine.machine_id ?? 'unnamed'),
|
|
526
|
+
h('span', { key: 'detail', className: cls('detail') }, machine.local === true
|
|
527
|
+
? t('local')
|
|
528
|
+
: (machine.online === true ? (machine.os ?? t('online')) : machine.state)),
|
|
529
|
+
]))),
|
|
530
|
+
])
|
|
531
|
+
: null;
|
|
532
|
+
|
|
533
|
+
return h(React.Fragment, null, [
|
|
534
|
+
h('button', {
|
|
535
|
+
key: 'trigger',
|
|
536
|
+
type: 'button',
|
|
537
|
+
ref: anchorRef,
|
|
538
|
+
className: cls('trigger'),
|
|
539
|
+
'aria-label': t('targetLabel', { name: label }),
|
|
540
|
+
'aria-haspopup': 'listbox',
|
|
541
|
+
'aria-expanded': open,
|
|
542
|
+
'aria-busy': busy,
|
|
543
|
+
title,
|
|
544
|
+
disabled,
|
|
545
|
+
onClick: () => setOpen((wasOpen) => !wasOpen),
|
|
546
|
+
}, [
|
|
547
|
+
h(StatusDot, { key: 'dot', kind: dot, title: title ?? '' }),
|
|
548
|
+
h('span', { key: 'label', className: cls('label') }, label),
|
|
549
|
+
h('span', { key: 'chevron', className: cls('chevron'), 'data-open': open ? 'true' : 'false', 'aria-hidden': 'true' },
|
|
550
|
+
h('svg', { width: 12, height: 12, viewBox: '0 0 12 12', fill: 'none' },
|
|
551
|
+
h('path', {
|
|
552
|
+
d: 'M3 4.5L6 7.5L9 4.5',
|
|
553
|
+
stroke: 'currentColor',
|
|
554
|
+
strokeWidth: 1.5,
|
|
555
|
+
strokeLinecap: 'round',
|
|
556
|
+
strokeLinejoin: 'round',
|
|
557
|
+
}))),
|
|
558
|
+
]),
|
|
559
|
+
menu,
|
|
560
|
+
]);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// -------------------------------------------------------------------------------------------
|
|
564
|
+
// Fleet chat panel (v1.1.18 addition: the missing browser half of `w2m_chat`)
|
|
565
|
+
// -------------------------------------------------------------------------------------------
|
|
566
|
+
//
|
|
567
|
+
// v1.1.17 shipped fleet chat as a tool only — its own CHANGELOG records "no client UI panel
|
|
568
|
+
// beyond the generic tool card". This is that panel: a session-scoped broadcast board the
|
|
569
|
+
// operator and the agent share. Every read and write goes through the host route
|
|
570
|
+
// `/api/w2m/chat` (host-side `fleet-chat-route.mjs`), so the device token stays in the host
|
|
571
|
+
// process and this file never holds a credential.
|
|
572
|
+
const CHAT_ROUTE = '/api/w2m/chat';
|
|
573
|
+
|
|
574
|
+
/** One JSON call to the host chat route; a refusal is a value, not an exception. */
|
|
575
|
+
async function chatCall(options) {
|
|
576
|
+
let response;
|
|
577
|
+
try {
|
|
578
|
+
response = await fetch(options.url, options.init);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
return { ok: false, error: `the plugin could not be reached: ${error instanceof Error ? error.message : String(error)}` };
|
|
581
|
+
}
|
|
582
|
+
let body = null;
|
|
583
|
+
try {
|
|
584
|
+
body = await response.json();
|
|
585
|
+
} catch {
|
|
586
|
+
return { ok: false, error: `the plugin answered HTTP ${response.status} with a body that is not JSON` };
|
|
587
|
+
}
|
|
588
|
+
if (response.status === 404) return { ok: false, error: 'this host does not serve the W2M chat route' };
|
|
589
|
+
if (response.ok !== true || body?.ok === false) {
|
|
590
|
+
return { ok: false, error: typeof body?.error === 'string' ? body.error : `HTTP ${response.status}` };
|
|
591
|
+
}
|
|
592
|
+
return { ok: true, state: body };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* The fleet-chat control: opens a panel with the session's messages, a compose box and the
|
|
597
|
+
* relay's current usage. The text stays on the relay; this component keeps only the last view.
|
|
598
|
+
*/
|
|
599
|
+
const AVATAR_PALETTE = ['#4c6ef5', '#12b886', '#f59f00', '#e8590c', '#ae3ec9', '#1098ad', '#d6336c', '#5c940d'];
|
|
600
|
+
|
|
601
|
+
/** A machine keeps its colour: the index comes from its id, not from the order of the list. */
|
|
602
|
+
function avatarColor(machineId) {
|
|
603
|
+
var h = 0;
|
|
604
|
+
var s = String(machineId == null ? '' : machineId);
|
|
605
|
+
for (var i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
|
606
|
+
return AVATAR_PALETTE[h % AVATAR_PALETTE.length];
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** The DeepSeek whale, filled with the machine colour. */
|
|
610
|
+
function WhaleAvatar({ machineId }) {
|
|
611
|
+
return h('span', { className: 'w2mChat_avatar', style: { color: avatarColor(machineId) }, 'aria-hidden': 'true' },
|
|
612
|
+
h('svg', { viewBox: '0 0 24 24', width: 16, height: 16 },
|
|
613
|
+
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' })));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** session = this machine typed it into DSH; chat = typed here; remote = another machine. */
|
|
617
|
+
function messageKind(message, localMachineId) {
|
|
618
|
+
if (localMachineId == null || message == null || message.machine_id !== localMachineId) return 'remote';
|
|
619
|
+
return message.origin === 'session' ? 'session' : 'chat';
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function FleetChat({ sessionId }) {
|
|
623
|
+
const [open, setOpen] = React.useState(false);
|
|
624
|
+
const [messages, setMessages] = React.useState([]);
|
|
625
|
+
const [usage, setUsage] = React.useState(null);
|
|
626
|
+
const [localMachineName, setLocalMachineName] = React.useState(null);
|
|
627
|
+
const [roster, setRoster] = React.useState(null);
|
|
628
|
+
const [toRecipient, setToRecipient] = React.useState('all');
|
|
629
|
+
const [replyTo, setReplyTo] = React.useState(null);
|
|
630
630
|
const [tasks, setTasks] = React.useState([]);
|
|
631
|
-
const [lastSeenMs, setLastSeenMs] = React.useState(() => {
|
|
632
|
-
try {
|
|
633
|
-
const raw = globalThis.localStorage?.getItem('w2m:chat:seen:' + sessionId);
|
|
634
|
-
const parsed = Number.parseInt(raw ?? '', 10);
|
|
635
|
-
return Number.isFinite(parsed) ? parsed : 0;
|
|
636
|
-
} catch {
|
|
637
|
-
return 0;
|
|
638
|
-
}
|
|
639
|
-
});
|
|
640
|
-
const [text, setText] = React.useState('');
|
|
641
|
-
const [error, setError] = React.useState(null);
|
|
642
|
-
const [busy, setBusy] = React.useState(false);
|
|
643
|
-
const [localMachineId, setLocalMachineId] = React.useState(null);
|
|
644
|
-
|
|
645
|
-
const load = React.useCallback(async (signal) => {
|
|
646
|
-
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
647
|
-
const result = await chatCall({
|
|
648
|
-
url: `${CHAT_ROUTE}?session_id=${encodeURIComponent(sessionId)}&limit=50`,
|
|
649
|
-
init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal },
|
|
650
|
-
});
|
|
651
|
-
if (result.ok) {
|
|
652
|
-
setMessages(Array.isArray(result.state.messages) ? result.state.messages : []);
|
|
653
|
-
setLocalMachineId(typeof result.state.local_machine_id === 'string' ? result.state.local_machine_id : null);
|
|
654
|
-
setLocalMachineName(typeof result.state.local_machine_name === 'string' && result.state.local_machine_name !== '' ? result.state.local_machine_name : null);
|
|
655
|
-
setUsage(result.state.usage ?? null);
|
|
656
|
-
setError(null);
|
|
657
|
-
} else {
|
|
658
|
-
setError(result.error);
|
|
659
|
-
}
|
|
660
|
-
}, [sessionId]);
|
|
661
|
-
|
|
662
|
-
React.useEffect(() => {
|
|
663
|
-
if (!open) return undefined;
|
|
664
|
-
const controller = new AbortController();
|
|
665
|
-
load(controller.signal);
|
|
666
|
-
const timer = setInterval(() => load(controller.signal), 3000);
|
|
667
|
-
return () => {
|
|
668
|
-
controller.abort();
|
|
669
|
-
clearInterval(timer);
|
|
670
|
-
};
|
|
671
|
-
}, [open, load]);
|
|
672
|
-
|
|
673
|
-
// The chat messages carry a machine id, not a name: the relay stores what a sender proves, and a name is
|
|
674
|
-
// not one of those proofs. The roster the target control already reads is where names live, so the panel
|
|
675
|
-
// borrows it once per open instead of inventing a label from the id.
|
|
676
|
-
React.useEffect(() => {
|
|
677
|
-
if (!open || typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
678
|
-
const controller = new AbortController();
|
|
679
|
-
readState(sessionId, controller.signal).then((result) => {
|
|
680
|
-
if (controller.signal.aborted) return;
|
|
681
|
-
setRoster(result.ok ? result.state : null);
|
|
682
|
-
});
|
|
683
|
-
return () => controller.abort();
|
|
684
|
-
}, [open, sessionId]);
|
|
631
|
+
const [lastSeenMs, setLastSeenMs] = React.useState(() => {
|
|
632
|
+
try {
|
|
633
|
+
const raw = globalThis.localStorage?.getItem('w2m:chat:seen:' + sessionId);
|
|
634
|
+
const parsed = Number.parseInt(raw ?? '', 10);
|
|
635
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
636
|
+
} catch {
|
|
637
|
+
return 0;
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
const [text, setText] = React.useState('');
|
|
641
|
+
const [error, setError] = React.useState(null);
|
|
642
|
+
const [busy, setBusy] = React.useState(false);
|
|
643
|
+
const [localMachineId, setLocalMachineId] = React.useState(null);
|
|
644
|
+
|
|
645
|
+
const load = React.useCallback(async (signal) => {
|
|
646
|
+
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
647
|
+
const result = await chatCall({
|
|
648
|
+
url: `${CHAT_ROUTE}?session_id=${encodeURIComponent(sessionId)}&limit=50`,
|
|
649
|
+
init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal },
|
|
650
|
+
});
|
|
651
|
+
if (result.ok) {
|
|
652
|
+
setMessages(Array.isArray(result.state.messages) ? result.state.messages : []);
|
|
653
|
+
setLocalMachineId(typeof result.state.local_machine_id === 'string' ? result.state.local_machine_id : null);
|
|
654
|
+
setLocalMachineName(typeof result.state.local_machine_name === 'string' && result.state.local_machine_name !== '' ? result.state.local_machine_name : null);
|
|
655
|
+
setUsage(result.state.usage ?? null);
|
|
656
|
+
setError(null);
|
|
657
|
+
} else {
|
|
658
|
+
setError(result.error);
|
|
659
|
+
}
|
|
660
|
+
}, [sessionId]);
|
|
661
|
+
|
|
662
|
+
React.useEffect(() => {
|
|
663
|
+
if (!open) return undefined;
|
|
664
|
+
const controller = new AbortController();
|
|
665
|
+
load(controller.signal);
|
|
666
|
+
const timer = setInterval(() => load(controller.signal), 3000);
|
|
667
|
+
return () => {
|
|
668
|
+
controller.abort();
|
|
669
|
+
clearInterval(timer);
|
|
670
|
+
};
|
|
671
|
+
}, [open, load]);
|
|
672
|
+
|
|
673
|
+
// The chat messages carry a machine id, not a name: the relay stores what a sender proves, and a name is
|
|
674
|
+
// not one of those proofs. The roster the target control already reads is where names live, so the panel
|
|
675
|
+
// borrows it once per open instead of inventing a label from the id.
|
|
676
|
+
React.useEffect(() => {
|
|
677
|
+
if (!open || typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
678
|
+
const controller = new AbortController();
|
|
679
|
+
readState(sessionId, controller.signal).then((result) => {
|
|
680
|
+
if (controller.signal.aborted) return;
|
|
681
|
+
setRoster(result.ok ? result.state : null);
|
|
682
|
+
});
|
|
683
|
+
return () => controller.abort();
|
|
684
|
+
}, [open, sessionId]);
|
|
685
685
|
|
|
686
686
|
// The task board is the relay's own list: the host proxies it so the panel never holds a device token.
|
|
687
687
|
React.useEffect(() => {
|
|
@@ -692,270 +692,271 @@ window.__ModuleLoader__.load({
|
|
|
692
692
|
.catch(() => { /* the board is an extra; a failed read must not break the chat */ });
|
|
693
693
|
return () => controller.abort();
|
|
694
694
|
}, [open]);
|
|
695
|
-
|
|
696
|
-
// Unread is measured against a per-conversation watermark in local storage: the relay stays stateless, and
|
|
697
|
-
// the count answers "is there anything I have not looked at" without claiming which messages a machine read.
|
|
698
|
-
React.useEffect(() => {
|
|
699
|
-
if (!open || messages.length === 0) return undefined;
|
|
700
|
-
const latest = messages.reduce((max, message) => (Number.isFinite(message?.at_ms) ? Math.max(max, message.at_ms) : max), 0);
|
|
701
|
-
if (latest <= 0 || latest <= lastSeenMs) return undefined;
|
|
702
|
-
setLastSeenMs(latest);
|
|
703
|
-
try { globalThis.localStorage?.setItem('w2m:chat:seen:' + sessionId, String(latest)); } catch { /* storage may be unavailable */ }
|
|
704
|
-
return undefined;
|
|
705
|
-
}, [open, messages, sessionId, lastSeenMs]);
|
|
706
|
-
|
|
707
|
-
// Consecutive messages that share a thread are shown under one thread head. Grouping only consecutive runs keeps
|
|
708
|
-
// the reading order the relay assigned -- re-sorting by thread would put an answer above the question it answers.
|
|
709
|
-
const groupThreads = (list) => {
|
|
710
|
-
const groups = [];
|
|
711
|
-
for (const message of list ?? []) {
|
|
712
|
-
const threadId = typeof message?.thread_id === 'string' && message.thread_id !== '' ? message.thread_id : null;
|
|
713
|
-
const last = groups[groups.length - 1] ?? null;
|
|
714
|
-
if (last !== null && last.threadId === threadId) { last.messages.push(message); continue; }
|
|
715
|
-
groups.push({ threadId, messages: [message] });
|
|
716
|
-
}
|
|
717
|
-
return groups;
|
|
718
|
-
};
|
|
719
|
-
|
|
720
|
-
const send = React.useCallback(async () => {
|
|
721
|
-
const value = text.trim();
|
|
722
|
-
if (value === '' || busy) return undefined;
|
|
723
|
-
setBusy(true);
|
|
724
|
-
const result = await chatCall({
|
|
725
|
-
url: CHAT_ROUTE,
|
|
726
|
-
init: {
|
|
727
|
-
method: 'POST',
|
|
728
|
-
credentials: 'same-origin',
|
|
729
|
-
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
730
|
-
body: JSON.stringify({
|
|
731
|
-
session_id: sessionId,
|
|
732
|
-
text: value,
|
|
733
|
-
...(toRecipient === 'all' ? {} : { to: toRecipient }),
|
|
734
|
-
...(replyTo === null ? {} : { reply_to: replyTo.id, ...(replyTo.threadId === null ? {} : { thread_id: replyTo.threadId }) }),
|
|
735
|
-
}),
|
|
736
|
-
},
|
|
737
|
-
});
|
|
738
|
-
setBusy(false);
|
|
739
|
-
if (result.ok) {
|
|
740
|
-
setText('');
|
|
741
|
-
setReplyTo(null);
|
|
742
|
-
await load();
|
|
743
|
-
} else {
|
|
744
|
-
setError(result.error);
|
|
745
|
-
}
|
|
746
|
-
return undefined;
|
|
747
|
-
}, [busy, load, sessionId, text]);
|
|
748
|
-
|
|
695
|
+
|
|
696
|
+
// Unread is measured against a per-conversation watermark in local storage: the relay stays stateless, and
|
|
697
|
+
// the count answers "is there anything I have not looked at" without claiming which messages a machine read.
|
|
698
|
+
React.useEffect(() => {
|
|
699
|
+
if (!open || messages.length === 0) return undefined;
|
|
700
|
+
const latest = messages.reduce((max, message) => (Number.isFinite(message?.at_ms) ? Math.max(max, message.at_ms) : max), 0);
|
|
701
|
+
if (latest <= 0 || latest <= lastSeenMs) return undefined;
|
|
702
|
+
setLastSeenMs(latest);
|
|
703
|
+
try { globalThis.localStorage?.setItem('w2m:chat:seen:' + sessionId, String(latest)); } catch { /* storage may be unavailable */ }
|
|
704
|
+
return undefined;
|
|
705
|
+
}, [open, messages, sessionId, lastSeenMs]);
|
|
706
|
+
|
|
707
|
+
// Consecutive messages that share a thread are shown under one thread head. Grouping only consecutive runs keeps
|
|
708
|
+
// the reading order the relay assigned -- re-sorting by thread would put an answer above the question it answers.
|
|
709
|
+
const groupThreads = (list) => {
|
|
710
|
+
const groups = [];
|
|
711
|
+
for (const message of list ?? []) {
|
|
712
|
+
const threadId = typeof message?.thread_id === 'string' && message.thread_id !== '' ? message.thread_id : null;
|
|
713
|
+
const last = groups[groups.length - 1] ?? null;
|
|
714
|
+
if (last !== null && last.threadId === threadId) { last.messages.push(message); continue; }
|
|
715
|
+
groups.push({ threadId, messages: [message] });
|
|
716
|
+
}
|
|
717
|
+
return groups;
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
const send = React.useCallback(async () => {
|
|
721
|
+
const value = text.trim();
|
|
722
|
+
if (value === '' || busy) return undefined;
|
|
723
|
+
setBusy(true);
|
|
724
|
+
const result = await chatCall({
|
|
725
|
+
url: CHAT_ROUTE,
|
|
726
|
+
init: {
|
|
727
|
+
method: 'POST',
|
|
728
|
+
credentials: 'same-origin',
|
|
729
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
730
|
+
body: JSON.stringify({
|
|
731
|
+
session_id: sessionId,
|
|
732
|
+
text: value,
|
|
733
|
+
...(toRecipient === 'all' ? {} : { to: toRecipient }),
|
|
734
|
+
...(replyTo === null ? {} : { reply_to: replyTo.id, ...(replyTo.threadId === null ? {} : { thread_id: replyTo.threadId }) }),
|
|
735
|
+
}),
|
|
736
|
+
},
|
|
737
|
+
});
|
|
738
|
+
setBusy(false);
|
|
739
|
+
if (result.ok) {
|
|
740
|
+
setText('');
|
|
741
|
+
setReplyTo(null);
|
|
742
|
+
await load();
|
|
743
|
+
} else {
|
|
744
|
+
setError(result.error);
|
|
745
|
+
}
|
|
746
|
+
return undefined;
|
|
747
|
+
}, [busy, load, sessionId, text]);
|
|
748
|
+
|
|
749
749
|
const unseenCount = messages.reduce((count, message) => (Number.isFinite(message?.at_ms) && message.at_ms > lastSeenMs ? count + 1 : count), 0);
|
|
750
|
-
const disabled = typeof sessionId !== 'string' || sessionId === '';
|
|
751
|
-
const usageText = usage === null ? null : `${Math.round((usage.used_24h_bytes ?? 0) / 1024)} KiB/24h · ${usage.messages ?? 0} 条`;
|
|
752
|
-
const machineLabel = (message) => {
|
|
753
|
-
const id = typeof message?.machine_id === 'string' ? message.machine_id : null;
|
|
754
|
-
if (id === null) return '?';
|
|
755
|
-
const known = Array.isArray(roster?.machines)
|
|
756
|
-
? roster.machines.find((machine) => machine.machine_id === id) ?? null
|
|
757
|
-
: null;
|
|
758
|
-
const mine = id === localMachineId;
|
|
759
|
-
const name = known?.name ?? message?.machine_name ?? (mine ? localMachineName : null);
|
|
760
|
-
const detail = mine ? '\u672c\u673a' : (known?.os ?? null);
|
|
761
|
-
// A machine with no name yet is shown by a short id, never by a full UUID, and the detail says which of the
|
|
762
|
-
// two it is -- "unknown" next to a UUID is what made the panel look like it was leaking identifiers.
|
|
763
|
-
const shown = name ?? (id.length > 12 ? `${id.slice(0, 8)}…` : id);
|
|
764
|
-
return detail === null ? shown : `${shown} · ${detail}`;
|
|
765
|
-
};
|
|
766
|
-
const timeLabel = (message) => (Number.isFinite(message?.at_ms) ? new Date(message.at_ms).toLocaleTimeString() : '');
|
|
767
|
-
|
|
768
|
-
return h('span', { className: 'w2mChat' },
|
|
769
|
-
h('button', {
|
|
770
|
-
type: 'button',
|
|
771
|
-
className: 'w2mChat_trigger',
|
|
772
|
-
title: '编队聊天(本会话广播)',
|
|
773
|
-
'aria-expanded': open,
|
|
774
|
-
disabled,
|
|
775
|
-
onClick: () => setOpen((value) => !value),
|
|
776
|
-
}, '💬', h('span', { className: 'w2mChat_label' }, '编队聊天'), unseenCount > 0 && !open ? h('span', { className: 'w2mChat_unseen' }, String(unseenCount)) : null),
|
|
777
|
-
open
|
|
778
|
-
? h('div', { className: 'w2mChat_panel', role: 'dialog', 'aria-label': '编队聊天' },
|
|
779
|
-
h('div', { className: 'w2mChat_head' },
|
|
780
|
-
h('select', {
|
|
781
|
-
className: 'w2mChat_to',
|
|
782
|
-
value: toRecipient,
|
|
783
|
-
title: '收件人',
|
|
784
|
-
onChange: (event) => setToRecipient(event.target.value),
|
|
785
|
-
}, [
|
|
786
|
-
h('option', { key: '__all__', value: 'all' }, '全部'),
|
|
787
|
-
...(Array.isArray(roster?.machines) ? roster.machines.filter((machine) => machine.online === true && typeof machine.machine_id === 'string').map((machine) => h('option', { key: machine.machine_id, value: machine.machine_id }, machine.name ?? machine.machine_id)) : []),
|
|
788
|
-
]),
|
|
789
|
-
h('span', null, '编队聊天'),
|
|
790
|
-
usageText === null ? null : h('span', { className: 'w2mChat_usage' }, usageText),
|
|
791
|
-
h('button', { type: 'button', className: 'w2mChat_close', title: '关闭', onClick: () => setOpen(false) }, '×')),
|
|
750
|
+
const disabled = typeof sessionId !== 'string' || sessionId === '';
|
|
751
|
+
const usageText = usage === null ? null : `${Math.round((usage.used_24h_bytes ?? 0) / 1024)} KiB/24h · ${usage.messages ?? 0} 条`;
|
|
752
|
+
const machineLabel = (message) => {
|
|
753
|
+
const id = typeof message?.machine_id === 'string' ? message.machine_id : null;
|
|
754
|
+
if (id === null) return '?';
|
|
755
|
+
const known = Array.isArray(roster?.machines)
|
|
756
|
+
? roster.machines.find((machine) => machine.machine_id === id) ?? null
|
|
757
|
+
: null;
|
|
758
|
+
const mine = id === localMachineId;
|
|
759
|
+
const name = known?.name ?? message?.machine_name ?? (mine ? localMachineName : null);
|
|
760
|
+
const detail = mine ? '\u672c\u673a' : (known?.os ?? null);
|
|
761
|
+
// A machine with no name yet is shown by a short id, never by a full UUID, and the detail says which of the
|
|
762
|
+
// two it is -- "unknown" next to a UUID is what made the panel look like it was leaking identifiers.
|
|
763
|
+
const shown = name ?? (id.length > 12 ? `${id.slice(0, 8)}…` : id);
|
|
764
|
+
return detail === null ? shown : `${shown} · ${detail}`;
|
|
765
|
+
};
|
|
766
|
+
const timeLabel = (message) => (Number.isFinite(message?.at_ms) ? new Date(message.at_ms).toLocaleTimeString() : '');
|
|
767
|
+
|
|
768
|
+
return h('span', { className: 'w2mChat' },
|
|
769
|
+
h('button', {
|
|
770
|
+
type: 'button',
|
|
771
|
+
className: 'w2mChat_trigger',
|
|
772
|
+
title: '编队聊天(本会话广播)',
|
|
773
|
+
'aria-expanded': open,
|
|
774
|
+
disabled,
|
|
775
|
+
onClick: () => setOpen((value) => !value),
|
|
776
|
+
}, '💬', h('span', { className: 'w2mChat_label' }, '编队聊天'), unseenCount > 0 && !open ? h('span', { className: 'w2mChat_unseen' }, String(unseenCount)) : null),
|
|
777
|
+
open
|
|
778
|
+
? h('div', { className: 'w2mChat_panel', role: 'dialog', 'aria-label': '编队聊天' },
|
|
779
|
+
h('div', { className: 'w2mChat_head' },
|
|
780
|
+
h('select', {
|
|
781
|
+
className: 'w2mChat_to',
|
|
782
|
+
value: toRecipient,
|
|
783
|
+
title: '收件人',
|
|
784
|
+
onChange: (event) => setToRecipient(event.target.value),
|
|
785
|
+
}, [
|
|
786
|
+
h('option', { key: '__all__', value: 'all' }, '全部'),
|
|
787
|
+
...(Array.isArray(roster?.machines) ? roster.machines.filter((machine) => machine.online === true && typeof machine.machine_id === 'string').map((machine) => h('option', { key: machine.machine_id, value: machine.machine_id }, machine.name ?? machine.machine_id)) : []),
|
|
788
|
+
]),
|
|
789
|
+
h('span', null, '编队聊天'),
|
|
790
|
+
usageText === null ? null : h('span', { className: 'w2mChat_usage' }, usageText),
|
|
791
|
+
h('button', { type: 'button', className: 'w2mChat_close', title: '关闭', onClick: () => setOpen(false) }, '×')),
|
|
792
792
|
tasks.length === 0 ? null : h('div', { className: 'w2mChat_board' }, [
|
|
793
793
|
h('div', { key: 'title', className: 'w2mChat_boardTitle' }, '任务看板'),
|
|
794
|
+
h('div', { key: 'counts', className: 'w2mChat_boardCounts' }, (() => { const b = { ready: 0, active: 0, waiting: 0, blocked: 0 }; for (const task of tasks) { const status = String(task?.status ?? ''); if (status === 'done' || status === 'completed') continue; if (status === 'failed' || status === 'cancelled' || status === 'refused') b.blocked += 1; else if (status === 'waiting' || (Array.isArray(task?.waiting_on) && task.waiting_on.length > 0)) b.waiting += 1; else if (status === 'active' || status === 'queued' || status === 'offered' || status === 'running') b.active += 1; else b.ready += 1; } return '准备 ' + b.ready + ' · 进行 ' + b.active + ' · 等待 ' + b.waiting + ' · 阻塞 ' + b.blocked; })()),
|
|
794
795
|
...tasks.slice(0, 6).map((task) => h('div', { key: String(task?.task_id ?? Math.random()), className: 'w2mChat_boardRow' },
|
|
795
796
|
`${String(task?.task_id ?? '').slice(0, 8)} ${task?.mode ?? '?'} ${task?.status ?? '?'}`)),
|
|
796
797
|
]),
|
|
797
|
-
h('div', { className: 'w2mChat_list' },
|
|
798
|
-
error !== null ? h('div', { className: 'w2mChat_error' }, error) : null,
|
|
799
|
-
messages.length === 0
|
|
800
|
-
? (error === null ? h('div', { className: 'w2mChat_empty' }, '本会话还没有消息') : null)
|
|
801
|
-
: groupThreads(messages).flatMap((group, groupIndex) => [
|
|
802
|
-
group.threadId === null ? null : h('div', { key: 'thread:' + group.threadId + ':' + groupIndex, className: 'w2mChat_threadHead' }, '#' + group.threadId),
|
|
803
|
-
...group.messages.map((message) => {
|
|
804
|
-
var kind = messageKind(message, localMachineId);
|
|
805
|
-
return h('div', { key: message?.message_id ?? `${message?.machine_id}:${message?.at_ms}`, className: 'w2mChat_msg is-' + kind, onClick: () => setReplyTo({ id: message?.message_id ?? null, threadId: message?.thread_id ?? null, text: typeof message?.text === 'string' ? message.text : '' }) },
|
|
806
|
-
h('div', { className: 'w2mChat_meta' }, h(WhaleAvatar, { machineId: message?.machine_id }), `${machineLabel(message)} · ${timeLabel(message)}`, kind === 'session' ? ' · 来自会话' : '', message?.thread_id ? h('span', { className: 'w2mChat_thread' }, '#' + message.thread_id) : null),
|
|
807
|
-
h('div', { className: 'w2mChat_text' }, message?.text ?? ''));
|
|
808
|
-
}),
|
|
809
|
-
].filter((node) => node !== null))),
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
h('div', { className: 'w2mChat_compose' },
|
|
813
|
-
replyTo === null ? null : h('div', { className: 'w2mChat_reply' }, [
|
|
814
|
-
h('span', { key: 'label' }, '回复:' + (replyTo.text.length > 40 ? replyTo.text.slice(0, 40) + '…' : replyTo.text)),
|
|
815
|
-
h('button', { key: 'cancel', type: 'button', className: 'w2mChat_replyCancel', title: '取消回复', onClick: () => setReplyTo(null) }, '×'),
|
|
816
|
-
]),
|
|
817
|
-
h('textarea', {
|
|
818
|
-
className: 'w2mChat_input',
|
|
819
|
-
value: text,
|
|
820
|
-
rows: 2,
|
|
821
|
-
placeholder: '说点什么,发给本会话的机器…',
|
|
822
|
-
disabled: busy,
|
|
823
|
-
onChange: (event) => setText(event.target.value),
|
|
824
|
-
onKeyDown: (event) => {
|
|
825
|
-
if (event.key === 'Enter' && event.shiftKey !== true) {
|
|
826
|
-
event.preventDefault();
|
|
827
|
-
send();
|
|
828
|
-
}
|
|
829
|
-
},
|
|
830
|
-
}),
|
|
831
|
-
h('button', { type: 'button', className: 'w2mChat_send', disabled: busy || text.trim() === '', onClick: send }, busy ? '发送中…' : '发送')))
|
|
832
|
-
: null);
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
/**
|
|
836
|
-
* Client plugin body: install the stylesheet, register the dictionaries, take the seat.
|
|
837
|
-
*
|
|
838
|
-
* @param ctx - the client root context.
|
|
839
|
-
*/
|
|
840
|
-
function apply(ctx) {
|
|
841
|
-
try {
|
|
842
|
-
mount(ctx);
|
|
843
|
-
} catch (error) {
|
|
844
|
-
// The audit that decides whether the app boots reports the entry's name and the word
|
|
845
|
-
// "failed" and nothing else, so this is where the cause has to be said out loud. The
|
|
846
|
-
// renderer console at error level is the channel the desktop crash log already carries
|
|
847
|
-
// (`--- renderer console (error level, oldest first) ---`), which makes this the one place a
|
|
848
|
-
// browser half can put its own diagnosis next to the failure it caused.
|
|
849
|
-
console.error(
|
|
850
|
-
'@twinsearth/w2m-dsh-plugin: the client entry failed to activate, so this page will not boot: ' +
|
|
851
|
-
`${error instanceof Error ? error.message : String(error)}`,
|
|
852
|
-
error,
|
|
853
|
-
);
|
|
854
|
-
// Deliberately rethrown. A swallowed failure would boot a page that looks healthy with no
|
|
855
|
-
// target control in it — the silent-half-arrival failure this package has already shipped
|
|
856
|
-
// once (v0.5.4) and refuses to ship again.
|
|
857
|
-
throw error;
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
/**
|
|
862
|
-
* The body of {@link apply}, wrapped so no failure can escape without a diagnostic.
|
|
863
|
-
*
|
|
864
|
-
* `slots.inject` (not a bare `register`) is what the slot's declaration requires of an entry
|
|
865
|
-
* from a package that does not own the seat: the parent declared `conversation.input.left`, and
|
|
866
|
-
* an entry may only register into a key it has announced. The returned `inject` factory is
|
|
867
|
-
* called per render position with that session's id, and its keys become this component's props
|
|
868
|
-
* — which is how the control knows which session's selection it is showing.
|
|
869
|
-
*
|
|
870
|
-
* The stylesheet is installed here rather than at module scope, so its lifetime is the plugin's:
|
|
871
|
-
* `ctx.effect` disposes it on unload, and nothing a bundle factory does at materialization can
|
|
872
|
-
* outlive the entry that materialized it.
|
|
873
|
-
*
|
|
874
|
-
* @param ctx - the client root context.
|
|
875
|
-
*/
|
|
876
|
-
function mount(ctx) {
|
|
877
|
-
// `ctx.slots` is legal to read because {@link inject} declares it — and declaring it is also
|
|
878
|
-
// what makes this entry wait for the seat instead of racing it. The shape check is for callers
|
|
879
|
-
// that are not a Cordis fiber at all (`test/client-bundle.test.mjs`): a missing or foreign
|
|
880
|
-
// registry is a control that cannot be mounted, and a control that cannot be mounted must not
|
|
881
|
-
// be allowed to take the whole page down with it.
|
|
882
|
-
const slots = ctx?.slots;
|
|
883
|
-
if (typeof slots?.inject !== 'function' || typeof slots?.register !== 'function') {
|
|
884
|
-
console.error(
|
|
885
|
-
'@twinsearth/w2m-dsh-plugin: the client half found no slot registry on this context, so the ' +
|
|
886
|
-
'dispatch-target control is not mounted; the rest of the page is unaffected',
|
|
887
|
-
);
|
|
888
|
-
return;
|
|
889
|
-
}
|
|
890
|
-
const styleDisposer = injectStyle(typeof document === 'undefined' ? null : document);
|
|
891
|
-
if (styleDisposer !== null && typeof ctx.effect === 'function') {
|
|
892
|
-
ctx.effect(() => styleDisposer, 'w2m: composer target control styles');
|
|
893
|
-
}
|
|
894
|
-
// Dictionaries as early as the composition allows: a namespace registered after the UI has
|
|
895
|
-
// mounted is still picked up, and the service is optional, so nothing here may block.
|
|
896
|
-
const locale = readOptionalService(ctx, 'locale');
|
|
897
|
-
registerDictionaries(ctx, locale);
|
|
898
|
-
const t = makeTranslator(() => readOptionalService(ctx, 'locale'));
|
|
899
|
-
slots.inject('conversation.input.left', () => slots.register({
|
|
900
|
-
name: 'conversation.input.left',
|
|
901
|
-
id: 'w2m-target',
|
|
902
|
-
// After the shipped permission control and the plan control, which sit earlier in the
|
|
903
|
-
// composer row: the target is a per-session choice like them, and the row reads left to
|
|
904
|
-
// right as "attach, permissions, plan, where it runs".
|
|
905
|
-
order: 40,
|
|
906
|
-
label: 'dispatch target',
|
|
907
|
-
inject: (sessionId) => ({
|
|
908
|
-
sessionId,
|
|
909
|
-
t,
|
|
910
|
-
}),
|
|
911
|
-
}, TargetSelect));
|
|
912
|
-
|
|
913
|
-
// The fleet-chat panel: the same session-scoped seat, one slot after the target control.
|
|
914
|
-
// Wrapped in its own try/catch on purpose — the target control is the primary half of this
|
|
915
|
-
// bundle, and a chat panel that cannot mount must not take the page (or the app) down with it.
|
|
916
|
-
try {
|
|
917
|
-
slots.inject('conversation.input.left', () => slots.register({
|
|
918
|
-
name: 'conversation.input.left',
|
|
919
|
-
id: 'w2m-chat',
|
|
920
|
-
order: 41,
|
|
921
|
-
label: 'fleet chat',
|
|
922
|
-
inject: (sessionId) => ({ sessionId }),
|
|
923
|
-
}, FleetChat));
|
|
924
|
-
} catch (error) {
|
|
925
|
-
console.error(
|
|
926
|
-
'@twinsearth/w2m-dsh-plugin: the fleet chat panel was not mounted; the rest of the page is unaffected',
|
|
927
|
-
error,
|
|
928
|
-
);
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
/**
|
|
933
|
-
* Cordis services this entry depends on, read by the fiber that mounts this bundle.
|
|
934
|
-
*
|
|
935
|
-
* `slots` is the seat: the control is registered into `conversation.input.left` through the
|
|
936
|
-
* slot registry, and a fiber that cannot see `slots` has nowhere to register. Declaring it is
|
|
937
|
-
* not bookkeeping. Cordis resolves `ctx.<name>` against this list, so reading an undeclared
|
|
938
|
-
* service **throws** (`cannot get property "slots" without inject`) even when the service is
|
|
939
|
-
* provided — measured in production: this bundle shipped with `inject = []` while reading
|
|
940
|
-
* `ctx.locale` and `ctx.slots`, the first read threw, and the app refused to boot.
|
|
941
|
-
*
|
|
942
|
-
* `locale` is deliberately absent, and that is the other half of the same rule: **every**
|
|
943
|
-
* declared name is a required dependency, so an entry that declares a service the composition
|
|
944
|
-
* does not provide never reaches `active` either — the audit then reports it as
|
|
945
|
-
* `pending (waiting for service: locale)`, which is just as fatal. Optional carriers are read
|
|
946
|
-
* with {@link readOptionalService} instead, and waited for by a child fiber when waiting is
|
|
947
|
-
* useful ({@link registerDictionaries}). Keep those two decisions in step with the reads in
|
|
948
|
-
* {@link mount}: `test/client-bundle.test.mjs` mounts this bundle behind a context that throws
|
|
949
|
-
* on any undeclared service read, exactly as Cordis does.
|
|
950
|
-
*/
|
|
951
|
-
const inject = ['slots'];
|
|
952
|
-
|
|
953
|
-
exports.apply = apply;
|
|
954
|
-
exports.inject = inject;
|
|
955
|
-
exports.TargetSelect = TargetSelect;
|
|
956
|
-
exports.FleetChat = FleetChat;
|
|
957
|
-
exports.injectStyle = injectStyle;
|
|
958
|
-
exports.makeTranslator = makeTranslator;
|
|
959
|
-
return module.exports;
|
|
960
|
-
},
|
|
961
|
-
});
|
|
798
|
+
h('div', { className: 'w2mChat_list' },
|
|
799
|
+
error !== null ? h('div', { className: 'w2mChat_error' }, error) : null,
|
|
800
|
+
messages.length === 0
|
|
801
|
+
? (error === null ? h('div', { className: 'w2mChat_empty' }, '本会话还没有消息') : null)
|
|
802
|
+
: groupThreads(messages).flatMap((group, groupIndex) => [
|
|
803
|
+
group.threadId === null ? null : h('div', { key: 'thread:' + group.threadId + ':' + groupIndex, className: 'w2mChat_threadHead' }, '#' + group.threadId),
|
|
804
|
+
...group.messages.map((message) => {
|
|
805
|
+
var kind = messageKind(message, localMachineId);
|
|
806
|
+
return h('div', { key: message?.message_id ?? `${message?.machine_id}:${message?.at_ms}`, className: 'w2mChat_msg is-' + kind, onClick: () => setReplyTo({ id: message?.message_id ?? null, threadId: message?.thread_id ?? null, text: typeof message?.text === 'string' ? message.text : '' }) },
|
|
807
|
+
h('div', { className: 'w2mChat_meta' }, h(WhaleAvatar, { machineId: message?.machine_id }), `${machineLabel(message)} · ${timeLabel(message)}`, kind === 'session' ? ' · 来自会话' : '', message?.thread_id ? h('span', { className: 'w2mChat_thread' }, '#' + message.thread_id) : null),
|
|
808
|
+
h('div', { className: 'w2mChat_text' }, message?.text ?? ''));
|
|
809
|
+
}),
|
|
810
|
+
].filter((node) => node !== null))),
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
h('div', { className: 'w2mChat_compose' },
|
|
814
|
+
replyTo === null ? null : h('div', { className: 'w2mChat_reply' }, [
|
|
815
|
+
h('span', { key: 'label' }, '回复:' + (replyTo.text.length > 40 ? replyTo.text.slice(0, 40) + '…' : replyTo.text)),
|
|
816
|
+
h('button', { key: 'cancel', type: 'button', className: 'w2mChat_replyCancel', title: '取消回复', onClick: () => setReplyTo(null) }, '×'),
|
|
817
|
+
]),
|
|
818
|
+
h('textarea', {
|
|
819
|
+
className: 'w2mChat_input',
|
|
820
|
+
value: text,
|
|
821
|
+
rows: 2,
|
|
822
|
+
placeholder: '说点什么,发给本会话的机器…',
|
|
823
|
+
disabled: busy,
|
|
824
|
+
onChange: (event) => setText(event.target.value),
|
|
825
|
+
onKeyDown: (event) => {
|
|
826
|
+
if (event.key === 'Enter' && event.shiftKey !== true) {
|
|
827
|
+
event.preventDefault();
|
|
828
|
+
send();
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
}),
|
|
832
|
+
h('button', { type: 'button', className: 'w2mChat_send', disabled: busy || text.trim() === '', onClick: send }, busy ? '发送中…' : '发送')))
|
|
833
|
+
: null);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Client plugin body: install the stylesheet, register the dictionaries, take the seat.
|
|
838
|
+
*
|
|
839
|
+
* @param ctx - the client root context.
|
|
840
|
+
*/
|
|
841
|
+
function apply(ctx) {
|
|
842
|
+
try {
|
|
843
|
+
mount(ctx);
|
|
844
|
+
} catch (error) {
|
|
845
|
+
// The audit that decides whether the app boots reports the entry's name and the word
|
|
846
|
+
// "failed" and nothing else, so this is where the cause has to be said out loud. The
|
|
847
|
+
// renderer console at error level is the channel the desktop crash log already carries
|
|
848
|
+
// (`--- renderer console (error level, oldest first) ---`), which makes this the one place a
|
|
849
|
+
// browser half can put its own diagnosis next to the failure it caused.
|
|
850
|
+
console.error(
|
|
851
|
+
'@twinsearth/w2m-dsh-plugin: the client entry failed to activate, so this page will not boot: ' +
|
|
852
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
853
|
+
error,
|
|
854
|
+
);
|
|
855
|
+
// Deliberately rethrown. A swallowed failure would boot a page that looks healthy with no
|
|
856
|
+
// target control in it — the silent-half-arrival failure this package has already shipped
|
|
857
|
+
// once (v0.5.4) and refuses to ship again.
|
|
858
|
+
throw error;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* The body of {@link apply}, wrapped so no failure can escape without a diagnostic.
|
|
864
|
+
*
|
|
865
|
+
* `slots.inject` (not a bare `register`) is what the slot's declaration requires of an entry
|
|
866
|
+
* from a package that does not own the seat: the parent declared `conversation.input.left`, and
|
|
867
|
+
* an entry may only register into a key it has announced. The returned `inject` factory is
|
|
868
|
+
* called per render position with that session's id, and its keys become this component's props
|
|
869
|
+
* — which is how the control knows which session's selection it is showing.
|
|
870
|
+
*
|
|
871
|
+
* The stylesheet is installed here rather than at module scope, so its lifetime is the plugin's:
|
|
872
|
+
* `ctx.effect` disposes it on unload, and nothing a bundle factory does at materialization can
|
|
873
|
+
* outlive the entry that materialized it.
|
|
874
|
+
*
|
|
875
|
+
* @param ctx - the client root context.
|
|
876
|
+
*/
|
|
877
|
+
function mount(ctx) {
|
|
878
|
+
// `ctx.slots` is legal to read because {@link inject} declares it — and declaring it is also
|
|
879
|
+
// what makes this entry wait for the seat instead of racing it. The shape check is for callers
|
|
880
|
+
// that are not a Cordis fiber at all (`test/client-bundle.test.mjs`): a missing or foreign
|
|
881
|
+
// registry is a control that cannot be mounted, and a control that cannot be mounted must not
|
|
882
|
+
// be allowed to take the whole page down with it.
|
|
883
|
+
const slots = ctx?.slots;
|
|
884
|
+
if (typeof slots?.inject !== 'function' || typeof slots?.register !== 'function') {
|
|
885
|
+
console.error(
|
|
886
|
+
'@twinsearth/w2m-dsh-plugin: the client half found no slot registry on this context, so the ' +
|
|
887
|
+
'dispatch-target control is not mounted; the rest of the page is unaffected',
|
|
888
|
+
);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
const styleDisposer = injectStyle(typeof document === 'undefined' ? null : document);
|
|
892
|
+
if (styleDisposer !== null && typeof ctx.effect === 'function') {
|
|
893
|
+
ctx.effect(() => styleDisposer, 'w2m: composer target control styles');
|
|
894
|
+
}
|
|
895
|
+
// Dictionaries as early as the composition allows: a namespace registered after the UI has
|
|
896
|
+
// mounted is still picked up, and the service is optional, so nothing here may block.
|
|
897
|
+
const locale = readOptionalService(ctx, 'locale');
|
|
898
|
+
registerDictionaries(ctx, locale);
|
|
899
|
+
const t = makeTranslator(() => readOptionalService(ctx, 'locale'));
|
|
900
|
+
slots.inject('conversation.input.left', () => slots.register({
|
|
901
|
+
name: 'conversation.input.left',
|
|
902
|
+
id: 'w2m-target',
|
|
903
|
+
// After the shipped permission control and the plan control, which sit earlier in the
|
|
904
|
+
// composer row: the target is a per-session choice like them, and the row reads left to
|
|
905
|
+
// right as "attach, permissions, plan, where it runs".
|
|
906
|
+
order: 40,
|
|
907
|
+
label: 'dispatch target',
|
|
908
|
+
inject: (sessionId) => ({
|
|
909
|
+
sessionId,
|
|
910
|
+
t,
|
|
911
|
+
}),
|
|
912
|
+
}, TargetSelect));
|
|
913
|
+
|
|
914
|
+
// The fleet-chat panel: the same session-scoped seat, one slot after the target control.
|
|
915
|
+
// Wrapped in its own try/catch on purpose — the target control is the primary half of this
|
|
916
|
+
// bundle, and a chat panel that cannot mount must not take the page (or the app) down with it.
|
|
917
|
+
try {
|
|
918
|
+
slots.inject('conversation.input.left', () => slots.register({
|
|
919
|
+
name: 'conversation.input.left',
|
|
920
|
+
id: 'w2m-chat',
|
|
921
|
+
order: 41,
|
|
922
|
+
label: 'fleet chat',
|
|
923
|
+
inject: (sessionId) => ({ sessionId }),
|
|
924
|
+
}, FleetChat));
|
|
925
|
+
} catch (error) {
|
|
926
|
+
console.error(
|
|
927
|
+
'@twinsearth/w2m-dsh-plugin: the fleet chat panel was not mounted; the rest of the page is unaffected',
|
|
928
|
+
error,
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Cordis services this entry depends on, read by the fiber that mounts this bundle.
|
|
935
|
+
*
|
|
936
|
+
* `slots` is the seat: the control is registered into `conversation.input.left` through the
|
|
937
|
+
* slot registry, and a fiber that cannot see `slots` has nowhere to register. Declaring it is
|
|
938
|
+
* not bookkeeping. Cordis resolves `ctx.<name>` against this list, so reading an undeclared
|
|
939
|
+
* service **throws** (`cannot get property "slots" without inject`) even when the service is
|
|
940
|
+
* provided — measured in production: this bundle shipped with `inject = []` while reading
|
|
941
|
+
* `ctx.locale` and `ctx.slots`, the first read threw, and the app refused to boot.
|
|
942
|
+
*
|
|
943
|
+
* `locale` is deliberately absent, and that is the other half of the same rule: **every**
|
|
944
|
+
* declared name is a required dependency, so an entry that declares a service the composition
|
|
945
|
+
* does not provide never reaches `active` either — the audit then reports it as
|
|
946
|
+
* `pending (waiting for service: locale)`, which is just as fatal. Optional carriers are read
|
|
947
|
+
* with {@link readOptionalService} instead, and waited for by a child fiber when waiting is
|
|
948
|
+
* useful ({@link registerDictionaries}). Keep those two decisions in step with the reads in
|
|
949
|
+
* {@link mount}: `test/client-bundle.test.mjs` mounts this bundle behind a context that throws
|
|
950
|
+
* on any undeclared service read, exactly as Cordis does.
|
|
951
|
+
*/
|
|
952
|
+
const inject = ['slots'];
|
|
953
|
+
|
|
954
|
+
exports.apply = apply;
|
|
955
|
+
exports.inject = inject;
|
|
956
|
+
exports.TargetSelect = TargetSelect;
|
|
957
|
+
exports.FleetChat = FleetChat;
|
|
958
|
+
exports.injectStyle = injectStyle;
|
|
959
|
+
exports.makeTranslator = makeTranslator;
|
|
960
|
+
return module.exports;
|
|
961
|
+
},
|
|
962
|
+
});
|