chamba 0.8.0 → 0.10.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 (73) hide show
  1. package/README.md +14 -3
  2. package/dist/commands/dev.js +11 -11
  3. package/dist/commands/menu.js +1 -1
  4. package/dist/commands/settings.js +9 -6
  5. package/dist/lib/awake-holder.js +3 -0
  6. package/dist/lib/awake-marker.js +4 -0
  7. package/dist/lib/awake.js +1 -0
  8. package/dist/lib/chamba-yaml.js +4 -4
  9. package/dist/lib/constants.js +1 -1
  10. package/dist/lib/global-config.js +3 -3
  11. package/dist/lib/skills.js +2 -2
  12. package/package.json +1 -1
  13. package/templates/Dockerfile +3 -3
  14. package/templates/context/baseline.md +2 -2
  15. package/templates/context/web-pane-craft.md +1 -1
  16. package/templates/pane-apps/client/assets/api-CguEsvNp.js +12 -0
  17. package/templates/pane-apps/client/assets/api-Cyoc3E6K.css +1 -0
  18. package/templates/pane-apps/client/assets/bee-B7t97l5B.webp +0 -0
  19. package/templates/pane-apps/client/assets/buzz-CUUsbnXH.mp3 +0 -0
  20. package/templates/pane-apps/client/assets/home-CLFnmg7u.css +1 -0
  21. package/templates/pane-apps/client/assets/home-CrhBkqDQ.js +1 -0
  22. package/templates/pane-apps/client/assets/pop-D5ZANBC3.mp3 +0 -0
  23. package/templates/pane-apps/client/assets/reviews-Bq8EwmG-.js +153 -0
  24. package/templates/pane-apps/client/assets/reviews-DzHtA35Q.css +1 -0
  25. package/templates/pane-apps/client/assets/search-DgFnnu-J.js +1 -0
  26. package/templates/pane-apps/client/assets/specs-JvdNFtXR.js +13 -0
  27. package/templates/pane-apps/client/assets/useNow-Ww-Gffnk.js +1 -0
  28. package/templates/pane-apps/client/home/index.html +16 -0
  29. package/templates/pane-apps/client/reviews/index.html +6 -4
  30. package/templates/pane-apps/client/specs/index.html +4 -3
  31. package/templates/pane-apps/server/home.mjs +11 -0
  32. package/templates/pane-apps/server/reviews.mjs +19 -18
  33. package/templates/pane-apps/server/specs.mjs +4 -4
  34. package/templates/skills/dx-review/SKILL.md +16 -3
  35. package/templates/skills/dx-review/references/acts.md +86 -15
  36. package/templates/skills/dx-spec/SKILL.md +11 -19
  37. package/templates/skills/dx-spec/references/review-guide.md +15 -9
  38. package/templates/skills/dx-spec/references/spec-guide.md +12 -5
  39. package/templates/skills/dx-spec/references/stages.md +44 -24
  40. package/templates/skills/dx-spec-execute/SKILL.md +78 -28
  41. package/templates/startup.mjs +13 -13
  42. package/templates/tool-helper.sh +4 -2
  43. package/templates/webterm/README.md +41 -17
  44. package/templates/webterm/awake.js +27 -0
  45. package/templates/webterm/config.js +30 -0
  46. package/templates/webterm/public/app/alerts.js +92 -11
  47. package/templates/webterm/public/app/composer.js +37 -3
  48. package/templates/webterm/public/app/connection.js +14 -0
  49. package/templates/webterm/public/app/dom.js +1 -0
  50. package/templates/webterm/public/app/frames.js +1 -1
  51. package/templates/webterm/public/app/home-host.js +16 -0
  52. package/templates/webterm/public/app/main.js +4 -2
  53. package/templates/webterm/public/app/pane-shape.js +5 -3
  54. package/templates/webterm/public/app/pane-shell.js +45 -5
  55. package/templates/webterm/public/app/pane.js +8 -2
  56. package/templates/webterm/public/app/reviews-host.js +5 -1
  57. package/templates/webterm/public/app/state.js +4 -0
  58. package/templates/webterm/public/app/tabs.js +37 -3
  59. package/templates/webterm/public/app/tool-host.js +19 -3
  60. package/templates/webterm/public/app/workspace-color.js +1 -1
  61. package/templates/webterm/public/index.html +5 -1
  62. package/templates/webterm/public/styles.css +65 -0
  63. package/templates/webterm/server.js +24 -1
  64. package/templates/webterm/tool-document.js +3 -2
  65. package/templates/webterm/tools/home.js +80 -0
  66. package/templates/webterm/tools/index.js +5 -4
  67. package/templates/webterm/tools/reviews.js +7 -3
  68. package/templates/pane-apps/client/assets/files-D0nJgFAA.js +0 -12
  69. package/templates/pane-apps/client/assets/files-h0K1vZyQ.css +0 -1
  70. package/templates/pane-apps/client/assets/reviews-BsoGZ35r.css +0 -1
  71. package/templates/pane-apps/client/assets/reviews-iW0Mbn4e.js +0 -153
  72. package/templates/pane-apps/client/assets/specs-D9z3neR-.js +0 -13
  73. package/templates/skills/dx-spec/references/principles-template.md +0 -2
