chamba 0.8.0 → 0.9.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/README.md +7 -1
- package/dist/lib/chamba-yaml.js +4 -4
- package/package.json +1 -1
- package/templates/Dockerfile +3 -3
- package/templates/context/baseline.md +2 -2
- package/templates/context/web-pane-craft.md +1 -1
- package/templates/pane-apps/client/assets/api-BBDf_rzz.js +12 -0
- package/templates/pane-apps/client/assets/api-DNLbSBpK.css +1 -0
- package/templates/pane-apps/client/assets/bee-B7t97l5B.webp +0 -0
- package/templates/pane-apps/client/assets/buzz-CUUsbnXH.mp3 +0 -0
- package/templates/pane-apps/client/assets/files-BkmyervY.js +1 -0
- package/templates/pane-apps/client/assets/home-3SeGzySM.css +1 -0
- package/templates/pane-apps/client/assets/home-Cu9xOd2i.js +1 -0
- package/templates/pane-apps/client/assets/pop-D5ZANBC3.mp3 +0 -0
- package/templates/pane-apps/client/assets/reviews-DMrGB-kG.js +153 -0
- package/templates/pane-apps/client/assets/reviews-nK_yRav9.css +1 -0
- package/templates/pane-apps/client/assets/specs-Cabb15F0.js +13 -0
- package/templates/pane-apps/client/assets/useNow-BHFKFraS.js +1 -0
- package/templates/pane-apps/client/home/index.html +16 -0
- package/templates/pane-apps/client/reviews/index.html +6 -4
- package/templates/pane-apps/client/specs/index.html +4 -3
- package/templates/pane-apps/server/home.mjs +12 -0
- package/templates/pane-apps/server/reviews.mjs +19 -18
- package/templates/pane-apps/server/specs.mjs +4 -4
- package/templates/skills/dx-review/SKILL.md +16 -3
- package/templates/skills/dx-review/references/acts.md +86 -15
- package/templates/skills/dx-spec/SKILL.md +7 -3
- package/templates/skills/dx-spec/references/review-guide.md +2 -2
- package/templates/skills/dx-spec/references/spec-guide.md +1 -1
- package/templates/skills/dx-spec/references/stages.md +6 -2
- package/templates/skills/dx-spec-execute/SKILL.md +73 -7
- package/templates/startup.mjs +13 -13
- package/templates/tool-helper.sh +4 -2
- package/templates/webterm/README.md +34 -13
- package/templates/webterm/config.js +20 -0
- package/templates/webterm/public/app/alerts.js +92 -11
- package/templates/webterm/public/app/composer.js +37 -3
- package/templates/webterm/public/app/connection.js +14 -0
- package/templates/webterm/public/app/dom.js +1 -0
- package/templates/webterm/public/app/frames.js +1 -1
- package/templates/webterm/public/app/home-host.js +16 -0
- package/templates/webterm/public/app/main.js +4 -2
- package/templates/webterm/public/app/pane-shape.js +5 -3
- package/templates/webterm/public/app/pane-shell.js +45 -5
- package/templates/webterm/public/app/pane.js +8 -2
- package/templates/webterm/public/app/reviews-host.js +5 -1
- package/templates/webterm/public/app/tool-host.js +19 -3
- package/templates/webterm/public/app/workspace-color.js +1 -1
- package/templates/webterm/public/index.html +5 -1
- package/templates/webterm/public/styles.css +52 -0
- package/templates/webterm/tool-document.js +3 -2
- package/templates/webterm/tools/home.js +58 -0
- package/templates/webterm/tools/index.js +5 -4
- package/templates/webterm/tools/reviews.js +7 -3
- package/templates/pane-apps/client/assets/files-D0nJgFAA.js +0 -12
- package/templates/pane-apps/client/assets/files-h0K1vZyQ.css +0 -1
- package/templates/pane-apps/client/assets/reviews-BsoGZ35r.css +0 -1
- package/templates/pane-apps/client/assets/reviews-iW0Mbn4e.js +0 -153
- package/templates/pane-apps/client/assets/specs-D9z3neR-.js +0 -13
|
@@ -9,6 +9,7 @@ import { sessions, workspaceName } from "./state.js";
|
|
|
9
9
|
import { arrivedAt, renderBar } from "./tabs.js";
|
|
10
10
|
import { focusTerminal } from "./terminal.js";
|
|
11
11
|
import { workspaceColor } from "./theme.js";
|
|
12
|
+
import { toolsHearSound } from "./tool-host.js";
|
|
12
13
|
|
|
13
14
|
// --- The browser's own tab ---------------------------------------------------------------------------------------------------------------
|
|
14
15
|
//
|
|
@@ -20,6 +21,12 @@ import { workspaceColor } from "./theme.js";
|
|
|
20
21
|
// nothing - nothing is happening in here
|
|
21
22
|
// white bar across the bottom - an agent is working, with a lit segment moving along it
|
|
22
23
|
// green dot in the top corner - an agent finished and is waiting for you, pulsing until you go and look
|
|
24
|
+
// grey all through, and still - this window cannot reach its container, so it says nothing else at all
|
|
25
|
+
//
|
|
26
|
+
// The grey one is the odd member of the four, because it is about this window rather than about the container.
|
|
27
|
+
// Every other mark stands on the last sessions frame the server sent, and while the socket is down that frame
|
|
28
|
+
// only gets older - so a green dot calls you back to a session this window can no longer see. Draining the
|
|
29
|
+
// icon says the one thing that is still true, and the title and the chime below are held to the same rule.
|
|
23
30
|
//
|
|
24
31
|
// One mark at a time, and the two are told apart by where they sit before any colour is read: working owns the
|
|
25
32
|
// bottom edge, waiting owns the top-right corner. That was the lesson of the first version, where both were a
|
|
@@ -50,10 +57,27 @@ const PULSE_TICKS = 3;
|
|
|
50
57
|
const SWEEP_TICKS = 16;
|
|
51
58
|
const DONE_COLOR = "#2fe58a";
|
|
52
59
|
const BUSY_COLOR = "#e6edf3";
|
|
60
|
+
// The offline pair: the interface's own muted text grey for the spark, and its border grey for the frame. Flat,
|
|
61
|
+
// with no gradient - at 16px a gradient between two greys says nothing. Anything fainter than this reads as a
|
|
62
|
+
// broken tab rather than an offline one, which is a different thing to tell someone.
|
|
63
|
+
const OFFLINE_SPARK = "#8b949e";
|
|
64
|
+
const OFFLINE_FRAME = "#30363d";
|
|
65
|
+
// How long the socket has to stay down before the icon says so. Most disconnects are a sleeping laptop or a wifi
|
|
66
|
+
// blip and are over inside this, so the icon holds still rather than flickering grey and back. The curtain waits
|
|
67
|
+
// the same three seconds, and the two waits cannot be shared: the curtain's is measured from the end of a /status
|
|
68
|
+
// request that has three seconds of its own, and it is skipped entirely for a window that has stopped trying.
|
|
69
|
+
// It is not only the icon's: the chime below reads the same flag, so lengthening this leaves a window sounding
|
|
70
|
+
// for a container it cannot reach for that much longer.
|
|
71
|
+
const OFFLINE_GRACE_MS = 3_000;
|
|
53
72
|
|
|
54
73
|
let iconCanvas = null;
|
|
55
74
|
let iconTimer = null;
|
|
56
75
|
let iconFrame = 0;
|
|
76
|
+
// Whether this window has given up on reaching its container, and the grace that is counting towards it. A
|
|
77
|
+
// slower thing than the `offline` class on the body, which goes on at the first close: that one stops the page
|
|
78
|
+
// taking clicks it cannot answer, and this one waits to see whether there is anything worth saying.
|
|
79
|
+
let offline = false;
|
|
80
|
+
let offlineTimer = null;
|
|
57
81
|
|
|
58
82
|
// What the dot should say right now, or null for no dot. Read fresh on every paint rather than passed in, so a
|
|
59
83
|
// paint from anywhere - an incoming bar, or the pulse - draws what is true now.
|
|
@@ -85,7 +109,10 @@ function paintFavicon() {
|
|
|
85
109
|
iconCanvas.height = ICON_SIZE;
|
|
86
110
|
const ctx = iconCanvas.getContext("2d");
|
|
87
111
|
if (!ctx) return;
|
|
88
|
-
|
|
112
|
+
// No mark at all while the window cannot reach its container, whatever the last frame said was happening:
|
|
113
|
+
// both marks stand on state nothing is coming to correct, so the grey is the whole message and the two ways
|
|
114
|
+
// of arriving at it - going offline while an agent works, and going offline while one waits - are one icon.
|
|
115
|
+
const badgeColor = offline ? null : dotColor();
|
|
89
116
|
// Only one mark is ever drawn, so both phases can be handed over and the drawing picks the one it needs.
|
|
90
117
|
try {
|
|
91
118
|
drawIcon(ctx, badgeColor, badgeColor === DONE_COLOR && pulseDim(), sweepAt());
|
|
@@ -106,7 +133,9 @@ function drawIcon(ctx, badgeColor, dim, sweep) {
|
|
|
106
133
|
ctx.beginPath();
|
|
107
134
|
ctx.roundRect(0, 0, 32, 32, 7);
|
|
108
135
|
ctx.fill();
|
|
109
|
-
|
|
136
|
+
// Offline drains the frame with everything else: the workspace hue is the liveliest thing in the icon, and
|
|
137
|
+
// one grey shape says more than a grey spark inside a bright ring.
|
|
138
|
+
ctx.strokeStyle = offline ? OFFLINE_FRAME : workspaceColor();
|
|
110
139
|
ctx.lineWidth = 2;
|
|
111
140
|
ctx.beginPath();
|
|
112
141
|
ctx.roundRect(1, 1, 30, 30, 6);
|
|
@@ -115,9 +144,14 @@ function drawIcon(ctx, badgeColor, dim, sweep) {
|
|
|
115
144
|
// The spark: four points with a waist, stroked as well as filled so it nearly fills the icon and still
|
|
116
145
|
// reads at 16px. Same geometry and gradient as favicon.svg, which is what a browser without canvas still
|
|
117
146
|
// gets - the two are one drawing in two languages, so a change to either is a change to both.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
147
|
+
let spark;
|
|
148
|
+
if (offline) {
|
|
149
|
+
spark = OFFLINE_SPARK;
|
|
150
|
+
} else {
|
|
151
|
+
spark = ctx.createLinearGradient(0, 0, 0, 32);
|
|
152
|
+
spark.addColorStop(0, "#f0c078");
|
|
153
|
+
spark.addColorStop(1, "#d08a3c");
|
|
154
|
+
}
|
|
121
155
|
ctx.fillStyle = spark;
|
|
122
156
|
ctx.strokeStyle = spark;
|
|
123
157
|
ctx.lineWidth = 2;
|
|
@@ -194,7 +228,9 @@ function tickIcon() {
|
|
|
194
228
|
export function refreshBrowserTab() {
|
|
195
229
|
// Just the workspace, no product name: a tab strip gives you a few characters, and the icon already says
|
|
196
230
|
// this is chamba. What the title is for is which workspace, and how many sessions want you.
|
|
197
|
-
|
|
231
|
+
// The count goes with the icon's marks while the socket is down, and for the same reason. The title says the
|
|
232
|
+
// workspace and stops there: being offline is the icon's to say, and a tab strip cannot say it twice.
|
|
233
|
+
const waiting = offline ? [] : sessions.filter((entry) => entry.attention);
|
|
198
234
|
const name = workspaceName || "chamba";
|
|
199
235
|
document.title = waiting.length > 0 ? `(${waiting.length}) ${name}` : name;
|
|
200
236
|
|
|
@@ -202,7 +238,9 @@ export function refreshBrowserTab() {
|
|
|
202
238
|
// what the sessions are doing: an alert arriving next to one already up is the same state, and restarting the
|
|
203
239
|
// cycle on every frame the server sends would make the mark stutter.
|
|
204
240
|
paintFavicon();
|
|
205
|
-
|
|
241
|
+
// The grey icon is one still frame: nothing in it moves, so nothing has to be redrawn, and a window sitting
|
|
242
|
+
// offline for a day costs exactly one paint.
|
|
243
|
+
if (offline || !dotColor()) {
|
|
206
244
|
if (iconTimer) clearTimeout(iconTimer);
|
|
207
245
|
iconTimer = null;
|
|
208
246
|
iconFrame = 0;
|
|
@@ -211,6 +249,42 @@ export function refreshBrowserTab() {
|
|
|
211
249
|
if (!iconTimer) iconTimer = setTimeout(tickIcon, ICON_TICK_MS);
|
|
212
250
|
}
|
|
213
251
|
|
|
252
|
+
// The socket went. The only thing the connection has to say, and everything that follows from it - the grace, the
|
|
253
|
+
// flag, the title, the paint and the chime - is this module's, because all five are the same subject as the three
|
|
254
|
+
// states above.
|
|
255
|
+
//
|
|
256
|
+
// Counted from the first close and not restarted by the ones after it, which is the whole difference between
|
|
257
|
+
// three seconds and however long the reconnect loop takes to give up: every failed attempt closes a socket of its
|
|
258
|
+
// own, and each of those closes arrives here. The curtain's own wait is guarded the same way, for the same
|
|
259
|
+
// reason, and a tab that greys later than the curtain it sits above is a tab still showing a live green dot over
|
|
260
|
+
// a page that has already said the container cannot be reached.
|
|
261
|
+
export function socketWentDown() {
|
|
262
|
+
if (offline || offlineTimer) return;
|
|
263
|
+
offlineTimer = setTimeout(() => {
|
|
264
|
+
offlineTimer = null;
|
|
265
|
+
offline = true;
|
|
266
|
+
refreshBrowserTab();
|
|
267
|
+
}, OFFLINE_GRACE_MS);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// And came back.
|
|
271
|
+
export function socketCameBack() {
|
|
272
|
+
if (offlineTimer) {
|
|
273
|
+
clearTimeout(offlineTimer);
|
|
274
|
+
offlineTimer = null;
|
|
275
|
+
}
|
|
276
|
+
if (!offline) return;
|
|
277
|
+
// Spend whatever came due while the tab was grey, before the flag drops. An alert still standing and already
|
|
278
|
+
// due at this moment can only have come due during the disconnect, because nothing else was left running to
|
|
279
|
+
// catch it: the chime arms one timer for the soonest alert alone, and the icon's clock, which is what catches
|
|
280
|
+
// the rest, is stopped while the icon is grey. Without this, the first tick after a reconnect would find one
|
|
281
|
+
// of those alerts unspent and sound it - a chime for a finish that happened while this window was away.
|
|
282
|
+
maybeChime();
|
|
283
|
+
// Then straight back to whatever the sessions say, without waiting for the first frame of the new socket.
|
|
284
|
+
offline = false;
|
|
285
|
+
refreshBrowserTab();
|
|
286
|
+
}
|
|
287
|
+
|
|
214
288
|
// --- A sound, for the session you are not at ---------------------------------------------------------------------------------------------
|
|
215
289
|
//
|
|
216
290
|
// The fourth thing the "an agent finished" alert does, and the only one that reaches a window behind an editor.
|
|
@@ -262,6 +336,9 @@ const CHIME_VOLUME = 0.35;
|
|
|
262
336
|
// reload and covers every window of the same container without touching any other workspace. On by default: a
|
|
263
337
|
// notification nobody discovers is not a notification, and the bell is right there to turn off.
|
|
264
338
|
let soundOn = readSoundPref();
|
|
339
|
+
// Every pane tool that makes a sound of its own reads this window's one preference, so it is said once here at
|
|
340
|
+
// start and again on every toggle.
|
|
341
|
+
toolsHearSound(soundOn);
|
|
265
342
|
|
|
266
343
|
function readSoundPref() {
|
|
267
344
|
try {
|
|
@@ -389,7 +466,10 @@ function maybeChime() {
|
|
|
389
466
|
// Spent whether or not it is heard, so a muted window does not save up its chimes for whenever the bell is
|
|
390
467
|
// unmuted - nothing new would have happened by then.
|
|
391
468
|
for (const entry of due) chimed.add(entry.id);
|
|
392
|
-
|
|
469
|
+
// A window that cannot reach its container is the second case of exactly that: calling you back to a session
|
|
470
|
+
// it can no longer see is worse than saying nothing, and a sound held for the reconnect would be reporting a
|
|
471
|
+
// state that has had a whole disconnect to move on.
|
|
472
|
+
if (offline || !soundOn) return;
|
|
393
473
|
const now = Date.now();
|
|
394
474
|
if (now - lastChimeAt < CHIME_GAP_MS) return;
|
|
395
475
|
if (!claimChime()) return;
|
|
@@ -424,9 +504,9 @@ export function bellButton() {
|
|
|
424
504
|
button.type = "button";
|
|
425
505
|
button.classList.toggle("muted", !soundOn);
|
|
426
506
|
button.title = soundOn
|
|
427
|
-
? "Sound on - a chime when an agent finishes in a session you have not touched in a while. Click to mute."
|
|
428
|
-
: "Sound muted - click for a chime when an agent finishes in a session you have not touched in a while.";
|
|
429
|
-
button.setAttribute("aria-label", soundOn ? "Mute the
|
|
507
|
+
? "Sound on - a chime when an agent finishes in a session you have not touched in a while, and the bee on the Home tab. Click to mute."
|
|
508
|
+
: "Sound muted - click for a chime when an agent finishes in a session you have not touched in a while, and for the bee on the Home tab.";
|
|
509
|
+
button.setAttribute("aria-label", soundOn ? "Mute the interface" : "Unmute the interface");
|
|
430
510
|
button.setAttribute("aria-pressed", String(soundOn));
|
|
431
511
|
const svg = document.createElementNS(SVG_NS, "svg");
|
|
432
512
|
svg.setAttribute("viewBox", "0 0 24 24");
|
|
@@ -464,6 +544,7 @@ function toggleSound() {
|
|
|
464
544
|
// Not remembered past this page, but the toggle still works for as long as it is open.
|
|
465
545
|
}
|
|
466
546
|
renderBar();
|
|
547
|
+
toolsHearSound(soundOn);
|
|
467
548
|
// The click landed on a button, which took the keyboard off the terminal.
|
|
468
549
|
focusTerminal();
|
|
469
550
|
// Turning it on plays it once, so the first time it happens behind your editor it is a sound you have already
|
|
@@ -43,15 +43,49 @@ export function refreshComposer() {
|
|
|
43
43
|
else input.placeholder = "Pick a session above to type in it.";
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// The box the text is measured in: a second textarea, hidden and of no height, styled as the composer is.
|
|
47
|
+
// The composer sits under the pane in one column, so a height it takes for one layout pass is a height the
|
|
48
|
+
// tool frames lose for that pass, and a frame in its own process paints at that size before the pass is
|
|
49
|
+
// corrected. Measuring here leaves the composer's height alone until the text needs another line.
|
|
50
|
+
let mirror = null;
|
|
51
|
+
function mirrorOf() {
|
|
52
|
+
if (mirror !== null) return mirror;
|
|
53
|
+
mirror = document.createElement("textarea");
|
|
54
|
+
mirror.id = "input-mirror";
|
|
55
|
+
mirror.tabIndex = -1;
|
|
56
|
+
mirror.setAttribute("aria-hidden", "true");
|
|
57
|
+
mirror.readOnly = true;
|
|
58
|
+
input.parentElement.appendChild(mirror);
|
|
59
|
+
return mirror;
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
export function autoGrow() {
|
|
47
|
-
|
|
63
|
+
const box = getComputedStyle(input);
|
|
64
|
+
const ruler = mirrorOf();
|
|
65
|
+
// What decides where the text wraps and how tall a line is, copied from the composer as it stands now.
|
|
66
|
+
for (const name of [
|
|
67
|
+
"font",
|
|
68
|
+
"letterSpacing",
|
|
69
|
+
"lineHeight",
|
|
70
|
+
"padding",
|
|
71
|
+
"border",
|
|
72
|
+
"boxSizing",
|
|
73
|
+
"whiteSpace",
|
|
74
|
+
"wordBreak",
|
|
75
|
+
"overflowWrap",
|
|
76
|
+
"tabSize",
|
|
77
|
+
]) {
|
|
78
|
+
ruler.style[name] = box[name];
|
|
79
|
+
}
|
|
80
|
+
ruler.style.width = `${input.clientWidth + Number.parseFloat(box.borderLeftWidth) + Number.parseFloat(box.borderRightWidth)}px`;
|
|
81
|
+
ruler.value = input.value;
|
|
48
82
|
// The floor keeps the box off the button block behind it and the ceiling limits it to seven lines. Both are
|
|
49
83
|
// read from the box itself rather than written here as well: the same two numbers in two files is how the
|
|
50
84
|
// box comes to spring back to a height the stylesheet no longer says.
|
|
51
|
-
const box = getComputedStyle(input);
|
|
52
85
|
const floor = Number.parseFloat(box.minHeight) || 0;
|
|
53
86
|
const ceiling = Number.parseFloat(box.maxHeight) || Number.POSITIVE_INFINITY;
|
|
54
|
-
|
|
87
|
+
const wanted = `${Math.min(Math.max(ruler.scrollHeight, floor), ceiling)}px`;
|
|
88
|
+
if (input.style.height !== wanted) input.style.height = wanted;
|
|
55
89
|
}
|
|
56
90
|
input.addEventListener("input", () => {
|
|
57
91
|
autoGrow();
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// the curtain is the one state that says "this window cannot do anything at all". They live together because
|
|
5
5
|
// they are the same subject read from two ends: what the socket is doing, and what the user is told about it.
|
|
6
6
|
|
|
7
|
+
import { socketCameBack, socketWentDown } from "./alerts.js";
|
|
7
8
|
import { refreshComposer } from "./composer.js";
|
|
8
9
|
import { curtain, curtainCard } from "./dom.js";
|
|
9
10
|
import { onFrame } from "./frames.js";
|
|
@@ -197,6 +198,12 @@ export function noteSocketAlive() {
|
|
|
197
198
|
// being abandoned must not schedule a second reconnect or overwrite the new one's state.
|
|
198
199
|
function dropSocket() {
|
|
199
200
|
if (!ws) return;
|
|
201
|
+
// The one close nobody else hears about. A socket retired while the page still believes it is up is the wake
|
|
202
|
+
// case: after a sleep the socket reads OPEN for minutes with nothing on the other end, the ping gets no
|
|
203
|
+
// answer, and this replaces it - with the handlers nulled, so no onclose ever fires for it. The browser tab
|
|
204
|
+
// is told here instead, because a tab left wearing a green dot for a container it cannot reach is exactly
|
|
205
|
+
// what this state is. Its own grace covers the ordinary case, where the replacement opens a moment later.
|
|
206
|
+
if (connected) socketWentDown();
|
|
200
207
|
ws.onopen = null;
|
|
201
208
|
ws.onmessage = null;
|
|
202
209
|
ws.onclose = null;
|
|
@@ -225,6 +232,9 @@ export function connect() {
|
|
|
225
232
|
clearCurtainTimer();
|
|
226
233
|
hideCurtain();
|
|
227
234
|
document.body.classList.remove("offline");
|
|
235
|
+
// The browser tab reads the socket too, and it keeps a grace of its own. Told that the socket moved and
|
|
236
|
+
// nothing more: what the tab does about it belongs to the tab.
|
|
237
|
+
socketCameBack();
|
|
228
238
|
// Ask for the session this window was last looking at. The server decides whether it can have
|
|
229
239
|
// it back, and picks something sensible when it cannot.
|
|
230
240
|
// The last session this window drove: the server hands it back when no other window is on it.
|
|
@@ -249,6 +259,10 @@ export function connect() {
|
|
|
249
259
|
// Immediately, ahead of any curtain: every control on the page is answered over this socket, so
|
|
250
260
|
// while it is down they must stop taking clicks.
|
|
251
261
|
document.body.classList.add("offline");
|
|
262
|
+
// And the browser tab, which is the only thing a hidden window can say any of this with. Said on every
|
|
263
|
+
// close, whichever curtain the diagnosis ends at: one rule covers a container that is down, a spent key,
|
|
264
|
+
// a container being stopped and a container that has gone.
|
|
265
|
+
socketWentDown();
|
|
252
266
|
// An anchored panel hangs off the body, so the bar's own offline rules do not reach it - and no frame
|
|
253
267
|
// is coming to redraw the bar, which leaves the age tick up to a minute away. Both are shut here.
|
|
254
268
|
closeNewPop();
|
|
@@ -28,6 +28,7 @@ export const paneHalf = document.getElementById("pane-half");
|
|
|
28
28
|
export const paneCollapse = document.getElementById("pane-collapse");
|
|
29
29
|
export const spine = document.getElementById("spine");
|
|
30
30
|
export const spineBadge = document.getElementById("spine-badge");
|
|
31
|
+
export const toolHome = document.getElementById("tool-home");
|
|
31
32
|
export const toolPages = document.getElementById("tool-pages");
|
|
32
33
|
export const toolSpecs = document.getElementById("tool-specs");
|
|
33
34
|
export const toolReviews = document.getElementById("tool-reviews");
|
|
@@ -169,7 +169,7 @@ function applySessions(msg) {
|
|
|
169
169
|
pruneHistory();
|
|
170
170
|
refreshComposer();
|
|
171
171
|
// Same for the pane: a session that left the bar takes this window's copy of its page list with it. The
|
|
172
|
-
// files themselves are untouched - a resumed conversation gets them back
|
|
172
|
+
// files themselves are untouched - a resumed conversation gets them back.
|
|
173
173
|
const live = new Set(sessions.map((entry) => entry.id));
|
|
174
174
|
prunePanes(live);
|
|
175
175
|
// And for the pane itself: the shape a session was left in ends with that session, the way its draft does.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// home-host.js - the Home tool on the page.
|
|
2
|
+
//
|
|
3
|
+
// The host around a tool's frame is `tool-host.js`, where every tool's is. What is here is which tool this
|
|
4
|
+
// is: its name, which is what its routes and its messages are named after, the panel the pane gave it, and
|
|
5
|
+
// the sentence its tab shows while it is opening.
|
|
6
|
+
|
|
7
|
+
import { toolHome } from "./dom.js";
|
|
8
|
+
import { HOME } from "./pane-shell.js";
|
|
9
|
+
import { createToolHost } from "./tool-host.js";
|
|
10
|
+
|
|
11
|
+
createToolHost({
|
|
12
|
+
id: HOME,
|
|
13
|
+
label: "Home",
|
|
14
|
+
panel: toolHome,
|
|
15
|
+
opening: "Reading the pulse of this repository.",
|
|
16
|
+
});
|
|
@@ -35,8 +35,9 @@
|
|
|
35
35
|
// pane-shape.js the shape a pane has beside one session, and the store that keeps one for each
|
|
36
36
|
// pane.js the Pages tool: the chips bar and the page
|
|
37
37
|
// tool-host.js a tool's frame, and the credentials the shell hands into it
|
|
38
|
-
//
|
|
39
|
-
//
|
|
38
|
+
// home-host.js the Home tool: which tool it is, and its panel
|
|
39
|
+
// specs-host.js the Specs tool, the same way
|
|
40
|
+
// reviews-host.js the Code Reviews tool, the same way again
|
|
40
41
|
// pane-frame.js a published page in a sandboxed frame, and the answer that comes back out of it
|
|
41
42
|
// pane-arrival.js which page an incoming list opens by itself, and when none of them does
|
|
42
43
|
// status-strip.js the attached session's model, context, quota meters and version, above the composer
|
|
@@ -72,6 +73,7 @@ import "./dom.js";
|
|
|
72
73
|
import "./palette.js";
|
|
73
74
|
import "./drafts.js";
|
|
74
75
|
import "./frames.js";
|
|
76
|
+
import "./home-host.js";
|
|
75
77
|
import "./new-session.js";
|
|
76
78
|
import "./note.js";
|
|
77
79
|
import "./pane.js";
|
|
@@ -15,12 +15,14 @@ export const PANE_KEY = "webterm-pane";
|
|
|
15
15
|
|
|
16
16
|
// The tools there are to open, which a shape holds one of. Their ids and nothing else about them: what a tool
|
|
17
17
|
// is made of is the pane's business, and all this module asks is whether a stored id is one of them.
|
|
18
|
+
export const HOME = "home";
|
|
18
19
|
export const PAGES = "pages";
|
|
19
20
|
export const SPECS = "specs";
|
|
20
21
|
export const REVIEWS = "reviews";
|
|
21
|
-
const SHAPE_TOOLS = [PAGES, SPECS, REVIEWS];
|
|
22
|
-
// The tool a session opens on.
|
|
23
|
-
|
|
22
|
+
const SHAPE_TOOLS = [HOME, PAGES, SPECS, REVIEWS];
|
|
23
|
+
// The tool a session opens on. Home, because it is the reading of the repository a session starts in, and
|
|
24
|
+
// because a stored shape naming another tool keeps that tool: this is what a session with no shape gets.
|
|
25
|
+
const DEFAULT_TOOL = HOME;
|
|
24
26
|
|
|
25
27
|
// Narrower than this and the pane is not a pane any more, so it becomes the spine instead. The mock's
|
|
26
28
|
// threshold, and the width the spine itself takes.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// pane-shell.js - the pane itself: which tool is open in it, and how wide it is.
|
|
2
2
|
//
|
|
3
|
-
// The pane is furniture. What sits in it are tools -
|
|
4
|
-
// (reviews-host.js) - and the line between them is the whole point of this module: a tool draws inside its
|
|
3
|
+
// The pane is furniture. What sits in it are tools - Home (home-host.js), Specs (specs-host.js), Code Reviews
|
|
4
|
+
// (reviews-host.js) and Pages (pane.js) - and the line between them is the whole point of this module: a tool draws inside its
|
|
5
5
|
// own panel and nothing else, while the tab bar that says which tool is open, and the controls that say how
|
|
6
6
|
// wide the pane is, are drawn out here. A tool can therefore never move the pane, and never lie about which
|
|
7
7
|
// tool the user is looking at.
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
paneHalf,
|
|
32
32
|
spine,
|
|
33
33
|
spineBadge,
|
|
34
|
+
toolHome,
|
|
34
35
|
toolPages,
|
|
35
36
|
toolReviews,
|
|
36
37
|
toolSpecs,
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
DEFAULT_FRACTION,
|
|
41
42
|
defaultShape,
|
|
42
43
|
draggedTo,
|
|
44
|
+
HOME,
|
|
43
45
|
isDefaultWidth,
|
|
44
46
|
mergeShape,
|
|
45
47
|
PAGES,
|
|
@@ -52,14 +54,17 @@ import {
|
|
|
52
54
|
|
|
53
55
|
// The tools' ids come from the shape, which holds one of them. Re-exported here because this module is the
|
|
54
56
|
// pane, and a tool that asks whether it is open is asking the pane rather than the store.
|
|
55
|
-
export { PAGES, REVIEWS, SPECS };
|
|
57
|
+
export { HOME, PAGES, REVIEWS, SPECS };
|
|
56
58
|
|
|
57
59
|
// The tools, in the order their tabs are drawn. A tool is a tab, a panel, and a render function it hands over
|
|
58
60
|
// when it loads; the pane knows nothing else about any of them.
|
|
59
61
|
const TOOLS = [
|
|
60
|
-
{ id:
|
|
62
|
+
{ id: HOME, label: "Home", panel: toolHome },
|
|
61
63
|
{ id: SPECS, label: "Specs", panel: toolSpecs },
|
|
62
|
-
|
|
64
|
+
// "Reviews" on the tab and Code Reviews everywhere else: four tabs have to be readable at the width the
|
|
65
|
+
// pane reopens at, and the tab is the one place the tool's whole name does not fit.
|
|
66
|
+
{ id: REVIEWS, label: "Reviews", panel: toolReviews },
|
|
67
|
+
{ id: PAGES, label: "Pages", panel: toolPages },
|
|
63
68
|
];
|
|
64
69
|
|
|
65
70
|
// The one shape this window is drawing, and the session it belongs to. One and not a map of them: the store
|
|
@@ -213,6 +218,14 @@ function drawTabs() {
|
|
|
213
218
|
badge.className = mark.counted ? "tool-mark" : "tool-mark dot";
|
|
214
219
|
if (mark.counted) badge.textContent = String(mark.unread);
|
|
215
220
|
else badge.setAttribute("aria-label", "something new");
|
|
221
|
+
// The ring, for a tool something landed in a moment ago. This element is new on every redraw, so
|
|
222
|
+
// the ring would start from the beginning each time; a negative delay of what has already run
|
|
223
|
+
// puts it back where it was.
|
|
224
|
+
const ring = pulsing.get(tool.id);
|
|
225
|
+
if (ring) {
|
|
226
|
+
badge.classList.add("pulse");
|
|
227
|
+
badge.style.setProperty("--ring-delay", `-${Date.now() - ring.at}ms`);
|
|
228
|
+
}
|
|
216
229
|
button.append(badge);
|
|
217
230
|
}
|
|
218
231
|
button.addEventListener("click", () => showTool(tool.id));
|
|
@@ -337,6 +350,33 @@ export function flashPane() {
|
|
|
337
350
|
arrivalTimer = setTimeout(() => pane.classList.remove("arriving"), ARRIVAL_MS);
|
|
338
351
|
}
|
|
339
352
|
|
|
353
|
+
// Three turns of the ring in styles.css, which runs for 1.6s each. The two numbers have to agree: the
|
|
354
|
+
// keyframes are there and what takes the ring off again is here.
|
|
355
|
+
const TAB_PULSE_MS = 4_800;
|
|
356
|
+
|
|
357
|
+
// id -> when that tool's ring started, and the timer that ends it. A tab is rebuilt on every redraw, so the
|
|
358
|
+
// ring cannot be left on the element the way the pane's own flash is - what holds it is this map.
|
|
359
|
+
const pulsing = new Map();
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Something landed in a tool the user is not looking at, so its tab draws a ring around its mark for three
|
|
363
|
+
* turns and then rests.
|
|
364
|
+
*
|
|
365
|
+
* A second arrival during those three turns starts them again, which is what the mark means: the tab says
|
|
366
|
+
* that something arrived just now, and the number beside it says how much is waiting.
|
|
367
|
+
*/
|
|
368
|
+
export function pulseTool(id) {
|
|
369
|
+
clearTimeout(pulsing.get(id)?.timer);
|
|
370
|
+
pulsing.set(id, {
|
|
371
|
+
at: Date.now(),
|
|
372
|
+
timer: setTimeout(() => {
|
|
373
|
+
pulsing.delete(id);
|
|
374
|
+
renderShell();
|
|
375
|
+
}, TAB_PULSE_MS),
|
|
376
|
+
});
|
|
377
|
+
renderShell();
|
|
378
|
+
}
|
|
379
|
+
|
|
340
380
|
// --- The divider -------------------------------------------------------------------------------------------------------------------------
|
|
341
381
|
|
|
342
382
|
// Dragging sets a pixel width until it stops making sense, and below the threshold the pane becomes the
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
isToolOpen,
|
|
35
35
|
noteTool,
|
|
36
36
|
PAGES,
|
|
37
|
+
pulseTool,
|
|
37
38
|
registerTool,
|
|
38
39
|
rememberPageSize,
|
|
39
40
|
setPaneCollapsed,
|
|
@@ -91,7 +92,10 @@ export function applyPages(msg) {
|
|
|
91
92
|
// saying-so is held back while another tool has the pane.
|
|
92
93
|
if (arrived !== null) {
|
|
93
94
|
select(msg.sid, arrived.id, { user: false });
|
|
95
|
+
// The pane switches tools for nobody. With Pages open the pane's own edge says the page is here;
|
|
96
|
+
// with another tool open the Pages tab says it, and the tool the user is working in is left alone.
|
|
94
97
|
if (isToolOpen(PAGES)) flashPane();
|
|
98
|
+
else pulseTool(PAGES);
|
|
95
99
|
}
|
|
96
100
|
// A pane someone put away comes back for a page, in the session the page was published in - which is
|
|
97
101
|
// the session this frame is about, since the list only reaches the tool for the one it is driving.
|
|
@@ -264,8 +268,10 @@ function render() {
|
|
|
264
268
|
// because a pane that only appears once an agent has used it is one nobody knows to ask for. What the
|
|
265
269
|
// empty pane says is the tool's own intro; the tab's mark is the shell's, from what the tool says here.
|
|
266
270
|
const bare = pages.length === 0 && !entry?.notice;
|
|
267
|
-
//
|
|
268
|
-
|
|
271
|
+
// How many pages nobody has opened, the way Specs counts its open asks. Three waiting is a different day
|
|
272
|
+
// from one, and the pages already read are not a number anyone acts on, so the tab counts the unopened
|
|
273
|
+
// ones alone and never the whole list.
|
|
274
|
+
noteTool(PAGES, { unread, empty: bare, counted: true });
|
|
269
275
|
|
|
270
276
|
chips.replaceChildren(...pages.map((page) => chipFor(sid, page, page.id === entry?.selected)));
|
|
271
277
|
const at = pages.findIndex((page) => page.id === entry?.selected);
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
//
|
|
3
3
|
// As the Specs host beside it: the tool's own name, its panel and its opening sentence, and the frame around
|
|
4
4
|
// them is `tool-host.js`.
|
|
5
|
+
//
|
|
6
|
+
// "Reviews" and not "Code Reviews", because this word is what the user reads on the tab and in the panel
|
|
7
|
+
// while the tool opens, and four tabs have to fit at the width the pane reopens at. The tool's own name is
|
|
8
|
+
// Code Reviews everywhere else, the server table included, so every refusal still names it in full.
|
|
5
9
|
|
|
6
10
|
import { toolReviews } from "./dom.js";
|
|
7
11
|
import { REVIEWS } from "./pane-shell.js";
|
|
@@ -9,7 +13,7 @@ import { createToolHost } from "./tool-host.js";
|
|
|
9
13
|
|
|
10
14
|
createToolHost({
|
|
11
15
|
id: REVIEWS,
|
|
12
|
-
label: "
|
|
16
|
+
label: "Reviews",
|
|
13
17
|
panel: toolReviews,
|
|
14
18
|
opening: "Opening the reviews in this repository.",
|
|
15
19
|
});
|
|
@@ -47,6 +47,17 @@ export function toolsMovedToSession() {
|
|
|
47
47
|
for (const host of hosts.values()) host.session();
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// Whether the bell in the bar is on. It rides in the session message, so a tool that makes a sound obeys the one
|
|
51
|
+
// control this window has rather than a mute of its own. Held here rather than read out of `alerts.js`, so the flow
|
|
52
|
+
// is one way: the bell says it moved, and every frame hears it in the message it already reads.
|
|
53
|
+
let toolSound = true;
|
|
54
|
+
|
|
55
|
+
/** The bell moved. Every frame hears the new preference in its next session message, which is sent now. */
|
|
56
|
+
export function toolsHearSound(on) {
|
|
57
|
+
toolSound = on;
|
|
58
|
+
toolsMovedToSession();
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
/**
|
|
51
62
|
* Put one tool on the page.
|
|
52
63
|
*
|
|
@@ -174,17 +185,22 @@ export function createToolHost({ id, label, panel, opening }) {
|
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
/**
|
|
177
|
-
* Tell one frame which session this window is driving,
|
|
188
|
+
* Tell one frame which session this window is driving, how that session reads, and whether this window
|
|
189
|
+
* makes a sound. `null` says there is no session.
|
|
178
190
|
*
|
|
179
191
|
* The theme rides with the session because it belongs to it: the frame has an opaque origin and no storage
|
|
180
|
-
* of its own, so what it was set to last is the pane's to keep and the pane's to hand back.
|
|
192
|
+
* of its own, so what it was set to last is the pane's to keep and the pane's to hand back. The sound
|
|
193
|
+
* preference rides in the same message because it reaches a frame the same way and moves the same way.
|
|
181
194
|
*/
|
|
182
195
|
function sendSession(target) {
|
|
183
196
|
const sid = attachedSid ?? null;
|
|
184
197
|
// Both fields say the same session or neither does: the pane is drawn in the session it opened with
|
|
185
198
|
// before the first frame names one, and handing the frame that session's palette beside "there is no
|
|
186
199
|
// session" would have it read in a theme it was never told the reason for.
|
|
187
|
-
target?.contentWindow?.postMessage(
|
|
200
|
+
target?.contentWindow?.postMessage(
|
|
201
|
+
{ t: named(SESSION), sid, theme: sid === null ? null : storedPaneTheme(), sound: toolSound },
|
|
202
|
+
"*",
|
|
203
|
+
);
|
|
188
204
|
}
|
|
189
205
|
|
|
190
206
|
/**
|
|
@@ -75,7 +75,7 @@ function cell(id, hex, label, stored) {
|
|
|
75
75
|
button.addEventListener("click", () => {
|
|
76
76
|
panel.close();
|
|
77
77
|
// Sent, never held: the server decides, and every window is told on the frame that comes back. A pick
|
|
78
|
-
// kept to send later would be undone by the next sessions frame anyway
|
|
78
|
+
// kept to send later would be undone by the next sessions frame anyway.
|
|
79
79
|
sendFrame({ t: "color", color: id });
|
|
80
80
|
});
|
|
81
81
|
return button;
|
|
@@ -65,9 +65,13 @@
|
|
|
65
65
|
</span>
|
|
66
66
|
</div>
|
|
67
67
|
|
|
68
|
+
<!-- Home: the pulse of the repository this workspace holds, in a frame of its own. The tool a
|
|
69
|
+
session with no stored shape opens on, so it is the one panel without `hidden`. -->
|
|
70
|
+
<div id="tool-home" class="tool-panel"></div>
|
|
71
|
+
|
|
68
72
|
<!-- Pages: the pages this session's agent published, with the chips bar and the counter drawn out
|
|
69
73
|
here by the interface, and the page itself inside a sandboxed frame. -->
|
|
70
|
-
<div id="tool-pages" class="tool-panel">
|
|
74
|
+
<div id="tool-pages" class="tool-panel" hidden>
|
|
71
75
|
<div id="pagebar">
|
|
72
76
|
<button type="button" id="pane-prev" class="pane-nav" title="Older pages" aria-label="Older pages" hidden>‹</button>
|
|
73
77
|
<div id="chips"></div>
|
|
@@ -760,6 +760,44 @@ body.dragging {
|
|
|
760
760
|
font-weight: 700;
|
|
761
761
|
}
|
|
762
762
|
|
|
763
|
+
/* Something landed in a tool the user is not looking at: a ring around its mark, three times, then rest.
|
|
764
|
+
TAB_PULSE_MS in app/pane-shell.js must match three turns of this - it is what takes the class off again,
|
|
765
|
+
since a tab, unlike the pane, is rebuilt from one frame to the next. The delay is how far a rebuilt tab is
|
|
766
|
+
already into the ring, so a redraw in the middle of a pulse resumes it. */
|
|
767
|
+
.tool-mark.pulse {
|
|
768
|
+
position: relative;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
.tool-mark.pulse::after {
|
|
772
|
+
content: "";
|
|
773
|
+
position: absolute;
|
|
774
|
+
inset: -4px;
|
|
775
|
+
border: 2px solid var(--accent);
|
|
776
|
+
border-radius: 12px;
|
|
777
|
+
animation: toolMarkRing 1.6s ease-out 3;
|
|
778
|
+
animation-delay: var(--ring-delay, 0s);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
@keyframes toolMarkRing {
|
|
782
|
+
from {
|
|
783
|
+
opacity: 0.9;
|
|
784
|
+
transform: scale(0.8);
|
|
785
|
+
}
|
|
786
|
+
to {
|
|
787
|
+
opacity: 0;
|
|
788
|
+
transform: scale(1.5);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/* The same moment, held instead of pulsed, the way the pane's own arrival highlight is. Without this the
|
|
793
|
+
ring would never appear at all for someone who asked for less movement. */
|
|
794
|
+
@media (prefers-reduced-motion: reduce) {
|
|
795
|
+
.tool-mark.pulse::after {
|
|
796
|
+
opacity: 0.9;
|
|
797
|
+
animation: none;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
763
801
|
/* A tool that counts nothing says only that something arrived, so its mark carries no number and is drawn
|
|
764
802
|
as the dot it means. */
|
|
765
803
|
.tool-mark.dot {
|
|
@@ -1493,6 +1531,20 @@ body.offline #wscolorpop {
|
|
|
1493
1531
|
/* Border carries the state (no separate status dot): lit in the workspace's colour when you can type into the
|
|
1494
1532
|
attached session, plain grey while this window is between sessions or reconnecting. A dropped connection is
|
|
1495
1533
|
not a failure here - the session on the other side kept running - so it goes quiet rather than warning. */
|
|
1534
|
+
/* The ruler autoGrow() measures the text in: out of the flow, out of sight, and of no height of its own, so
|
|
1535
|
+
its scroll height is the height of the text and nothing about it moves the composer. */
|
|
1536
|
+
#input-mirror {
|
|
1537
|
+
position: absolute;
|
|
1538
|
+
top: 0;
|
|
1539
|
+
left: 0;
|
|
1540
|
+
height: 0;
|
|
1541
|
+
min-height: 0;
|
|
1542
|
+
overflow: hidden;
|
|
1543
|
+
visibility: hidden;
|
|
1544
|
+
pointer-events: none;
|
|
1545
|
+
resize: none;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1496
1548
|
#input.online {
|
|
1497
1549
|
border-color: var(--ws);
|
|
1498
1550
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ws) 25%, transparent);
|