@twinsearth/w2m-dsh-plugin 0.5.3 → 0.5.4
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 +28 -0
- package/lib/client.js +513 -0
- package/package.json +7 -3
- package/src/plugin/fleet-target.mjs +603 -0
- package/src/plugin/tools.mjs +149 -26
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,34 @@ 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.4] — 2026-10-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
* **A dispatch-target control inside the chat composer.** A client half registers into
|
|
12
|
+
`conversation.input.left` — a `list` seat with `replaceRisk: none` — so the control is added *beside*
|
|
13
|
+
the permission control rather than shadowing it; `conversation.input.permission` is never touched. The
|
|
14
|
+
roster is the relay's own device list, read through the same helper `w2m_devices` uses, so the tool and
|
|
15
|
+
the control cannot disagree; a machine the relay does not list as online is shown but not selectable.
|
|
16
|
+
* **The selection means something, and only via fields that already existed.** Picking a machine stores a
|
|
17
|
+
per-session target that survives a page reload; `w2m_run` maps it onto `target_machines` for
|
|
18
|
+
replicate/split/pipeline and onto `executor_machine_id` for broadcast, and a caller that names an
|
|
19
|
+
executor explicitly always wins. No new tool parameter, no protocol change, `envelope_version`
|
|
20
|
+
untouched. `w2m_status` reports the current target.
|
|
21
|
+
* `lib/client.js` is the shipped browser bundle, written by hand in the runtime's lazy-CJS loader format
|
|
22
|
+
because this package has no bundler; `dsh.client` and `exports["./client"]` declare it additively.
|
|
23
|
+
|
|
24
|
+
### Notes
|
|
25
|
+
|
|
26
|
+
* Verified by test and by a live probe against the real relay: selecting `jp-shared` and dispatching with
|
|
27
|
+
no executor named produced leases `jp-shared: offered`, the other two `observing`, and a `consistent`
|
|
28
|
+
verdict from the machine that was selected. **Not verified: how the control renders.** Every client
|
|
29
|
+
Inspect query timed out for the whole session, so the page is not answering the client inspect channel
|
|
30
|
+
and the rendered text and position beside the permission control are unconfirmed.
|
|
31
|
+
* **A page refresh cannot show it.** The running Harness composes the client boot graph at startup and
|
|
32
|
+
serves the plugin from the profile's `node_modules`; the new revision must be installed *and the
|
|
33
|
+
Harness restarted* before the control appears. The control is therefore in the artifact and not in the
|
|
34
|
+
running UI, and this entry says so rather than implying otherwise.
|
|
7
35
|
## [0.5.3] — 2026-10-11
|
|
8
36
|
|
|
9
37
|
### Added
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
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. Plain same-origin `fetch` rather
|
|
56
|
+
// than a Remote method: the route is mounted inside the host's authentication fence, so the
|
|
57
|
+
// page's own cookie is the credential and there is no descriptor to keep in sync.
|
|
58
|
+
const ROUTE = '/w2m/target';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The locale namespace and its two dictionaries.
|
|
62
|
+
*
|
|
63
|
+
* Registered through `ctx.locale` when the composition provides it, exactly as the shipped
|
|
64
|
+
* permission control beside this one does; the Chinese dictionary is the fallback when it does
|
|
65
|
+
* not, because a missing localisation service is not a reason for an operator to lose the
|
|
66
|
+
* control. Only the *visible* strings are here. A refusal from the host is reported in the
|
|
67
|
+
* host's own words — it is a diagnostic naming a machine, a relay or an HTTP status, and
|
|
68
|
+
* translating it would put a second, drifting copy of that vocabulary in the browser.
|
|
69
|
+
*/
|
|
70
|
+
const NS = 'w2m.target';
|
|
71
|
+
const DICT = {
|
|
72
|
+
zh: {
|
|
73
|
+
all: '全部机器',
|
|
74
|
+
machine: '机器',
|
|
75
|
+
local: '本机',
|
|
76
|
+
online: '在线',
|
|
77
|
+
noRoster: '无列表',
|
|
78
|
+
empty: '机器列表里没有机器',
|
|
79
|
+
costAll: '本次会话的派发会送到所有在线机器',
|
|
80
|
+
costOne: '本次会话的派发会送到 {name}',
|
|
81
|
+
unconfirmed: '(未确认在线)',
|
|
82
|
+
targetLabel: '目标机器:{name}',
|
|
83
|
+
unreachable: '无法连接插件',
|
|
84
|
+
routeMissing: '这个 Harness 没有提供插件目标路由',
|
|
85
|
+
fleetUnreadable: '机器列表读取失败',
|
|
86
|
+
},
|
|
87
|
+
en: {
|
|
88
|
+
all: 'All machines',
|
|
89
|
+
machine: 'Machine',
|
|
90
|
+
local: 'This machine',
|
|
91
|
+
online: 'online',
|
|
92
|
+
noRoster: 'no roster',
|
|
93
|
+
empty: 'the fleet lists no machines',
|
|
94
|
+
costAll: 'Work from this session goes to every online machine',
|
|
95
|
+
costOne: 'Work from this session goes to {name}',
|
|
96
|
+
unconfirmed: ' (not confirmed online)',
|
|
97
|
+
targetLabel: 'target machine: {name}',
|
|
98
|
+
unreachable: 'the plugin could not be reached',
|
|
99
|
+
routeMissing: 'this host does not serve the W2M target route',
|
|
100
|
+
fleetUnreadable: 'the fleet could not be read',
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A translator over {@link DICT}, preferring the shell's locale service.
|
|
106
|
+
*
|
|
107
|
+
* `{name}` substitution is deliberately the only templating: these strings have one variable
|
|
108
|
+
* each, and a message format would be a dependency this package does not have.
|
|
109
|
+
*
|
|
110
|
+
* @param ctx - the client plugin context.
|
|
111
|
+
* @returns {(key: string, values?: object) => string}
|
|
112
|
+
*/
|
|
113
|
+
function makeTranslator(ctx) {
|
|
114
|
+
const bound = typeof ctx?.locale?.bind === 'function' ? ctx.locale.bind(NS) : null;
|
|
115
|
+
return (key, values) => {
|
|
116
|
+
const template = bound === null
|
|
117
|
+
? (DICT.zh[key] ?? DICT.en[key] ?? key)
|
|
118
|
+
: bound(key, values);
|
|
119
|
+
const text = typeof template === 'string' && template !== '' ? template : (DICT.en[key] ?? key);
|
|
120
|
+
if (values === undefined) return text;
|
|
121
|
+
return text.replace(/\{(\w+)\}/g, (match, name) => (values[name] === undefined ? match : String(values[name])));
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One JSON call to the host half, folded into a value: a refusal is never an exception here. */
|
|
126
|
+
async function callHost(options) {
|
|
127
|
+
let response;
|
|
128
|
+
try {
|
|
129
|
+
response = await fetch(options.url, options.init);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return { ok: false, error: `the plugin could not be reached: ${error instanceof Error ? error.message : String(error)}`, status: 0 };
|
|
132
|
+
}
|
|
133
|
+
if (response.status === 404) {
|
|
134
|
+
return { ok: false, status: 404, error: 'this host does not serve the W2M target route' };
|
|
135
|
+
}
|
|
136
|
+
let body = null;
|
|
137
|
+
try {
|
|
138
|
+
body = await response.json();
|
|
139
|
+
} catch {
|
|
140
|
+
return { ok: false, status: response.status, error: `the plugin answered HTTP ${response.status} with a body that is not JSON` };
|
|
141
|
+
}
|
|
142
|
+
if (response.ok !== true) {
|
|
143
|
+
return { ok: false, status: response.status, error: typeof body?.error === 'string' ? body.error : `HTTP ${response.status}` };
|
|
144
|
+
}
|
|
145
|
+
return { ok: true, status: response.status, state: body };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const readState = (sessionId, signal) => callHost({
|
|
149
|
+
url: `${ROUTE}?session_id=${encodeURIComponent(sessionId)}`,
|
|
150
|
+
init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const writeSelection = (sessionId, machineId, machineName) => callHost({
|
|
154
|
+
url: ROUTE,
|
|
155
|
+
init: {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
credentials: 'same-origin',
|
|
158
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
159
|
+
body: JSON.stringify({ session_id: sessionId, machine_id: machineId, machine_name: machineName }),
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const labelOf = (state, t) => state?.selected?.machine_id != null
|
|
164
|
+
? (state.selected.name ?? state.selected.machine_id)
|
|
165
|
+
: t('all');
|
|
166
|
+
|
|
167
|
+
/** The one-line reason the list cannot be trusted, or null when it can. */
|
|
168
|
+
function fleetProblem(state, t) {
|
|
169
|
+
if (state === null || state === undefined) return null;
|
|
170
|
+
if (state.host?.detail_unavailable === true) return t('routeMissing');
|
|
171
|
+
if (state.fleet?.ok === true) return null;
|
|
172
|
+
return state.fleet?.error ?? t('fleetUnreadable');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// -------------------------------------------------------------------------------------------
|
|
176
|
+
// Styles
|
|
177
|
+
// -------------------------------------------------------------------------------------------
|
|
178
|
+
//
|
|
179
|
+
// Token colours only: `--dsw-*` is the shell's own theme surface, so the control follows the
|
|
180
|
+
// light/dark switch instead of inventing a palette that is wrong in one of them. The geometry
|
|
181
|
+
// (28px height, `--dsw-radius-sm`, 13px/500 label, 4px gap, 8px leading pad) is copied from the
|
|
182
|
+
// shipped permission trigger in the same row, and the status dot uses the theme's state colours:
|
|
183
|
+
// business blue for this machine, success green for a reachable peer, warn/error for the two
|
|
184
|
+
// states that are not "ready".
|
|
185
|
+
const TAG_ID = '@twinsearth/w2m-dsh-plugin/TargetSelect.css';
|
|
186
|
+
const CSS = [
|
|
187
|
+
'.w2mTarget_trigger{display:inline-flex;align-items:center;gap:4px;height:28px;max-width:220px;min-width:0;',
|
|
188
|
+
'padding:0 4px 0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;',
|
|
189
|
+
'color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;cursor:pointer;outline:none}',
|
|
190
|
+
'.w2mTarget_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
191
|
+
'.w2mTarget_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}',
|
|
192
|
+
'.w2mTarget_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
|
|
193
|
+
'.w2mTarget_dot{flex:none;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-state-idle-primary);',
|
|
194
|
+
'box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2)}',
|
|
195
|
+
'.w2mTarget_dot[data-state="local"]{background:var(--dsw-alias-state-business-primary)}',
|
|
196
|
+
'.w2mTarget_dot[data-state="online"]{background:var(--dsw-alias-state-success-primary)}',
|
|
197
|
+
'.w2mTarget_dot[data-state="stale"]{background:transparent;border:1px solid var(--dsw-alias-state-warn-primary)}',
|
|
198
|
+
'.w2mTarget_dot[data-state="none"]{background:transparent;border:1px solid var(--dsw-alias-label-caption)}',
|
|
199
|
+
'.w2mTarget_label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
200
|
+
'.w2mTarget_chevron{flex:none;display:inline-flex;color:var(--dsw-alias-label-caption);transition:transform .12s}',
|
|
201
|
+
'.w2mTarget_chevron[data-open="true"]{transform:rotate(180deg)}',
|
|
202
|
+
'.w2mTarget_menu{position:fixed;z-index:60;box-sizing:border-box;min-width:200px;max-width:300px;',
|
|
203
|
+
'padding:4px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);',
|
|
204
|
+
'background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);',
|
|
205
|
+
'font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
|
|
206
|
+
'.w2mTarget_note{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-weight:400}',
|
|
207
|
+
'.w2mTarget_option{display:block;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:var(--dsw-radius-sm);',
|
|
208
|
+
'background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
|
|
209
|
+
'.w2mTarget_option:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
210
|
+
'.w2mTarget_option:disabled{cursor:default;color:var(--dsw-alias-label-dimmed)}',
|
|
211
|
+
'.w2mTarget_option[data-active="true"]{color:var(--dsw-alias-state-business-primary)}',
|
|
212
|
+
'.w2mTarget_row{display:flex;align-items:center;gap:8px}',
|
|
213
|
+
'.w2mTarget_name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
214
|
+
'.w2mTarget_detail{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:400}',
|
|
215
|
+
'.w2mTarget_option:disabled .w2mTarget_detail{color:var(--dsw-alias-label-caption)}',
|
|
216
|
+
].join('');
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Install the stylesheet once per document, and hand back a disposer for the plugin's lifetime.
|
|
220
|
+
*
|
|
221
|
+
* Idempotent by the tag id, not by a module flag: two plugin instances in one page (a reload
|
|
222
|
+
* that did not evict the old one, two compositions) must not produce two stylesheets, and an
|
|
223
|
+
* unload must take exactly the one this call created. When a tag is already present this is a
|
|
224
|
+
* no-op that returns nothing, because removing a tag this call did not add would be a
|
|
225
|
+
* disposer that breaks whichever instance owns it.
|
|
226
|
+
*
|
|
227
|
+
* @returns {(() => void)|null} Disposer, or null when the stylesheet was already installed.
|
|
228
|
+
*/
|
|
229
|
+
function injectStyle(hostDocument) {
|
|
230
|
+
if (hostDocument === undefined || hostDocument === null) return null;
|
|
231
|
+
if (hostDocument.querySelector(`style[data-plugin-css="${TAG_ID}"]`) !== null) return null;
|
|
232
|
+
const tag = hostDocument.createElement('style');
|
|
233
|
+
tag.dataset.plugin = '@twinsearth/w2m-dsh-plugin';
|
|
234
|
+
tag.dataset.pluginCss = TAG_ID;
|
|
235
|
+
tag.textContent = CSS;
|
|
236
|
+
hostDocument.head.appendChild(tag);
|
|
237
|
+
return () => {
|
|
238
|
+
tag.remove();
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const cls = (name) => `w2mTarget_${name}`;
|
|
243
|
+
const h = React.createElement;
|
|
244
|
+
|
|
245
|
+
/** The status dot: the colour is the machine's state, and the title says it in words. */
|
|
246
|
+
function StatusDot({ kind, title }) {
|
|
247
|
+
return h('span', { className: cls('dot'), 'data-state': kind, title, 'aria-hidden': 'true' });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* This machine is `local` — the one whose composer this is — and the blue dot marks it; a
|
|
252
|
+
* reachable peer is green; anything else is drawn as an outline in the state's own colour so an
|
|
253
|
+
* offline row can never be mistaken for a live one.
|
|
254
|
+
*/
|
|
255
|
+
function dotKind(machine) {
|
|
256
|
+
if (machine.online !== true) return machine.state === 'expired' ? 'none' : 'stale';
|
|
257
|
+
return machine.local === true ? 'local' : 'online';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The label the trigger shows before the host has answered: the configured fleet name. */
|
|
261
|
+
const provisionalLabel = (state) => state?.host?.local_machine_name ?? null;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* `locked` is accepted for parity with the seat's other occupants and deliberately unused: the
|
|
265
|
+
* target is a choice about the *next* dispatch, which is legitimate to change while a turn runs.
|
|
266
|
+
* `t` arrives through the entry's inject face (see {@link apply}).
|
|
267
|
+
*/
|
|
268
|
+
function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
269
|
+
const [open, setOpen] = React.useState(false);
|
|
270
|
+
const [reloadSeq, setReloadSeq] = React.useState(0);
|
|
271
|
+
const [state, setState] = React.useState(null);
|
|
272
|
+
const [error, setError] = React.useState(null);
|
|
273
|
+
const [busy, setBusy] = React.useState(false);
|
|
274
|
+
const anchorRef = React.useRef(null);
|
|
275
|
+
const menuRef = React.useRef(null);
|
|
276
|
+
const [placement, setPlacement] = React.useState(null);
|
|
277
|
+
|
|
278
|
+
// The host half is the source of truth, so every read replaces the mirror rather than merging
|
|
279
|
+
// into it: a stale "online" must not outlive the answer that disproved it.
|
|
280
|
+
React.useEffect(() => {
|
|
281
|
+
if (typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
282
|
+
const controller = new AbortController();
|
|
283
|
+
readState(sessionId, controller.signal).then((result) => {
|
|
284
|
+
if (controller.signal.aborted) return;
|
|
285
|
+
if (result.ok) {
|
|
286
|
+
setState(result.state);
|
|
287
|
+
setError(null);
|
|
288
|
+
} else {
|
|
289
|
+
setError(result.error);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
return () => controller.abort();
|
|
293
|
+
}, [sessionId, reloadSeq]);
|
|
294
|
+
|
|
295
|
+
// Opening the menu is a read: the roster and the selection are re-asked for every time, so a
|
|
296
|
+
// machine that went away while the menu was closed cannot be clicked as if it were live. The
|
|
297
|
+
// host caches its own relay read for a few seconds, so this is not a request storm.
|
|
298
|
+
React.useEffect(() => {
|
|
299
|
+
if (open) setReloadSeq((seq) => seq + 1);
|
|
300
|
+
}, [open]);
|
|
301
|
+
|
|
302
|
+
React.useEffect(() => {
|
|
303
|
+
if (!open) return undefined;
|
|
304
|
+
const reposition = () => {
|
|
305
|
+
const anchor = anchorRef.current;
|
|
306
|
+
if (anchor === null) return;
|
|
307
|
+
const rect = anchor.getBoundingClientRect();
|
|
308
|
+
const width = Math.min(300, Math.max(200, Math.round(rect.width) + 96));
|
|
309
|
+
const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
|
|
310
|
+
// Above the composer by default — it is at the bottom of the window — and below it only
|
|
311
|
+
// when the space above is genuinely unusable.
|
|
312
|
+
const roomAbove = rect.top;
|
|
313
|
+
const maxHeight = Math.max(160, Math.min(320, roomAbove > 240 ? roomAbove - 16 : window.innerHeight - rect.bottom - 24));
|
|
314
|
+
setPlacement(roomAbove > 240
|
|
315
|
+
? { left, width, maxHeight, bottom: window.innerHeight - rect.top + 6, top: 'auto' }
|
|
316
|
+
: { left, width, maxHeight, top: rect.bottom + 6, bottom: 'auto' });
|
|
317
|
+
};
|
|
318
|
+
const onKeyDown = (event) => {
|
|
319
|
+
if (event.key === 'Escape') {
|
|
320
|
+
setOpen(false);
|
|
321
|
+
anchorRef.current?.focus?.();
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
const onPointerDown = (event) => {
|
|
325
|
+
const target = event.target;
|
|
326
|
+
if (anchorRef.current?.contains(target) === true) return;
|
|
327
|
+
if (menuRef.current?.contains(target) === true) return;
|
|
328
|
+
setOpen(false);
|
|
329
|
+
};
|
|
330
|
+
reposition();
|
|
331
|
+
// `capture` matters: the composer's own editor listens on the document too, and Escape has
|
|
332
|
+
// to close this menu before anything else reads it.
|
|
333
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
334
|
+
document.addEventListener('pointerdown', onPointerDown, true);
|
|
335
|
+
window.addEventListener('resize', reposition);
|
|
336
|
+
window.addEventListener('scroll', reposition, true);
|
|
337
|
+
return () => {
|
|
338
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
339
|
+
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
340
|
+
window.removeEventListener('resize', reposition);
|
|
341
|
+
window.removeEventListener('scroll', reposition, true);
|
|
342
|
+
};
|
|
343
|
+
}, [open]);
|
|
344
|
+
|
|
345
|
+
const chosen = state?.selected?.machine_id ?? null;
|
|
346
|
+
const machines = Array.isArray(state?.machines) ? state.machines : [];
|
|
347
|
+
const problem = error ?? fleetProblem(state, t);
|
|
348
|
+
const disabled = typeof sessionId !== 'string' || sessionId === '';
|
|
349
|
+
const label = state === null ? (provisionalLabel(state) ?? t('machine')) : labelOf(state, t);
|
|
350
|
+
const selectedMachine = machines.find((machine) => machine.machine_id === chosen) ?? null;
|
|
351
|
+
const selectedOnline = state === null
|
|
352
|
+
? null
|
|
353
|
+
: (selectedMachine !== null ? selectedMachine.online === true : (chosen === null ? null : state?.selected?.online ?? null));
|
|
354
|
+
const dot = chosen === null
|
|
355
|
+
? 'none'
|
|
356
|
+
: (selectedOnline === true ? (selectedMachine?.local === true ? 'local' : 'online') : 'stale');
|
|
357
|
+
const title = problem ?? (chosen === null
|
|
358
|
+
? t('costAll')
|
|
359
|
+
: `${t('costOne', { name: label })}${selectedOnline === true ? '' : t('unconfirmed')}`);
|
|
360
|
+
|
|
361
|
+
const choose = (machineId, machineName) => {
|
|
362
|
+
setOpen(false);
|
|
363
|
+
if (machineId === chosen) return;
|
|
364
|
+
setBusy(true);
|
|
365
|
+
writeSelection(sessionId, machineId, machineName).then((result) => {
|
|
366
|
+
setBusy(false);
|
|
367
|
+
if (result.ok) {
|
|
368
|
+
setState(result.state);
|
|
369
|
+
setError(null);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
// The write is refused for a reason the operator can act on (the machine went away, the
|
|
373
|
+
// fleet could not be read), so it is shown where they are looking rather than swallowed.
|
|
374
|
+
setError(result.error);
|
|
375
|
+
setReloadSeq((seq) => seq + 1);
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const menu = open && placement !== null
|
|
380
|
+
? h('div', {
|
|
381
|
+
className: cls('menu'),
|
|
382
|
+
role: 'listbox',
|
|
383
|
+
'aria-label': t('targetLabel', { name: label }),
|
|
384
|
+
ref: menuRef,
|
|
385
|
+
style: {
|
|
386
|
+
left: `${placement.left}px`,
|
|
387
|
+
top: placement.top === 'auto' ? 'auto' : `${placement.top}px`,
|
|
388
|
+
bottom: placement.bottom === undefined || placement.bottom === 'auto' ? 'auto' : `${placement.bottom}px`,
|
|
389
|
+
width: `${placement.width}px`,
|
|
390
|
+
maxHeight: `${placement.maxHeight}px`,
|
|
391
|
+
overflowY: 'auto',
|
|
392
|
+
},
|
|
393
|
+
}, [
|
|
394
|
+
h('button', {
|
|
395
|
+
key: '__all__',
|
|
396
|
+
type: 'button',
|
|
397
|
+
role: 'option',
|
|
398
|
+
className: cls('option'),
|
|
399
|
+
'data-active': chosen === null ? 'true' : 'false',
|
|
400
|
+
'aria-selected': chosen === null,
|
|
401
|
+
onClick: () => choose(null, null),
|
|
402
|
+
title: t('costAll'),
|
|
403
|
+
}, h('span', { className: cls('row') }, [
|
|
404
|
+
h(StatusDot, { key: 'dot', kind: 'none', title: t('all') }),
|
|
405
|
+
h('span', { key: 'name', className: cls('name') }, t('all')),
|
|
406
|
+
h('span', { key: 'detail', className: cls('detail') }, state?.fleet?.ok === true ? `${state.fleet.online} ${t('online')}` : '—'),
|
|
407
|
+
])),
|
|
408
|
+
problem === null ? null : h('div', { key: '__problem__', className: cls('note'), role: 'status' }, problem),
|
|
409
|
+
machines.length === 0
|
|
410
|
+
? h('div', { key: '__empty__', className: cls('note') }, problem === null ? t('empty') : t('noRoster'))
|
|
411
|
+
: machines.map((machine) => h('button', {
|
|
412
|
+
key: machine.machine_id ?? machine.name ?? Math.random().toString(36).slice(2),
|
|
413
|
+
type: 'button',
|
|
414
|
+
role: 'option',
|
|
415
|
+
className: cls('option'),
|
|
416
|
+
'data-active': machine.machine_id !== null && machine.machine_id === chosen ? 'true' : 'false',
|
|
417
|
+
'aria-selected': machine.machine_id !== null && machine.machine_id === chosen,
|
|
418
|
+
disabled: machine.online !== true,
|
|
419
|
+
title: machine.online === true
|
|
420
|
+
? `${machine.name ?? machine.machine_id} — ${machine.local === true ? t('local') : t('online')}`
|
|
421
|
+
: `${machine.name ?? machine.machine_id} — ${machine.state}`,
|
|
422
|
+
onClick: () => choose(machine.machine_id, machine.name),
|
|
423
|
+
}, h('span', { className: cls('row') }, [
|
|
424
|
+
h(StatusDot, { key: 'dot', kind: dotKind(machine), title: machine.state }),
|
|
425
|
+
h('span', { key: 'name', className: cls('name') }, machine.name ?? machine.machine_id ?? 'unnamed'),
|
|
426
|
+
h('span', { key: 'detail', className: cls('detail') }, machine.local === true
|
|
427
|
+
? t('local')
|
|
428
|
+
: (machine.online === true ? (machine.os ?? t('online')) : machine.state)),
|
|
429
|
+
]))),
|
|
430
|
+
])
|
|
431
|
+
: null;
|
|
432
|
+
|
|
433
|
+
return h(React.Fragment, null, [
|
|
434
|
+
h('button', {
|
|
435
|
+
key: 'trigger',
|
|
436
|
+
type: 'button',
|
|
437
|
+
ref: anchorRef,
|
|
438
|
+
className: cls('trigger'),
|
|
439
|
+
'aria-label': t('targetLabel', { name: label }),
|
|
440
|
+
'aria-haspopup': 'listbox',
|
|
441
|
+
'aria-expanded': open,
|
|
442
|
+
'aria-busy': busy,
|
|
443
|
+
title,
|
|
444
|
+
disabled,
|
|
445
|
+
onClick: () => setOpen((wasOpen) => !wasOpen),
|
|
446
|
+
}, [
|
|
447
|
+
h(StatusDot, { key: 'dot', kind: dot, title: title ?? '' }),
|
|
448
|
+
h('span', { key: 'label', className: cls('label') }, label),
|
|
449
|
+
h('span', { key: 'chevron', className: cls('chevron'), 'data-open': open ? 'true' : 'false', 'aria-hidden': 'true' },
|
|
450
|
+
h('svg', { width: 12, height: 12, viewBox: '0 0 12 12', fill: 'none' },
|
|
451
|
+
h('path', {
|
|
452
|
+
d: 'M3 4.5L6 7.5L9 4.5',
|
|
453
|
+
stroke: 'currentColor',
|
|
454
|
+
strokeWidth: 1.5,
|
|
455
|
+
strokeLinecap: 'round',
|
|
456
|
+
strokeLinejoin: 'round',
|
|
457
|
+
}))),
|
|
458
|
+
]),
|
|
459
|
+
menu,
|
|
460
|
+
]);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Client plugin body.
|
|
465
|
+
*
|
|
466
|
+
* `slots.inject` (not a bare `register`) is what the slot's declaration requires of an entry
|
|
467
|
+
* from a package that does not own the seat: the parent declared `conversation.input.left`, and
|
|
468
|
+
* an entry may only register into a key it has announced. The returned `inject` factory is
|
|
469
|
+
* called per render position with that session's id, and its keys become this component's props
|
|
470
|
+
* — which is how the control knows which session's selection it is showing.
|
|
471
|
+
*
|
|
472
|
+
* The stylesheet is installed here rather than at module scope, so its lifetime is the plugin's:
|
|
473
|
+
* `ctx.effect` disposes it on unload, and nothing a bundle factory does at materialization can
|
|
474
|
+
* outlive the entry that materialized it.
|
|
475
|
+
*
|
|
476
|
+
* @param ctx - the client root context.
|
|
477
|
+
*/
|
|
478
|
+
function apply(ctx) {
|
|
479
|
+
const styleDisposer = injectStyle(typeof document === 'undefined' ? null : document);
|
|
480
|
+
if (styleDisposer !== null && typeof ctx.effect === 'function') {
|
|
481
|
+
ctx.effect(() => styleDisposer, 'w2m: composer target control styles');
|
|
482
|
+
}
|
|
483
|
+
// Dictionaries first: a namespace registered after the UI has mounted is still picked up.
|
|
484
|
+
if (typeof ctx.locale?.register === 'function') {
|
|
485
|
+
ctx.effect(() => ctx.locale.register(NS, DICT), 'w2m: composer target control dictionaries');
|
|
486
|
+
}
|
|
487
|
+
const t = makeTranslator(ctx);
|
|
488
|
+
ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
|
|
489
|
+
name: 'conversation.input.left',
|
|
490
|
+
id: 'w2m-target',
|
|
491
|
+
// After the shipped permission control and the plan control, which sit earlier in the
|
|
492
|
+
// composer row: the target is a per-session choice like them, and the row reads left to
|
|
493
|
+
// right as "attach, permissions, plan, where it runs".
|
|
494
|
+
order: 40,
|
|
495
|
+
label: 'dispatch target',
|
|
496
|
+
inject: (sessionId) => ({
|
|
497
|
+
sessionId,
|
|
498
|
+
t,
|
|
499
|
+
}),
|
|
500
|
+
}, TargetSelect));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Cordis services this entry needs; `ctx.slots` is read directly, as every client plugin does. */
|
|
504
|
+
const inject = [];
|
|
505
|
+
|
|
506
|
+
exports.apply = apply;
|
|
507
|
+
exports.inject = inject;
|
|
508
|
+
exports.TargetSelect = TargetSelect;
|
|
509
|
+
exports.injectStyle = injectStyle;
|
|
510
|
+
exports.makeTranslator = makeTranslator;
|
|
511
|
+
return module.exports;
|
|
512
|
+
},
|
|
513
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"exports": {
|
|
13
13
|
".": "./lib/index.js",
|
|
14
14
|
"./tools": "./lib/tools.js",
|
|
15
|
+
"./client": "./lib/client.js",
|
|
15
16
|
"./rabbit": "./bin/w2m-rabbit.mjs",
|
|
16
17
|
"./localside": "./bin/w2m-localside.mjs",
|
|
17
18
|
"./stun": "./bin/w2m-stun.mjs",
|
|
@@ -38,6 +39,9 @@
|
|
|
38
39
|
"kind": "server",
|
|
39
40
|
"bundle": {
|
|
40
41
|
"patch": "cordis.patch.yml"
|
|
42
|
+
},
|
|
43
|
+
"client": {
|
|
44
|
+
"platform": "web"
|
|
41
45
|
}
|
|
42
46
|
},
|
|
43
47
|
"disclosure": {
|
|
@@ -92,13 +96,13 @@
|
|
|
92
96
|
}
|
|
93
97
|
},
|
|
94
98
|
"scripts": {
|
|
95
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/stun-server.test.mjs",
|
|
99
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/stun-server.test.mjs",
|
|
96
100
|
"test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
|
|
97
101
|
"test:relay": "node --test test/relay.test.mjs",
|
|
98
102
|
"test:agent": "node --test test/agent.test.mjs",
|
|
99
103
|
"test:tools": "node --test test/tools.test.mjs",
|
|
100
104
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
101
|
-
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs",
|
|
105
|
+
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs",
|
|
102
106
|
"pack": "node scripts/pack.mjs",
|
|
103
107
|
"lint": "eslint .",
|
|
104
108
|
"lint:fix": "eslint . --fix",
|