@@ -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
  });
@@ -17,6 +17,9 @@ export let workspaceName = "";
17
17
  // the screen: the panel marks this, so "nothing picked" stays tellable from "picked the colour the port
18
18
  // would have given anyway".
19
19
  export let workspaceColorId = "";
20
+ // Whether the host is holding itself awake for this container. A fact about the machine the container runs
21
+ // on, which the server reads off a file the host writes; nothing in here can see it any other way.
22
+ export let hostAwake = false;
20
23
  // Where "+ New session" starts an agent, relative to the workspace root ("" is the root itself). The server
21
24
  // owns it - it follows the directory chamba was last run in - and the picker opens on it.
22
25
  export let defaultCwd = "";
@@ -31,6 +34,7 @@ export function adoptSessionsFrame(msg) {
31
34
  if (Array.isArray(msg.agents)) agents = msg.agents;
32
35
  workspaceName = msg.workspace || "";
33
36
  workspaceColorId = typeof msg.color === "string" ? msg.color : "";
37
+ hostAwake = msg.awake === true;
34
38
  defaultCwd = msg.defaultCwd ?? "";
35
39
  }
36
40
 
@@ -15,7 +15,7 @@ import { closeCard, stopCard } from "./cards.js";
15
15
  import { sendFrame } from "./connection.js";
16
16
  import { SVG_NS, tabbar } from "./dom.js";
17
17
  import { repositionNewPop, toggleNewPop } from "./new-session.js";
18
- import { agents, attachedSid, defaultAgent, defaultCwd, maxSessions, sessions, workspaceName } from "./state.js";
18
+ import { agents, attachedSid, defaultAgent, defaultCwd, hostAwake, maxSessions, sessions, workspaceName } from "./state.js";
19
19
  import { focusTerminal, term } from "./terminal.js";
20
20
  import { colorFor } from "./theme.js";
21
21
  import { repositionColorPop, workspaceColorButton } from "./workspace-color.js";
@@ -444,6 +444,37 @@ function stopButton() {
444
444
  return button;
445
445
  }
446
446
 
