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.
Files changed (59) hide show
  1. package/README.md +7 -1
  2. package/dist/lib/chamba-yaml.js +4 -4
  3. package/package.json +1 -1
  4. package/templates/Dockerfile +3 -3
  5. package/templates/context/baseline.md +2 -2
  6. package/templates/context/web-pane-craft.md +1 -1
  7. package/templates/pane-apps/client/assets/api-BBDf_rzz.js +12 -0
  8. package/templates/pane-apps/client/assets/api-DNLbSBpK.css +1 -0
  9. package/templates/pane-apps/client/assets/bee-B7t97l5B.webp +0 -0
  10. package/templates/pane-apps/client/assets/buzz-CUUsbnXH.mp3 +0 -0
  11. package/templates/pane-apps/client/assets/files-BkmyervY.js +1 -0
  12. package/templates/pane-apps/client/assets/home-3SeGzySM.css +1 -0
  13. package/templates/pane-apps/client/assets/home-Cu9xOd2i.js +1 -0
  14. package/templates/pane-apps/client/assets/pop-D5ZANBC3.mp3 +0 -0
  15. package/templates/pane-apps/client/assets/reviews-DMrGB-kG.js +153 -0
  16. package/templates/pane-apps/client/assets/reviews-nK_yRav9.css +1 -0
  17. package/templates/pane-apps/client/assets/specs-Cabb15F0.js +13 -0
  18. package/templates/pane-apps/client/assets/useNow-BHFKFraS.js +1 -0
  19. package/templates/pane-apps/client/home/index.html +16 -0
  20. package/templates/pane-apps/client/reviews/index.html +6 -4
  21. package/templates/pane-apps/client/specs/index.html +4 -3
  22. package/templates/pane-apps/server/home.mjs +12 -0
  23. package/templates/pane-apps/server/reviews.mjs +19 -18
  24. package/templates/pane-apps/server/specs.mjs +4 -4
  25. package/templates/skills/dx-review/SKILL.md +16 -3
  26. package/templates/skills/dx-review/references/acts.md +86 -15
  27. package/templates/skills/dx-spec/SKILL.md +7 -3
  28. package/templates/skills/dx-spec/references/review-guide.md +2 -2
  29. package/templates/skills/dx-spec/references/spec-guide.md +1 -1
  30. package/templates/skills/dx-spec/references/stages.md +6 -2
  31. package/templates/skills/dx-spec-execute/SKILL.md +73 -7
  32. package/templates/startup.mjs +13 -13
  33. package/templates/tool-helper.sh +4 -2
  34. package/templates/webterm/README.md +34 -13
  35. package/templates/webterm/config.js +20 -0
  36. package/templates/webterm/public/app/alerts.js +92 -11
  37. package/templates/webterm/public/app/composer.js +37 -3
  38. package/templates/webterm/public/app/connection.js +14 -0
  39. package/templates/webterm/public/app/dom.js +1 -0
  40. package/templates/webterm/public/app/frames.js +1 -1
  41. package/templates/webterm/public/app/home-host.js +16 -0
  42. package/templates/webterm/public/app/main.js +4 -2
  43. package/templates/webterm/public/app/pane-shape.js +5 -3
  44. package/templates/webterm/public/app/pane-shell.js +45 -5
  45. package/templates/webterm/public/app/pane.js +8 -2
  46. package/templates/webterm/public/app/reviews-host.js +5 -1
  47. package/templates/webterm/public/app/tool-host.js +19 -3
  48. package/templates/webterm/public/app/workspace-color.js +1 -1
  49. package/templates/webterm/public/index.html +5 -1
  50. package/templates/webterm/public/styles.css +52 -0
  51. package/templates/webterm/tool-document.js +3 -2
  52. package/templates/webterm/tools/home.js +58 -0
  53. package/templates/webterm/tools/index.js +5 -4
  54. package/templates/webterm/tools/reviews.js +7 -3
  55. package/templates/pane-apps/client/assets/files-D0nJgFAA.js +0 -12
  56. package/templates/pane-apps/client/assets/files-h0K1vZyQ.css +0 -1
  57. package/templates/pane-apps/client/assets/reviews-BsoGZ35r.css +0 -1
  58. package/templates/pane-apps/client/assets/reviews-iW0Mbn4e.js +0 -153
  59. 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
- const badgeColor = dotColor();
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
- ctx.strokeStyle = workspaceColor();
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
- const spark = ctx.createLinearGradient(0, 0, 0, 32);
119
- spark.addColorStop(0, "#f0c078");
120
- spark.addColorStop(1, "#d08a3c");
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
- const waiting = sessions.filter((entry) => entry.attention);
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
- if (!dotColor()) {
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
- if (!soundOn) return;
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 finish sound" : "Unmute the finish sound");
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
- input.style.height = "auto";
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
- input.style.height = `${Math.min(Math.max(input.scrollHeight, floor), ceiling)}px`;
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 (FR-15).
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
- // specs-host.js the Specs tool: which tool it is, and its panel
39
- // reviews-host.js the Code Reviews tool, the same way
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
- const DEFAULT_TOOL = PAGES;
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 - Pages (pane.js), Specs (specs-host.js) and Code Reviews
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: PAGES, label: "Pages", panel: toolPages },
62
+ { id: HOME, label: "Home", panel: toolHome },
61
63
  { id: SPECS, label: "Specs", panel: toolSpecs },
62
- { id: REVIEWS, label: "Code Reviews", panel: toolReviews },
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
- // A page that arrived is a thing to look at, not a thing to answer, so the tab says so without a number.
268
- noteTool(PAGES, { unread, empty: bare, counted: false });
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: "Code Reviews",
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, and how that session reads. `null` says there is none.
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({ t: named(SESSION), sid, theme: sid === null ? null : storedPaneTheme() }, "*");
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 (FR-17).
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>&#8249;</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);