dsh-workflow-map 0.0.0-stage → 0.3.2
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 +98 -0
- package/LICENSE +21 -0
- package/README.en.md +447 -0
- package/README.md +423 -2
- package/client.js +9673 -0
- package/cordis.patch.yml +11 -0
- package/docs/OPEN-SOURCE-GUIDE.md +256 -0
- package/docs/screenshot-detail.png +0 -0
- package/docs/screenshot.png +0 -0
- package/icon.svg +6 -0
- package/index.js +12 -0
- package/lib/index.js +974 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +65 -4
- package/src/client/Branches.js +241 -0
- package/src/client/FlowColumn.js +572 -0
- package/src/client/FlowEffects.js +677 -0
- package/src/client/HotInject.js +628 -0
- package/src/client/MainFlow.js +359 -0
- package/src/client/NodeCard.js +545 -0
- package/src/client/NodeDetail.js +569 -0
- package/src/client/ProgressUnder.js +216 -0
- package/src/client/RightPage.js +285 -0
- package/src/client/Settings.js +212 -0
- package/src/client/StepColumn.js +479 -0
- package/src/client/StepDetail.js +250 -0
- package/src/client/export.js +280 -0
- package/src/client/panel.js +1911 -0
- package/src/client/pulse.js +126 -0
- package/src/client/store.js +1090 -0
- package/src/client/theme.js +1164 -0
- package/src/fold.mjs +2633 -0
- package/src/orchestrator.mjs +426 -0
- package/src/route.js +621 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HotInject.js — the right-hand column that hot-injects a prompt.
|
|
3
|
+
* adds its undo: the "revoke" button on a history row (`POST /dsh-workflow/unsteer`).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS FILE EXISTS. The panel's other chunks are pure renderers of data the host already
|
|
6
|
+
* folded. This one owns the plugin's TWO writes: a `POST /dsh-workflow/steer` that puts one
|
|
7
|
+
* `user/message` into the running session's next-step inbox, and a `POST /dsh-workflow/unsteer`
|
|
8
|
+
* that takes that same message back out while it is still unclaimed. Everything that can be
|
|
9
|
+
* decided without the DOM — what may be sent, what the four states look like, what the host's
|
|
10
|
+
* answer means — lives here so tools/selftest-panel.mjs can drive it in Node.
|
|
11
|
+
*
|
|
12
|
+
* THE SEND STATE MACHINE (four states, one source of truth):
|
|
13
|
+
*
|
|
14
|
+
* idle ──send──▶ sending ──{ok:true}──▶ sent ──1.5 s──▶ idle
|
|
15
|
+
* │ ▲
|
|
16
|
+
* └──{ok:false}──▶ failed ──1.5 s──▶ idle
|
|
17
|
+
*
|
|
18
|
+
* `sent` and `failed` are ACKNOWLEDGEMENTS, not resting states: they clear themselves after
|
|
19
|
+
* WF_INJECT_ACK_MS so the panel never sits on a stale "injected" state. The textarea is never cleared
|
|
20
|
+
* by the machine — only the user's own cancel does that — so a failed send keeps what was typed.
|
|
21
|
+
*
|
|
22
|
+
* THE REVOKE STATE MACHINE (a second, separate one — one revocation at a time):
|
|
23
|
+
*
|
|
24
|
+
* idle ──click──▶ revoking ──{ok:true}──▶ revoked ──(the folded row stops being pending)──▶ gone
|
|
25
|
+
* │
|
|
26
|
+
* └──{ok:false}──▶ failed (the host's own reason, under that row)
|
|
27
|
+
*
|
|
28
|
+
* `revoked` is deliberately NOT optimistic: the host's 200 means the message left the inbox, and
|
|
29
|
+
* the row turns "revoked" on the next poll, from the host's own durable
|
|
30
|
+
* `agent/inbox/spliced { outcome: 'canceled' }`. Until then the button reads "revoking..." and is disabled,
|
|
31
|
+
* which is what stops a second click from becoming a second post.
|
|
32
|
+
*
|
|
33
|
+
* HONESTY RULES (each one has an assertion):
|
|
34
|
+
* 1. Sending is refused while `state === "sending"` — no double post, ever.
|
|
35
|
+
* 2. Empty text is refused before any request leaves (the host would answer 400 anyway, but a
|
|
36
|
+
* round trip for something the browser already knows is not honest either).
|
|
37
|
+
* 3. A success is claimed ONLY on `{ok:true}` from the host. On anything else the panel shows
|
|
38
|
+
* the host's OWN reason verbatim - never an invented "injection failed".
|
|
39
|
+
* 4. A 409 carries the host's text ("session is not running, hot injection unavailable"), and it is rendered as a
|
|
40
|
+
* failure with that text, not silently swallowed.
|
|
41
|
+
* 5. `sessionId` is required: without one there is no session to address, so the button is
|
|
42
|
+
* disabled rather than posting something the host will reject.
|
|
43
|
+
*
|
|
44
|
+
* Pure: `h` and `say` are injected. Inlined into client.js by tools/build-client.mjs.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/** The text bound, mirroring the host's STEER_TEXT_LIMIT (src/route.js). */
|
|
48
|
+
var WF_INJECT_LIMIT = 2000;
|
|
49
|
+
|
|
50
|
+
/** How long "injected" / a failure line stays on screen (the copy receipt's rhythm). */
|
|
51
|
+
var WF_INJECT_ACK_MS = 1500;
|
|
52
|
+
|
|
53
|
+
/** The four states. Anything else is treated as idle: a corrupted record cannot wedge the UI. */
|
|
54
|
+
var WF_INJECT_STATES = ["idle", "sending", "sent", "failed"];
|
|
55
|
+
|
|
56
|
+
function wfInjectState(value) {
|
|
57
|
+
for (var i = 0; i < WF_INJECT_STATES.length; i += 1) {
|
|
58
|
+
if (value === WF_INJECT_STATES[i]) return value;
|
|
59
|
+
}
|
|
60
|
+
return "idle";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether a send is allowed right now. The ONE gate the button and the submit share, so the
|
|
65
|
+
* disabled attribute and the click handler can never disagree.
|
|
66
|
+
*/
|
|
67
|
+
function wfInjectCanSend(state, sessionId, text) {
|
|
68
|
+
if (wfInjectState(state) !== "idle") return false;
|
|
69
|
+
if (typeof sessionId !== "string" || sessionId === "") return false;
|
|
70
|
+
if (typeof text !== "string" || text.trim() === "") return false;
|
|
71
|
+
return text.length <= WF_INJECT_LIMIT;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Trimmed, bounded text — what would actually be sent. "" when nothing may be. */
|
|
75
|
+
function wfInjectText(value) {
|
|
76
|
+
if (typeof value !== "string") return "";
|
|
77
|
+
var trimmed = value.replace(/^\s+|\s+$/g, "");
|
|
78
|
+
return trimmed.length > WF_INJECT_LIMIT ? trimmed.slice(0, WF_INJECT_LIMIT) : trimmed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Turn the host's answer into the UI's outcome. `{ok:true}` → sent; anything else → failed with
|
|
83
|
+
* the host's own reason. A missing reason is replaced here (the UI must not print "undefined"),
|
|
84
|
+
* but a PRESENT one is passed through verbatim — that is rule 3.
|
|
85
|
+
*/
|
|
86
|
+
function wfInjectOutcome(response, say) {
|
|
87
|
+
var fire = typeof say === "function" ? say : function (key) {
|
|
88
|
+
return key;
|
|
89
|
+
};
|
|
90
|
+
if (response && response.ok === true) {
|
|
91
|
+
return { kind: "sent", text: fire("panel.inject.sent"), detail: "" };
|
|
92
|
+
}
|
|
93
|
+
var reason =
|
|
94
|
+
response && typeof response.error === "string" && response.error !== ""
|
|
95
|
+
? response.error
|
|
96
|
+
: fire("panel.inject.unknown");
|
|
97
|
+
return { kind: "failed", text: reason, detail: "" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The state after one acknowledged outcome: `sent`/`failed` clear back to idle, everything else
|
|
102
|
+
* is already idle. Pure, so the timer's only job is to call it.
|
|
103
|
+
*/
|
|
104
|
+
function wfInjectAckDone(state) {
|
|
105
|
+
var current = wfInjectState(state);
|
|
106
|
+
return current === "sent" || current === "failed" ? "idle" : current;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ------------------------------------------------------------------------- */
|
|
110
|
+
/* the revoke state machine */
|
|
111
|
+
/* ------------------------------------------------------------------------- */
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The four states of ONE revocation. `idle` = nothing in flight, `revoking` = the POST is out,
|
|
115
|
+
* `revoked` = the host said 200 and the row is waiting for the next poll to say "revoked",
|
|
116
|
+
* `failed` = the host refused and its own reason is on screen. `revoked` does NOT disappear by
|
|
117
|
+
* itself: it disappears when the folded row stops being `pending`, which is the host's own
|
|
118
|
+
* durable proof that the message really left the inbox.
|
|
119
|
+
*/
|
|
120
|
+
var WF_REVOKE_STATES = ["idle", "revoking", "revoked", "failed"];
|
|
121
|
+
|
|
122
|
+
function wfRevokeState(value) {
|
|
123
|
+
for (var i = 0; i < WF_REVOKE_STATES.length; i += 1) {
|
|
124
|
+
if (value === WF_REVOKE_STATES[i]) return value;
|
|
125
|
+
}
|
|
126
|
+
return "idle";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Turn the host's answer to `/unsteer` into the UI's outcome. The SAME honesty rule as the send:
|
|
131
|
+
* only `{ok:true}` is a revocation, and every failure prints the host's own reason verbatim — the
|
|
132
|
+
* one text this function may supply is the "the host gave no reason" fallback.
|
|
133
|
+
*/
|
|
134
|
+
function wfRevokeOutcome(response, say) {
|
|
135
|
+
var fire = typeof say === "function" ? say : function (key) {
|
|
136
|
+
return key;
|
|
137
|
+
};
|
|
138
|
+
if (response && response.ok === true) {
|
|
139
|
+
return { kind: "revoked", text: "" };
|
|
140
|
+
}
|
|
141
|
+
var reason =
|
|
142
|
+
response && typeof response.error === "string" && response.error !== ""
|
|
143
|
+
? response.error
|
|
144
|
+
: fire("panel.inject.revoke.failed");
|
|
145
|
+
return { kind: "failed", text: reason };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The receipt line: how many characters are left before the host would refuse. */
|
|
149
|
+
function wfInjectCounter(text, say) {
|
|
150
|
+
var fire = typeof say === "function" ? say : function (key) {
|
|
151
|
+
return key;
|
|
152
|
+
};
|
|
153
|
+
var used = typeof text === "string" ? text.length : 0;
|
|
154
|
+
var left = WF_INJECT_LIMIT - used;
|
|
155
|
+
if (left < 0) left = 0;
|
|
156
|
+
return fire("panel.inject.counter", { used: used, left: left, max: WF_INJECT_LIMIT });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** how many rows still wait for the turn — the header badge's only input. */
|
|
160
|
+
function wfPendingInjectCount(rows) {
|
|
161
|
+
if (!Array.isArray(rows)) return 0;
|
|
162
|
+
var n = 0;
|
|
163
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
164
|
+
if (rows[i] && rows[i].status === "pending") n += 1;
|
|
165
|
+
}
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One mapping for the three status words, shared by the history rows and the persistent dock. */
|
|
170
|
+
function wfInjectStatusKey(status) {
|
|
171
|
+
return status === "picked"
|
|
172
|
+
? "panel.inject.row.picked"
|
|
173
|
+
: status === "canceled"
|
|
174
|
+
? "panel.inject.row.canceled"
|
|
175
|
+
: "panel.inject.row.pending";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ------------------------------------------------------------------------- */
|
|
179
|
+
/* the request */
|
|
180
|
+
/* ------------------------------------------------------------------------- */
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* One `POST /dsh-workflow/steer`. Never throws: every outcome is a value, so the caller's
|
|
184
|
+
* `.then` can always decide what to paint.
|
|
185
|
+
*
|
|
186
|
+
* @param {Function} fetchImpl - `window.fetch` (injected so Node can drive this)
|
|
187
|
+
* @param {string} url
|
|
188
|
+
* @param {string} sessionId
|
|
189
|
+
* @param {string} text
|
|
190
|
+
* @returns {Promise<{status:number, payload:object|null, error:string}>}
|
|
191
|
+
*/
|
|
192
|
+
function wfPostSteer(fetchImpl, url, sessionId, text, signal) {
|
|
193
|
+
return wfPostJson(fetchImpl, url, { sessionId: sessionId, text: text }, signal);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* One `POST /dsh-workflow/unsteer` — the SAME transport, one different body field.
|
|
198
|
+
*
|
|
199
|
+
* This is the round trip the history row's "revoke" button makes. It answers 200 only when the
|
|
200
|
+
* message was still in the inbox; 409 means the running turn already took it (or it never was
|
|
201
|
+
* there), and that reason is printed verbatim in the row.
|
|
202
|
+
*
|
|
203
|
+
* @param {Function} fetchImpl - `window.fetch`
|
|
204
|
+
* @param {string} url
|
|
205
|
+
* @param {string} sessionId
|
|
206
|
+
* @param {string} messageId - the id the host returned from `/steer`
|
|
207
|
+
* @returns {Promise<{status:number, payload:object|null, error:string}>}
|
|
208
|
+
*/
|
|
209
|
+
function wfPostUnsteer(fetchImpl, url, sessionId, messageId) {
|
|
210
|
+
return wfPostJson(fetchImpl, url, { sessionId: sessionId, messageId: messageId });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The one POST implementation both writes share: body in, `{status, payload, error}` out. */
|
|
214
|
+
function wfPostJson(fetchImpl, url, payload, signal) {
|
|
215
|
+
var body = JSON.stringify(payload);
|
|
216
|
+
return Promise.resolve()
|
|
217
|
+
.then(function () {
|
|
218
|
+
if (typeof fetchImpl !== "function") throw new Error("fetch unavailable");
|
|
219
|
+
// Cancellable before send: while the request is in flight the user can press cancel and the fetch aborts immediately.
|
|
220
|
+
// This only stops requests that are STILL IN FLIGHT; once the host has returned 200 (the message is in the inbox),
|
|
221
|
+
// pulling it back relies on `POST /dsh-workflow/unsteer` (host inbox revoke) instead of aborting the request.
|
|
222
|
+
return fetchImpl(url, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
cache: "no-store",
|
|
225
|
+
headers: { "content-type": "application/json" },
|
|
226
|
+
body: body,
|
|
227
|
+
signal: signal,
|
|
228
|
+
});
|
|
229
|
+
})
|
|
230
|
+
.then(function (response) {
|
|
231
|
+
var status = response && wfFinite(response.status) !== null ? response.status : 0;
|
|
232
|
+
if (!response || response.ok !== true) {
|
|
233
|
+
// The host answers JSON on every failure branch, so read it: its `error` is the reason
|
|
234
|
+
// the panel must print verbatim (rule 3).
|
|
235
|
+
return Promise.resolve()
|
|
236
|
+
.then(function () {
|
|
237
|
+
return response && typeof response.json === "function" ? response.json() : null;
|
|
238
|
+
})
|
|
239
|
+
.catch(function () {
|
|
240
|
+
return null;
|
|
241
|
+
})
|
|
242
|
+
.then(function (payload) {
|
|
243
|
+
var reason =
|
|
244
|
+
payload && typeof payload.error === "string" && payload.error !== ""
|
|
245
|
+
? payload.error
|
|
246
|
+
: status === 0
|
|
247
|
+
? "network error"
|
|
248
|
+
: "HTTP " + status;
|
|
249
|
+
return { status: status, payload: payload, error: reason };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return Promise.resolve()
|
|
253
|
+
.then(function () {
|
|
254
|
+
return typeof response.json === "function" ? response.json() : null;
|
|
255
|
+
})
|
|
256
|
+
.catch(function () {
|
|
257
|
+
return null;
|
|
258
|
+
})
|
|
259
|
+
.then(function (payload) {
|
|
260
|
+
if (!payload || payload.ok !== true) {
|
|
261
|
+
return {
|
|
262
|
+
status: status,
|
|
263
|
+
payload: payload,
|
|
264
|
+
error: payload && payload.error ? payload.error : "bad payload",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
return { status: 200, payload: payload, error: "" };
|
|
268
|
+
});
|
|
269
|
+
})
|
|
270
|
+
.catch(function (error) {
|
|
271
|
+
return { status: 0, payload: null, error: (error && error.message) || String(error) };
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* ------------------------------------------------------------------------- */
|
|
276
|
+
/* the renderer */
|
|
277
|
+
/* ------------------------------------------------------------------------- */
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The right-hand column. `extras` carries
|
|
281
|
+
* {sessionId, state, text, reason, history, revokeId, revokeState, revokeReason}.
|
|
282
|
+
*
|
|
283
|
+
* Nothing here is mounted when the panel is collapsed: the caller decides.
|
|
284
|
+
*/
|
|
285
|
+
function wfRenderHotInject(h, say, actions, extras) {
|
|
286
|
+
var fire = actions || {};
|
|
287
|
+
var ui = extras || {};
|
|
288
|
+
var sessionId = typeof ui.sessionId === "string" ? ui.sessionId : "";
|
|
289
|
+
var state = wfInjectState(ui.state);
|
|
290
|
+
var text = typeof ui.text === "string" ? ui.text : "";
|
|
291
|
+
var reason = typeof ui.reason === "string" ? ui.reason : "";
|
|
292
|
+
// the ONE revocation currently on screen. `revokeId` names the history row it
|
|
293
|
+
// belongs to, so a failure line lands under ITS row and never under a neighbour's.
|
|
294
|
+
var revokeId = typeof ui.revokeId === "string" ? ui.revokeId : "";
|
|
295
|
+
var revokeState = wfRevokeState(ui.revokeState);
|
|
296
|
+
var revokeReason = typeof ui.revokeReason === "string" ? ui.revokeReason : "";
|
|
297
|
+
var sending = state === "sending";
|
|
298
|
+
var canSend = wfInjectCanSend(state, sessionId, text);
|
|
299
|
+
|
|
300
|
+
var note =
|
|
301
|
+
state === "sent"
|
|
302
|
+
? say("panel.inject.sent")
|
|
303
|
+
: state === "failed"
|
|
304
|
+
? reason === ""
|
|
305
|
+
? say("panel.inject.unknown")
|
|
306
|
+
: reason
|
|
307
|
+
: "";
|
|
308
|
+
|
|
309
|
+
// the HISTORY rows (newest last in the model, drawn newest first so the latest send is
|
|
310
|
+
// the one the user sees without scrolling). Purely derived from the fold: nothing here talks
|
|
311
|
+
// to the host, and an empty list renders the honest "none yet" line instead of a blank block.
|
|
312
|
+
var history = [];
|
|
313
|
+
if (Array.isArray(ui.history)) history = ui.history.slice().reverse();
|
|
314
|
+
|
|
315
|
+
return h(
|
|
316
|
+
"div",
|
|
317
|
+
{
|
|
318
|
+
className: "wf-inject",
|
|
319
|
+
key: "inject",
|
|
320
|
+
"data-dsh-workflow-map": "inject",
|
|
321
|
+
"data-state": state,
|
|
322
|
+
},
|
|
323
|
+
h(
|
|
324
|
+
"div",
|
|
325
|
+
{ className: "wf-inject-head", key: "head" },
|
|
326
|
+
h("span", { className: "wf-inject-title", key: "title" }, say("panel.inject.title")),
|
|
327
|
+
h(
|
|
328
|
+
"button",
|
|
329
|
+
{
|
|
330
|
+
type: "button",
|
|
331
|
+
key: "close",
|
|
332
|
+
className: "wf-link wf-inject-close",
|
|
333
|
+
"data-inject": "cancel",
|
|
334
|
+
title: say("panel.inject.cancel"),
|
|
335
|
+
"aria-label": say("panel.inject.cancel"),
|
|
336
|
+
onClick: function () {
|
|
337
|
+
wfFire(fire.onInjectCancel);
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
say("panel.inject.cancel")
|
|
341
|
+
)
|
|
342
|
+
),
|
|
343
|
+
h(
|
|
344
|
+
"p",
|
|
345
|
+
{ className: "wf-inject-hint", key: "hint" },
|
|
346
|
+
say("panel.inject.hint")
|
|
347
|
+
),
|
|
348
|
+
h("textarea", {
|
|
349
|
+
key: "area",
|
|
350
|
+
className: "wf-inject-area",
|
|
351
|
+
"data-dsh-workflow-map": "inject-text",
|
|
352
|
+
rows: 6,
|
|
353
|
+
value: text,
|
|
354
|
+
placeholder: say("panel.inject.placeholder"),
|
|
355
|
+
"aria-label": say("panel.inject.title"),
|
|
356
|
+
disabled: sending,
|
|
357
|
+
onChange: function (event) {
|
|
358
|
+
var value = event && event.target ? event.target.value : "";
|
|
359
|
+
wfFire(fire.onInjectText, value);
|
|
360
|
+
},
|
|
361
|
+
}),
|
|
362
|
+
h(
|
|
363
|
+
"div",
|
|
364
|
+
{ className: "wf-inject-foot", key: "foot" },
|
|
365
|
+
h("span", { className: "wf-inject-count", key: "count" }, wfInjectCounter(text, say)),
|
|
366
|
+
h(
|
|
367
|
+
"button",
|
|
368
|
+
{
|
|
369
|
+
type: "button",
|
|
370
|
+
key: "send",
|
|
371
|
+
className: "wf-link wf-inject-send",
|
|
372
|
+
"data-inject": "send",
|
|
373
|
+
"data-ready": canSend ? "true" : "false",
|
|
374
|
+
disabled: canSend ? undefined : true,
|
|
375
|
+
title: say("panel.inject.send"),
|
|
376
|
+
onClick: function () {
|
|
377
|
+
wfFire(fire.onInjectSend, wfInjectText(text));
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
sending ? say("panel.inject.sending") : say("panel.inject.send")
|
|
381
|
+
),
|
|
382
|
+
// While sending, show "cancel": abort the in-flight request (fetch signal).
|
|
383
|
+
// After a 200 (message already in the inbox) this button disappears, because it can no longer be pulled back.
|
|
384
|
+
sending
|
|
385
|
+
? h(
|
|
386
|
+
"button",
|
|
387
|
+
{
|
|
388
|
+
type: "button",
|
|
389
|
+
key: "abort",
|
|
390
|
+
className: "wf-link wf-inject-abort",
|
|
391
|
+
"data-inject": "abort",
|
|
392
|
+
title: say("panel.inject.abort"),
|
|
393
|
+
onClick: function () {
|
|
394
|
+
wfFire(fire.onInjectAbort);
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
say("panel.inject.abort"),
|
|
398
|
+
)
|
|
399
|
+
: null
|
|
400
|
+
),
|
|
401
|
+
// The acknowledgement: one line, present only while the state is sent/failed. It carries
|
|
402
|
+
// `aria-live` so a screen reader hears the host's own reason, not just a colour change.
|
|
403
|
+
note === ""
|
|
404
|
+
? null
|
|
405
|
+
: h(
|
|
406
|
+
"div",
|
|
407
|
+
{
|
|
408
|
+
className: "wf-inject-note",
|
|
409
|
+
key: "note",
|
|
410
|
+
"data-kind": state === "sent" ? "sent" : "failed",
|
|
411
|
+
role: "status",
|
|
412
|
+
"aria-live": "polite",
|
|
413
|
+
},
|
|
414
|
+
note
|
|
415
|
+
),
|
|
416
|
+
// the history block. It sits UNDER the acknowledgement so the composer's shape does
|
|
417
|
+
// not change; the newest row is first, and each row states the one thing the user asked to
|
|
418
|
+
// know: was this one taken by the running turn, or is it still waiting.
|
|
419
|
+
h(
|
|
420
|
+
"div",
|
|
421
|
+
{ className: "wf-inject-history", key: "history", "data-dsh-workflow-map": "inject-history", "data-count": history.length },
|
|
422
|
+
h("div", { className: "wf-inject-history-label", key: "label" }, say("panel.inject.history")),
|
|
423
|
+
history.length === 0
|
|
424
|
+
? h("div", { className: "wf-inject-history-empty", key: "empty" }, say("panel.inject.history.empty"))
|
|
425
|
+
: h(
|
|
426
|
+
"ul",
|
|
427
|
+
{ className: "wf-inject-list", key: "list", role: "list" },
|
|
428
|
+
history.map(function (row) {
|
|
429
|
+
// Three states: picked up / awaiting pickup / revoked. The third comes only from the host's own log event
|
|
430
|
+
// (`agent/inbox/spliced` with `outcome: 'canceled'`, the durable record of a successful revoke).
|
|
431
|
+
var status = wfInjectStatusKey(row.status);
|
|
432
|
+
// "revoke" exists for exactly one window: a row the turn has NOT
|
|
433
|
+
// claimed yet. A `picked` row cannot be un-read, and a `canceled` row has nothing
|
|
434
|
+
// left to take back, so neither renders the button (not a disabled one — none).
|
|
435
|
+
var pending = row.status === "pending";
|
|
436
|
+
var mine = pending && revokeId === row.id;
|
|
437
|
+
var inFlight = mine && (revokeState === "revoking" || revokeState === "revoked");
|
|
438
|
+
return h(
|
|
439
|
+
"li",
|
|
440
|
+
{
|
|
441
|
+
className: "wf-inject-row",
|
|
442
|
+
key: "row-" + row.id,
|
|
443
|
+
"data-dsh-workflow-map": "inject-row",
|
|
444
|
+
"data-status": row.status,
|
|
445
|
+
},
|
|
446
|
+
h(
|
|
447
|
+
"span",
|
|
448
|
+
{ className: "wf-inject-row-status", key: "status", "data-status": row.status },
|
|
449
|
+
say(status)
|
|
450
|
+
),
|
|
451
|
+
h("span", { className: "wf-inject-row-text", key: "text" }, row.text || "—"),
|
|
452
|
+
h(
|
|
453
|
+
"span",
|
|
454
|
+
{ className: "wf-inject-row-time", key: "time" },
|
|
455
|
+
row.status === "picked" && row.pickedUpAt !== null ? wfFormatClock(row.pickedUpAt) : wfFormatClock(row.at)
|
|
456
|
+
),
|
|
457
|
+
pending
|
|
458
|
+
? h(
|
|
459
|
+
"button",
|
|
460
|
+
{
|
|
461
|
+
type: "button",
|
|
462
|
+
key: "revoke",
|
|
463
|
+
className: "wf-link wf-inject-row-revoke",
|
|
464
|
+
"data-inject": "revoke",
|
|
465
|
+
"data-inject-id": row.id,
|
|
466
|
+
// Two clicks must not become two posts: the button is dead from the moment
|
|
467
|
+
// the request leaves until the row stops being `pending`.
|
|
468
|
+
disabled: inFlight ? true : undefined,
|
|
469
|
+
title: say("panel.inject.revoke.hint"),
|
|
470
|
+
"aria-label": say("panel.inject.revoke"),
|
|
471
|
+
onClick: function () {
|
|
472
|
+
wfFire(fire.onInjectRevoke, row.id);
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
inFlight ? say("panel.inject.revoking") : say("panel.inject.revoke")
|
|
476
|
+
)
|
|
477
|
+
: null,
|
|
478
|
+
// The host's own words, verbatim, under the row that failed (rule 3). The stylesheet
|
|
479
|
+
// makes it span the row so it never reads as a fourth column.
|
|
480
|
+
mine && revokeState === "failed"
|
|
481
|
+
? h(
|
|
482
|
+
"span",
|
|
483
|
+
{
|
|
484
|
+
className: "wf-inject-row-error",
|
|
485
|
+
key: "revoke-error",
|
|
486
|
+
"data-inject": "revoke-error",
|
|
487
|
+
role: "status",
|
|
488
|
+
"aria-live": "polite",
|
|
489
|
+
},
|
|
490
|
+
revokeReason === "" ? say("panel.inject.revoke.failed") : revokeReason
|
|
491
|
+
)
|
|
492
|
+
: null
|
|
493
|
+
);
|
|
494
|
+
})
|
|
495
|
+
),
|
|
496
|
+
),
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* the PERSISTENT dock: one row at the right page's foot, rendered for
|
|
502
|
+
* every page kind that is NOT the composer, so a prompt can be typed without opening it first.
|
|
503
|
+
*
|
|
504
|
+
* It shares ONE piece of state with the full composer (`inject.text`): both render the same
|
|
505
|
+
* `ui.text` and both fire the same `onInjectText`, so switching between the two forms never drops
|
|
506
|
+
* a draft. The send gate is the same `wfInjectCanSend`, and the acknowledgement is the host's own
|
|
507
|
+
* words or nothing — the dock never invents a success, and never updates optimistically.
|
|
508
|
+
*
|
|
509
|
+
* `extras` = {sessionId, state, text, reason, history} — the same shape the composer takes.
|
|
510
|
+
*/
|
|
511
|
+
function wfRenderInjectDock(h, say, actions, extras) {
|
|
512
|
+
var fire = actions || {};
|
|
513
|
+
var ui = extras || {};
|
|
514
|
+
var sessionId = typeof ui.sessionId === "string" ? ui.sessionId : "";
|
|
515
|
+
var state = wfInjectState(ui.state);
|
|
516
|
+
var text = typeof ui.text === "string" ? ui.text : "";
|
|
517
|
+
var reason = typeof ui.reason === "string" ? ui.reason : "";
|
|
518
|
+
var sending = state === "sending";
|
|
519
|
+
var canSend = wfInjectCanSend(state, sessionId, text);
|
|
520
|
+
var history = Array.isArray(ui.history) ? ui.history : [];
|
|
521
|
+
// The model keeps the newest LAST (wfInjectRows), so the newest row is the last element.
|
|
522
|
+
var latest = history.length > 0 ? history[history.length - 1] : null;
|
|
523
|
+
|
|
524
|
+
var note =
|
|
525
|
+
state === "sent"
|
|
526
|
+
? say("panel.inject.sent")
|
|
527
|
+
: state === "failed"
|
|
528
|
+
? reason === ""
|
|
529
|
+
? say("panel.inject.unknown")
|
|
530
|
+
: reason
|
|
531
|
+
: "";
|
|
532
|
+
|
|
533
|
+
function send() {
|
|
534
|
+
wfFire(fire.onInjectSend, wfInjectText(text));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return h(
|
|
538
|
+
"div",
|
|
539
|
+
{
|
|
540
|
+
className: "wf-inject-dock",
|
|
541
|
+
key: "inject-dock",
|
|
542
|
+
"data-dsh-workflow-map": "inject-dock",
|
|
543
|
+
"data-state": state,
|
|
544
|
+
},
|
|
545
|
+
h(
|
|
546
|
+
"div",
|
|
547
|
+
{ className: "wf-inject-dock-row", key: "row" },
|
|
548
|
+
h(
|
|
549
|
+
"button",
|
|
550
|
+
{
|
|
551
|
+
type: "button",
|
|
552
|
+
key: "open",
|
|
553
|
+
className: "wf-inject-dock-label",
|
|
554
|
+
"data-inject": "dock-open",
|
|
555
|
+
title: say("panel.inject.open"),
|
|
556
|
+
"aria-label": say("panel.inject.open"),
|
|
557
|
+
onClick: function () {
|
|
558
|
+
wfFire(fire.onToggleInject);
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
say("panel.inject.banner")
|
|
562
|
+
),
|
|
563
|
+
h("input", {
|
|
564
|
+
type: "text",
|
|
565
|
+
key: "text",
|
|
566
|
+
className: "wf-inject-dock-input",
|
|
567
|
+
"data-dsh-workflow-map": "inject-dock-text",
|
|
568
|
+
value: text,
|
|
569
|
+
placeholder: say("panel.inject.placeholder"),
|
|
570
|
+
"aria-label": say("panel.inject.title"),
|
|
571
|
+
disabled: sending,
|
|
572
|
+
onChange: function (event) {
|
|
573
|
+
var value = event && event.target ? event.target.value : "";
|
|
574
|
+
wfFire(fire.onInjectText, value);
|
|
575
|
+
},
|
|
576
|
+
// Enter is the dock's whole point: one line, no reaching for the button. The gate is the
|
|
577
|
+
// SAME wfInjectCanSend the full composer uses, so an empty / over-long / session-less text
|
|
578
|
+
// simply does nothing instead of posting something the host would reject.
|
|
579
|
+
onKeyDown: function (event) {
|
|
580
|
+
if (!event || event.key !== "Enter") return;
|
|
581
|
+
if (typeof event.preventDefault === "function") event.preventDefault();
|
|
582
|
+
if (canSend) send();
|
|
583
|
+
},
|
|
584
|
+
}),
|
|
585
|
+
h(
|
|
586
|
+
"button",
|
|
587
|
+
{
|
|
588
|
+
type: "button",
|
|
589
|
+
key: "send",
|
|
590
|
+
className: "wf-link wf-inject-dock-send",
|
|
591
|
+
"data-inject": "dock-send",
|
|
592
|
+
"data-ready": canSend ? "true" : "false",
|
|
593
|
+
disabled: canSend ? undefined : true,
|
|
594
|
+
title: say("panel.inject.send"),
|
|
595
|
+
onClick: send,
|
|
596
|
+
},
|
|
597
|
+
sending ? say("panel.inject.sending") : say("panel.inject.send")
|
|
598
|
+
),
|
|
599
|
+
latest
|
|
600
|
+
? h(
|
|
601
|
+
"span",
|
|
602
|
+
{
|
|
603
|
+
className: "wf-inject-dock-state",
|
|
604
|
+
key: "state",
|
|
605
|
+
"data-status": latest.status,
|
|
606
|
+
title: say(wfInjectStatusKey(latest.status)),
|
|
607
|
+
},
|
|
608
|
+
say(wfInjectStatusKey(latest.status))
|
|
609
|
+
)
|
|
610
|
+
: null
|
|
611
|
+
),
|
|
612
|
+
// The acknowledgement, verbatim: the host's own reason on failure, "injected" only on a real
|
|
613
|
+
// {ok:true}. It sits inside the bar, under the row, so a 409 is never silent.
|
|
614
|
+
note === ""
|
|
615
|
+
? null
|
|
616
|
+
: h(
|
|
617
|
+
"div",
|
|
618
|
+
{
|
|
619
|
+
className: "wf-inject-dock-note",
|
|
620
|
+
key: "note",
|
|
621
|
+
"data-kind": state === "sent" ? "sent" : "failed",
|
|
622
|
+
role: "status",
|
|
623
|
+
"aria-live": "polite",
|
|
624
|
+
},
|
|
625
|
+
note
|
|
626
|
+
)
|
|
627
|
+
);
|
|
628
|
+
}
|