@fractalsynapse/dsh-chapters 0.0.0-stage → 0.2.21
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/LICENSE +14 -0
- package/README.md +76 -2
- package/cordis.patch.yml +363 -0
- package/docs/architecture.md +692 -0
- package/docs/autorun-guide.md +248 -0
- package/docs/backlog.md +2022 -0
- package/docs/contract.md +938 -0
- package/docs/development.md +990 -0
- package/docs/host-compaction-seam.md +225 -0
- package/docs/host-protected-head-ask.md +80 -0
- package/docs/knowledge-repo.md +1332 -0
- package/docs/plans-carriage.md +571 -0
- package/docs/project-state.md +642 -0
- package/docs/provider.md +98 -0
- package/docs/superpowers/plans/2026-10-17-chapters-save.md +198 -0
- package/docs/superpowers/plans/2026-11-02-chapter-removal.md +151 -0
- package/docs/superpowers/specs/2026-11-02-chapter-removal-design.md +115 -0
- package/docs/verify.md +561 -0
- package/lib/archive.d.ts +161 -0
- package/lib/archive.js +229 -0
- package/lib/archive.js.map +1 -0
- package/lib/arrival.d.ts +23 -0
- package/lib/arrival.js +125 -0
- package/lib/arrival.js.map +1 -0
- package/lib/artifact-query.d.ts +62 -0
- package/lib/artifact-query.js +187 -0
- package/lib/artifact-query.js.map +1 -0
- package/lib/attach.d.ts +46 -0
- package/lib/attach.js +120 -0
- package/lib/attach.js.map +1 -0
- package/lib/auto-save.d.ts +145 -0
- package/lib/auto-save.js +334 -0
- package/lib/auto-save.js.map +1 -0
- package/lib/aux-message.d.ts +17 -0
- package/lib/aux-message.js +20 -0
- package/lib/aux-message.js.map +1 -0
- package/lib/client.js +1419 -0
- package/lib/client.js.map +1 -0
- package/lib/codegraph.d.ts +76 -0
- package/lib/codegraph.js +190 -0
- package/lib/codegraph.js.map +1 -0
- package/lib/commands.d.ts +52 -0
- package/lib/commands.js +946 -0
- package/lib/commands.js.map +1 -0
- package/lib/compose.d.ts +30 -0
- package/lib/compose.js +185 -0
- package/lib/compose.js.map +1 -0
- package/lib/continue-core.d.ts +245 -0
- package/lib/continue-core.js +405 -0
- package/lib/continue-core.js.map +1 -0
- package/lib/diag.d.ts +55 -0
- package/lib/diag.js +186 -0
- package/lib/diag.js.map +1 -0
- package/lib/engine-core.d.ts +301 -0
- package/lib/engine-core.js +531 -0
- package/lib/engine-core.js.map +1 -0
- package/lib/engine-note.d.ts +35 -0
- package/lib/engine-note.js +32 -0
- package/lib/engine-note.js.map +1 -0
- package/lib/engine.d.ts +329 -0
- package/lib/engine.js +1179 -0
- package/lib/engine.js.map +1 -0
- package/lib/enrich-queue.d.ts +66 -0
- package/lib/enrich-queue.js +126 -0
- package/lib/enrich-queue.js.map +1 -0
- package/lib/enrich-store.d.ts +31 -0
- package/lib/enrich-store.js +103 -0
- package/lib/enrich-store.js.map +1 -0
- package/lib/enrich-wire.d.ts +163 -0
- package/lib/enrich-wire.js +353 -0
- package/lib/enrich-wire.js.map +1 -0
- package/lib/enrich.d.ts +102 -0
- package/lib/enrich.js +267 -0
- package/lib/enrich.js.map +1 -0
- package/lib/gitops.d.ts +107 -0
- package/lib/gitops.js +539 -0
- package/lib/gitops.js.map +1 -0
- package/lib/index.d.ts +241 -0
- package/lib/index.js +1162 -0
- package/lib/index.js.map +1 -0
- package/lib/indexing.d.ts +98 -0
- package/lib/indexing.js +223 -0
- package/lib/indexing.js.map +1 -0
- package/lib/injections.d.ts +90 -0
- package/lib/injections.js +165 -0
- package/lib/injections.js.map +1 -0
- package/lib/last-resort.d.ts +24 -0
- package/lib/last-resort.js +27 -0
- package/lib/last-resort.js.map +1 -0
- package/lib/log-shape.d.ts +38 -0
- package/lib/log-shape.js +93 -0
- package/lib/log-shape.js.map +1 -0
- package/lib/models-command.d.ts +74 -0
- package/lib/models-command.js +100 -0
- package/lib/models-command.js.map +1 -0
- package/lib/notice.d.ts +74 -0
- package/lib/notice.js +92 -0
- package/lib/notice.js.map +1 -0
- package/lib/plans.d.ts +154 -0
- package/lib/plans.js +315 -0
- package/lib/plans.js.map +1 -0
- package/lib/preset.d.ts +186 -0
- package/lib/preset.js +212 -0
- package/lib/preset.js.map +1 -0
- package/lib/prompt-keys.d.ts +7 -0
- package/lib/prompt-keys.js +8 -0
- package/lib/prompt-keys.js.map +1 -0
- package/lib/prompt-loop.d.ts +73 -0
- package/lib/prompt-loop.js +147 -0
- package/lib/prompt-loop.js.map +1 -0
- package/lib/prompt-push.d.ts +47 -0
- package/lib/prompt-push.js +115 -0
- package/lib/prompt-push.js.map +1 -0
- package/lib/prompt-runner.d.ts +48 -0
- package/lib/prompt-runner.js +90 -0
- package/lib/prompt-runner.js.map +1 -0
- package/lib/prompt-state.d.ts +142 -0
- package/lib/prompt-state.js +101 -0
- package/lib/prompt-state.js.map +1 -0
- package/lib/prompt.d.ts +270 -0
- package/lib/prompt.js +408 -0
- package/lib/prompt.js.map +1 -0
- package/lib/provider.d.ts +44 -0
- package/lib/provider.js +66 -0
- package/lib/provider.js.map +1 -0
- package/lib/redact.d.ts +24 -0
- package/lib/redact.js +85 -0
- package/lib/redact.js.map +1 -0
- package/lib/registry.d.ts +187 -0
- package/lib/registry.js +235 -0
- package/lib/registry.js.map +1 -0
- package/lib/removal.d.ts +159 -0
- package/lib/removal.js +556 -0
- package/lib/removal.js.map +1 -0
- package/lib/render.d.ts +97 -0
- package/lib/render.js +530 -0
- package/lib/render.js.map +1 -0
- package/lib/repair-run.d.ts +76 -0
- package/lib/repair-run.js +506 -0
- package/lib/repair-run.js.map +1 -0
- package/lib/repair.d.ts +208 -0
- package/lib/repair.js +357 -0
- package/lib/repair.js.map +1 -0
- package/lib/repo.d.ts +78 -0
- package/lib/repo.js +184 -0
- package/lib/repo.js.map +1 -0
- package/lib/rules-commands.d.ts +46 -0
- package/lib/rules-commands.js +379 -0
- package/lib/rules-commands.js.map +1 -0
- package/lib/rules.d.ts +267 -0
- package/lib/rules.js +478 -0
- package/lib/rules.js.map +1 -0
- package/lib/save-core.d.ts +72 -0
- package/lib/save-core.js +220 -0
- package/lib/save-core.js.map +1 -0
- package/lib/search.d.ts +112 -0
- package/lib/search.js +282 -0
- package/lib/search.js.map +1 -0
- package/lib/session-rules.d.ts +222 -0
- package/lib/session-rules.js +454 -0
- package/lib/session-rules.js.map +1 -0
- package/lib/signature.d.ts +45 -0
- package/lib/signature.js +199 -0
- package/lib/signature.js.map +1 -0
- package/lib/store.d.ts +156 -0
- package/lib/store.js +368 -0
- package/lib/store.js.map +1 -0
- package/lib/sync.d.ts +220 -0
- package/lib/sync.js +810 -0
- package/lib/sync.js.map +1 -0
- package/lib/tools.d.ts +113 -0
- package/lib/tools.js +1090 -0
- package/lib/tools.js.map +1 -0
- package/lib/treedx/client.d.ts +57 -0
- package/lib/treedx/client.js +82 -0
- package/lib/treedx/client.js.map +1 -0
- package/lib/treedx/provider.d.ts +39 -0
- package/lib/treedx/provider.js +479 -0
- package/lib/treedx/provider.js.map +1 -0
- package/lib/treedx/state.d.ts +27 -0
- package/lib/treedx/state.js +65 -0
- package/lib/treedx/state.js.map +1 -0
- package/lib/types.d.ts +210 -0
- package/lib/types.js +31 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +9 -0
- package/lib/version.js +10 -0
- package/lib/version.js.map +1 -0
- package/lib/vocabulary.d.ts +41 -0
- package/lib/vocabulary.js +118 -0
- package/lib/vocabulary.js.map +1 -0
- package/lib/warnings.d.ts +10 -0
- package/lib/warnings.js +18 -0
- package/lib/warnings.js.map +1 -0
- package/package.json +151 -3
package/lib/client.js
ADDED
|
@@ -0,0 +1,1419 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@fractalsynapse/dsh-chapters",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
// src/client/tooltip.ts
|
|
9
|
+
/**
|
|
10
|
+
* The host's Tooltip seat, resolved OPTIONALLY (#194-compat — measured on 0.2.1-rc.5, not guessed):
|
|
11
|
+
*
|
|
12
|
+
* `@deepseek-ai/dsh-client-ui-primitives` exists in the 0.2.0-rc.2 host tree and is ABSENT from the
|
|
13
|
+
* entire 0.2.1-rc.5 install tree — no successor package exports `Tooltip`. Our bundle's static
|
|
14
|
+
* import becomes a LAZY require in the built CJS: the bundle loads and components run, but the
|
|
15
|
+
* first render that touches `Tooltip` throws, and React's error boundary swallows the subtree
|
|
16
|
+
* silently. Measured symptoms on 0.2.1: door registration fully healthy (inject returned a
|
|
17
|
+
* disposer; the factory ran; both register effects are functions) yet the assistant-actions region
|
|
18
|
+
* renders ZERO entries, and the dock renders no row — because both wrap their buttons in Tooltip.
|
|
19
|
+
*
|
|
20
|
+
* The fix keeps one resolution point and one fallback (#59 — never per-component try/catch):
|
|
21
|
+
* probe the module once; when it is absent, buttons carry the platform's own `title` tooltip, so
|
|
22
|
+
* hover text survives on every host and the fancy tooltip survives where it exists.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* The tooltip recipe, field for field the goal bar's own (GoalBar.tsx:101-142: `portal`,
|
|
26
|
+
* `side="bottom"`, `delayMs={500}`) — the shipped occupant of the same dock strip, whose tooltips
|
|
27
|
+
* are NOT covered by the composer. `portal` is the load-bearing one: it renders the popup at
|
|
28
|
+
* document level instead of inside our dock's stacking context, which is exactly the maintainer's
|
|
29
|
+
* report (#210: "the hoverover tooltips are covered by the main chat input container"). Our old
|
|
30
|
+
* compile-time types rejected portal/delayMs at the overload; the runtime host always accepted
|
|
31
|
+
* them, and since the seat is typed by us, the recipe is pinned by a row instead of by luck.
|
|
32
|
+
*/
|
|
33
|
+
function tooltipPropsFor(label) {
|
|
34
|
+
return {
|
|
35
|
+
label,
|
|
36
|
+
side: "bottom",
|
|
37
|
+
portal: true,
|
|
38
|
+
delayMs: 500
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
let resolved;
|
|
42
|
+
function resolveTooltip() {
|
|
43
|
+
if (resolved !== void 0) return resolved;
|
|
44
|
+
resolved = null;
|
|
45
|
+
try {
|
|
46
|
+
if (typeof require === "function") {
|
|
47
|
+
const T = require("@deepseek-ai/dsh-client-ui-primitives")?.Tooltip;
|
|
48
|
+
if (typeof T === "function" || typeof T === "object") resolved = T;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
resolved = null;
|
|
52
|
+
}
|
|
53
|
+
return resolved;
|
|
54
|
+
}
|
|
55
|
+
/** Wrap in the host Tooltip when it exists; otherwise return the child (which must carry `title`). */
|
|
56
|
+
function withTooltip(label, child) {
|
|
57
|
+
const T = resolveTooltip();
|
|
58
|
+
return T !== null ? (0, react.createElement)(T, {
|
|
59
|
+
...tooltipPropsFor(label),
|
|
60
|
+
children: child
|
|
61
|
+
}) : child;
|
|
62
|
+
}
|
|
63
|
+
/** The native-tooltip attribute, present ONLY when the host seat is absent — no double tooltips. */
|
|
64
|
+
function nativeTitle(label) {
|
|
65
|
+
return resolveTooltip() === null ? { title: label } : {};
|
|
66
|
+
}
|
|
67
|
+
// src/prompt-keys.ts
|
|
68
|
+
/**
|
|
69
|
+
* Identifiers shared by BOTH planes of the push transport (#194). One module, one spelling: the
|
|
70
|
+
* server registers the projection under this key and the client reads `useProjection(key)` — two
|
|
71
|
+
* literals in two planes would be exactly the drift #59 forbids. No node imports (the client
|
|
72
|
+
* compiles this), no DOM (the server imports this).
|
|
73
|
+
*/
|
|
74
|
+
const PROMPT_PROJECTION_KEY = "chaptersPrompt";
|
|
75
|
+
// src/client/prompt-bar.ts
|
|
76
|
+
function initialBarState(autorun = "disarmed") {
|
|
77
|
+
return {
|
|
78
|
+
status: "off",
|
|
79
|
+
text: null,
|
|
80
|
+
turnSeq: null,
|
|
81
|
+
error: null,
|
|
82
|
+
note: "",
|
|
83
|
+
intent: "worker-step",
|
|
84
|
+
goalPhase: "none",
|
|
85
|
+
goal: null,
|
|
86
|
+
autorun,
|
|
87
|
+
expanded: false,
|
|
88
|
+
running: false,
|
|
89
|
+
hiddenTurnSeq: null
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function promptBarReducer(state, ev) {
|
|
93
|
+
if (ev.t === "note") return {
|
|
94
|
+
...state,
|
|
95
|
+
note: ev.note
|
|
96
|
+
};
|
|
97
|
+
const next = baseBarReducer(state, ev);
|
|
98
|
+
return ev.t === "polled" && (next.turnSeq !== state.turnSeq || next.text !== state.text) ? {
|
|
99
|
+
...next,
|
|
100
|
+
note: ""
|
|
101
|
+
} : next;
|
|
102
|
+
}
|
|
103
|
+
function baseBarReducer(state, ev) {
|
|
104
|
+
switch (ev.t) {
|
|
105
|
+
case "polled":
|
|
106
|
+
if (ev.status === "off") return {
|
|
107
|
+
...state,
|
|
108
|
+
status: "off",
|
|
109
|
+
text: null,
|
|
110
|
+
turnSeq: null,
|
|
111
|
+
error: null,
|
|
112
|
+
expanded: false,
|
|
113
|
+
autorun: "disarmed"
|
|
114
|
+
};
|
|
115
|
+
if (ev.status === "analyzing") return {
|
|
116
|
+
...state,
|
|
117
|
+
status: "analyzing",
|
|
118
|
+
text: null,
|
|
119
|
+
error: null
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
...state,
|
|
123
|
+
status: ev.status,
|
|
124
|
+
text: ev.status === "ready" ? ev.text ?? null : null,
|
|
125
|
+
intent: ev.status === "ready" ? ev.intent ?? "worker-step" : "worker-step",
|
|
126
|
+
turnSeq: ev.turnSeq ?? state.turnSeq,
|
|
127
|
+
error: ev.status === "failed" ? ev.error ?? "the suggestion call failed" : null
|
|
128
|
+
};
|
|
129
|
+
case "expand": return {
|
|
130
|
+
...state,
|
|
131
|
+
expanded: true
|
|
132
|
+
};
|
|
133
|
+
case "collapse": return {
|
|
134
|
+
...state,
|
|
135
|
+
expanded: false
|
|
136
|
+
};
|
|
137
|
+
case "autorun": return {
|
|
138
|
+
...state,
|
|
139
|
+
autorun: ev.on ? "armed" : "disarmed"
|
|
140
|
+
};
|
|
141
|
+
case "running": return {
|
|
142
|
+
...state,
|
|
143
|
+
running: ev.running
|
|
144
|
+
};
|
|
145
|
+
case "human/prompt": return {
|
|
146
|
+
...state,
|
|
147
|
+
autorun: "disarmed",
|
|
148
|
+
hiddenTurnSeq: state.turnSeq,
|
|
149
|
+
expanded: false
|
|
150
|
+
};
|
|
151
|
+
case "off": return {
|
|
152
|
+
...state,
|
|
153
|
+
status: "off",
|
|
154
|
+
text: null,
|
|
155
|
+
turnSeq: null,
|
|
156
|
+
error: null,
|
|
157
|
+
expanded: false,
|
|
158
|
+
autorun: "disarmed"
|
|
159
|
+
};
|
|
160
|
+
case "goal": return {
|
|
161
|
+
...state,
|
|
162
|
+
goalPhase: ev.phase,
|
|
163
|
+
autorun: ev.phase === "complete" || ev.phase === "blocked" ? "disarmed" : state.autorun
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return state;
|
|
167
|
+
}
|
|
168
|
+
/** The status label. `failed` keeps the ready label so the error span is the delta, not a mystery row. */
|
|
169
|
+
function barLabel(s) {
|
|
170
|
+
if (s.status === "analyzing") return "Analyzing situation…";
|
|
171
|
+
if (s.status === "ready" || s.status === "failed") return "Recommended Next Step";
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* #193: the pull cadence, as a decision instead of an interval constant. Every pull is two recorded
|
|
176
|
+
* host lifecycle events (`command/run` + `command/done`) the UI renders as history nodes — cadence
|
|
177
|
+
* IS the litter, so it belongs in this module with a row for every arm. Active = the state can
|
|
178
|
+
* plausibly change on a short clock (generation in flight, an armed loop to fire, a turn running
|
|
179
|
+
* that will end into an offer). Quiet = stable; one pull a minute is plenty for a state that only
|
|
180
|
+
* changes at turn boundaries. Hidden-and-quiet = pull NOTHING (a background tab should write zero
|
|
181
|
+
* history); hidden-and-active still pulls, because walking away from a running loop is the point of
|
|
182
|
+
* autorun and pausing it silently would be the loop dying with nobody told.
|
|
183
|
+
*/
|
|
184
|
+
const ACTIVE_POLL_MS = 1e4;
|
|
185
|
+
/**
|
|
186
|
+
* #229: the quiet heartbeat is CONFIG-GATED (`promptQuietHeartbeatSec`) and defaults to ZERO.
|
|
187
|
+
* Measured on the maintainer's live log after 0.2.11 installed correctly (gaps exactly 60.0s): every
|
|
188
|
+
* pull is two durable nodes — a host `command/run` + `command/done` pair — and the user's rule is
|
|
189
|
+
* absolute: never repeated chapters-prompt executions. The sanctioned moments (mount discovery once
|
|
190
|
+
* per page load, the turn-end pulse, visibility return with an untrusted cache, forced confirms)
|
|
191
|
+
* remain; the ambient drumbeat does not exist unless configured. This module is client-safe (no node
|
|
192
|
+
* builtins), so the wiring injects the config value at boot via `configureQuietHeartbeat`.
|
|
193
|
+
*/
|
|
194
|
+
let QUIET_POLL_MS = 0;
|
|
195
|
+
function configureQuietHeartbeat(seconds) {
|
|
196
|
+
QUIET_POLL_MS = typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 ? Math.trunc(seconds * 1e3) : 0;
|
|
197
|
+
}
|
|
198
|
+
function pollSpacingFor(s) {
|
|
199
|
+
if (s.turnEndPulse === true) return ACTIVE_POLL_MS;
|
|
200
|
+
if (s.edges) {
|
|
201
|
+
if (s.status === "analyzing" || s.autorun === "armed") return ACTIVE_POLL_MS;
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const active = s.status === "analyzing" || s.autorun === "armed";
|
|
205
|
+
if (!active && s.hidden) return null;
|
|
206
|
+
if (!active) return QUIET_POLL_MS > 0 ? QUIET_POLL_MS : null;
|
|
207
|
+
return ACTIVE_POLL_MS;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* #203: whether a freshly mounted bar pulls, or renders its cache and stays silent. The maintainer's
|
|
211
|
+
* rule: generate or read AFTER THE TURN ENDS, never when you activate a session. So a mount pulls
|
|
212
|
+
* only when the cache cannot be trusted — absent (first mount after a page load), stale (a turn
|
|
213
|
+
* could have ended since; this guarded pull is what keeps Run honest, because clicking a stale
|
|
214
|
+
* offer would submit the row's newer text while showing the old), mid-generation, or armed. A
|
|
215
|
+
* cached `off` never pulls: off is a human/config switch, and only an explicit `on` revisits it.
|
|
216
|
+
*/
|
|
217
|
+
function mountPullDecision(c) {
|
|
218
|
+
if (!c.hasCache) return true;
|
|
219
|
+
if (c.status === "off") return false;
|
|
220
|
+
if (c.autorun === "armed" || c.status === "analyzing") return true;
|
|
221
|
+
return QUIET_POLL_MS > 0 && c.ageMs > QUIET_POLL_MS;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* #203: fold a cached wire event back into row state through the SAME reducer the live path uses —
|
|
225
|
+
* one fold, no second implementation to drift (the #59 rule).
|
|
226
|
+
*/
|
|
227
|
+
function stateFromCache(ev, autorun) {
|
|
228
|
+
const folded = promptBarReducer(initialBarState(), ev);
|
|
229
|
+
return autorun === null ? folded : promptBarReducer(folded, {
|
|
230
|
+
t: "autorun",
|
|
231
|
+
on: autorun === "armed"
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function barVisible(s) {
|
|
235
|
+
if (s.status === "off") return false;
|
|
236
|
+
if (s.status === "ready" && (s.text === null || s.text === "")) return false;
|
|
237
|
+
if (s.hiddenTurnSeq !== null && s.hiddenTurnSeq === s.turnSeq) return false;
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
/** May the bar submit its own offer? Armed, present, idle, not dismissed, and still serving a purpose. */
|
|
241
|
+
function shouldAutoFire(s) {
|
|
242
|
+
if (s.status !== "ready") return false;
|
|
243
|
+
if (s.text === null || s.text === "") return false;
|
|
244
|
+
if (s.intent === "goal-edit") return false;
|
|
245
|
+
if (s.goal !== null && s.goal.phase === "active" && s.goal.activation === "armed") return false;
|
|
246
|
+
if (s.autorun !== "armed") return false;
|
|
247
|
+
if (s.running) return false;
|
|
248
|
+
if (s.goalPhase === "complete" || s.goalPhase === "blocked") return false;
|
|
249
|
+
if (s.hiddenTurnSeq !== null && s.hiddenTurnSeq === s.turnSeq) return false;
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
/** The command the bar pulls. One string, so the client and the command plane cannot drift silently. */
|
|
253
|
+
const BAR_STATE_COMMAND = "/chapters-prompt state";
|
|
254
|
+
/** The bar's Run button: the server submits the on-screen offer as the human's own turn. */
|
|
255
|
+
const BAR_RUN_COMMAND = "/chapters-prompt run";
|
|
256
|
+
/**
|
|
257
|
+
* Parse the `state` verb's `k: v` lines into a pull event.
|
|
258
|
+
*
|
|
259
|
+
* One output serving the human and the component is the design (see `docs/contract.md`); this function is
|
|
260
|
+
* the cost of that choice, and it is kept honest: an unrecognised line is IGNORED, not guessed at, and a
|
|
261
|
+
* payload that yields no `status` is `null` so the caller keeps its last state rather than inventing `off`
|
|
262
|
+
* (which would hide a bar that is merely mid-reply). Indentation and the prompt's own lines are the reason
|
|
263
|
+
* the prompt is collected separately from the scalar fields — it is the only multi-line value.
|
|
264
|
+
*/
|
|
265
|
+
function parseBarState(text) {
|
|
266
|
+
const lines = text.split("\n");
|
|
267
|
+
const head = lines[0] ?? "";
|
|
268
|
+
const statusWord = head.startsWith("suggested next step:") ? head.slice(20).trim() : "";
|
|
269
|
+
const status = statusWord === "off" ? "off" : statusWord === "idle" ? "idle" : statusWord === "analyzing" ? "analyzing" : statusWord === "ready" ? "ready" : statusWord === "failed" ? "failed" : null;
|
|
270
|
+
if (status === null) return null;
|
|
271
|
+
const scalars = {};
|
|
272
|
+
const prompt = [];
|
|
273
|
+
let inPrompt = false;
|
|
274
|
+
for (const line of lines.slice(1)) {
|
|
275
|
+
if (inPrompt) {
|
|
276
|
+
prompt.push(line.replace(/^ {4}/, ""));
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const m = /^ {2}([a-z]+): ?(.*)$/.exec(line);
|
|
280
|
+
if (m === null) continue;
|
|
281
|
+
const key = m[1], value = m[2].trim();
|
|
282
|
+
if (key === "prompt") {
|
|
283
|
+
inPrompt = true;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
scalars[key] !== void 0 ? scalars[key] += ` ${value}` : scalars[key] = value;
|
|
287
|
+
}
|
|
288
|
+
configureQuietHeartbeat(Number.parseInt((scalars["heartbeat"] ?? "").trim(), 10) || 0);
|
|
289
|
+
const turnRaw = scalars["turn"] ?? "";
|
|
290
|
+
const turnSeq = /^\d+$/.test(turnRaw) ? Number(turnRaw) : null;
|
|
291
|
+
const errorRaw = scalars["error"] ?? "";
|
|
292
|
+
return {
|
|
293
|
+
t: "polled",
|
|
294
|
+
intent: ((scalars["intent"] ?? "").split(" ")[0] ?? "") === "goal-edit" ? "goal-edit" : "worker-step",
|
|
295
|
+
status,
|
|
296
|
+
...status === "ready" ? {
|
|
297
|
+
text: prompt.join("\n"),
|
|
298
|
+
turnSeq
|
|
299
|
+
} : {},
|
|
300
|
+
...status === "failed" ? {
|
|
301
|
+
error: errorRaw,
|
|
302
|
+
turnSeq
|
|
303
|
+
} : {}
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/** The autorun flag, read from the same payload — the row is durable, the flag is not. */
|
|
307
|
+
function parseBarAutorun(text) {
|
|
308
|
+
const m = /^ {2}autorun: (armed|disarmed)\b/m.exec(text);
|
|
309
|
+
return m === null ? null : m[1];
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Does the host say this session is busy right now?
|
|
313
|
+
*
|
|
314
|
+
* The dock's props are HOOKS (`useSessionStatus`, `useChat`, `useInput`, …) — documented in
|
|
315
|
+
* docs/contract.md § "What a dock component is handed" — so a component that waits for a scalar
|
|
316
|
+
* `props.running` waits forever, which is precisely why Autorun declared itself unavailable while the host
|
|
317
|
+
* was publishing the signal one field away. Shapes differ across hosts and versions, so every form is folded
|
|
318
|
+
* here, in one pure function, with `undefined` (unknown) kept distinct from `false` (idle): the loop refuses
|
|
319
|
+
* to start a turn on an unknown, and that distinction is the whole safety property.
|
|
320
|
+
*/
|
|
321
|
+
function deriveRunning(status) {
|
|
322
|
+
if (status === void 0 || status === null) return void 0;
|
|
323
|
+
if (typeof status === "boolean") return status;
|
|
324
|
+
if (typeof status === "string") {
|
|
325
|
+
if (/^(running|streaming|busy|active)$/i.test(status)) return true;
|
|
326
|
+
if (/^(idle|done|stopped|finished|ready|waiting)$/i.test(status)) return false;
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (typeof status === "object") {
|
|
330
|
+
const s = status;
|
|
331
|
+
if (s.running !== void 0) return typeof s.running === "boolean" ? s.running : void 0;
|
|
332
|
+
return deriveRunning(s.status ?? s.state ?? s.phase);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* #208: the plane-wide relevance fact, read from the `state` block's own model line. `model: unset`
|
|
337
|
+
* means the feature does not exist on this plane — one discovery reply carrying it silences EVERY
|
|
338
|
+
* dock on the page (the pull transport cannot do better than one discovery; the push transport
|
|
339
|
+
* (#194) removes even that). An unrecognised block is false, never a guess.
|
|
340
|
+
*/
|
|
341
|
+
function parseModelAbsent(text) {
|
|
342
|
+
return /^\s*model:\s*unset\b/m.test(text);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* A pushed payload becomes the SAME events the pull path dispatches — one fold, one reducer, no
|
|
346
|
+
* second implementation to drift (#59). Null when the payload is not ours or not trustworthy: an
|
|
347
|
+
* unrecognised push is ignored, never guessed at, and the pull schedule underneath keeps running
|
|
348
|
+
* until a payload this function accepts actually arrives.
|
|
349
|
+
*/
|
|
350
|
+
function pushEventsOf(payload) {
|
|
351
|
+
if (payload === null || payload === void 0 || typeof payload !== "object") return null;
|
|
352
|
+
const p = payload;
|
|
353
|
+
if (p.v !== 1 || typeof p.status !== "string") return null;
|
|
354
|
+
const status = p.status;
|
|
355
|
+
if (status !== "off" && status !== "idle" && status !== "analyzing" && status !== "ready" && status !== "failed") return null;
|
|
356
|
+
return {
|
|
357
|
+
polled: {
|
|
358
|
+
t: "polled",
|
|
359
|
+
status,
|
|
360
|
+
...status === "ready" ? {
|
|
361
|
+
text: typeof p.text === "string" ? p.text : "",
|
|
362
|
+
turnSeq: p.turnSeq ?? null,
|
|
363
|
+
intent: p.intent ?? "worker-step"
|
|
364
|
+
} : {},
|
|
365
|
+
...status === "failed" ? { error: p.error ?? "the suggestion call failed" } : {}
|
|
366
|
+
},
|
|
367
|
+
autorun: {
|
|
368
|
+
t: "autorun",
|
|
369
|
+
on: p.autorun === "armed"
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* The running signal's resolution order (scalar prop → `useSession` snapshot via `deriveRunning` →
|
|
375
|
+
* unknown) lives in the dock as `props.running ?? deriveRunning(sessionSnapshot)` — ONE expression,
|
|
376
|
+
* pinned by a source-scan row, folded by the deriveRunning rows that already cover every shape.
|
|
377
|
+
* A second pure helper for the same decision would be exactly the drift #59 forbids; the draft of
|
|
378
|
+
* #194 shipped one and this comment is its headstone.
|
|
379
|
+
*/
|
|
380
|
+
/** The staleness-confirm's one question: did a push arrive after the edge that scheduled it? */
|
|
381
|
+
function pushIsFresher(lastPushAt, edgeAt) {
|
|
382
|
+
return lastPushAt !== null && lastPushAt > edgeAt;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* #218: a DEAD-PUSH HEARTBEAT, not a staleness confirm — 20s fired mid-turn before the turn's
|
|
386
|
+
* record could exist (turns outlast 20s), which was confirm spam wearing a safety hat. 5 minutes:
|
|
387
|
+
* during normal work a record arrives every turn and the push effect retires the pending timer, so
|
|
388
|
+
* a healthy push NEVER fires it; only a push silent for a whole 5 minutes pulls once — a frozen
|
|
389
|
+
* bar is the failure this bounds, and it is worth one node every 5 minutes, not one every 20
|
|
390
|
+
* seconds.
|
|
391
|
+
* within moments (the runner writes it BEFORE calling the model). If no push arrived this long
|
|
392
|
+
* after the edge, the push is presumed dead and ONE confirm pull runs — a silently dead push
|
|
393
|
+
* degrades to one pull per turn, never to a frozen bar.
|
|
394
|
+
*/
|
|
395
|
+
const PUSH_CONFIRM_GRACE_MS = 3e5;
|
|
396
|
+
/**
|
|
397
|
+
* #211 — the edge kick's delay, measured on the maintainer's host the hard way: some hosts publish
|
|
398
|
+
* `running` at STEP granularity (it flickers at every tool call and thinking block), and an
|
|
399
|
+
* immediate kick per falling edge was a rendered `chapters-prompt` node per tool call — all waste,
|
|
400
|
+
* because the server row changes ONLY at turn boundaries. So: a falling edge schedules its pull
|
|
401
|
+
* after the debounce, a rising edge cancels it (step flicker coalesces to zero pulls), and the rate
|
|
402
|
+
* floor bounds even a host that holds `running` low through long tool calls. A real turn end — a
|
|
403
|
+
* falling edge with no rising edge after it — pulls exactly once.
|
|
404
|
+
*/
|
|
405
|
+
const EDGE_DEBOUNCE_MS = 2e3;
|
|
406
|
+
const EDGE_FLOOR_MS = 15e3;
|
|
407
|
+
function edgeKickDelayMs(msSinceLastPull) {
|
|
408
|
+
return Math.max(EDGE_DEBOUNCE_MS, EDGE_FLOOR_MS - msSinceLastPull);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* #221: the turn-end pulse, derived from the host's own turn-boundary projection — no plugin
|
|
412
|
+
* record API needed, so it works on 0.2.0 hosts (where appendPluginRecord is absent) exactly as
|
|
413
|
+
* on newer ones. The host maintains a `turnBoundary` projection (the agent loop reads its
|
|
414
|
+
* lastTurn for its own bookkeeping); reading what the host already folds costs zero writes and
|
|
415
|
+
* delivers reactively through the same standard useProjection seat as any other projection.
|
|
416
|
+
* Returns true when a NEW turn ended since the last observation; the dock calls it once per
|
|
417
|
+
* render and stores the returned stamp back into `seen`.
|
|
418
|
+
*/
|
|
419
|
+
function turnEndPulse(lastTurn, seen) {
|
|
420
|
+
const cur = typeof lastTurn === "number" && Number.isFinite(lastTurn) ? lastTurn : 0;
|
|
421
|
+
if (cur > seen) return {
|
|
422
|
+
pulse: true,
|
|
423
|
+
seen: cur
|
|
424
|
+
};
|
|
425
|
+
return {
|
|
426
|
+
pulse: false,
|
|
427
|
+
seen
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
// src/client/prompt-dock.ts
|
|
431
|
+
/**
|
|
432
|
+
* The Suggested Next Step dock row (#113) — thin by design.
|
|
433
|
+
*
|
|
434
|
+
* Every decision lives in `prompt-bar.ts` where a row can pin it; this file only moves pixels and bytes
|
|
435
|
+
* between the host slot and that reducer. That split is the reason it exists: the ghost was rejected for
|
|
436
|
+
* behaviour that had no home, and "one turn, one offer" cannot be tested in a component.
|
|
437
|
+
*
|
|
438
|
+
* Placement is measured, not styled: `conversation.input.dock` is the host's full-width stack rendered
|
|
439
|
+
* ABOVE the composer card (todo `order: 0`, goal `10`, queue `20`), so `order: 30` puts this row under every
|
|
440
|
+
* other dock entry and directly above the input — and because it is a docked row rather than an overlay, it
|
|
441
|
+
* can neither cover the placeholder nor collide with the send buttons. The ghost's ugliest defect was
|
|
442
|
+
* structural, and the structure fixes it.
|
|
443
|
+
*
|
|
444
|
+
* The bar is PUSH-SHAPED, PULL-FED. A registrant can own a reactive observable (`hooks: { x }` → prop
|
|
445
|
+
* `useX`) — that is how the goal bar gets its `activation` — but a plugin cannot publish a projection, and
|
|
446
|
+
* the only plugin-reachable call path is `remote.commands.execute`. So the component pulls the `state` verb
|
|
447
|
+
* on edges it can actually observe. Stated here because the alternative is a reader assuming a subscription
|
|
448
|
+
* that does not exist.
|
|
449
|
+
*
|
|
450
|
+
* @module dsh-chapters/src/client/prompt-dock
|
|
451
|
+
*/
|
|
452
|
+
const CSS$1 = [
|
|
453
|
+
".dsh-chapters_promptDock{box-sizing:border-box;width:calc(100% - 2 * var(--dsh-composer-side-clearance) - 4 * var(--dsh-composer-dock-inset));max-width:calc(var(--dsh-composer-card-max-width) - 4 * var(--dsh-composer-dock-inset));margin:0 auto}",
|
|
454
|
+
".dsh-chapters_promptBar{isolation:isolate;box-sizing:border-box;width:100%;border-radius:var(--dsw-radius-md);height:36px;box-shadow:var(--dsw-elevation-panel);border:0;align-items:center;gap:10px;padding:4px 5px 4px 12px;display:flex;position:relative}",
|
|
455
|
+
".dsh-chapters_promptBar::before{z-index:-1;border-radius:inherit;background:var(--dsw-specific-menu);backdrop-filter:var(--dsw-menu-backdrop-filter);content:\"\";pointer-events:none;position:absolute;inset:0}",
|
|
456
|
+
".dsh-chapters_promptExpanded::before{align-items:flex-start;position:absolute;inset:0}",
|
|
457
|
+
".dsh-chapters_promptExpanded{height:auto;min-height:36px;padding-top:8px;padding-bottom:8px}",
|
|
458
|
+
".dsh-chapters_promptGlyph{color:var(--dsw-alias-label-tertiary);flex:none;display:inline-flex}",
|
|
459
|
+
".dsh-chapters_promptLabel{color:var(--dsw-alias-label-primary);flex:none;font-size:13px;font-weight:500;line-height:24px}",
|
|
460
|
+
".dsh-chapters_promptText{min-width:0;color:var(--dsw-alias-label-primary-dimmed);font-size:13px;line-height:20px;cursor:pointer;border:0;background:0 0;padding:0;text-align:left;flex:1 1 100%}",
|
|
461
|
+
".dsh-chapters_promptEllipsis{white-space:nowrap;text-overflow:ellipsis;overflow:hidden;display:block}",
|
|
462
|
+
".dsh-chapters_promptFull{white-space:pre-wrap;overflow-wrap:break-word;word-break:normal;line-height:20px;font-size:13px;color:var(--dsw-alias-label-primary);display:block;max-height:9rem;overflow-y:auto}",
|
|
463
|
+
".dsh-chapters_promptError{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:20px;flex:none;max-width:34%}",
|
|
464
|
+
".dsh-chapters_promptNote{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:20px;min-width:0}",
|
|
465
|
+
".dsh-chapters_promptIntent{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:20px}",
|
|
466
|
+
".dsh-chapters_promptActions{flex:none;align-items:center;gap:10px;display:flex}",
|
|
467
|
+
".dsh-chapters_promptBtn{corner-shape:round;width:28px;height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;border-radius:999px;justify-content:center;align-items:center;padding:0;display:inline-flex;font-size:13px}",
|
|
468
|
+
".dsh-chapters_promptBtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}",
|
|
469
|
+
".dsh-chapters_promptBtn:disabled{opacity:.4;cursor:default}"
|
|
470
|
+
].join("\n");
|
|
471
|
+
const CSS_ID = "dsh-chapters/prompt-dock.css";
|
|
472
|
+
const ensureCss = () => {
|
|
473
|
+
if (typeof document === "undefined") return;
|
|
474
|
+
if (document.getElementById(CSS_ID) !== null) return;
|
|
475
|
+
const style = document.createElement("style");
|
|
476
|
+
style.id = CSS_ID;
|
|
477
|
+
style.setAttribute("data-plugin-css", CSS_ID);
|
|
478
|
+
style.textContent = CSS$1;
|
|
479
|
+
document.head.appendChild(style);
|
|
480
|
+
};
|
|
481
|
+
/** The analysis glyph, drawn to the goal icon's metric (16 viewBox, stroke currentColor, width 1).
|
|
482
|
+
* A prism-and-arrow: the feature reads the situation and points at the next move. */
|
|
483
|
+
const Glyph = () => (0, react.createElement)("svg", {
|
|
484
|
+
viewBox: "0 0 16 16",
|
|
485
|
+
width: 14,
|
|
486
|
+
height: 14,
|
|
487
|
+
fill: "none",
|
|
488
|
+
"aria-hidden": "true",
|
|
489
|
+
"stroke-width": "1"
|
|
490
|
+
}, (0, react.createElement)("path", {
|
|
491
|
+
d: "M2.5 3.5h11L8 9.5 2.5 3.5Z",
|
|
492
|
+
stroke: "currentColor"
|
|
493
|
+
}), (0, react.createElement)("path", {
|
|
494
|
+
d: "M8 9.5V14",
|
|
495
|
+
stroke: "currentColor"
|
|
496
|
+
}), (0, react.createElement)("path", {
|
|
497
|
+
d: "M6 12l2 2 2-2",
|
|
498
|
+
stroke: "currentColor"
|
|
499
|
+
}));
|
|
500
|
+
/** One action button, labelled through the host tooltip exactly as the goal bar labels its own. */
|
|
501
|
+
const Action = (props) => {
|
|
502
|
+
const button = (0, react.createElement)("button", {
|
|
503
|
+
type: "button",
|
|
504
|
+
className: "dsh-chapters_promptBtn",
|
|
505
|
+
"aria-label": props.label,
|
|
506
|
+
...props.disabled === true ? { disabled: true } : {},
|
|
507
|
+
...props.title !== void 0 ? { title: props.title } : nativeTitle(props.label),
|
|
508
|
+
onClick: props.onClick
|
|
509
|
+
}, props.glyph);
|
|
510
|
+
return withTooltip(props.label, button);
|
|
511
|
+
};
|
|
512
|
+
/** Run is allowed whenever there is an offer; AUTORUN is what the intent and the goal gate. Keeping the
|
|
513
|
+
* human's own button enabled is deliberate — the point of the protocol is to route a goal edit to a keystroke,
|
|
514
|
+
* never to disable the human's ability to send it. */
|
|
515
|
+
/**
|
|
516
|
+
* The words the collapsed row shows, per state. `idle` and `analyzing` must say something, because an empty
|
|
517
|
+
* row is indistinguishable from a broken one — the whole reason this feature took a day to read: the bar was
|
|
518
|
+
* live, enabled, and rendering nothing but whitespace.
|
|
519
|
+
*/
|
|
520
|
+
function lineFor(state) {
|
|
521
|
+
if (state.status === "ready") return state.text ?? "";
|
|
522
|
+
if (state.status === "analyzing") return "Analyzing the session…";
|
|
523
|
+
if (state.status === "failed") return state.error !== null && state.error !== "" ? `Cannot recommend: ${state.error}` : "Cannot recommend a next step right now";
|
|
524
|
+
return "No suggestion yet — I will recommend one after your next turn";
|
|
525
|
+
}
|
|
526
|
+
const copyToClipboard = (text) => {
|
|
527
|
+
try {
|
|
528
|
+
navigator.clipboard?.writeText(text);
|
|
529
|
+
return true;
|
|
530
|
+
} catch {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
const canRunHere = (state) => state.text !== null && state.text !== "";
|
|
535
|
+
/** How long a state pull may take before we call it failed. Generous on purpose: this is a local RPC that
|
|
536
|
+
* answers in milliseconds when the session is attachable, and the failure it guards against is a hang. */
|
|
537
|
+
const PULL_TIMEOUT_MS = 15e3;
|
|
538
|
+
const stateCache = /* @__PURE__ */ new Map();
|
|
539
|
+
/**
|
|
540
|
+
* #251: the memory cache dies with the page, and the maintainer's rule is absolute — a reload must
|
|
541
|
+
* cost ZERO chapters-prompt commands. Two page loads producing two visible `state` nodes still reads
|
|
542
|
+
* as "the same shit" from the chair, sanctioned or not. So the last-known truth persists per session
|
|
543
|
+
* in localStorage (the host's own convention for composer state — `dsh.sessions.current`, persisted
|
|
544
|
+
* drafts), and activation reads memory first, then storage. A cold tab renders the last offer with
|
|
545
|
+
* no command at all; turn boundaries refresh it through the pulse/confirm paths that already exist.
|
|
546
|
+
*/
|
|
547
|
+
const PERSIST_PREFIX = "dsh.chapters.promptState.";
|
|
548
|
+
const PERSIST_MAX = 50;
|
|
549
|
+
const persistKey = (sessionId) => `${PERSIST_PREFIX}${sessionId}`;
|
|
550
|
+
function readPersisted(sessionId) {
|
|
551
|
+
try {
|
|
552
|
+
const raw = window.localStorage.getItem(persistKey(sessionId));
|
|
553
|
+
if (raw === null) return null;
|
|
554
|
+
const p = JSON.parse(raw);
|
|
555
|
+
if (typeof p.at !== "number" || p.ev === null || typeof p.ev !== "object") return null;
|
|
556
|
+
if (p.ev.t !== "polled") return null;
|
|
557
|
+
const autorun = p.autorun === "armed" || p.autorun === "disarmed" ? p.autorun : null;
|
|
558
|
+
return {
|
|
559
|
+
ev: p.ev,
|
|
560
|
+
autorun,
|
|
561
|
+
at: p.at
|
|
562
|
+
};
|
|
563
|
+
} catch {
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
function writePersisted(sessionId, entry) {
|
|
568
|
+
try {
|
|
569
|
+
window.localStorage.setItem(persistKey(sessionId), JSON.stringify(entry));
|
|
570
|
+
const keys = [];
|
|
571
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
572
|
+
const k = window.localStorage.key(i);
|
|
573
|
+
if (k !== null && k.startsWith(PERSIST_PREFIX)) keys.push(k);
|
|
574
|
+
}
|
|
575
|
+
if (keys.length <= PERSIST_MAX) return;
|
|
576
|
+
const aged = keys.map((k) => {
|
|
577
|
+
let at = 0;
|
|
578
|
+
try {
|
|
579
|
+
at = Number(JSON.parse(window.localStorage.getItem(k) ?? "{}").at ?? 0);
|
|
580
|
+
} catch {
|
|
581
|
+
at = 0;
|
|
582
|
+
}
|
|
583
|
+
return {
|
|
584
|
+
k,
|
|
585
|
+
at
|
|
586
|
+
};
|
|
587
|
+
}).sort((x, y) => x.at - y.at);
|
|
588
|
+
for (const old of aged.slice(0, keys.length - PERSIST_MAX)) window.localStorage.removeItem(old.k);
|
|
589
|
+
} catch {}
|
|
590
|
+
}
|
|
591
|
+
/** Memory first, then disk — one reader so every decision sees the same truth (#59). */
|
|
592
|
+
function cachedState(sessionId) {
|
|
593
|
+
if (sessionId === void 0) return void 0;
|
|
594
|
+
const mem = stateCache.get(sessionId);
|
|
595
|
+
if (mem !== void 0) return mem;
|
|
596
|
+
const disk = readPersisted(sessionId);
|
|
597
|
+
if (disk !== null) {
|
|
598
|
+
stateCache.set(sessionId, disk);
|
|
599
|
+
return disk;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* #208: set by the first reply reporting the plane-wide `model: unset` — the feature does not
|
|
604
|
+
* exist here, so NO dock on this page pulls again for anything: not mounts, not idle edges, not
|
|
605
|
+
* timers, not visibility. One discovery per page load is the pull transport's floor; a page reload
|
|
606
|
+
* is the only revival, and the enabling commands say so.
|
|
607
|
+
*/
|
|
608
|
+
let featureAbsent = false;
|
|
609
|
+
/** Retry ceiling for the FIRST pull only, in attempts and spacing. A mount-time race must not be able to
|
|
610
|
+
* starve the bar forever, and a real `off` answer stops the loop on the first reply either way. */
|
|
611
|
+
function execWithTimeout(exec, line, ms) {
|
|
612
|
+
return new Promise((resolve, reject) => {
|
|
613
|
+
const timer = setTimeout(() => {
|
|
614
|
+
reject(/* @__PURE__ */ new Error(`no answer within ${String(ms)}ms`));
|
|
615
|
+
}, ms);
|
|
616
|
+
exec(line).then((text) => {
|
|
617
|
+
clearTimeout(timer);
|
|
618
|
+
resolve(text);
|
|
619
|
+
}, (error) => {
|
|
620
|
+
clearTimeout(timer);
|
|
621
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
function PromptDock(props) {
|
|
626
|
+
const { sessionId, inputActions, exec } = props;
|
|
627
|
+
const sessionSnapshot = (props.useSession ?? (() => void 0))((snapshot) => snapshot);
|
|
628
|
+
const running = props.running ?? deriveRunning(sessionSnapshot);
|
|
629
|
+
const useProjectionHook = props.useProjection ?? (() => void 0);
|
|
630
|
+
const pushed = useProjectionHook(PROMPT_PROJECTION_KEY);
|
|
631
|
+
const turnBoundary = useProjectionHook("turnBoundary");
|
|
632
|
+
const [state, dispatch] = (0, react.useReducer)(promptBarReducer, initialBarState(), (s) => {
|
|
633
|
+
const pushEvs = pushEventsOf(pushed);
|
|
634
|
+
if (pushEvs !== null) return {
|
|
635
|
+
...promptBarReducer(promptBarReducer(s, pushEvs.polled), pushEvs.autorun),
|
|
636
|
+
running: running ?? true
|
|
637
|
+
};
|
|
638
|
+
const c = cachedState(sessionId);
|
|
639
|
+
return {
|
|
640
|
+
...c !== void 0 ? stateFromCache(c.ev, c.autorun) : s,
|
|
641
|
+
running: running ?? true
|
|
642
|
+
};
|
|
643
|
+
});
|
|
644
|
+
const pulling = (0, react.useRef)(false);
|
|
645
|
+
const probed = (0, react.useRef)(false);
|
|
646
|
+
const pulled = (0, react.useRef)(false);
|
|
647
|
+
const alive = (0, react.useRef)(true);
|
|
648
|
+
(0, react.useEffect)(() => () => {
|
|
649
|
+
alive.current = false;
|
|
650
|
+
}, []);
|
|
651
|
+
const [busy, setBusy] = (0, react.useState)(false);
|
|
652
|
+
const kick = (0, react.useRef)(void 0);
|
|
653
|
+
const lastPullAt = (0, react.useRef)(null);
|
|
654
|
+
const edgeTimer = (0, react.useRef)(void 0);
|
|
655
|
+
const pushLiveRef = (0, react.useRef)(false);
|
|
656
|
+
const seenTurnRef = (0, react.useRef)(0);
|
|
657
|
+
const lastPushAt = (0, react.useRef)(null);
|
|
658
|
+
const answered = (0, react.useRef)(false);
|
|
659
|
+
const replyStatus = (0, react.useRef)("unknown");
|
|
660
|
+
const stateRef = (0, react.useRef)(initialBarState());
|
|
661
|
+
stateRef.current = state;
|
|
662
|
+
const edgesRef = (0, react.useRef)(props.running !== void 0);
|
|
663
|
+
edgesRef.current = props.running !== void 0;
|
|
664
|
+
if (!probed.current) {
|
|
665
|
+
probed.current = true;
|
|
666
|
+
console.info(`[client:prompt] inputActions keys: ${Object.keys(inputActions ?? {}).join(",") || "NONE"}`);
|
|
667
|
+
}
|
|
668
|
+
const refresh = (0, react.useCallback)(async () => {
|
|
669
|
+
if (exec === void 0 || pulling.current) return;
|
|
670
|
+
pulling.current = true;
|
|
671
|
+
lastPullAt.current = Date.now();
|
|
672
|
+
try {
|
|
673
|
+
const text = await execWithTimeout(exec, BAR_STATE_COMMAND, PULL_TIMEOUT_MS);
|
|
674
|
+
try {
|
|
675
|
+
window.__dshPromptReply = text;
|
|
676
|
+
} catch {}
|
|
677
|
+
const ev = parseBarState(text);
|
|
678
|
+
if (ev !== null) {
|
|
679
|
+
answered.current = true;
|
|
680
|
+
replyStatus.current = ev.status ?? replyStatus.current;
|
|
681
|
+
dispatch(ev);
|
|
682
|
+
}
|
|
683
|
+
const autorun = parseBarAutorun(text);
|
|
684
|
+
if (autorun !== null) dispatch({
|
|
685
|
+
t: "autorun",
|
|
686
|
+
on: autorun === "armed"
|
|
687
|
+
});
|
|
688
|
+
if (parseModelAbsent(text)) featureAbsent = true;
|
|
689
|
+
if (ev !== null && sessionId !== void 0) {
|
|
690
|
+
stateCache.set(sessionId, {
|
|
691
|
+
ev,
|
|
692
|
+
autorun: autorun === "armed" || autorun === "disarmed" ? autorun : null,
|
|
693
|
+
at: Date.now()
|
|
694
|
+
});
|
|
695
|
+
writePersisted(sessionId, {
|
|
696
|
+
ev,
|
|
697
|
+
autorun: autorun === "armed" || autorun === "disarmed" ? autorun : null,
|
|
698
|
+
at: Date.now()
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
} catch (error) {
|
|
702
|
+
console.warn(`[client:prompt] pull failed: ${String(error?.message ?? error).slice(0, 180)}`);
|
|
703
|
+
} finally {
|
|
704
|
+
pulling.current = false;
|
|
705
|
+
}
|
|
706
|
+
}, [exec]);
|
|
707
|
+
(0, react.useEffect)(() => {
|
|
708
|
+
const evs = pushEventsOf(pushed);
|
|
709
|
+
if (evs === null) return;
|
|
710
|
+
pushLiveRef.current = true;
|
|
711
|
+
lastPushAt.current = Date.now();
|
|
712
|
+
dispatch(evs.polled);
|
|
713
|
+
dispatch(evs.autorun);
|
|
714
|
+
if (edgeTimer.current !== void 0) {
|
|
715
|
+
clearTimeout(edgeTimer.current);
|
|
716
|
+
edgeTimer.current = void 0;
|
|
717
|
+
}
|
|
718
|
+
}, [pushed]);
|
|
719
|
+
(0, react.useEffect)(() => {
|
|
720
|
+
ensureCss();
|
|
721
|
+
if (pulled.current) return;
|
|
722
|
+
pulled.current = true;
|
|
723
|
+
let timer;
|
|
724
|
+
const effectiveStatus = () => replyStatus.current === "unknown" ? stateRef.current.status : replyStatus.current;
|
|
725
|
+
const spacingNow = () => {
|
|
726
|
+
if (stateRef.current.autorun === "armed") return ACTIVE_POLL_MS;
|
|
727
|
+
const pe = turnEndPulse(turnBoundary?.lastTurn, seenTurnRef.current);
|
|
728
|
+
if (pe.pulse) seenTurnRef.current = pe.seen;
|
|
729
|
+
return pollSpacingFor({
|
|
730
|
+
status: effectiveStatus(),
|
|
731
|
+
autorun: stateRef.current.autorun,
|
|
732
|
+
running: stateRef.current.running,
|
|
733
|
+
hidden: typeof document !== "undefined" && document.hidden === true,
|
|
734
|
+
edges: edgesRef.current,
|
|
735
|
+
turnEndPulse: pe.pulse
|
|
736
|
+
});
|
|
737
|
+
};
|
|
738
|
+
const duePull = () => {
|
|
739
|
+
const c = cachedState(sessionId);
|
|
740
|
+
return mountPullDecision({
|
|
741
|
+
hasCache: c !== void 0,
|
|
742
|
+
ageMs: c !== void 0 ? Date.now() - c.at : Number.MAX_SAFE_INTEGER,
|
|
743
|
+
status: effectiveStatus(),
|
|
744
|
+
autorun: stateRef.current.autorun
|
|
745
|
+
});
|
|
746
|
+
};
|
|
747
|
+
const tick = async (opts) => {
|
|
748
|
+
if (pushLiveRef.current && opts?.force !== true) return;
|
|
749
|
+
if (featureAbsent) return;
|
|
750
|
+
if (effectiveStatus() === "off") {
|
|
751
|
+
if (!(opts?.mount === true && duePull())) return;
|
|
752
|
+
}
|
|
753
|
+
const spacing = spacingNow();
|
|
754
|
+
const mountDiscovery = opts?.mount === true && duePull();
|
|
755
|
+
if (opts?.force === true || mountDiscovery || spacing !== null && opts?.mount !== true) await refresh();
|
|
756
|
+
if (replyStatus.current === "off") return;
|
|
757
|
+
const next = spacingNow();
|
|
758
|
+
if (next !== null) timer = setTimeout(() => {
|
|
759
|
+
tick();
|
|
760
|
+
}, next);
|
|
761
|
+
};
|
|
762
|
+
kick.current = () => {
|
|
763
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
764
|
+
tick({ force: true });
|
|
765
|
+
};
|
|
766
|
+
const onVisible = () => {
|
|
767
|
+
if (typeof document !== "undefined" && !document.hidden && duePull()) kick.current?.();
|
|
768
|
+
};
|
|
769
|
+
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
|
|
770
|
+
tick({ mount: true });
|
|
771
|
+
return () => {
|
|
772
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
773
|
+
if (edgeTimer.current !== void 0) {
|
|
774
|
+
clearTimeout(edgeTimer.current);
|
|
775
|
+
edgeTimer.current = void 0;
|
|
776
|
+
}
|
|
777
|
+
kick.current = void 0;
|
|
778
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
|
|
779
|
+
};
|
|
780
|
+
}, [refresh]);
|
|
781
|
+
const prevRunning = (0, react.useRef)(running ?? true);
|
|
782
|
+
(0, react.useEffect)(() => {
|
|
783
|
+
const next = running ?? true;
|
|
784
|
+
const prev = prevRunning.current;
|
|
785
|
+
prevRunning.current = next;
|
|
786
|
+
dispatch({
|
|
787
|
+
t: "running",
|
|
788
|
+
running: next
|
|
789
|
+
});
|
|
790
|
+
if (prev && !next) {
|
|
791
|
+
if (edgeTimer.current !== void 0) clearTimeout(edgeTimer.current);
|
|
792
|
+
const since = lastPullAt.current === null ? Number.MAX_SAFE_INTEGER : Date.now() - lastPullAt.current;
|
|
793
|
+
const delay = pushLiveRef.current ? Math.max(PUSH_CONFIRM_GRACE_MS, edgeKickDelayMs(since)) : edgeKickDelayMs(since);
|
|
794
|
+
const edgeAt = Date.now();
|
|
795
|
+
edgeTimer.current = setTimeout(() => {
|
|
796
|
+
edgeTimer.current = void 0;
|
|
797
|
+
if (pushLiveRef.current && pushIsFresher(lastPushAt.current, edgeAt)) return;
|
|
798
|
+
kick.current?.();
|
|
799
|
+
}, delay);
|
|
800
|
+
} else if (!prev && next) {
|
|
801
|
+
if (edgeTimer.current !== void 0) {
|
|
802
|
+
clearTimeout(edgeTimer.current);
|
|
803
|
+
edgeTimer.current = void 0;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}, [running]);
|
|
807
|
+
const run = (0, react.useCallback)(() => {
|
|
808
|
+
if (state.text === null || state.text === "" || exec === void 0) return;
|
|
809
|
+
setBusy(true);
|
|
810
|
+
exec(BAR_RUN_COMMAND).then((out) => {
|
|
811
|
+
if (!alive.current) return;
|
|
812
|
+
if (/^cannot run:/i.test(out.trim())) dispatch({
|
|
813
|
+
t: "polled",
|
|
814
|
+
status: "failed",
|
|
815
|
+
text: state.text,
|
|
816
|
+
error: out.trim().slice(0, 200),
|
|
817
|
+
turnSeq: state.turnSeq
|
|
818
|
+
});
|
|
819
|
+
else dispatch({ t: "collapse" });
|
|
820
|
+
}).catch((error) => {
|
|
821
|
+
console.warn(`[client:prompt] run failed: ${String(error?.message ?? error).slice(0, 160)}`);
|
|
822
|
+
}).finally(() => {
|
|
823
|
+
if (alive.current) setBusy(false);
|
|
824
|
+
});
|
|
825
|
+
}, [
|
|
826
|
+
exec,
|
|
827
|
+
state.text,
|
|
828
|
+
state.turnSeq
|
|
829
|
+
]);
|
|
830
|
+
(0, react.useEffect)(() => {
|
|
831
|
+
if (!shouldAutoFire(state)) return;
|
|
832
|
+
run();
|
|
833
|
+
}, [state, run]);
|
|
834
|
+
const autorunDisabled = running === void 0;
|
|
835
|
+
if (sessionId === void 0 || !barVisible(state)) return null;
|
|
836
|
+
const populate = () => {
|
|
837
|
+
console.info(`[client:prompt] populate expanded=${String(state.expanded)} hasInsertText=${String(typeof inputActions?.insertText)} hasSetDraft=${String(typeof inputActions?.setDraft)} textLen=${String(state.text?.length ?? -1)}`);
|
|
838
|
+
if (!state.expanded) {
|
|
839
|
+
dispatch({ t: "expand" });
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (state.text === null || state.text === "") return;
|
|
843
|
+
if (typeof inputActions?.insertText === "function") {
|
|
844
|
+
try {
|
|
845
|
+
document.querySelector("[contenteditable=\"true\"][role=\"textbox\"]")?.focus();
|
|
846
|
+
} catch {}
|
|
847
|
+
const refuse = (why) => {
|
|
848
|
+
console.warn(`[client:prompt] click-to-draft refused: ${why}`);
|
|
849
|
+
const copied = copyToClipboard(state.text ?? "");
|
|
850
|
+
dispatch({
|
|
851
|
+
t: "note",
|
|
852
|
+
note: copied ? "The composer refused this write (" + why.slice(0, 60) + ") — the prompt is COPIED: paste it into the input to edit, or press Run ▶ to send as-is." : "The composer refused this write (" + why.slice(0, 60) + ") — press Run ▶ to send the offer as-is, or copy the text above."
|
|
853
|
+
});
|
|
854
|
+
};
|
|
855
|
+
let wrote = false;
|
|
856
|
+
try {
|
|
857
|
+
const span = typeof inputActions.captureInsertion === "function" ? inputActions.captureInsertion() : null;
|
|
858
|
+
if (span !== null && span !== void 0) wrote = inputActions.insertText(state.text, span) === true;
|
|
859
|
+
} catch {
|
|
860
|
+
wrote = false;
|
|
861
|
+
}
|
|
862
|
+
if (!wrote && typeof inputActions.setDraft === "function") try {
|
|
863
|
+
inputActions.setDraft(state.text);
|
|
864
|
+
wrote = true;
|
|
865
|
+
} catch {
|
|
866
|
+
wrote = false;
|
|
867
|
+
}
|
|
868
|
+
if (!wrote) {
|
|
869
|
+
refuse("neither insertText nor setDraft was accepted");
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
const head = state.text.slice(0, 24);
|
|
873
|
+
setTimeout(() => {
|
|
874
|
+
try {
|
|
875
|
+
const dom = (document.querySelector("[contenteditable=\"true\"][role=\"textbox\"]")?.innerText ?? "").includes(head);
|
|
876
|
+
const stored = Object.entries(localStorage).some(([, v]) => String(v).includes(head));
|
|
877
|
+
if (!dom && !stored) refuse("the write was accepted but nothing holds the text");
|
|
878
|
+
} catch {}
|
|
879
|
+
}, 400);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (typeof inputActions?.setDraft === "function") {
|
|
883
|
+
inputActions.setDraft(state.text);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
console.warn("[client:prompt] click-to-draft refused: this host exposes neither insertText nor setDraft");
|
|
887
|
+
};
|
|
888
|
+
return (0, react.createElement)("div", {
|
|
889
|
+
className: "dsh-chapters_promptDock",
|
|
890
|
+
"data-prompt-bar": "true"
|
|
891
|
+
}, (0, react.createElement)("div", { className: `dsh-chapters_promptBar${state.expanded ? " dsh-chapters_promptExpanded" : ""}` }, (0, react.createElement)("span", { className: "dsh-chapters_promptGlyph" }, (0, react.createElement)(Glyph)), (0, react.createElement)("span", { className: "dsh-chapters_promptLabel" }, barLabel(state) ?? ""), ...state.intent === "goal-edit" ? [(0, react.createElement)("span", {
|
|
892
|
+
key: "intent",
|
|
893
|
+
className: "dsh-chapters_promptIntent",
|
|
894
|
+
"aria-label": "This step edits the goal"
|
|
895
|
+
}, "edits the goal · run it yourself")] : [], (0, react.createElement)("button", {
|
|
896
|
+
type: "button",
|
|
897
|
+
className: "dsh-chapters_promptText",
|
|
898
|
+
"aria-label": state.expanded ? "Put this suggested prompt in the input box" : "Show the full suggested prompt",
|
|
899
|
+
onClick: populate
|
|
900
|
+
}, state.expanded ? (0, react.createElement)("span", { className: "dsh-chapters_promptFull" }, state.text ?? "") : (0, react.createElement)("span", {
|
|
901
|
+
className: "dsh-chapters_promptEllipsis",
|
|
902
|
+
title: state.text ?? void 0
|
|
903
|
+
}, lineFor(state))), ...state.note === "" ? [] : [(0, react.createElement)("span", {
|
|
904
|
+
key: "note",
|
|
905
|
+
className: "dsh-chapters_promptNote",
|
|
906
|
+
role: "status"
|
|
907
|
+
}, state.note)], ...state.error === "" ? [] : [(0, react.createElement)("span", {
|
|
908
|
+
key: "err",
|
|
909
|
+
className: "dsh-chapters_promptError",
|
|
910
|
+
role: "alert"
|
|
911
|
+
}, state.error)], (0, react.createElement)("div", { className: "dsh-chapters_promptActions" }, (0, react.createElement)(Action, {
|
|
912
|
+
label: "Run the suggested next step",
|
|
913
|
+
glyph: "▶",
|
|
914
|
+
onClick: run,
|
|
915
|
+
disabled: busy,
|
|
916
|
+
...state.text === null || state.text === "" || !canRunHere(state) ? { disabled: true } : {},
|
|
917
|
+
...state.intent === "goal-edit" ? { title: "Goal edits need a human turn. Run still works; autorun will never do this one." } : {}
|
|
918
|
+
}), (0, react.createElement)(Action, {
|
|
919
|
+
label: state.autorun === "armed" ? "Autorun off" : "Autorun",
|
|
920
|
+
glyph: state.autorun === "armed" ? "⟳" : "⟲",
|
|
921
|
+
disabled: autorunDisabled,
|
|
922
|
+
...autorunDisabled ? { title: "Autorun needs the host to publish an idle signal; this build does not, so nothing would fire safely." } : {},
|
|
923
|
+
onClick: () => {
|
|
924
|
+
(async () => {
|
|
925
|
+
const arm = state.autorun !== "armed";
|
|
926
|
+
if (exec !== void 0) await exec(`/chapters-prompt autorun ${arm ? "on" : "off"}`);
|
|
927
|
+
dispatch({
|
|
928
|
+
t: "autorun",
|
|
929
|
+
on: arm
|
|
930
|
+
});
|
|
931
|
+
if (!pushLiveRef.current) await refresh();
|
|
932
|
+
})();
|
|
933
|
+
}
|
|
934
|
+
}), (0, react.createElement)(Action, {
|
|
935
|
+
label: "Turn prompt suggestions off for this session",
|
|
936
|
+
glyph: "✕",
|
|
937
|
+
onClick: () => {
|
|
938
|
+
(async () => {
|
|
939
|
+
if (exec !== void 0) await exec("/chapters-prompt off");
|
|
940
|
+
dispatch({ t: "off" });
|
|
941
|
+
if (sessionId !== void 0) {
|
|
942
|
+
const offEntry = {
|
|
943
|
+
ev: {
|
|
944
|
+
t: "polled",
|
|
945
|
+
status: "off"
|
|
946
|
+
},
|
|
947
|
+
autorun: null,
|
|
948
|
+
at: Date.now()
|
|
949
|
+
};
|
|
950
|
+
stateCache.set(sessionId, offEntry);
|
|
951
|
+
writePersisted(sessionId, offEntry);
|
|
952
|
+
}
|
|
953
|
+
})();
|
|
954
|
+
}
|
|
955
|
+
}))));
|
|
956
|
+
}
|
|
957
|
+
// src/client/doors.ts
|
|
958
|
+
const DOOR_ACTIONS = [{
|
|
959
|
+
id: "chapters-fork",
|
|
960
|
+
order: 30,
|
|
961
|
+
label: "Fork with chapters",
|
|
962
|
+
command: "/chapters-fork",
|
|
963
|
+
noun: "branch",
|
|
964
|
+
iconPath: "M5 3.25a2.25 2.25 0 1 0-1.5 2.12v5.26a2.25 2.25 0 1 0 1.5 0V9h5a2.25 2.25 0 0 0 2.25-2.25v-1.4a2.25 2.25 0 1 0-1.5 0v1.4A.75.75 0 0 1 10 7.5H5V5.37A2.25 2.25 0 0 0 5 3.25Z"
|
|
965
|
+
}, {
|
|
966
|
+
id: "chapters-continue",
|
|
967
|
+
order: 31,
|
|
968
|
+
label: "Continue with chapters",
|
|
969
|
+
command: "/chapters-continue",
|
|
970
|
+
noun: "continuation",
|
|
971
|
+
iconPath: "M1 8a.75.75 0 0 1 0-1.5h9.44L7.72 3.28a.75.75 0 1 1 1.06-1.06l3.5 3.5.53.53-.53.53-3.5 3.5a.75.75 0 1 1-1.06-1.06L10.44 9.5H1Zm13-5.25v10.5a.75.75 0 0 1-1.5 0v-10.5a.75.75 0 0 1 1.5 0Z"
|
|
972
|
+
}];
|
|
973
|
+
/** Pull the child id out of either door's durable success text — both end in
|
|
974
|
+
* `… is session ch-…` (src/tools.ts), so this regex is door-agnostic. */
|
|
975
|
+
/**
|
|
976
|
+
* Which build of THIS file produced a door note (#77). When the live probe reported a roster
|
|
977
|
+
* trace of `missing → missing → missing` — meaning adoption should have fired three times — while
|
|
978
|
+
* showing no adoption outcome at all, the first question stopped being "why did the client refuse"
|
|
979
|
+
* and became "did the browser run the code I am reading?". A note that names its own build makes
|
|
980
|
+
* that a glance instead of an archaeology session into symlinked plugin trees.
|
|
981
|
+
*/
|
|
982
|
+
const DOOR_BUILD = "2026-10-22T23:55 door-build-stamp";
|
|
983
|
+
const CHILD_ID = /is session (ch-[\w-]+)/;
|
|
984
|
+
/** The quoted title the door gave the child, matched by its OWN noun so a
|
|
985
|
+
* refusal or an unrelated quote cannot pass for it. */
|
|
986
|
+
const doorTitle = (noun) => new RegExp(`${noun} \\u201C([^\\u201D]+)\\u201D is session`);
|
|
987
|
+
/**
|
|
988
|
+
* A refusal reason that can never render as nothing. `String(err.message ?? err)` is `''` for a
|
|
989
|
+
* host error built with an empty message, and an empty reason disappears from the note — the
|
|
990
|
+
* reader then concludes "no refusal happened", which is the opposite of the truth (#77).
|
|
991
|
+
*/
|
|
992
|
+
function refusalReason(error) {
|
|
993
|
+
const trimmed = (error instanceof Error ? error.message : String(error)).trim();
|
|
994
|
+
const named = trimmed === "" ? error instanceof Error && error.name.trim() !== "" ? error.name : "" : trimmed;
|
|
995
|
+
return (named === "" ? error === null ? "null thrown" : typeof error : named).replace(/\s+/g, " ").slice(0, 90);
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Is the child in the client's OWN roster? This is the question that separates two very
|
|
999
|
+
* different failures, and the door used to report the symptom without it:
|
|
1000
|
+
* - the client never learned the session exists (roster: missing) → the fix is server-side
|
|
1001
|
+
* publication, and no amount of clicking `open` will ever work;
|
|
1002
|
+
* - the client knows it and `open` still did nothing (roster: contains) → a navigation bug.
|
|
1003
|
+
* Measured host 0.2.0-rc.2: the sessions service exposes `projectList`, `refresh`, `retain`,
|
|
1004
|
+
* `handleSessionAdded` — the roster is a projection the client fills from control frames.
|
|
1005
|
+
*/
|
|
1006
|
+
/**
|
|
1007
|
+
* The client's own session ids, or `null` when the store cannot be read. ONE reader for both
|
|
1008
|
+
* the note and the adoption decision, so the door never acts on a verdict it did not report.
|
|
1009
|
+
*/
|
|
1010
|
+
function rosterIds(sessions) {
|
|
1011
|
+
const store = sessions?.list;
|
|
1012
|
+
if (typeof store?.getSnapshot !== "function") return null;
|
|
1013
|
+
const snap = store.getSnapshot();
|
|
1014
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1015
|
+
if (Array.isArray(snap?.ids)) for (const id of snap.ids) ids.add(String(id));
|
|
1016
|
+
else if (snap?.byId !== void 0) for (const k of Object.keys(snap.byId)) ids.add(k);
|
|
1017
|
+
else return null;
|
|
1018
|
+
return ids;
|
|
1019
|
+
}
|
|
1020
|
+
function rosterNote(sessions, child) {
|
|
1021
|
+
try {
|
|
1022
|
+
const ids = rosterIds(sessions);
|
|
1023
|
+
if (ids === null) return ` (roster: ${typeof (sessions?.list)?.getSnapshot !== "function" ? "no list store on the service" : "snapshot unreadable"} — presence of ${child.slice(0, 11)}… is UNKNOWN, not absent)`;
|
|
1024
|
+
if (ids.size === 0) return ` (roster: empty — presence of ${child.slice(0, 11)}… is UNKNOWN, not absent)`;
|
|
1025
|
+
const n = ids.size;
|
|
1026
|
+
return ids.has(child) ? ` (roster: contains ${child.slice(0, 11)}…, ${String(n)} sessions — open() is the failure)` : ` (roster: MISSING, ${String(n)} sessions — the client never catalogued this session)`;
|
|
1027
|
+
} catch {
|
|
1028
|
+
return "";
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
/** The service's own method names, as a hint attached to a failure note (never a guess). */
|
|
1032
|
+
function verbsNote(sessions) {
|
|
1033
|
+
try {
|
|
1034
|
+
const svc = sessions;
|
|
1035
|
+
if (svc === null || svc === void 0 || typeof svc !== "object") return " (sessions service: absent)";
|
|
1036
|
+
const found = /* @__PURE__ */ new Set();
|
|
1037
|
+
let node = svc;
|
|
1038
|
+
for (let depth = 0; depth < 6 && node !== null && node !== void 0 && node !== Object.prototype; depth++) {
|
|
1039
|
+
for (const key of Object.getOwnPropertyNames(node)) {
|
|
1040
|
+
if (key === "constructor") continue;
|
|
1041
|
+
try {
|
|
1042
|
+
if (typeof svc[key] === "function") found.add(key);
|
|
1043
|
+
} catch {}
|
|
1044
|
+
}
|
|
1045
|
+
node = Object.getPrototypeOf(node);
|
|
1046
|
+
}
|
|
1047
|
+
const verbs = [...found].sort().slice(0, 24);
|
|
1048
|
+
return verbs.length === 0 ? " (sessions service: no methods on the chain)" : ` (sessions verbs: ${verbs.join(", ")})`;
|
|
1049
|
+
} catch {
|
|
1050
|
+
return " (sessions service: unreadable)";
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
function openByService(sessions, sessionId, adopt, nav) {
|
|
1054
|
+
return (async () => {
|
|
1055
|
+
const svc = sessions;
|
|
1056
|
+
if (svc === void 0) return false;
|
|
1057
|
+
const navigate = typeof nav?.openSession === "function" ? (id) => nav.openSession(id) : typeof svc?.open === "function" ? (id) => svc.open(id) : null;
|
|
1058
|
+
if (navigate === null) {
|
|
1059
|
+
adopt?.onStep?.("no-verb");
|
|
1060
|
+
return false;
|
|
1061
|
+
}
|
|
1062
|
+
try {
|
|
1063
|
+
await svc.refresh?.();
|
|
1064
|
+
} catch {}
|
|
1065
|
+
try {
|
|
1066
|
+
svc.projectList?.();
|
|
1067
|
+
} catch {}
|
|
1068
|
+
if (adopt?.needed === true && typeof svc.create === "function") {
|
|
1069
|
+
adopt.onStep?.("enter-create");
|
|
1070
|
+
try {
|
|
1071
|
+
await svc.create({ sessionId });
|
|
1072
|
+
adopt.onStep?.("settle-create");
|
|
1073
|
+
try {
|
|
1074
|
+
svc.projectList?.();
|
|
1075
|
+
} catch {}
|
|
1076
|
+
const after = rosterIds(sessions);
|
|
1077
|
+
adopt.onAfter?.(after === null ? null : after.has(sessionId));
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
adopt.onStep?.("refuse-create");
|
|
1080
|
+
adopt.onRefused(refusalReason(error));
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
if (adopt?.needed === true && typeof svc.using === "function" && rosterIds(sessions)?.has(sessionId) === false) {
|
|
1084
|
+
adopt.onStep?.("enter-using");
|
|
1085
|
+
try {
|
|
1086
|
+
await svc.using(sessionId, { source: "dsh-chapters-door" }, async () => void 0);
|
|
1087
|
+
adopt.onStep?.("settle-using");
|
|
1088
|
+
try {
|
|
1089
|
+
svc.projectList?.();
|
|
1090
|
+
} catch {}
|
|
1091
|
+
const seen = rosterIds(sessions);
|
|
1092
|
+
adopt.onAfter?.(seen === null ? null : seen.has(sessionId));
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
adopt.onStep?.("refuse-using");
|
|
1095
|
+
adopt.onSubscribeRefused?.(refusalReason(error));
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
const started = Date.now();
|
|
1099
|
+
for (;;) try {
|
|
1100
|
+
navigate(sessionId);
|
|
1101
|
+
return true;
|
|
1102
|
+
} catch {
|
|
1103
|
+
if (Date.now() - started > 4e3) return false;
|
|
1104
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1105
|
+
}
|
|
1106
|
+
})();
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Fallback path: expand collapsed groups / show-more buttons, then click the
|
|
1110
|
+
* newest treeitem whose label contains the child title (newest-updated sorts
|
|
1111
|
+
* first, so the first match is the fresh child).
|
|
1112
|
+
*/
|
|
1113
|
+
async function openBranchRow(title, budgetMs = 4e3) {
|
|
1114
|
+
if (typeof document === "undefined") return false;
|
|
1115
|
+
const needle = title.toLowerCase();
|
|
1116
|
+
const tryClick = () => {
|
|
1117
|
+
const hit = Array.from(document.querySelectorAll("[role=\"treeitem\"]")).find((row) => (row.textContent ?? "").toLowerCase().includes(needle));
|
|
1118
|
+
if (hit === void 0) return false;
|
|
1119
|
+
hit.click();
|
|
1120
|
+
return true;
|
|
1121
|
+
};
|
|
1122
|
+
const clickMore = () => {
|
|
1123
|
+
const buttons = Array.from(document.querySelectorAll("button")).filter((b) => /more sessions|ungrouped/i.test(b.textContent ?? "") && b.getAttribute("aria-expanded") !== "true");
|
|
1124
|
+
for (const b of buttons) b.click();
|
|
1125
|
+
return buttons.length;
|
|
1126
|
+
};
|
|
1127
|
+
const started = Date.now();
|
|
1128
|
+
let expansions = 0;
|
|
1129
|
+
while (Date.now() - started < budgetMs) {
|
|
1130
|
+
if (tryClick()) return true;
|
|
1131
|
+
if (expansions < 4 && clickMore() > 0) {
|
|
1132
|
+
expansions += 1;
|
|
1133
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
1137
|
+
}
|
|
1138
|
+
return tryClick();
|
|
1139
|
+
}
|
|
1140
|
+
function makeDoorRunner(remote, sessions, door, verifySwitched, opts) {
|
|
1141
|
+
return async function run(sessionId, anchorSeq) {
|
|
1142
|
+
try {
|
|
1143
|
+
if (remote.commands?.execute === void 0) return {
|
|
1144
|
+
ok: false,
|
|
1145
|
+
message: "commands remote unavailable"
|
|
1146
|
+
};
|
|
1147
|
+
const line = typeof anchorSeq === "number" && Number.isFinite(anchorSeq) ? `${door.command} ${String(Math.trunc(anchorSeq))}` : door.command;
|
|
1148
|
+
const res = await remote.commands.execute(sessionId, line, []);
|
|
1149
|
+
if (res.ok !== true) return {
|
|
1150
|
+
ok: false,
|
|
1151
|
+
message: (res.error?.message ?? `the ${door.noun} call failed`).slice(0, 90)
|
|
1152
|
+
};
|
|
1153
|
+
const result = res.value?.result;
|
|
1154
|
+
const text = typeof result?.text === "string" ? result.text : "";
|
|
1155
|
+
if (result?.kind !== "success") return {
|
|
1156
|
+
ok: false,
|
|
1157
|
+
message: (text || `the ${door.noun} was refused — see the command result`).slice(0, 90)
|
|
1158
|
+
};
|
|
1159
|
+
const child = CHILD_ID.exec(text)?.[1];
|
|
1160
|
+
if (child === void 0) return {
|
|
1161
|
+
ok: true,
|
|
1162
|
+
message: `${door.noun} created — appearing in the sidebar`
|
|
1163
|
+
};
|
|
1164
|
+
const switchedOnce = async () => {
|
|
1165
|
+
if (verifySwitched === void 0) return true;
|
|
1166
|
+
try {
|
|
1167
|
+
return await verifySwitched(child);
|
|
1168
|
+
} catch {
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
const maxAttempts = typeof sessions?.refresh === "function" ? opts?.attempts ?? 3 : 1;
|
|
1173
|
+
let tried = 0;
|
|
1174
|
+
let adoptRefusal = "";
|
|
1175
|
+
let subscribeRefusal = "";
|
|
1176
|
+
const rosterTrace = [];
|
|
1177
|
+
const steps = [];
|
|
1178
|
+
let adoptSaw;
|
|
1179
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
1180
|
+
tried += 1;
|
|
1181
|
+
const ids = rosterIds(sessions);
|
|
1182
|
+
const needed = ids !== null && !ids.has(child);
|
|
1183
|
+
const svcShape = sessions;
|
|
1184
|
+
rosterTrace.push(`${ids === null ? "unknown" : ids.size === 0 ? "empty" : ids.has(child) ? "has" : "missing"}${typeof svcShape?.create === "function" ? "+c" : "-c"}${typeof svcShape?.using === "function" ? "+u" : "-u"}${typeof svcShape?.open === "function" ? "+o" : "-o"}${typeof opts?.navigator?.openSession === "function" ? "+w" : "-w"}`);
|
|
1185
|
+
if (await openByService(sessions, child, {
|
|
1186
|
+
needed,
|
|
1187
|
+
onRefused: (reason) => {
|
|
1188
|
+
adoptRefusal = reason;
|
|
1189
|
+
},
|
|
1190
|
+
onSubscribeRefused: (reason) => {
|
|
1191
|
+
subscribeRefusal = reason;
|
|
1192
|
+
},
|
|
1193
|
+
onAfter: (seen) => {
|
|
1194
|
+
adoptSaw = seen;
|
|
1195
|
+
},
|
|
1196
|
+
onStep: (step) => {
|
|
1197
|
+
if (!steps.includes(step)) steps.push(step);
|
|
1198
|
+
}
|
|
1199
|
+
}, opts?.navigator) && await switchedOnce()) return {
|
|
1200
|
+
ok: true,
|
|
1201
|
+
message: ""
|
|
1202
|
+
};
|
|
1203
|
+
if (i + 1 < maxAttempts) await new Promise((r) => {
|
|
1204
|
+
setTimeout(r, opts?.attemptPauseMs ?? 300).unref?.();
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
const title = doorTitle(door.noun).exec(text)?.[1];
|
|
1208
|
+
if (title !== void 0 && await openBranchRow(title) && await switchedOnce()) return {
|
|
1209
|
+
ok: true,
|
|
1210
|
+
message: ""
|
|
1211
|
+
};
|
|
1212
|
+
return verifySwitched === void 0 ? {
|
|
1213
|
+
ok: true,
|
|
1214
|
+
message: `${door.noun} created — open it in the sidebar`
|
|
1215
|
+
} : {
|
|
1216
|
+
ok: true,
|
|
1217
|
+
message: `${door.noun} created — the app did not switch after ${String(tried)} attempt${tried === 1 ? "" : "s"}; open it from the sidebar [roster per attempt: ${rosterTrace.join(" → ")}] [tried: ${steps.length === 0 ? "none" : steps.includes("no-verb") ? "no open or openSession verb on any injected service" : steps.join(", ")}] (build: ${DOOR_BUILD})${adoptRefusal === "" ? "" : ` (adoption refused: ${adoptRefusal})`}${subscribeRefusal === "" ? "" : ` (subscribe refused: ${subscribeRefusal})`}${adoptSaw === void 0 ? "" : ` (right after adopt: ${adoptSaw ? "contains" : "missing"} ${child.slice(0, 15)})`}${rosterNote(sessions, child)}${verbsNote(sessions)}`
|
|
1218
|
+
};
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
return {
|
|
1221
|
+
ok: false,
|
|
1222
|
+
message: String(error?.message ?? error).slice(0, 90)
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Execute a host command line for one session and return its text, or '' when the command plane is
|
|
1229
|
+
* unavailable. The bar's `state` pull and `off` write ride this, because it is the ONE place the client
|
|
1230
|
+
* talks to the wire: the guard row "there is no second runner body in the client chrome" exists to keep
|
|
1231
|
+
* `index.ts` as registration chrome, and a duplicated `commands.execute` in two files is how a wire
|
|
1232
|
+
* behaviour drifts from its own test (#59's one-predicate rule, applied to the transport).
|
|
1233
|
+
*/
|
|
1234
|
+
async function runCommandText(remote, sessionId, line) {
|
|
1235
|
+
const exec = remote?.commands?.execute;
|
|
1236
|
+
if (exec === void 0) return "";
|
|
1237
|
+
const res = await exec(sessionId, line, []);
|
|
1238
|
+
if (res.ok !== true) return "";
|
|
1239
|
+
return res.value?.result?.text ?? "";
|
|
1240
|
+
}
|
|
1241
|
+
// src/client/index.ts
|
|
1242
|
+
/**
|
|
1243
|
+
* dsh-chapters client entry: the per-message door buttons.
|
|
1244
|
+
*
|
|
1245
|
+
* Registers the two deterministic archive doors into the assistant action row
|
|
1246
|
+
* (`conversation.chat.assistant-actions` — the slot the official feedback
|
|
1247
|
+
* package populates beside copy/branch). Clicking one executes its host
|
|
1248
|
+
* command — `/chapters-fork`, `/chapters-continue` — through `remote.commands`,
|
|
1249
|
+
* the exact wire the composer's input line rides, then SWITCHES the app to the
|
|
1250
|
+
* fresh child:
|
|
1251
|
+
*
|
|
1252
|
+
* 1. `sessions.open(child)` — the client sessions service carries open() on
|
|
1253
|
+
* its prototype (Playwright-verified against this host; a keys-dump alone
|
|
1254
|
+
* hid it, which is why an earlier round wrongly gave up on this path);
|
|
1255
|
+
* 2. fallback: click the child's sidebar row after expanding collapsed
|
|
1256
|
+
* groups (plugin children can render under collapsed 'Ungrouped' heads);
|
|
1257
|
+
* 3. last resort: an inline note — the child is durable either way.
|
|
1258
|
+
*
|
|
1259
|
+
* There is deliberately NO second implementation of door logic here: the
|
|
1260
|
+
* buttons are mouths for the host commands (watermark, segmentation, budget,
|
|
1261
|
+
* titles, refusal-with-numbers all server-side, all tested), and the runner
|
|
1262
|
+
* they share is `src/client/doors.ts` — one body, one row per door. The purity
|
|
1263
|
+
* gate in tsdown.client.config.ts keeps this file cross-plugin-free.
|
|
1264
|
+
*
|
|
1265
|
+
* @module dsh-chapters/src/client
|
|
1266
|
+
*/
|
|
1267
|
+
const inject = [
|
|
1268
|
+
"slots",
|
|
1269
|
+
"remote",
|
|
1270
|
+
"remote.commands",
|
|
1271
|
+
"sessions",
|
|
1272
|
+
"uiWorkspace"
|
|
1273
|
+
];
|
|
1274
|
+
/**
|
|
1275
|
+
* Style parity with the native IconActions row: rules copied verbatim from the
|
|
1276
|
+
* host's MessageIconActions module (`._xzv4MW_action` in dsh-client-ui-chat's
|
|
1277
|
+
* compiled client + the feedback package's :disabled) — same metrics, tokens,
|
|
1278
|
+
* hover. Update this string if the host restyles its action row. Both doors
|
|
1279
|
+
* share these metrics.
|
|
1280
|
+
*
|
|
1281
|
+
* The final rule hides the native branch action IN THE MESSAGE ROW only
|
|
1282
|
+
* (user-approved cosmetic decision, docs/architecture.md): built-in chrome, so
|
|
1283
|
+
* the selector is its accessible label scoped to action-row buttons; both
|
|
1284
|
+
* locale spellings covered. If the host rewords the label the button simply
|
|
1285
|
+
* reappears — fails open, visibly.
|
|
1286
|
+
*/
|
|
1287
|
+
const CSS = [
|
|
1288
|
+
".dsh-chapters_forkAction{width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;border-radius:28px;justify-content:center;align-items:center;padding:6px;display:inline-flex}",
|
|
1289
|
+
".dsh-chapters_forkAction svg{width:calc(15px + var(--dsh-content-font-delta,0px));height:calc(15px + var(--dsh-content-font-delta,0px))}",
|
|
1290
|
+
".dsh-chapters_forkAction:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}",
|
|
1291
|
+
".dsh-chapters_forkAction:disabled{cursor:default;opacity:.4}",
|
|
1292
|
+
".dsh-chapters_forkNote{color:var(--dsw-alias-label-tertiary);padding-left:4px;font-size:13px;line-height:20px}",
|
|
1293
|
+
"button[class*=\"_action\"][aria-label=\"Branch into a new conversation\"],button[class*=\"_action\"][aria-label=\"在新对话中分支\"]{display:none !important}"
|
|
1294
|
+
].join("\n");
|
|
1295
|
+
const CSS_TAG_ID = "dsh-chapters/assistant-action.css";
|
|
1296
|
+
function injectStyles() {
|
|
1297
|
+
try {
|
|
1298
|
+
if (typeof document === "undefined" || document.querySelector(`style[data-plugin-css=${JSON.stringify(CSS_TAG_ID)}]`) !== null) return;
|
|
1299
|
+
const tag = document.createElement("style");
|
|
1300
|
+
tag.dataset.plugin = "dsh-chapters";
|
|
1301
|
+
tag.dataset.pluginCss = CSS_TAG_ID;
|
|
1302
|
+
tag.textContent = CSS;
|
|
1303
|
+
document.head.appendChild(tag);
|
|
1304
|
+
} catch {}
|
|
1305
|
+
}
|
|
1306
|
+
/** One component body for every door row; only the label and glyph differ. */
|
|
1307
|
+
function makeDoorAction(door) {
|
|
1308
|
+
return function ChaptersDoorAction({ run }) {
|
|
1309
|
+
const [busy, setBusy] = (0, react.useState)(false);
|
|
1310
|
+
const [note, setNote] = (0, react.useState)(null);
|
|
1311
|
+
const alive = (0, react.useRef)(true);
|
|
1312
|
+
(0, react.useEffect)(() => () => {
|
|
1313
|
+
alive.current = false;
|
|
1314
|
+
}, []);
|
|
1315
|
+
const onClick = (0, react.useCallback)(() => {
|
|
1316
|
+
if (busy) return;
|
|
1317
|
+
setBusy(true);
|
|
1318
|
+
setNote(null);
|
|
1319
|
+
run().then((result) => {
|
|
1320
|
+
if (!alive.current) return;
|
|
1321
|
+
setBusy(false);
|
|
1322
|
+
setNote(result.message === "" ? null : result.message);
|
|
1323
|
+
setTimeout(() => {
|
|
1324
|
+
if (alive.current) setNote(null);
|
|
1325
|
+
}, 8e3);
|
|
1326
|
+
});
|
|
1327
|
+
}, [busy, run]);
|
|
1328
|
+
const button = (0, react.createElement)("button", {
|
|
1329
|
+
type: "button",
|
|
1330
|
+
"aria-label": door.label,
|
|
1331
|
+
...nativeTitle(door.label),
|
|
1332
|
+
disabled: busy,
|
|
1333
|
+
onClick,
|
|
1334
|
+
className: "dsh-chapters_forkAction"
|
|
1335
|
+
}, (0, react.createElement)("svg", {
|
|
1336
|
+
viewBox: "0 0 16 16",
|
|
1337
|
+
width: "15",
|
|
1338
|
+
height: "15",
|
|
1339
|
+
fill: "currentColor",
|
|
1340
|
+
"aria-hidden": "true"
|
|
1341
|
+
}, (0, react.createElement)("path", { d: door.iconPath })));
|
|
1342
|
+
return (0, react.createElement)("span", { style: {
|
|
1343
|
+
display: "inline-flex",
|
|
1344
|
+
alignItems: "center",
|
|
1345
|
+
gap: "4px"
|
|
1346
|
+
} }, withTooltip(door.label, button), note !== null ? (0, react.createElement)("span", { className: "dsh-chapters_forkNote" }, note) : null);
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
function apply(ctx) {
|
|
1350
|
+
injectStyles();
|
|
1351
|
+
const slots = ctx.slots;
|
|
1352
|
+
const remote = ctx.get("remote") ?? {};
|
|
1353
|
+
let sessions;
|
|
1354
|
+
try {
|
|
1355
|
+
sessions = ctx.get("sessions");
|
|
1356
|
+
} catch {
|
|
1357
|
+
sessions = void 0;
|
|
1358
|
+
}
|
|
1359
|
+
let uiWorkspace;
|
|
1360
|
+
try {
|
|
1361
|
+
uiWorkspace = ctx.get("uiWorkspace");
|
|
1362
|
+
} catch {
|
|
1363
|
+
uiWorkspace = void 0;
|
|
1364
|
+
}
|
|
1365
|
+
const currentSessionId = () => {
|
|
1366
|
+
try {
|
|
1367
|
+
const raw = window.localStorage.getItem("dsh.sessions.current");
|
|
1368
|
+
if (raw === null) return null;
|
|
1369
|
+
return JSON.parse(raw)?.sessionId ?? null;
|
|
1370
|
+
} catch {
|
|
1371
|
+
return null;
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
const rows = DOOR_ACTIONS.map((door) => ({
|
|
1375
|
+
door,
|
|
1376
|
+
run: makeDoorRunner(remote, sessions, door, async (child) => {
|
|
1377
|
+
for (let i = 0; i < 8; i++) {
|
|
1378
|
+
if (currentSessionId() === child) return true;
|
|
1379
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1380
|
+
}
|
|
1381
|
+
return currentSessionId() === child;
|
|
1382
|
+
}, { ...uiWorkspace !== void 0 ? { navigator: uiWorkspace } : {} }),
|
|
1383
|
+
component: makeDoorAction(door)
|
|
1384
|
+
}));
|
|
1385
|
+
console.info(`[client:chapters] injecting ${String(rows.length)} assistant-actions door(s)`);
|
|
1386
|
+
const disposeDoors = slots.inject("conversation.chat.assistant-actions", () => {
|
|
1387
|
+
const effects = rows.map(({ door, run, component }) => slots.register({
|
|
1388
|
+
name: "conversation.chat.assistant-actions",
|
|
1389
|
+
id: door.id,
|
|
1390
|
+
label: door.label,
|
|
1391
|
+
inject: (sessionId, owner) => ({ run: async () => {
|
|
1392
|
+
let anchor = null;
|
|
1393
|
+
const mid = typeof owner?.messageId === "string" ? owner.messageId : "";
|
|
1394
|
+
if (mid !== "") try {
|
|
1395
|
+
const out = await runCommandText(remote, sessionId, `/chapters-anchor ${mid}`);
|
|
1396
|
+
const n = Number.parseInt(out.trim(), 10);
|
|
1397
|
+
if (Number.isFinite(n) && n > 0) anchor = n;
|
|
1398
|
+
} catch {}
|
|
1399
|
+
return run(sessionId, anchor);
|
|
1400
|
+
} })
|
|
1401
|
+
}, component));
|
|
1402
|
+
console.info(`[client:chapters] door factory ran: ${String(effects.length)} register effect(s), types: ${effects.map((e) => typeof e).join(",")}`);
|
|
1403
|
+
return effects;
|
|
1404
|
+
});
|
|
1405
|
+
console.info(`[client:chapters] assistant-actions inject returned ${typeof disposeDoors}`);
|
|
1406
|
+
slots.inject("conversation.input.dock", () => slots.register({
|
|
1407
|
+
name: "conversation.input.dock",
|
|
1408
|
+
id: "chapters-prompt",
|
|
1409
|
+
order: 30,
|
|
1410
|
+
inject: (sessionId) => ({ exec: (line) => runCommandText(remote, sessionId, line) })
|
|
1411
|
+
}, PromptDock));
|
|
1412
|
+
}
|
|
1413
|
+
exports.apply = apply;
|
|
1414
|
+
exports.inject = inject;
|
|
1415
|
+
return module.exports;
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
//# sourceMappingURL=client.js.map
|