@twinsearth/w2m-dsh-plugin 0.5.3 → 0.5.5
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/CHANGELOG.md +90 -0
- package/cordis.patch.yml +27 -4
- package/lib/client.js +515 -0
- package/lib/index.js +16 -11
- package/lib/tools.js +9 -3
- package/package.json +7 -3
- package/src/plugin/fleet-target.mjs +667 -0
- package/src/plugin/tools.mjs +152 -26
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,96 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.5.5] — 2026-10-11
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
* **The v0.5.4 loader failure, with the cause the loader actually reported** rather than the hypothesis
|
|
12
|
+
that stood in for it:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
w2m (@twinsearth/w2m-dsh-plugin/tools): Error: cannot get property "connection" without inject
|
|
16
|
+
at registerTargetRoutes (src/plugin/fleet-target.mjs:483:25)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
cordis throws on reading a service property the fiber never declared, so the entry died and took all
|
|
20
|
+
eight tools with it. **Every existing test was blind to this**, because they drive the plugin with a
|
|
21
|
+
plain-object context where an undeclared read merely yields `undefined` — the fake was more forgiving
|
|
22
|
+
than the runtime. Fixed with `ctx.inject(['connection'], …)`, which is also the only shape that can
|
|
23
|
+
work: in the real composition the service is genuinely absent while `apply()` runs, and siblings are
|
|
24
|
+
mounted concurrently.
|
|
25
|
+
* **The client half was never composed either, for a second and independent reason:** the bundle row
|
|
26
|
+
named the subpath `@twinsearth/w2m-dsh-plugin/tools` instead of the package, and a client package is
|
|
27
|
+
identified by its Loader row specifier — a subpath resolves to no `package.json`, so `dsh.client` was
|
|
28
|
+
never read and no boot-graph row was built. The row now names the package.
|
|
29
|
+
* **A silent route rejection:** the control fetched `/w2m/target`, but fetch routes are validated
|
|
30
|
+
against `/api/`, and the refusal failed only a child fiber, so nothing surfaced. Now
|
|
31
|
+
`/api/w2m/target`, mirrored in the shipped bundle.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
* **`test/loader-entry.test.mjs`** — the test that would have caught v0.5.4: it packs the tree, installs
|
|
36
|
+
the tarball with the installation's own `dsh plugin add` into a throwaway profile under a throwaway
|
|
37
|
+
`DSH_HOME`, and boots it with the real `runProfile` under the Harness runtime, asserting that the
|
|
38
|
+
entry activates, all eight tools are registered, the boot-graph row is composed from the shipped
|
|
39
|
+
bundle, and no failed entry names the package. **Proved in both directions**: 6/6 pass on the fixed
|
|
40
|
+
tree and 6/6 fail against the preserved v0.5.4 tarball, failing on precisely the loader error quoted
|
|
41
|
+
above. It skips loudly, naming the probes it tried, when no installation is found.
|
|
42
|
+
* Cheap always-on guards in `test/client-bundle.test.mjs` that need no Harness: the platform field, the
|
|
43
|
+
client export, the row must be the bare package name, and the bundle's route must equal the host's.
|
|
44
|
+
|
|
45
|
+
### Notes
|
|
46
|
+
|
|
47
|
+
* **An upgrade from v0.5.4 requires one edit outside this package:** `cordis.patch.yml` in the profile
|
|
48
|
+
must name the package (`@twinsearth/w2m-dsh-plugin`), not the `/tools` subpath. With the corrected
|
|
49
|
+
bundle row, a mismatch makes the loader skip the whole patch — silently reverting `rabbitUrl`,
|
|
50
|
+
`operatorToken`, `stateDir`, `projectDir`, `machineName`, `p2pMode` and `allowedCommands` to their
|
|
51
|
+
shipped defaults. Measured both ways with `--dump-config`.
|
|
52
|
+
* **0.5.4 remains DO NOT INSTALL.** This release exists so that a rebuilt artifact is distinguishable
|
|
53
|
+
from the broken one by version alone.
|
|
54
|
+
* Still unverified, and not claimed: how the control *renders*. No browser was opened. What is proven is
|
|
55
|
+
the entire host side — the entry activates, the eight tools register, the boot-graph row is composed
|
|
56
|
+
from the shipped bundle, the route is registered and attached — plus the seat shape in a VM test.
|
|
57
|
+
## [0.5.4] — 2026-10-11
|
|
58
|
+
|
|
59
|
+
> **DO NOT INSTALL 0.5.4.** It fails to load in the Harness: the loader entry
|
|
60
|
+
> include:@twinsearth/w2m-dsh-plugin/tools reports iberPhase: failed, so **all eight tools
|
|
61
|
+
> disappear** and the client half never registers. The package verifies in isolation
|
|
62
|
+
> (scripts/verify-installed.mjs reports 8 tools registered) and its exports map is complete, so the
|
|
63
|
+
> failure is in the real loader path -- the newly declared dsh.client half being composed there, with
|
|
64
|
+
> lib/client.js being browser code that has no window under a host-side load. That cause is a
|
|
65
|
+
> hypothesis, not a measurement; the loader's own error is still to be read. 0.5.5 fixes it and adds the
|
|
66
|
+
> test that should have caught it: **load the packed artifact through the real loader path**, which is
|
|
67
|
+
> the step this release skipped. The tag and the npm version stay as published history; they are simply
|
|
68
|
+
> not installable.
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
|
|
73
|
+
* **A dispatch-target control inside the chat composer.** A client half registers into
|
|
74
|
+
`conversation.input.left` — a `list` seat with `replaceRisk: none` — so the control is added *beside*
|
|
75
|
+
the permission control rather than shadowing it; `conversation.input.permission` is never touched. The
|
|
76
|
+
roster is the relay's own device list, read through the same helper `w2m_devices` uses, so the tool and
|
|
77
|
+
the control cannot disagree; a machine the relay does not list as online is shown but not selectable.
|
|
78
|
+
* **The selection means something, and only via fields that already existed.** Picking a machine stores a
|
|
79
|
+
per-session target that survives a page reload; `w2m_run` maps it onto `target_machines` for
|
|
80
|
+
replicate/split/pipeline and onto `executor_machine_id` for broadcast, and a caller that names an
|
|
81
|
+
executor explicitly always wins. No new tool parameter, no protocol change, `envelope_version`
|
|
82
|
+
untouched. `w2m_status` reports the current target.
|
|
83
|
+
* `lib/client.js` is the shipped browser bundle, written by hand in the runtime's lazy-CJS loader format
|
|
84
|
+
because this package has no bundler; `dsh.client` and `exports["./client"]` declare it additively.
|
|
85
|
+
|
|
86
|
+
### Notes
|
|
87
|
+
|
|
88
|
+
* Verified by test and by a live probe against the real relay: selecting `jp-shared` and dispatching with
|
|
89
|
+
no executor named produced leases `jp-shared: offered`, the other two `observing`, and a `consistent`
|
|
90
|
+
verdict from the machine that was selected. **Not verified: how the control renders.** Every client
|
|
91
|
+
Inspect query timed out for the whole session, so the page is not answering the client inspect channel
|
|
92
|
+
and the rendered text and position beside the permission control are unconfirmed.
|
|
93
|
+
* **A page refresh cannot show it.** The running Harness composes the client boot graph at startup and
|
|
94
|
+
serves the plugin from the profile's `node_modules`; the new revision must be installed *and the
|
|
95
|
+
Harness restarted* before the control appears. The control is therefore in the artifact and not in the
|
|
96
|
+
running UI, and this entry says so rather than implying otherwise.
|
|
7
97
|
## [0.5.3] — 2026-10-11
|
|
8
98
|
|
|
9
99
|
### Added
|
package/cordis.patch.yml
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
1
|
# This file is the bundle patch declared by package.json's `dsh.bundle.patch`.
|
|
2
|
-
# It mounts the plugin
|
|
3
|
-
#
|
|
4
|
-
#
|
|
2
|
+
# It mounts the plugin by its package name, whose root export is the plugin
|
|
3
|
+
# (`lib/index.js` re-exports `./tools.js`), so reading this file tells you which
|
|
4
|
+
# package becomes the plugin.
|
|
5
|
+
#
|
|
6
|
+
# ---------------------------------------------------------------------------
|
|
7
|
+
# Why the row names the package and not the `/tools` subpath (v0.5.4)
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
# Until v0.5.4 this row said `name: '@twinsearth/w2m-dsh-plugin/tools'`, and for a
|
|
10
|
+
# host-only plugin that was fine. It stops being fine the moment the package also
|
|
11
|
+
# ships a browser half, because the client module system identifies a package by
|
|
12
|
+
# the *package specifier* its Loader row carries:
|
|
13
|
+
#
|
|
14
|
+
# client-modules: resolveMeta(loaderName, baseUrl) (dsh-client-modules)
|
|
15
|
+
# exactPackageSpecifier('@twinsearth/w2m-dsh-plugin/tools') -> undefined
|
|
16
|
+
# ... -> locatePkgJson returns undefined -> the entry is not a client package
|
|
17
|
+
#
|
|
18
|
+
# A subpath specifier resolves to no package manifest at all, so a `/tools` row is
|
|
19
|
+
# invisible to the scan: `lib/client.js` is never composed into `__DSH_BOOT__`, and
|
|
20
|
+
# the composer control can never register -- whatever else is fixed. Measured on the
|
|
21
|
+
# 0.5.4 tarball through the real Loader: the composed boot graph held rows for
|
|
22
|
+
# `@deepseek-ai/dsh-typert-registry` and `@deepseek-ai/dsh-api-gateway` and none for
|
|
23
|
+
# this package. Naming the bare package fixes it, because the root export already
|
|
24
|
+
# re-exports exactly what `/tools` exports (`test/tools.test.mjs` asserts the two
|
|
25
|
+
# carry the same `inject`), so the host half is unchanged. `./tools` remains a
|
|
26
|
+
# supported mount point for anyone who wants it -- it is simply not the one this
|
|
27
|
+
# bundle uses.
|
|
5
28
|
#
|
|
6
29
|
# ---------------------------------------------------------------------------
|
|
7
30
|
# Why this is an `insert:` list and not a bare `- id: w2m`
|
|
@@ -83,7 +106,7 @@
|
|
|
83
106
|
# is visible rather than assumed.
|
|
84
107
|
- insert:
|
|
85
108
|
- id: w2m
|
|
86
|
-
name: '@twinsearth/w2m-dsh-plugin
|
|
109
|
+
name: '@twinsearth/w2m-dsh-plugin'
|
|
87
110
|
config:
|
|
88
111
|
rabbitUrl: ''
|
|
89
112
|
operatorToken: ''
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
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; `src/client/w2m-target.mjs` is the same code laid out for a human reader.
|
|
12
|
+
*
|
|
13
|
+
* Two consequences, both deliberate:
|
|
14
|
+
*
|
|
15
|
+
* * **No JSX and no bundler.** `React.createElement` keeps the file plain JavaScript a reviewer
|
|
16
|
+
* can read, and the module system's platform seed table already holds React (`react`,
|
|
17
|
+
* `react/jsx-runtime`, `react-dom`, Cordis and the shell's static UI libraries are frozen seeds
|
|
18
|
+
* for every dynamic bundle), so `require('react')` resolves without this package shipping or
|
|
19
|
+
* declaring anything.
|
|
20
|
+
* * **Only React is required.** `@deepseek-ai/dsh-client-ui-primitives` is *available* to a
|
|
21
|
+
* dynamic bundle — the shipped permission control next to this one uses its `Menu` — but a
|
|
22
|
+
* package that must stay dependency-free should not take a rendering dependency for one
|
|
23
|
+
* dropdown. The control is styled with the shell's own theme tokens instead, which is what
|
|
24
|
+
* makes it correct in both themes rather than "similar in one".
|
|
25
|
+
*
|
|
26
|
+
* # Where it sits
|
|
27
|
+
*
|
|
28
|
+
* `conversation.input.left` — `kind: list`, `scope: session`, `replaceRisk: none`. The shipped
|
|
29
|
+
* permission control owns `conversation.input.permission` (`single`, `replaceRisk:
|
|
30
|
+
* shadows-shipped-ui`) and is **not** touched: this entry is appended to the composer's left group,
|
|
31
|
+
* so it renders to the right of the permission control and never competes with it for a seat.
|
|
32
|
+
*
|
|
33
|
+
* # What it shows
|
|
34
|
+
*
|
|
35
|
+
* The machine this session's work will be dispatched to, and — on click — the fleet the relay
|
|
36
|
+
* currently holds. Liveness comes from the host plugin, never from this file: a machine is
|
|
37
|
+
* selectable because the relay lists it as online, and one that is not listed that way is rendered
|
|
38
|
+
* with its state and cannot be chosen. A selection is written to the host process, so it survives a
|
|
39
|
+
* reload and is visible to `w2m_run`, which is where it actually means something.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
window.__ModuleLoader__.load({
|
|
43
|
+
id: '@twinsearth/w2m-dsh-plugin',
|
|
44
|
+
factory: (require) => {
|
|
45
|
+
var module = { exports: {} };
|
|
46
|
+
var exports = module.exports;
|
|
47
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
48
|
+
|
|
49
|
+
const React = require('react');
|
|
50
|
+
|
|
51
|
+
// -------------------------------------------------------------------------------------------
|
|
52
|
+
// Wire
|
|
53
|
+
// -------------------------------------------------------------------------------------------
|
|
54
|
+
//
|
|
55
|
+
// Mirrors TARGET_ROUTE_PATH in src/plugin/fleet-target.mjs, `/api` prefix included: the host's
|
|
56
|
+
// Connection service mounts its exact-fetch-route registry under that channel and refuses any
|
|
57
|
+
// path that does not start with `/api/`. Plain same-origin `fetch` rather than a Remote method:
|
|
58
|
+
// the route is mounted inside the host's authentication fence, so the page's own cookie is the
|
|
59
|
+
// credential and there is no descriptor to keep in sync.
|
|
60
|
+
const ROUTE = '/api/w2m/target';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The locale namespace and its two dictionaries.
|
|
64
|
+
*
|
|
65
|
+
* Registered through `ctx.locale` when the composition provides it, exactly as the shipped
|
|
66
|
+
* permission control beside this one does; the Chinese dictionary is the fallback when it does
|
|
67
|
+
* not, because a missing localisation service is not a reason for an operator to lose the
|
|
68
|
+
* control. Only the *visible* strings are here. A refusal from the host is reported in the
|
|
69
|
+
* host's own words — it is a diagnostic naming a machine, a relay or an HTTP status, and
|
|
70
|
+
* translating it would put a second, drifting copy of that vocabulary in the browser.
|
|
71
|
+
*/
|
|
72
|
+
const NS = 'w2m.target';
|
|
73
|
+
const DICT = {
|
|
74
|
+
zh: {
|
|
75
|
+
all: '全部机器',
|
|
76
|
+
machine: '机器',
|
|
77
|
+
local: '本机',
|
|
78
|
+
online: '在线',
|
|
79
|
+
noRoster: '无列表',
|
|
80
|
+
empty: '机器列表里没有机器',
|
|
81
|
+
costAll: '本次会话的派发会送到所有在线机器',
|
|
82
|
+
costOne: '本次会话的派发会送到 {name}',
|
|
83
|
+
unconfirmed: '(未确认在线)',
|
|
84
|
+
targetLabel: '目标机器:{name}',
|
|
85
|
+
unreachable: '无法连接插件',
|
|
86
|
+
routeMissing: '这个 Harness 没有提供插件目标路由',
|
|
87
|
+
fleetUnreadable: '机器列表读取失败',
|
|
88
|
+
},
|
|
89
|
+
en: {
|
|
90
|
+
all: 'All machines',
|
|
91
|
+
machine: 'Machine',
|
|
92
|
+
local: 'This machine',
|
|
93
|
+
online: 'online',
|
|
94
|
+
noRoster: 'no roster',
|
|
95
|
+
empty: 'the fleet lists no machines',
|
|
96
|
+
costAll: 'Work from this session goes to every online machine',
|
|
97
|
+
costOne: 'Work from this session goes to {name}',
|
|
98
|
+
unconfirmed: ' (not confirmed online)',
|
|
99
|
+
targetLabel: 'target machine: {name}',
|
|
100
|
+
unreachable: 'the plugin could not be reached',
|
|
101
|
+
routeMissing: 'this host does not serve the W2M target route',
|
|
102
|
+
fleetUnreadable: 'the fleet could not be read',
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A translator over {@link DICT}, preferring the shell's locale service.
|
|
108
|
+
*
|
|
109
|
+
* `{name}` substitution is deliberately the only templating: these strings have one variable
|
|
110
|
+
* each, and a message format would be a dependency this package does not have.
|
|
111
|
+
*
|
|
112
|
+
* @param ctx - the client plugin context.
|
|
113
|
+
* @returns {(key: string, values?: object) => string}
|
|
114
|
+
*/
|
|
115
|
+
function makeTranslator(ctx) {
|
|
116
|
+
const bound = typeof ctx?.locale?.bind === 'function' ? ctx.locale.bind(NS) : null;
|
|
117
|
+
return (key, values) => {
|
|
118
|
+
const template = bound === null
|
|
119
|
+
? (DICT.zh[key] ?? DICT.en[key] ?? key)
|
|
120
|
+
: bound(key, values);
|
|
121
|
+
const text = typeof template === 'string' && template !== '' ? template : (DICT.en[key] ?? key);
|
|
122
|
+
if (values === undefined) return text;
|
|
123
|
+
return text.replace(/\{(\w+)\}/g, (match, name) => (values[name] === undefined ? match : String(values[name])));
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One JSON call to the host half, folded into a value: a refusal is never an exception here. */
|
|
128
|
+
async function callHost(options) {
|
|
129
|
+
let response;
|
|
130
|
+
try {
|
|
131
|
+
response = await fetch(options.url, options.init);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return { ok: false, error: `the plugin could not be reached: ${error instanceof Error ? error.message : String(error)}`, status: 0 };
|
|
134
|
+
}
|
|
135
|
+
if (response.status === 404) {
|
|
136
|
+
return { ok: false, status: 404, error: 'this host does not serve the W2M target route' };
|
|
137
|
+
}
|
|
138
|
+
let body = null;
|
|
139
|
+
try {
|
|
140
|
+
body = await response.json();
|
|
141
|
+
} catch {
|
|
142
|
+
return { ok: false, status: response.status, error: `the plugin answered HTTP ${response.status} with a body that is not JSON` };
|
|
143
|
+
}
|
|
144
|
+
if (response.ok !== true) {
|
|
145
|
+
return { ok: false, status: response.status, error: typeof body?.error === 'string' ? body.error : `HTTP ${response.status}` };
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, status: response.status, state: body };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const readState = (sessionId, signal) => callHost({
|
|
151
|
+
url: `${ROUTE}?session_id=${encodeURIComponent(sessionId)}`,
|
|
152
|
+
init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const writeSelection = (sessionId, machineId, machineName) => callHost({
|
|
156
|
+
url: ROUTE,
|
|
157
|
+
init: {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
credentials: 'same-origin',
|
|
160
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
161
|
+
body: JSON.stringify({ session_id: sessionId, machine_id: machineId, machine_name: machineName }),
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const labelOf = (state, t) => state?.selected?.machine_id != null
|
|
166
|
+
? (state.selected.name ?? state.selected.machine_id)
|
|
167
|
+
: t('all');
|
|
168
|
+
|
|
169
|
+
/** The one-line reason the list cannot be trusted, or null when it can. */
|
|
170
|
+
function fleetProblem(state, t) {
|
|
171
|
+
if (state === null || state === undefined) return null;
|
|
172
|
+
if (state.host?.detail_unavailable === true) return t('routeMissing');
|
|
173
|
+
if (state.fleet?.ok === true) return null;
|
|
174
|
+
return state.fleet?.error ?? t('fleetUnreadable');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// -------------------------------------------------------------------------------------------
|
|
178
|
+
// Styles
|
|
179
|
+
// -------------------------------------------------------------------------------------------
|
|
180
|
+
//
|
|
181
|
+
// Token colours only: `--dsw-*` is the shell's own theme surface, so the control follows the
|
|
182
|
+
// light/dark switch instead of inventing a palette that is wrong in one of them. The geometry
|
|
183
|
+
// (28px height, `--dsw-radius-sm`, 13px/500 label, 4px gap, 8px leading pad) is copied from the
|
|
184
|
+
// shipped permission trigger in the same row, and the status dot uses the theme's state colours:
|
|
185
|
+
// business blue for this machine, success green for a reachable peer, warn/error for the two
|
|
186
|
+
// states that are not "ready".
|
|
187
|
+
const TAG_ID = '@twinsearth/w2m-dsh-plugin/TargetSelect.css';
|
|
188
|
+
const CSS = [
|
|
189
|
+
'.w2mTarget_trigger{display:inline-flex;align-items:center;gap:4px;height:28px;max-width:220px;min-width:0;',
|
|
190
|
+
'padding:0 4px 0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;',
|
|
191
|
+
'color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;cursor:pointer;outline:none}',
|
|
192
|
+
'.w2mTarget_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
193
|
+
'.w2mTarget_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}',
|
|
194
|
+
'.w2mTarget_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
|
|
195
|
+
'.w2mTarget_dot{flex:none;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-state-idle-primary);',
|
|
196
|
+
'box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2)}',
|
|
197
|
+
'.w2mTarget_dot[data-state="local"]{background:var(--dsw-alias-state-business-primary)}',
|
|
198
|
+
'.w2mTarget_dot[data-state="online"]{background:var(--dsw-alias-state-success-primary)}',
|
|
199
|
+
'.w2mTarget_dot[data-state="stale"]{background:transparent;border:1px solid var(--dsw-alias-state-warn-primary)}',
|
|
200
|
+
'.w2mTarget_dot[data-state="none"]{background:transparent;border:1px solid var(--dsw-alias-label-caption)}',
|
|
201
|
+
'.w2mTarget_label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
202
|
+
'.w2mTarget_chevron{flex:none;display:inline-flex;color:var(--dsw-alias-label-caption);transition:transform .12s}',
|
|
203
|
+
'.w2mTarget_chevron[data-open="true"]{transform:rotate(180deg)}',
|
|
204
|
+
'.w2mTarget_menu{position:fixed;z-index:60;box-sizing:border-box;min-width:200px;max-width:300px;',
|
|
205
|
+
'padding:4px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);',
|
|
206
|
+
'background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);',
|
|
207
|
+
'font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
|
|
208
|
+
'.w2mTarget_note{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-weight:400}',
|
|
209
|
+
'.w2mTarget_option{display:block;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:var(--dsw-radius-sm);',
|
|
210
|
+
'background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
|
|
211
|
+
'.w2mTarget_option:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
212
|
+
'.w2mTarget_option:disabled{cursor:default;color:var(--dsw-alias-label-dimmed)}',
|
|
213
|
+
'.w2mTarget_option[data-active="true"]{color:var(--dsw-alias-state-business-primary)}',
|
|
214
|
+
'.w2mTarget_row{display:flex;align-items:center;gap:8px}',
|
|
215
|
+
'.w2mTarget_name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
216
|
+
'.w2mTarget_detail{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:400}',
|
|
217
|
+
'.w2mTarget_option:disabled .w2mTarget_detail{color:var(--dsw-alias-label-caption)}',
|
|
218
|
+
].join('');
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Install the stylesheet once per document, and hand back a disposer for the plugin's lifetime.
|
|
222
|
+
*
|
|
223
|
+
* Idempotent by the tag id, not by a module flag: two plugin instances in one page (a reload
|
|
224
|
+
* that did not evict the old one, two compositions) must not produce two stylesheets, and an
|
|
225
|
+
* unload must take exactly the one this call created. When a tag is already present this is a
|
|
226
|
+
* no-op that returns nothing, because removing a tag this call did not add would be a
|
|
227
|
+
* disposer that breaks whichever instance owns it.
|
|
228
|
+
*
|
|
229
|
+
* @returns {(() => void)|null} Disposer, or null when the stylesheet was already installed.
|
|
230
|
+
*/
|
|
231
|
+
function injectStyle(hostDocument) {
|
|
232
|
+
if (hostDocument === undefined || hostDocument === null) return null;
|
|
233
|
+
if (hostDocument.querySelector(`style[data-plugin-css="${TAG_ID}"]`) !== null) return null;
|
|
234
|
+
const tag = hostDocument.createElement('style');
|
|
235
|
+
tag.dataset.plugin = '@twinsearth/w2m-dsh-plugin';
|
|
236
|
+
tag.dataset.pluginCss = TAG_ID;
|
|
237
|
+
tag.textContent = CSS;
|
|
238
|
+
hostDocument.head.appendChild(tag);
|
|
239
|
+
return () => {
|
|
240
|
+
tag.remove();
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const cls = (name) => `w2mTarget_${name}`;
|
|
245
|
+
const h = React.createElement;
|
|
246
|
+
|
|
247
|
+
/** The status dot: the colour is the machine's state, and the title says it in words. */
|
|
248
|
+
function StatusDot({ kind, title }) {
|
|
249
|
+
return h('span', { className: cls('dot'), 'data-state': kind, title, 'aria-hidden': 'true' });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* This machine is `local` — the one whose composer this is — and the blue dot marks it; a
|
|
254
|
+
* reachable peer is green; anything else is drawn as an outline in the state's own colour so an
|
|
255
|
+
* offline row can never be mistaken for a live one.
|
|
256
|
+
*/
|
|
257
|
+
function dotKind(machine) {
|
|
258
|
+
if (machine.online !== true) return machine.state === 'expired' ? 'none' : 'stale';
|
|
259
|
+
return machine.local === true ? 'local' : 'online';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The label the trigger shows before the host has answered: the configured fleet name. */
|
|
263
|
+
const provisionalLabel = (state) => state?.host?.local_machine_name ?? null;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* `locked` is accepted for parity with the seat's other occupants and deliberately unused: the
|
|
267
|
+
* target is a choice about the *next* dispatch, which is legitimate to change while a turn runs.
|
|
268
|
+
* `t` arrives through the entry's inject face (see {@link apply}).
|
|
269
|
+
*/
|
|
270
|
+
function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
271
|
+
const [open, setOpen] = React.useState(false);
|
|
272
|
+
const [reloadSeq, setReloadSeq] = React.useState(0);
|
|
273
|
+
const [state, setState] = React.useState(null);
|
|
274
|
+
const [error, setError] = React.useState(null);
|
|
275
|
+
const [busy, setBusy] = React.useState(false);
|
|
276
|
+
const anchorRef = React.useRef(null);
|
|
277
|
+
const menuRef = React.useRef(null);
|
|
278
|
+
const [placement, setPlacement] = React.useState(null);
|
|
279
|
+
|
|
280
|
+
// The host half is the source of truth, so every read replaces the mirror rather than merging
|
|
281
|
+
// into it: a stale "online" must not outlive the answer that disproved it.
|
|
282
|
+
React.useEffect(() => {
|
|
283
|
+
if (typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
284
|
+
const controller = new AbortController();
|
|
285
|
+
readState(sessionId, controller.signal).then((result) => {
|
|
286
|
+
if (controller.signal.aborted) return;
|
|
287
|
+
if (result.ok) {
|
|
288
|
+
setState(result.state);
|
|
289
|
+
setError(null);
|
|
290
|
+
} else {
|
|
291
|
+
setError(result.error);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
return () => controller.abort();
|
|
295
|
+
}, [sessionId, reloadSeq]);
|
|
296
|
+
|
|
297
|
+
// Opening the menu is a read: the roster and the selection are re-asked for every time, so a
|
|
298
|
+
// machine that went away while the menu was closed cannot be clicked as if it were live. The
|
|
299
|
+
// host caches its own relay read for a few seconds, so this is not a request storm.
|
|
300
|
+
React.useEffect(() => {
|
|
301
|
+
if (open) setReloadSeq((seq) => seq + 1);
|
|
302
|
+
}, [open]);
|
|
303
|
+
|
|
304
|
+
React.useEffect(() => {
|
|
305
|
+
if (!open) return undefined;
|
|
306
|
+
const reposition = () => {
|
|
307
|
+
const anchor = anchorRef.current;
|
|
308
|
+
if (anchor === null) return;
|
|
309
|
+
const rect = anchor.getBoundingClientRect();
|
|
310
|
+
const width = Math.min(300, Math.max(200, Math.round(rect.width) + 96));
|
|
311
|
+
const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
|
|
312
|
+
// Above the composer by default — it is at the bottom of the window — and below it only
|
|
313
|
+
// when the space above is genuinely unusable.
|
|
314
|
+
const roomAbove = rect.top;
|
|
315
|
+
const maxHeight = Math.max(160, Math.min(320, roomAbove > 240 ? roomAbove - 16 : window.innerHeight - rect.bottom - 24));
|
|
316
|
+
setPlacement(roomAbove > 240
|
|
317
|
+
? { left, width, maxHeight, bottom: window.innerHeight - rect.top + 6, top: 'auto' }
|
|
318
|
+
: { left, width, maxHeight, top: rect.bottom + 6, bottom: 'auto' });
|
|
319
|
+
};
|
|
320
|
+
const onKeyDown = (event) => {
|
|
321
|
+
if (event.key === 'Escape') {
|
|
322
|
+
setOpen(false);
|
|
323
|
+
anchorRef.current?.focus?.();
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
const onPointerDown = (event) => {
|
|
327
|
+
const target = event.target;
|
|
328
|
+
if (anchorRef.current?.contains(target) === true) return;
|
|
329
|
+
if (menuRef.current?.contains(target) === true) return;
|
|
330
|
+
setOpen(false);
|
|
331
|
+
};
|
|
332
|
+
reposition();
|
|
333
|
+
// `capture` matters: the composer's own editor listens on the document too, and Escape has
|
|
334
|
+
// to close this menu before anything else reads it.
|
|
335
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
336
|
+
document.addEventListener('pointerdown', onPointerDown, true);
|
|
337
|
+
window.addEventListener('resize', reposition);
|
|
338
|
+
window.addEventListener('scroll', reposition, true);
|
|
339
|
+
return () => {
|
|
340
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
341
|
+
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
342
|
+
window.removeEventListener('resize', reposition);
|
|
343
|
+
window.removeEventListener('scroll', reposition, true);
|
|
344
|
+
};
|
|
345
|
+
}, [open]);
|
|
346
|
+
|
|
347
|
+
const chosen = state?.selected?.machine_id ?? null;
|
|
348
|
+
const machines = Array.isArray(state?.machines) ? state.machines : [];
|
|
349
|
+
const problem = error ?? fleetProblem(state, t);
|
|
350
|
+
const disabled = typeof sessionId !== 'string' || sessionId === '';
|
|
351
|
+
const label = state === null ? (provisionalLabel(state) ?? t('machine')) : labelOf(state, t);
|
|
352
|
+
const selectedMachine = machines.find((machine) => machine.machine_id === chosen) ?? null;
|
|
353
|
+
const selectedOnline = state === null
|
|
354
|
+
? null
|
|
355
|
+
: (selectedMachine !== null ? selectedMachine.online === true : (chosen === null ? null : state?.selected?.online ?? null));
|
|
356
|
+
const dot = chosen === null
|
|
357
|
+
? 'none'
|
|
358
|
+
: (selectedOnline === true ? (selectedMachine?.local === true ? 'local' : 'online') : 'stale');
|
|
359
|
+
const title = problem ?? (chosen === null
|
|
360
|
+
? t('costAll')
|
|
361
|
+
: `${t('costOne', { name: label })}${selectedOnline === true ? '' : t('unconfirmed')}`);
|
|
362
|
+
|
|
363
|
+
const choose = (machineId, machineName) => {
|
|
364
|
+
setOpen(false);
|
|
365
|
+
if (machineId === chosen) return;
|
|
366
|
+
setBusy(true);
|
|
367
|
+
writeSelection(sessionId, machineId, machineName).then((result) => {
|
|
368
|
+
setBusy(false);
|
|
369
|
+
if (result.ok) {
|
|
370
|
+
setState(result.state);
|
|
371
|
+
setError(null);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
// The write is refused for a reason the operator can act on (the machine went away, the
|
|
375
|
+
// fleet could not be read), so it is shown where they are looking rather than swallowed.
|
|
376
|
+
setError(result.error);
|
|
377
|
+
setReloadSeq((seq) => seq + 1);
|
|
378
|
+
});
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const menu = open && placement !== null
|
|
382
|
+
? h('div', {
|
|
383
|
+
className: cls('menu'),
|
|
384
|
+
role: 'listbox',
|
|
385
|
+
'aria-label': t('targetLabel', { name: label }),
|
|
386
|
+
ref: menuRef,
|
|
387
|
+
style: {
|
|
388
|
+
left: `${placement.left}px`,
|
|
389
|
+
top: placement.top === 'auto' ? 'auto' : `${placement.top}px`,
|
|
390
|
+
bottom: placement.bottom === undefined || placement.bottom === 'auto' ? 'auto' : `${placement.bottom}px`,
|
|
391
|
+
width: `${placement.width}px`,
|
|
392
|
+
maxHeight: `${placement.maxHeight}px`,
|
|
393
|
+
overflowY: 'auto',
|
|
394
|
+
},
|
|
395
|
+
}, [
|
|
396
|
+
h('button', {
|
|
397
|
+
key: '__all__',
|
|
398
|
+
type: 'button',
|
|
399
|
+
role: 'option',
|
|
400
|
+
className: cls('option'),
|
|
401
|
+
'data-active': chosen === null ? 'true' : 'false',
|
|
402
|
+
'aria-selected': chosen === null,
|
|
403
|
+
onClick: () => choose(null, null),
|
|
404
|
+
title: t('costAll'),
|
|
405
|
+
}, h('span', { className: cls('row') }, [
|
|
406
|
+
h(StatusDot, { key: 'dot', kind: 'none', title: t('all') }),
|
|
407
|
+
h('span', { key: 'name', className: cls('name') }, t('all')),
|
|
408
|
+
h('span', { key: 'detail', className: cls('detail') }, state?.fleet?.ok === true ? `${state.fleet.online} ${t('online')}` : '—'),
|
|
409
|
+
])),
|
|
410
|
+
problem === null ? null : h('div', { key: '__problem__', className: cls('note'), role: 'status' }, problem),
|
|
411
|
+
machines.length === 0
|
|
412
|
+
? h('div', { key: '__empty__', className: cls('note') }, problem === null ? t('empty') : t('noRoster'))
|
|
413
|
+
: machines.map((machine) => h('button', {
|
|
414
|
+
key: machine.machine_id ?? machine.name ?? Math.random().toString(36).slice(2),
|
|
415
|
+
type: 'button',
|
|
416
|
+
role: 'option',
|
|
417
|
+
className: cls('option'),
|
|
418
|
+
'data-active': machine.machine_id !== null && machine.machine_id === chosen ? 'true' : 'false',
|
|
419
|
+
'aria-selected': machine.machine_id !== null && machine.machine_id === chosen,
|
|
420
|
+
disabled: machine.online !== true,
|
|
421
|
+
title: machine.online === true
|
|
422
|
+
? `${machine.name ?? machine.machine_id} — ${machine.local === true ? t('local') : t('online')}`
|
|
423
|
+
: `${machine.name ?? machine.machine_id} — ${machine.state}`,
|
|
424
|
+
onClick: () => choose(machine.machine_id, machine.name),
|
|
425
|
+
}, h('span', { className: cls('row') }, [
|
|
426
|
+
h(StatusDot, { key: 'dot', kind: dotKind(machine), title: machine.state }),
|
|
427
|
+
h('span', { key: 'name', className: cls('name') }, machine.name ?? machine.machine_id ?? 'unnamed'),
|
|
428
|
+
h('span', { key: 'detail', className: cls('detail') }, machine.local === true
|
|
429
|
+
? t('local')
|
|
430
|
+
: (machine.online === true ? (machine.os ?? t('online')) : machine.state)),
|
|
431
|
+
]))),
|
|
432
|
+
])
|
|
433
|
+
: null;
|
|
434
|
+
|
|
435
|
+
return h(React.Fragment, null, [
|
|
436
|
+
h('button', {
|
|
437
|
+
key: 'trigger',
|
|
438
|
+
type: 'button',
|
|
439
|
+
ref: anchorRef,
|
|
440
|
+
className: cls('trigger'),
|
|
441
|
+
'aria-label': t('targetLabel', { name: label }),
|
|
442
|
+
'aria-haspopup': 'listbox',
|
|
443
|
+
'aria-expanded': open,
|
|
444
|
+
'aria-busy': busy,
|
|
445
|
+
title,
|
|
446
|
+
disabled,
|
|
447
|
+
onClick: () => setOpen((wasOpen) => !wasOpen),
|
|
448
|
+
}, [
|
|
449
|
+
h(StatusDot, { key: 'dot', kind: dot, title: title ?? '' }),
|
|
450
|
+
h('span', { key: 'label', className: cls('label') }, label),
|
|
451
|
+
h('span', { key: 'chevron', className: cls('chevron'), 'data-open': open ? 'true' : 'false', 'aria-hidden': 'true' },
|
|
452
|
+
h('svg', { width: 12, height: 12, viewBox: '0 0 12 12', fill: 'none' },
|
|
453
|
+
h('path', {
|
|
454
|
+
d: 'M3 4.5L6 7.5L9 4.5',
|
|
455
|
+
stroke: 'currentColor',
|
|
456
|
+
strokeWidth: 1.5,
|
|
457
|
+
strokeLinecap: 'round',
|
|
458
|
+
strokeLinejoin: 'round',
|
|
459
|
+
}))),
|
|
460
|
+
]),
|
|
461
|
+
menu,
|
|
462
|
+
]);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Client plugin body.
|
|
467
|
+
*
|
|
468
|
+
* `slots.inject` (not a bare `register`) is what the slot's declaration requires of an entry
|
|
469
|
+
* from a package that does not own the seat: the parent declared `conversation.input.left`, and
|
|
470
|
+
* an entry may only register into a key it has announced. The returned `inject` factory is
|
|
471
|
+
* called per render position with that session's id, and its keys become this component's props
|
|
472
|
+
* — which is how the control knows which session's selection it is showing.
|
|
473
|
+
*
|
|
474
|
+
* The stylesheet is installed here rather than at module scope, so its lifetime is the plugin's:
|
|
475
|
+
* `ctx.effect` disposes it on unload, and nothing a bundle factory does at materialization can
|
|
476
|
+
* outlive the entry that materialized it.
|
|
477
|
+
*
|
|
478
|
+
* @param ctx - the client root context.
|
|
479
|
+
*/
|
|
480
|
+
function apply(ctx) {
|
|
481
|
+
const styleDisposer = injectStyle(typeof document === 'undefined' ? null : document);
|
|
482
|
+
if (styleDisposer !== null && typeof ctx.effect === 'function') {
|
|
483
|
+
ctx.effect(() => styleDisposer, 'w2m: composer target control styles');
|
|
484
|
+
}
|
|
485
|
+
// Dictionaries first: a namespace registered after the UI has mounted is still picked up.
|
|
486
|
+
if (typeof ctx.locale?.register === 'function') {
|
|
487
|
+
ctx.effect(() => ctx.locale.register(NS, DICT), 'w2m: composer target control dictionaries');
|
|
488
|
+
}
|
|
489
|
+
const t = makeTranslator(ctx);
|
|
490
|
+
ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
|
|
491
|
+
name: 'conversation.input.left',
|
|
492
|
+
id: 'w2m-target',
|
|
493
|
+
// After the shipped permission control and the plan control, which sit earlier in the
|
|
494
|
+
// composer row: the target is a per-session choice like them, and the row reads left to
|
|
495
|
+
// right as "attach, permissions, plan, where it runs".
|
|
496
|
+
order: 40,
|
|
497
|
+
label: 'dispatch target',
|
|
498
|
+
inject: (sessionId) => ({
|
|
499
|
+
sessionId,
|
|
500
|
+
t,
|
|
501
|
+
}),
|
|
502
|
+
}, TargetSelect));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Cordis services this entry needs; `ctx.slots` is read directly, as every client plugin does. */
|
|
506
|
+
const inject = [];
|
|
507
|
+
|
|
508
|
+
exports.apply = apply;
|
|
509
|
+
exports.inject = inject;
|
|
510
|
+
exports.TargetSelect = TargetSelect;
|
|
511
|
+
exports.injectStyle = injectStyle;
|
|
512
|
+
exports.makeTranslator = makeTranslator;
|
|
513
|
+
return module.exports;
|
|
514
|
+
},
|
|
515
|
+
});
|