chamba 0.3.0 → 0.4.0
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 +21 -0
- package/README.md +47 -44
- package/bin/chamba.js +212 -0
- package/dist/commands/advanced.js +278 -0
- package/dist/commands/dev.js +619 -0
- package/dist/commands/doctor.js +29 -0
- package/dist/commands/menu.js +80 -0
- package/dist/commands/onboard.js +229 -0
- package/dist/commands/settings.js +349 -0
- package/dist/lib/agent-context.js +177 -0
- package/dist/lib/browser.js +40 -0
- package/dist/lib/chamba-yaml.js +191 -0
- package/dist/lib/constants.js +135 -0
- package/dist/lib/dockerfile-builder.js +267 -0
- package/dist/lib/env.js +78 -0
- package/dist/lib/global-config.js +66 -0
- package/dist/lib/pnpm-store.js +19 -0
- package/dist/lib/ports.js +210 -0
- package/dist/lib/safe-rm.js +26 -0
- package/dist/lib/sessions.js +34 -0
- package/dist/lib/shadows.js +174 -0
- package/dist/lib/webterm.js +490 -0
- package/dist/lib/workspace-identity.js +260 -0
- package/package.json +61 -24
- package/schema/chamba.schema.json +65 -0
- package/templates/.dockerignore +3 -0
- package/templates/Dockerfile +173 -0
- package/templates/claude-statusline.sh +120 -0
- package/templates/context/baseline.md +13 -0
- package/templates/context/context-usage.md +1 -0
- package/templates/context/git-mode-local.md +1 -0
- package/templates/context/git-mode-strict.md +1 -0
- package/templates/context/git-mode-unrestricted.md +1 -0
- package/templates/context/git-unavailable.md +1 -0
- package/templates/context/shadow-paths.md +3 -0
- package/templates/context-usage.sh +249 -0
- package/templates/git-readonly-wrapper.mjs +309 -0
- package/templates/npmrc +2 -0
- package/templates/pnpm-config.yaml +9 -0
- package/templates/runtime-constants.mjs +18 -0
- package/templates/skills/chamba-statusline/SKILL.md +79 -0
- package/templates/skills/context-usage/SKILL.md +53 -0
- package/templates/skills/web-pane/SKILL.md +62 -0
- package/templates/startup-git-mode.mjs +145 -0
- package/templates/startup.mjs +333 -0
- package/templates/webpane.sh +126 -0
- package/templates/webterm/README.md +157 -0
- package/templates/webterm/artifacts.js +583 -0
- package/templates/webterm/config.js +269 -0
- package/templates/webterm/context/claude.md +14 -0
- package/templates/webterm/conversation.js +248 -0
- package/templates/webterm/package-lock.json +884 -0
- package/templates/webterm/package.json +17 -0
- package/templates/webterm/pane.js +156 -0
- package/templates/webterm/proc.js +89 -0
- package/templates/webterm/public/app/alerts.js +472 -0
- package/templates/webterm/public/app/cards.js +123 -0
- package/templates/webterm/public/app/clipboard.js +229 -0
- package/templates/webterm/public/app/composer.js +226 -0
- package/templates/webterm/public/app/connection.js +342 -0
- package/templates/webterm/public/app/dictation.js +98 -0
- package/templates/webterm/public/app/dom.js +37 -0
- package/templates/webterm/public/app/drafts.js +244 -0
- package/templates/webterm/public/app/frames.js +166 -0
- package/templates/webterm/public/app/main.js +82 -0
- package/templates/webterm/public/app/new-session.js +188 -0
- package/templates/webterm/public/app/note.js +24 -0
- package/templates/webterm/public/app/pane-frame.js +166 -0
- package/templates/webterm/public/app/pane.js +353 -0
- package/templates/webterm/public/app/state.js +51 -0
- package/templates/webterm/public/app/status-strip.js +170 -0
- package/templates/webterm/public/app/tabs.js +475 -0
- package/templates/webterm/public/app/terminal.js +102 -0
- package/templates/webterm/public/app/theme.js +46 -0
- package/templates/webterm/public/favicon.svg +21 -0
- package/templates/webterm/public/index.html +105 -0
- package/templates/webterm/public/styles.css +1193 -0
- package/templates/webterm/server.js +1142 -0
- package/templates/webterm/sessions.js +515 -0
- package/templates/webterm/snapshot.js +135 -0
- package/templates/webterm.sh +167 -0
- package/dist/cli.js +0 -1691
- package/dist/server.js +0 -1919
- package/inject/annotate.js +0 -18
- package/skill/README.md +0 -12
- package/skill/SKILL.md +0 -93
- package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
- package/web/assets/index-B9DI4F1Z.js +0 -202
- package/web/assets/index-DK_n6CTo.css +0 -2
- package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
- package/web/index.html +0 -28
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// connection.js - the one socket everything on this page rides on, and the curtain for when it is not there.
|
|
2
|
+
//
|
|
3
|
+
// The socket carries the key this window was handed in its URL, the reconnect loop heals a blip on its own, and
|
|
4
|
+
// the curtain is the one state that says "this window cannot do anything at all". They live together because
|
|
5
|
+
// they are the same subject read from two ends: what the socket is doing, and what the user is told about it.
|
|
6
|
+
|
|
7
|
+
import { refreshComposer } from "./composer.js";
|
|
8
|
+
import { curtain, curtainCard } from "./dom.js";
|
|
9
|
+
import { onFrame } from "./frames.js";
|
|
10
|
+
import { attachedSid, KEY_QUERY, sessions } from "./state.js";
|
|
11
|
+
import { term } from "./terminal.js";
|
|
12
|
+
|
|
13
|
+
// Whether the socket is up right now, and whether it has ever been up on this page. The second one is what
|
|
14
|
+
// tells an opened page from a reconnected one, which the server treats differently.
|
|
15
|
+
export let connected = false;
|
|
16
|
+
export let everConnected = false;
|
|
17
|
+
|
|
18
|
+
// --- The curtain -------------------------------------------------------------------------------------------------------------------------
|
|
19
|
+
//
|
|
20
|
+
// One state for "this window cannot do anything at all", drawn over the whole page - bar, terminal and
|
|
21
|
+
// composer alike. It replaces disabling each control on its own, which is what used to leave a dead page
|
|
22
|
+
// looking alive: tabs that still hovered, and an X that opened an end-session prompt nothing would answer.
|
|
23
|
+
//
|
|
24
|
+
// Four reasons a window ends up here, and they are not the same to the user:
|
|
25
|
+
// down - nothing answers. The container is stopped or gone; keeps reconnecting, so it heals itself.
|
|
26
|
+
// locked - the relay answered and refused this window's key. The interface restarted and minted a new
|
|
27
|
+
// one, so this URL is spent; reconnecting is pointless and stops.
|
|
28
|
+
// stopping - the user just stopped the container from here. Same end state as "down", but it is not a
|
|
29
|
+
// failure and must not read like one.
|
|
30
|
+
// stopped - it has gone. The one curtain that replaces another rather than appearing on its own: stopping
|
|
31
|
+
// is a wait, and a wait that never resolves is indistinguishable from a page that hung.
|
|
32
|
+
//
|
|
33
|
+
// A dropped socket is routine (a sleeping laptop, a wifi blip), so the curtain waits out a short grace and
|
|
34
|
+
// usually never appears. What does happen immediately is the page going inert, because a click landing on
|
|
35
|
+
// a control whose answer goes nowhere is the actual bug.
|
|
36
|
+
const CURTAIN_DELAY_MS = 3_000;
|
|
37
|
+
|
|
38
|
+
let curtainKind = null;
|
|
39
|
+
let curtainTimer = null;
|
|
40
|
+
// Set for the two states nothing on this page can recover from: no more reconnecting, no more probing.
|
|
41
|
+
let halted = false;
|
|
42
|
+
|
|
43
|
+
// Whether a curtain is up, which is what makes the page inert - nothing types into a page that cannot answer.
|
|
44
|
+
export function isCurtainUp() {
|
|
45
|
+
return curtainKind !== null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The container is still here after all, so this window may go back to reconnecting.
|
|
49
|
+
export function resumeReconnects() {
|
|
50
|
+
halted = false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Three dots that say a curtain is waiting for something rather than reporting a state that has settled. Three
|
|
54
|
+
// elements with staggered fades rather than an animated `content`, which not every engine interpolates.
|
|
55
|
+
function pendingDots() {
|
|
56
|
+
const dots = document.createElement("span");
|
|
57
|
+
dots.className = "dots";
|
|
58
|
+
for (let i = 0; i < 3; i++) {
|
|
59
|
+
const dot = document.createElement("i");
|
|
60
|
+
dot.style.animationDelay = `${i * 180}ms`;
|
|
61
|
+
dots.append(dot);
|
|
62
|
+
}
|
|
63
|
+
return dots;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function showCurtain(kind, title, body, action) {
|
|
67
|
+
curtainKind = kind;
|
|
68
|
+
curtainCard.textContent = "";
|
|
69
|
+
const heading = document.createElement("h2");
|
|
70
|
+
heading.textContent = title;
|
|
71
|
+
// One of these states is a wait rather than a report: the container is on its way down and this page is
|
|
72
|
+
// watching for it to land. The dots say so, and they are gone the moment it has.
|
|
73
|
+
if (kind === "stopping") heading.append(pendingDots());
|
|
74
|
+
const text = document.createElement("p");
|
|
75
|
+
text.textContent = body;
|
|
76
|
+
curtainCard.append(heading, text);
|
|
77
|
+
if (action) {
|
|
78
|
+
const row = document.createElement("div");
|
|
79
|
+
row.className = "row";
|
|
80
|
+
const button = document.createElement("button");
|
|
81
|
+
button.type = "button";
|
|
82
|
+
button.className = "btn primary";
|
|
83
|
+
button.textContent = action.label;
|
|
84
|
+
button.addEventListener("click", action.run);
|
|
85
|
+
row.append(button);
|
|
86
|
+
curtainCard.append(row);
|
|
87
|
+
}
|
|
88
|
+
curtain.classList.add("show");
|
|
89
|
+
// The terminal keeps focus through everything else, so without this the keyboard would still be typing
|
|
90
|
+
// into a session behind the curtain.
|
|
91
|
+
term.blur();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function hideCurtain() {
|
|
95
|
+
curtainKind = null;
|
|
96
|
+
curtain.classList.remove("show");
|
|
97
|
+
curtainCard.textContent = "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Nothing answers. Held back by the grace delay, since most disconnects are over before it fires.
|
|
101
|
+
function armDownCurtain() {
|
|
102
|
+
if (curtainTimer || curtainKind || halted) return;
|
|
103
|
+
curtainTimer = setTimeout(() => {
|
|
104
|
+
curtainTimer = null;
|
|
105
|
+
if (connected || halted) return;
|
|
106
|
+
showCurtain(
|
|
107
|
+
"down",
|
|
108
|
+
"Can't reach the container",
|
|
109
|
+
"It is stopped, or still coming back. Nothing was lost: sessions live in the container, and this window " +
|
|
110
|
+
"picks them up again the moment it answers.",
|
|
111
|
+
{ label: "Try again", run: reconnectNow },
|
|
112
|
+
);
|
|
113
|
+
}, CURTAIN_DELAY_MS);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function clearCurtainTimer() {
|
|
117
|
+
if (!curtainTimer) return;
|
|
118
|
+
clearTimeout(curtainTimer);
|
|
119
|
+
curtainTimer = null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The relay is up and refused this window. Shown at once - a spent key does not heal, and the wait would
|
|
123
|
+
// only delay telling the user where the current URL is.
|
|
124
|
+
function lockedCurtain() {
|
|
125
|
+
halted = true;
|
|
126
|
+
clearCurtainTimer();
|
|
127
|
+
showCurtain(
|
|
128
|
+
"locked",
|
|
129
|
+
"This link is no longer valid",
|
|
130
|
+
"The web interface issues a new key every time it starts, and this window is holding the old one. Get the " +
|
|
131
|
+
"current URL from the session greeting, or by running webterm <agent> in the container.",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The user asked for this one, so it says so rather than reporting a failure. It is also the only curtain that is
|
|
136
|
+
// still waiting on something: the container takes a moment to go, and a page cannot see that from inside itself.
|
|
137
|
+
// So this is the first half of a pair, and it deliberately says nothing yet about what to do next - the container
|
|
138
|
+
// has not gone, and a container that turns out not to stop at all recovers this page instead.
|
|
139
|
+
export function stoppingCurtain() {
|
|
140
|
+
halted = true;
|
|
141
|
+
clearCurtainTimer();
|
|
142
|
+
showCurtain("stopping", "Stopping the container", "Every session in it is ending.");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The relay went while the container was on its way down, which is the container going: nothing else takes the
|
|
146
|
+
// server with it at that moment. This is the half that says it is over and what to do next - "Stopping the
|
|
147
|
+
// container" left up for good reads as a page that got stuck halfway through, which is exactly what it looks like
|
|
148
|
+
// when the thing it was waiting for happened seconds ago.
|
|
149
|
+
function stoppedCurtain() {
|
|
150
|
+
halted = true;
|
|
151
|
+
clearCurtainTimer();
|
|
152
|
+
showCurtain(
|
|
153
|
+
"stopped",
|
|
154
|
+
"The container is stopped",
|
|
155
|
+
"Every session in it has ended, and nothing on disk was touched. Start your next one with npx chamba on the " +
|
|
156
|
+
"host - it comes back with a fresh URL, since the key goes with the container.",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- The socket --------------------------------------------------------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export const LAST_SID_KEY = "webterm-last-session";
|
|
163
|
+
const RECONNECT_MIN_MS = 500;
|
|
164
|
+
const RECONNECT_MAX_MS = 5_000;
|
|
165
|
+
// How long a woken window waits for an answer before treating the socket as dead. A live loopback socket
|
|
166
|
+
// answers in milliseconds; one the OS has not yet noticed is gone answers never.
|
|
167
|
+
const PROBE_TIMEOUT_MS = 3_000;
|
|
168
|
+
|
|
169
|
+
// Built when a socket is opened, not when this module loads: reading another module's value at load time
|
|
170
|
+
// would depend on that module being a leaf, and the graph has cycles.
|
|
171
|
+
function wsUrl() {
|
|
172
|
+
return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws${KEY_QUERY}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let ws = null;
|
|
176
|
+
let reconnectDelay = RECONNECT_MIN_MS;
|
|
177
|
+
let reconnectTimer = null;
|
|
178
|
+
let probeTimer = null;
|
|
179
|
+
|
|
180
|
+
export function sendFrame(frame) {
|
|
181
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Any frame at all proves the socket works, which is what a wake probe is waiting to hear. Called by the frame
|
|
185
|
+
// router, since every frame counts and only it sees them all.
|
|
186
|
+
export function noteSocketAlive() {
|
|
187
|
+
if (probeTimer) {
|
|
188
|
+
clearTimeout(probeTimer);
|
|
189
|
+
probeTimer = null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Retire the current socket before replacing it, handlers first: a late close or error from the socket
|
|
194
|
+
// being abandoned must not schedule a second reconnect or overwrite the new one's state.
|
|
195
|
+
function dropSocket() {
|
|
196
|
+
if (!ws) return;
|
|
197
|
+
ws.onopen = null;
|
|
198
|
+
ws.onmessage = null;
|
|
199
|
+
ws.onclose = null;
|
|
200
|
+
ws.onerror = null;
|
|
201
|
+
try {
|
|
202
|
+
ws.close();
|
|
203
|
+
} catch {
|
|
204
|
+
// Already closing.
|
|
205
|
+
}
|
|
206
|
+
ws = null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function connect() {
|
|
210
|
+
dropSocket();
|
|
211
|
+
ws = new WebSocket(wsUrl());
|
|
212
|
+
|
|
213
|
+
ws.onopen = () => {
|
|
214
|
+
// Read before the flag is set: this is the page's first socket, as opposed to a reconnect. It is
|
|
215
|
+
// what tells the server whether an empty container should get a session - opening the page means
|
|
216
|
+
// "put me somewhere", coming back from a blip means "give me back what I had", empty bar included.
|
|
217
|
+
const fresh = !everConnected;
|
|
218
|
+
connected = true;
|
|
219
|
+
everConnected = true;
|
|
220
|
+
reconnectDelay = RECONNECT_MIN_MS;
|
|
221
|
+
// Whatever was wrong is over: the page comes back to life before anything else is sent.
|
|
222
|
+
clearCurtainTimer();
|
|
223
|
+
hideCurtain();
|
|
224
|
+
document.body.classList.remove("offline");
|
|
225
|
+
// Ask for the session this window was last looking at. The server decides whether it can have
|
|
226
|
+
// it back, and picks something sensible when it cannot.
|
|
227
|
+
// The last session this window drove: the server hands it back when no other window is on it.
|
|
228
|
+
sendFrame({ t: "hello", sid: sessionStorage.getItem(LAST_SID_KEY) ?? "", fresh });
|
|
229
|
+
refreshComposer();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
ws.onmessage = (event) => {
|
|
233
|
+
let msg;
|
|
234
|
+
try {
|
|
235
|
+
msg = JSON.parse(event.data);
|
|
236
|
+
} catch {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
onFrame(msg);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// A dropped socket says nothing about the session on the other side, so the screen stays exactly as
|
|
243
|
+
// it is and the window just reconnects. This is what makes closing the lid harmless.
|
|
244
|
+
ws.onclose = () => {
|
|
245
|
+
connected = false;
|
|
246
|
+
// Immediately, ahead of any curtain: every control on the page is answered over this socket, so
|
|
247
|
+
// while it is down they must stop taking clicks.
|
|
248
|
+
document.body.classList.add("offline");
|
|
249
|
+
refreshComposer();
|
|
250
|
+
// The container was on its way down and the relay has now gone with it. That is the confirmation the
|
|
251
|
+
// stopping curtain is waiting for, and there is nothing to diagnose: this close was the point.
|
|
252
|
+
if (curtainKind === "stopping") {
|
|
253
|
+
stoppedCurtain();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
diagnose();
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
ws.onerror = () => {
|
|
260
|
+
// onclose follows and does the work.
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Why the socket went away, asked of the one route that can answer without one. A refusal means the relay
|
|
265
|
+
// is alive and this window's key is not the live one any more - a different thing from a container that is
|
|
266
|
+
// gone, and the only one reconnecting cannot fix. Anything else (an answer, or nothing at all) goes back
|
|
267
|
+
// through the reconnect loop, so a blip heals silently and a stopped container heals when it comes back.
|
|
268
|
+
// Run on every close, not just the first: it is also what notices a relay that came back with a new key.
|
|
269
|
+
async function diagnose() {
|
|
270
|
+
if (halted) return;
|
|
271
|
+
try {
|
|
272
|
+
const res = await fetch(`/status${KEY_QUERY}`, { cache: "no-store", signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
273
|
+
if (res.status === 403) {
|
|
274
|
+
lockedCurtain();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
// Nothing answered, which is the ordinary "container is down" case.
|
|
279
|
+
}
|
|
280
|
+
scheduleReconnect();
|
|
281
|
+
armDownCurtain();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function scheduleReconnect() {
|
|
285
|
+
if (halted || reconnectTimer) return;
|
|
286
|
+
reconnectTimer = setTimeout(() => {
|
|
287
|
+
reconnectTimer = null;
|
|
288
|
+
connect();
|
|
289
|
+
}, reconnectDelay);
|
|
290
|
+
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function reconnectNow() {
|
|
294
|
+
if (halted) return;
|
|
295
|
+
if (reconnectTimer) {
|
|
296
|
+
clearTimeout(reconnectTimer);
|
|
297
|
+
reconnectTimer = null;
|
|
298
|
+
}
|
|
299
|
+
reconnectDelay = RECONNECT_MIN_MS;
|
|
300
|
+
connect();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Waking up is the case a plain reconnect loop misses: after a sleep the socket can look OPEN for
|
|
304
|
+
// minutes while the other end is long gone. So the window asks, and reconnects when nothing answers.
|
|
305
|
+
function probeConnection() {
|
|
306
|
+
if (halted) return; // Nothing to wake up to: this window's way back is a new URL, not a new socket.
|
|
307
|
+
if (!connected) {
|
|
308
|
+
reconnectNow();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (probeTimer) return;
|
|
312
|
+
probeTimer = setTimeout(() => {
|
|
313
|
+
probeTimer = null;
|
|
314
|
+
reconnectNow();
|
|
315
|
+
}, PROBE_TIMEOUT_MS);
|
|
316
|
+
sendFrame({ t: "ping" });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
window.addEventListener("online", probeConnection);
|
|
320
|
+
document.addEventListener("visibilitychange", () => {
|
|
321
|
+
if (!document.hidden) probeConnection();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// --- Telling the server you are here -----------------------------------------------------------------------------------------------------
|
|
325
|
+
//
|
|
326
|
+
// The server cannot see the user, and one thing depends on it: whether an alert that is standing has been answered,
|
|
327
|
+
// which is what calls off the sound waiting behind it. A touch is anything deliberate - a key, a click, a scroll.
|
|
328
|
+
// Bare mouse movement is not one: a pointer crossing the window on its way somewhere else says nothing about who is
|
|
329
|
+
// reading it.
|
|
330
|
+
//
|
|
331
|
+
// Only ever sent while the session in front of you is actually waiting on you. Nothing else is listening for it, so
|
|
332
|
+
// the quiet case - which is nearly all of the time - costs no traffic at all, and there is nothing to throttle.
|
|
333
|
+
function noteSeen() {
|
|
334
|
+
if (!attachedSid) return;
|
|
335
|
+
const entry = sessions.find((session) => session.id === attachedSid);
|
|
336
|
+
if (!entry?.attention) return;
|
|
337
|
+
sendFrame({ t: "seen" });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
document.addEventListener("pointerdown", noteSeen, { passive: true });
|
|
341
|
+
document.addEventListener("keydown", noteSeen);
|
|
342
|
+
document.addEventListener("wheel", noteSeen, { passive: true });
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// dictation.js - the microphone button (Web Speech API, best-effort).
|
|
2
|
+
//
|
|
3
|
+
// Note: this needs a real Chromium browser (Chrome/Edge/Brave/Arc). Embedded webviews such as the
|
|
4
|
+
// VS Code / Codium "Simple Browser" have no microphone access, so dictation cannot work there.
|
|
5
|
+
|
|
6
|
+
import { insertAtCursor } from "./composer.js";
|
|
7
|
+
import { input } from "./dom.js";
|
|
8
|
+
import { captureDraft, schedulePersist } from "./drafts.js";
|
|
9
|
+
import { noteMsg } from "./note.js";
|
|
10
|
+
import { attachedSid } from "./state.js";
|
|
11
|
+
|
|
12
|
+
const micBtn = document.getElementById("mic");
|
|
13
|
+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
14
|
+
const MIC_HELP = "dictation needs a real browser (Chrome/Edge) with mic access - it cannot work in the editor's embedded browser";
|
|
15
|
+
let recognition = null;
|
|
16
|
+
let recording = false;
|
|
17
|
+
// The session dictation was started in. Recognition results arrive a moment after the speech, so a switch in
|
|
18
|
+
// between would otherwise drop a sentence meant for one agent into another one's box.
|
|
19
|
+
let dictationSid = null;
|
|
20
|
+
|
|
21
|
+
// Dictation is this window talking, not session state, so switching sessions ends it rather than carrying a
|
|
22
|
+
// half-finished transcript into the next conversation.
|
|
23
|
+
export function stopDictation() {
|
|
24
|
+
if (!recording || !recognition) return;
|
|
25
|
+
try {
|
|
26
|
+
recognition.stop();
|
|
27
|
+
} catch {
|
|
28
|
+
// Not running after all; onend clears the flag either way.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!SpeechRecognition) {
|
|
33
|
+
micBtn.disabled = true;
|
|
34
|
+
micBtn.title = "Dictation not supported in this browser";
|
|
35
|
+
} else {
|
|
36
|
+
recognition = new SpeechRecognition();
|
|
37
|
+
recognition.interimResults = false;
|
|
38
|
+
recognition.continuous = true;
|
|
39
|
+
|
|
40
|
+
recognition.onresult = (e) => {
|
|
41
|
+
// A result that outlived the session it was dictated for is dropped rather than misfiled.
|
|
42
|
+
if (dictationSid && dictationSid !== attachedSid) return;
|
|
43
|
+
let transcript = "";
|
|
44
|
+
for (let i = e.resultIndex; i < e.results.length; i++) transcript += e.results[i][0].transcript;
|
|
45
|
+
if (!transcript) return;
|
|
46
|
+
insertAtCursor(transcript.trim());
|
|
47
|
+
captureDraft(attachedSid);
|
|
48
|
+
schedulePersist();
|
|
49
|
+
};
|
|
50
|
+
recognition.onerror = (e) => {
|
|
51
|
+
if (e.error === "not-allowed" || e.error === "service-not-allowed") {
|
|
52
|
+
noteMsg(MIC_HELP, true);
|
|
53
|
+
} else if (e.error === "no-speech") {
|
|
54
|
+
noteMsg("dictation: no speech detected", true);
|
|
55
|
+
} else {
|
|
56
|
+
noteMsg(`dictation: ${e.error}`, true);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
recognition.onend = () => {
|
|
60
|
+
recording = false;
|
|
61
|
+
dictationSid = null;
|
|
62
|
+
micBtn.classList.remove("recording");
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Prime the mic permission via getUserMedia first: SpeechRecognition on its own often fails
|
|
66
|
+
// straight to "not-allowed" without prompting. In an embedded webview getUserMedia is missing
|
|
67
|
+
// or blocked, so we surface the "use a real browser" hint.
|
|
68
|
+
async function startDictation() {
|
|
69
|
+
try {
|
|
70
|
+
if (!navigator.mediaDevices?.getUserMedia) {
|
|
71
|
+
noteMsg(MIC_HELP, true);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
75
|
+
for (const track of stream.getTracks()) track.stop();
|
|
76
|
+
} catch {
|
|
77
|
+
noteMsg(MIC_HELP, true);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
recognition.start();
|
|
82
|
+
recording = true;
|
|
83
|
+
dictationSid = attachedSid;
|
|
84
|
+
micBtn.classList.add("recording");
|
|
85
|
+
input.focus();
|
|
86
|
+
} catch {
|
|
87
|
+
// start() throws if already running; ignore.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
micBtn.addEventListener("click", () => {
|
|
92
|
+
if (recording) {
|
|
93
|
+
recognition.stop();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
startDictation();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// dom.js - the elements the page ships with, looked up once.
|
|
2
|
+
//
|
|
3
|
+
// Only what index.html holds: everything the bar, the cards and the composer draw is built as it is needed and
|
|
4
|
+
// never queried back. An element that belongs to exactly one part of the interface (the microphone button, the
|
|
5
|
+
// icon link) is looked up there instead, so this list stays the page's frame rather than a bag of ids.
|
|
6
|
+
|
|
7
|
+
export const tabbar = document.getElementById("tabbar");
|
|
8
|
+
export const termEl = document.getElementById("term");
|
|
9
|
+
export const overlay = document.getElementById("overlay");
|
|
10
|
+
export const card = document.getElementById("card");
|
|
11
|
+
export const curtain = document.getElementById("curtain");
|
|
12
|
+
export const curtainCard = document.getElementById("curtain-card");
|
|
13
|
+
export const input = document.getElementById("input");
|
|
14
|
+
export const sendBtn = document.getElementById("send");
|
|
15
|
+
export const attachBtn = document.getElementById("attach");
|
|
16
|
+
export const fileInput = document.getElementById("file-input");
|
|
17
|
+
export const note = document.getElementById("note");
|
|
18
|
+
|
|
19
|
+
// The web pane. Every one of these is chrome the interface draws around a published page - the page itself
|
|
20
|
+
// lives in a frame built by app/pane-frame.js and is never queried back.
|
|
21
|
+
export const grip = document.getElementById("grip");
|
|
22
|
+
export const pane = document.getElementById("pane");
|
|
23
|
+
export const pagebar = document.getElementById("pagebar");
|
|
24
|
+
export const panePrev = document.getElementById("pane-prev");
|
|
25
|
+
export const paneNext = document.getElementById("pane-next");
|
|
26
|
+
export const chips = document.getElementById("chips");
|
|
27
|
+
export const pageCount = document.getElementById("page-count");
|
|
28
|
+
export const paneCollapse = document.getElementById("pane-collapse");
|
|
29
|
+
export const pageDoc = document.getElementById("page-doc");
|
|
30
|
+
export const spine = document.getElementById("spine");
|
|
31
|
+
export const spineBadge = document.getElementById("spine-badge");
|
|
32
|
+
|
|
33
|
+
// The status strip. One element: everything in it is built per frame from the attached session's snapshot.
|
|
34
|
+
export const strip = document.getElementById("strip");
|
|
35
|
+
|
|
36
|
+
// Every icon in the bar is drawn rather than shipped, because the bar is rebuilt from scratch on every frame.
|
|
37
|
+
export const SVG_NS = "http://www.w3.org/2000/svg";
|