@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 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 explicitly at `./tools` rather than relying on a
3
- # "root export is the plugin" convention, so reading this file tells you which
4
- # module of the package becomes the plugin.
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/tools'
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
+ });