447
+ // The host is holding itself awake for this container. A sign and not a button: there is nothing here for a
448
+ // window to change - the setting is the machine's, and it is changed in the chamba menu on the host.
449
+ function awakeIcon() {
450
+ const sign = document.createElement("span");
451
+ sign.id = "awakeicon";
452
+ sign.title = "The host stays awake while this container is up";
453
+ sign.setAttribute("role", "img");
454
+ sign.setAttribute("aria-label", "The host stays awake while this container is up");
455
+ const svg = document.createElementNS(SVG_NS, "svg");
456
+ svg.setAttribute("viewBox", "0 0 24 24");
457
+ svg.setAttribute("width", "14");
458
+ svg.setAttribute("height", "14");
459
+ svg.setAttribute("fill", "none");
460
+ svg.setAttribute("stroke", "currentColor");
461
+ svg.setAttribute("stroke-width", "2");
462
+ svg.setAttribute("stroke-linecap", "round");
463
+ // A sun: a small circle with eight rays, drawn as one dashed ring outside it so the rays cost one node.
464
+ const core = document.createElementNS(SVG_NS, "circle");
465
+ core.setAttribute("cx", "12");
466
+ core.setAttribute("cy", "12");
467
+ core.setAttribute("r", "4");
468
+ const rays = document.createElementNS(SVG_NS, "circle");
469
+ rays.setAttribute("cx", "12");
470
+ rays.setAttribute("cy", "12");
471
+ rays.setAttribute("r", "9");
472
+ rays.setAttribute("stroke-dasharray", "0.1 6.97");
473
+ svg.append(core, rays);
474
+ sign.append(svg);
475
+ return sign;
476
+ }
477
+
447
478
  function chevronIcon() {
448
479
  const svg = document.createElementNS(SVG_NS, "svg");
449
480
  svg.setAttribute("viewBox", "0 0 24 24");
@@ -514,8 +545,11 @@ export function renderBar() {
514
545
  ws.textContent = workspaceName;
515
546
  right.append(ws);
516
547
  }
517
- // The colour first: it is about this window's identity rather than about what the window does, so it sits
518
- // next to the name it belongs with, on the far side of the bell from the button that ends everything.
548
+ // The awake sign first, before the buttons: it is the one thing here nobody presses, and it says
549
+ // something about the machine this container runs on rather than about this window or this session.
550
+ if (hostAwake) right.append(awakeIcon());
551
+ // The colour next: it is about this window's identity rather than about what the window does, so it sits
552
+ // at the near end of the buttons, on the far side of the bell from the one that ends everything.
519
553
  right.append(workspaceColorButton(), bellButton(), stopButton());
520
554
  tabbar.append(right);
521
555
 
@@ -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 {
@@ -1329,6 +1367,19 @@ body.offline #wscolorpop {
1329
1367
  pointer-events: none;
1330
1368
  }
1331
1369
 
1370
+ /* ---- The host is being kept awake ---- */
1371
+
1372
+ /* A sign rather than a control: no border, no hover, no pointer. It sits among buttons, so it has to read as
1373
+ the one thing here that nothing happens to when it is pressed. */
1374
+ #awakeicon {
1375
+ display: flex;
1376
+ align-items: center;
1377
+ justify-content: center;
1378
+ padding: 4px;
1379
+ color: var(--muted);
1380
+ opacity: 0.75;
1381
+ }
1382
+
1332
1383
  /* ---- Sound, and stopping the container ---- */
1333
1384
 
1334
1385
  /* Both sit at the far right of the bar, away from the tabs: neither one is about a single session. */
@@ -1493,6 +1544,20 @@ body.offline #wscolorpop {
1493
1544
  /* Border carries the state (no separate status dot): lit in the workspace's colour when you can type into the
1494
1545
  attached session, plain grey while this window is between sessions or reconnecting. A dropped connection is
1495
1546
  not a failure here - the session on the other side kept running - so it goes quiet rather than warning. */
1547
+ /* The ruler autoGrow() measures the text in: out of the flow, out of sight, and of no height of its own, so
1548
+ its scroll height is the height of the text and nothing about it moves the composer. */
1549
+ #input-mirror {
1550
+ position: absolute;
1551
+ top: 0;
1552
+ left: 0;
1553
+ height: 0;
1554
+ min-height: 0;
1555
+ overflow: hidden;
1556
+ visibility: hidden;
1557
+ pointer-events: none;
1558
+ resize: none;
1559
+ }
1560
+
1496
1561
  #input.online {
1497
1562
  border-color: var(--ws);
1498
1563
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--ws) 25%, transparent);
@@ -18,11 +18,14 @@ import { join, resolve, sep } from "node:path";
18
18
  import express from "express";
19
19
  import { WebSocketServer } from "ws";
20
20
  import { cleanLine, cleanSubmission, createPaneStore, isPageId } from "./artifacts.js";
21
+ import { isHostAwake } from "./awake.js";
21
22
  import { readColor, storeColor } from "./color.js";
22
23
  import {
23
24
  AGENT_ARGS,
24
25
  AGENT_STORES,
25
26
  AGENTS,
27
+ AWAKE_FILE,
28
+ AWAKE_STALE_MS,
26
29
  agentSpawnArgv,
27
30
  CHECK_INTERVAL_MS,
28
31
  CLIENT_PING_INTERVAL_MS,
@@ -171,6 +174,10 @@ function sessionAgent(raw) {
171
174
  // nothing was picked, and the browser works one out from the published port.
172
175
  let chosenColor = readColor(COLOR_FILE);
173
176
 
177
+ // Whether the host is being kept awake for this container. A fact about the machine out there rather than
178
+ // anything this server does, read off the marker on the status timer below and carried on the sessions frame.
179
+ let hostAwake = isHostAwake(AWAKE_FILE, AWAKE_STALE_MS);
180
+
174
181
  // The one-line file that names the default agent, for the `webterm` launcher to read back. Written when the
175
182
  // port is bound and again whenever the default moves, so a second `webterm <agent>` can say what is live.
176
183
  function publishAgent() {
@@ -501,6 +508,9 @@ function broadcastSessions() {
501
508
  // The accent colour, on the one frame that already reaches every window: a change has to travel
502
509
  // to all of them either way, so this closes the door rather than adding a second one.
503
510
  color: chosenColor,
511
+ // The same reasoning as the colour: window-wide, true for every session in here at once, and
512
+ // already travelling to all of them, so it rides this frame rather than opening a second door.
513
+ awake: hostAwake,
504
514
  max: MAX_SESSIONS,
505
515
  defaultCwd: workspaceLabel(defaultCwd),
506
516
  list,
@@ -691,6 +701,16 @@ function sweepStatus() {
691
701
  }
692
702
  }
693
703
 
704
+ // The marker read, on the same pass. Nothing in the container hears a write the host made to the mount, so
705
+ // looking again is the whole mechanism - and a frame goes out only when the answer changed, because every
706
+ // window already has the last one.
707
+ function sweepAwake() {
708
+ const awake = isHostAwake(AWAKE_FILE, AWAKE_STALE_MS);
709
+ if (awake === hostAwake) return;
710
+ hostAwake = awake;
711
+ broadcastSessions();
712
+ }
713
+
694
714
  // What the host asks before it offers to stop the container. `sessions` is how many conversations are
695
715
  // alive (they all die with the container), `attached` how many a window is watching right now, `agent` the
696
716
  // one a new session gets and `agents` what is actually running, per agent. Counts and names only - nothing
@@ -1485,7 +1505,10 @@ setInterval(() => pane.sweep(), PANE_SCAN_MS).unref();
1485
1505
 
1486
1506
  // The strip's scan, for the same reason: a snapshot file is written by a shell script into a bind mount, so
1487
1507
  // the only way to know it moved is to look.
1488
- setInterval(() => sweepStatus(), STATUS_SCAN_MS).unref();
1508
+ setInterval(() => {
1509
+ sweepStatus();
1510
+ sweepAwake();
1511
+ }, STATUS_SCAN_MS).unref();
1489
1512
 
1490
1513
  // Keepalive pass. A window that stops answering is terminated, which releases the session it was
1491
1514
  // driving so the window that comes back can pick it up without a takeover prompt. Sessions themselves
@@ -46,8 +46,8 @@ export function withNonce(html, nonce) {
46
46
  *
47
47
  * `default-src 'none'` is the floor, so anything not named below is refused. What runs is named by the nonce
48
48
  * alone. What is fetched is named by `origin` - the scheme and host the document was loaded from - because
49
- * the client's own assets, the tool's data routes and the jailed workspace files are all served from here,
50
- * and an opaque origin has no `'self'` to say that with. `base-uri` and `form-action` are named on their own,
49
+ * the client's own assets, its images and its clips, the tool's data routes and the jailed workspace files
50
+ * are all served from here, and an opaque origin has no `'self'` to say that with. `base-uri` and `form-action` are named on their own,
51
51
  * since neither one falls back to `default-src`.
52
52
  */
53
53
  export function documentPolicy(nonce, origin) {
@@ -56,6 +56,7 @@ export function documentPolicy(nonce, origin) {
56
56
  `script-src 'nonce-${nonce}'`,
57
57
  `style-src-elem 'nonce-${nonce}'`,
58
58
  `img-src ${origin}`,
59
+ `media-src ${origin}`,
59
60
  `connect-src ${origin}`,
60
61
  `frame-src ${origin}`,
61
62
  "base-uri 'none'",
@@ -0,0 +1,80 @@
1
+ // =========================================================================================================================================
2
+ // tools/home.js - What the Home tool is, as one table.
3
+ //
4
+ // The smallest table of the three, and the shape of a tool that only reads. There are no verbs, because Home has no helper and nothing
5
+ // to say to an agent; no events, because it delivers nothing into a session; and no raw side, because it serves no file of the
6
+ // workspace at all. What is left is three reads of the repository the workspace holds.
7
+ //
8
+ // The module is handed two directories rather than one: the workspace, which is the repository it draws, and the state directory
9
+ // outside it, which is where its cache goes. The cache is derived from the repository and is nobody's record, so a file of it in the
10
+ // workspace would show up in the `git status` of every repository Home opens. The module refuses a cache directory inside the
11
+ // workspace, so the two can never be the same place.
12
+ // =========================================================================================================================================
13
+
14
+ import { createHomeModule, refuseRaw, statusFor } from "../../pane-apps/server/home.mjs";
15
+ import {
16
+ HOME_CACHE_DIR,
17
+ HOME_KEY,
18
+ HOME_RAW_TOKEN,
19
+ MAX_HOME_BYTES,
20
+ REVIEWS_ROOT,
21
+ SPECS_ROOT,
22
+ WORKSPACE,
23
+ WORKSPACE_ROOT,
24
+ } from "../config.js";
25
+
26
+ // The module maker, re-exported so the path to the built dist is written once, as it is for every tool.
27
+ export { createHomeModule };
28
+
29
+ export const HOME_TABLE = {
30
+ id: "home",
31
+ /** What a refusal calls the tool. The word on the tab is the client's own, in `tool-host.js`. */
32
+ label: "Home",
33
+ key: HOME_KEY,
34
+ rawToken: HOME_RAW_TOKEN,
35
+ statusFor,
36
+
37
+ /**
38
+ * The repository this draws, the directory outside it that the cache goes in, the name to call it, and the two directories the
39
+ * other tools keep their work in.
40
+ *
41
+ * The name is chamba's own for this workspace, which is what the bar wears, so the header row of the page and the corner of the
42
+ * window say the same word. Without it the header would show the mount point, which is "workspace" in every container there is.
43
+ *
44
+ * The two roots are the ones the Specs tool and the Code Reviews tool are mounted with, and Home leaves what is inside them out of
45
+ * every count of lines and of files, with a switch on the page to put them back. Both tools write what they serve - a spec's forms
46
+ * and answers, a review's grouping and its baseline blobs - and counted as code they say a workspace is mostly JSON. They come from
47
+ * here rather than from inside Home for the same reason each tool takes its own root here: a repository that keeps either one
48
+ * elsewhere is served the same way, and Home holds no word about what they are called.
49
+ */
50
+ create: () =>
51
+ createHomeModule({
52
+ root: WORKSPACE_ROOT,
53
+ cacheDir: HOME_CACHE_DIR,
54
+ name: WORKSPACE,
55
+ toolPaths: [SPECS_ROOT, REVIEWS_ROOT],
56
+ }),
57
+
58
+ /** Home delivers nothing into a session, so there is no sentence for one to arrive as. */
59
+ events: {},
60
+
61
+ /**
62
+ * How much a request may carry. Every route is a GET with no body, so nothing reaches this; it is here because the door that
63
+ * gates a body reads it for every tool, and a tool with no number is a tool with no gate.
64
+ */
65
+ limits: { bytes: MAX_HOME_BYTES },
66
+
67
+ /** Home serves no workspace bytes at all, so every path is refused rather than resolved. */
68
+ raw: (_module, path) => refuseRaw(path),
69
+
70
+ // The three reads, each one a section of the page. Three and not one: each costs what its own question costs, and the page draws
71
+ // each section the moment that section's answer lands.
72
+ routes: (module) => ({
73
+ history: { GET: () => module.history() },
74
+ branches: { GET: () => module.branches() },
75
+ tree: { GET: () => module.tree() },
76
+ }),
77
+
78
+ /** No verbs. The helper door is registered for every tool and answers 400 for every name it is given. */
79
+ verbs: () => ({}),
80
+ };
@@ -5,15 +5,16 @@
5
5
  // types and how much of the typing channel one session gets. server.js reads the list and never a tool's name, so a third tool is
6
6
  // a row here and a table beside it.
7
7
  //
8
- // The order is the order the tabs are drawn in, which is the pane's own list in `public/app/pane-shell.js`. The two lists are
9
- // checked against each other by the interface's tests rather than derived from one another, because the pane is drawn in the
10
- // browser and this file is read in the server.
8
+ // The order is the order the tabs are drawn in, which is the pane's own list in `public/app/pane-shell.js`, less Pages: the
9
+ // interface draws that one itself and it has no table here. The two lists are checked against each other by the interface's tests
10
+ // rather than derived from one another, because the pane is drawn in the browser and this file is read in the server.
11
11
  // =========================================================================================================================================
12
12
 
13
+ import { HOME_TABLE } from "./home.js";
13
14
  import { REVIEWS_TABLE } from "./reviews.js";
14
15
  import { SPECS_TABLE } from "./specs.js";
15
16
 
16
- export const TOOLS = [SPECS_TABLE, REVIEWS_TABLE];
17
+ export const TOOLS = [HOME_TABLE, SPECS_TABLE, REVIEWS_TABLE];
17
18
 
18
19
  /** The table of one tool, or undefined. Asked with `find`, so a name that is not a tool's is not one. */
19
20
  export function toolTable(id) {
@@ -4,8 +4,9 @@
4
4
  // The same shape as the Specs table beside it, and the differences are the tool's own. A review is a directory with a slug, so its
5
5
  // routes name their subject in the path rather than in a query, and the acts of the screen are one route with the act as a segment.
6
6
  // Its raw files are two named locations rather than any workspace file, so a browser reaches a stored blob and a signature and
7
- // nothing else. And it is the one module in this container that runs a child process, which is why it is handed the workspace as
8
- // well as its own root: every git call it makes runs with the repository as its working directory.
7
+ // nothing else. And it is one of the two modules in this container that run a child process, Home being the other, which is why it
8
+ // is handed the workspace as well as its own root: every git call it makes runs with the repository as its working directory. The
9
+ // runner itself is shared, in the pane-apps server library, and this module hands it a closed table of read-only operations of its own.
9
10
  //
10
11
  // The framed sentences are the three acts that keep the review with the reviewer. Sending feedback, syncing and filing a review all
11
12
  // hand it over, and each of those types the review's command instead: the act starts the agent, and the skill reads the review's
@@ -111,7 +112,7 @@ export const REVIEWS_TABLE = {
111
112
  "review/:slug/blob": {
112
113
  GET: ({ params, query }) => module.blobLines(params.slug, field(query.name), count(query.from), count(query.count)),
113
114
  },
114
- // One route for every act of FR-22 to FR-49. The module holds the list of them and refuses a name that is not one, so what
115
+ // One route for every act of a review. The module holds the list of them and refuses a name that is not one, so what
115
116
  // an act is stays in one place rather than being a row here as well.
116
117
  "review/:slug/:act": { POST: ({ params, body }) => module.act(params.slug, params.act, body) },
117
118
  }),
@@ -125,6 +126,9 @@ export const REVIEWS_TABLE = {
125
126
  // A review filed from the terminal, which the tab shows with a note saying so.
126
127
  start: (_entry, payload) => module.start(payload, "terminal"),
127
128
  metadata: (entry, payload) => module.metadata(entry, payload),
129
+ // One group at a time, which several workers may post at once without treading on one another.
130
+ group: (entry, payload) => module.group(entry, payload),
131
+ ungroup: (entry, payload) => module.ungroup(entry, payload),
128
132
  reply: (entry, payload) => module.reply(entry, payload),
129
133
  activity: (entry, payload) => module.activity(entry, payload),
130
134
  done: (entry) => module.done(entry),