chamba 0.7.0 → 0.8.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 (120) hide show
  1. package/LICENSE +53 -14
  2. package/README.md +28 -8
  3. package/dist/commands/advanced.js +7 -278
  4. package/dist/commands/dev.js +11 -612
  5. package/dist/commands/doctor.js +1 -29
  6. package/dist/commands/menu.js +1 -80
  7. package/dist/commands/onboard.js +6 -230
  8. package/dist/commands/settings.js +15 -349
  9. package/dist/lib/agent-commands.js +2 -0
  10. package/dist/lib/agent-context.js +6 -210
  11. package/dist/lib/browser.js +1 -40
  12. package/dist/lib/chamba-yaml.js +10 -191
  13. package/dist/lib/constants.js +1 -125
  14. package/dist/lib/dockerfile-builder.js +41 -261
  15. package/dist/lib/env.js +2 -78
  16. package/dist/lib/git-env.js +1 -21
  17. package/dist/lib/global-config.js +4 -66
  18. package/dist/lib/pnpm-store.js +1 -19
  19. package/dist/lib/ports.js +1 -210
  20. package/dist/lib/safe-rm.js +1 -36
  21. package/dist/lib/sessions.js +2 -34
  22. package/dist/lib/shadows.js +1 -176
  23. package/dist/lib/skills.js +2 -0
  24. package/dist/lib/templates.js +1 -0
  25. package/dist/lib/webterm.js +1 -305
  26. package/dist/lib/workspace-identity.js +4 -260
  27. package/package.json +4 -4
  28. package/templates/Dockerfile +12 -19
  29. package/templates/claude-statusline.sh +5 -7
  30. package/templates/context/baseline.md +2 -0
  31. package/templates/pane-apps/client/assets/c-BNa-DeUl.json +3552 -0
  32. package/templates/pane-apps/client/assets/cpp-B-T4NRoH.json +20239 -0
  33. package/templates/pane-apps/client/assets/csharp-BbxHChhA.json +6010 -0
  34. package/templates/pane-apps/client/assets/css-CWCxuwmz.json +1860 -0
  35. package/templates/pane-apps/client/assets/diff-B-2N89g8.json +154 -0
  36. package/templates/pane-apps/client/assets/docker-DWTvYW26.json +103 -0
  37. package/templates/pane-apps/client/assets/files-D0nJgFAA.js +12 -0
  38. package/templates/pane-apps/client/assets/files-h0K1vZyQ.css +1 -0
  39. package/templates/pane-apps/client/assets/go-B2hyYeNg.json +3156 -0
  40. package/templates/pane-apps/client/assets/graphql-Z6Id2aNO.json +1292 -0
  41. package/templates/pane-apps/client/assets/html-BtzvlIfu.json +2627 -0
  42. package/templates/pane-apps/client/assets/ini-Bn3Twap1.json +108 -0
  43. package/templates/pane-apps/client/assets/java-C7t2dwLO.json +1885 -0
  44. package/templates/pane-apps/client/assets/javascript-DuC2v0C0.json +5996 -0
  45. package/templates/pane-apps/client/assets/json-CwdkWeV5.json +206 -0
  46. package/templates/pane-apps/client/assets/jsonc-MYWDbTU3.json +206 -0
  47. package/templates/pane-apps/client/assets/jsx-Cn8EZuIC.json +5996 -0
  48. package/templates/pane-apps/client/assets/kotlin-CFNOz1gw.json +580 -0
  49. package/templates/pane-apps/client/assets/make-BVnlFCcl.json +629 -0
  50. package/templates/pane-apps/client/assets/markdown-B4Xk2WM_.json +3263 -0
  51. package/templates/pane-apps/client/assets/php-CAQzQmmI.json +4188 -0
  52. package/templates/pane-apps/client/assets/python-CYQee8m4.json +4174 -0
  53. package/templates/pane-apps/client/assets/reviews-BsoGZ35r.css +1 -0
  54. package/templates/pane-apps/client/assets/reviews-iW0Mbn4e.js +153 -0
  55. package/templates/pane-apps/client/assets/ruby-D7R58tus.json +3114 -0
  56. package/templates/pane-apps/client/assets/rust-BFBbFiei.json +1103 -0
  57. package/templates/pane-apps/client/assets/scss-TQRhBkPG.json +1874 -0
  58. package/templates/pane-apps/client/assets/shellscript-DieAkC4W.json +2375 -0
  59. package/templates/pane-apps/client/assets/specs-D9z3neR-.js +13 -0
  60. package/templates/pane-apps/client/assets/sql-Bxd030LV.json +634 -0
  61. package/templates/pane-apps/client/assets/toml-ChrKGzsV.json +430 -0
  62. package/templates/pane-apps/client/assets/tsx-D2e90MFw.json +5996 -0
  63. package/templates/pane-apps/client/assets/typescript-DonXQ_n1.json +5747 -0
  64. package/templates/pane-apps/client/assets/xml-B17r2RXF.json +382 -0
  65. package/templates/pane-apps/client/assets/yaml-CKn4h1zy.json +627 -0
  66. package/templates/pane-apps/client/reviews/index.html +15 -0
  67. package/templates/pane-apps/client/specs/index.html +3 -2
  68. package/templates/pane-apps/server/reviews.mjs +20 -0
  69. package/templates/pane-apps/server/specs.mjs +5 -1588
  70. package/templates/skills/chamba-statusline/SKILL.md +7 -19
  71. package/templates/skills/dx-review/SKILL.md +123 -0
  72. package/templates/skills/dx-review/references/acts.md +162 -0
  73. package/templates/skills/dx-spec/SKILL.md +223 -27
  74. package/templates/skills/dx-spec/references/principles-template.md +2 -0
  75. package/templates/skills/dx-spec/references/review-guide.md +4 -50
  76. package/templates/skills/dx-spec/references/spec-guide.md +24 -4
  77. package/templates/skills/dx-spec/references/stages.md +143 -58
  78. package/templates/skills/dx-spec-execute/SKILL.md +148 -35
  79. package/templates/startup.mjs +10 -4
  80. package/templates/tool-helper.sh +166 -0
  81. package/templates/webterm/README.md +60 -26
  82. package/templates/webterm/color.js +61 -0
  83. package/templates/webterm/config.js +35 -4
  84. package/templates/webterm/context/claude.md +0 -1
  85. package/templates/webterm/public/app/alerts.js +4 -4
  86. package/templates/webterm/public/app/anchored.js +81 -0
  87. package/templates/webterm/public/app/connection.js +9 -0
  88. package/templates/webterm/public/app/dom.js +2 -0
  89. package/templates/webterm/public/app/frames.js +51 -17
  90. package/templates/webterm/public/app/main.js +22 -8
  91. package/templates/webterm/public/app/new-session.js +13 -41
  92. package/templates/webterm/public/app/palette.js +58 -0
  93. package/templates/webterm/public/app/pane-shape.js +167 -0
  94. package/templates/webterm/public/app/pane-shell.js +199 -134
  95. package/templates/webterm/public/app/pane.js +33 -24
  96. package/templates/webterm/public/app/reviews-host.js +15 -0
  97. package/templates/webterm/public/app/specs-host.js +12 -218
  98. package/templates/webterm/public/app/state.js +5 -0
  99. package/templates/webterm/public/app/tabs.js +6 -2
  100. package/templates/webterm/public/app/theme.js +75 -27
  101. package/templates/webterm/public/app/tool-host.js +302 -0
  102. package/templates/webterm/public/app/workspace-color.js +192 -0
  103. package/templates/webterm/public/index.html +17 -3
  104. package/templates/webterm/public/styles.css +143 -21
  105. package/templates/webterm/server.js +198 -150
  106. package/templates/webterm/tools/commands.js +61 -0
  107. package/templates/webterm/tools/index.js +25 -0
  108. package/templates/webterm/tools/mount.js +337 -0
  109. package/templates/webterm/tools/paths.js +34 -0
  110. package/templates/webterm/tools/reviews.js +133 -0
  111. package/templates/webterm/tools/specs.js +154 -0
  112. package/templates/webterm/typed-line.js +70 -7
  113. package/templates/context/context-usage.md +0 -1
  114. package/templates/context-usage.sh +0 -266
  115. package/templates/pane-apps/client/assets/specs-B1970L17.css +0 -1
  116. package/templates/pane-apps/client/assets/specs-cEee_SPn.js +0 -23
  117. package/templates/skills/dx-spec-config/SKILL.md +0 -313
  118. package/templates/skills/dx-spec-config/references/principles-template.md +0 -12
  119. package/templates/specs.sh +0 -106
  120. package/templates/webterm/specs.js +0 -358
@@ -1,9 +1,10 @@
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 the line
4
- // between them is the whole point of this module: a tool draws inside its own panel and nothing else, while
5
- // the tab bar that says which tool is open, and the controls that say how wide the pane is, are drawn out
6
- // here. A tool can therefore never move the pane, and never lie about which tool the user is looking at.
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
5
+ // own panel and nothing else, while the tab bar that says which tool is open, and the controls that say how
6
+ // wide the pane is, are drawn out here. A tool can therefore never move the pane, and never lie about which
7
+ // tool the user is looking at.
7
8
  //
8
9
  // Width has three states, and only one of them is a width. Between the two ends the divider drags anything,
9
10
  // clamped as it always was. Past the near end the pane is put away and becomes the spine. Past the far end it
@@ -11,90 +12,153 @@
11
12
  // drag's own clamps stay where they are. The grip is reachable in every one of them, and dragging it is
12
13
  // always the way back.
13
14
  //
14
- // What the pane is belongs to the window - how wide, which tool, how big a page is drawn - and survives a
15
- // session switch and a reload. What is in a tool belongs to the session, and the tool keeps that itself.
15
+ // What the pane is belongs to the session it is beside - how wide, which tool, how big a page is drawn, and
16
+ // the reading theme - so switching sessions gives each one the pane it was left with, and a reload gives them
17
+ // all back. What is in a tool belongs to the session too, and the tool keeps that itself.
16
18
  //
17
- // This module imports nothing but dom.js, deliberately: the tools import it and register themselves as they
18
- // load, so it has to be finished before any of them runs.
19
-
20
- import { grip, pane, panebar, paneCollapse, paneFull, spine, spineBadge, toolPages, toolSpecs, toolTabs } from "./dom.js";
19
+ // This module imports dom.js and pane-shape.js and nothing else, deliberately: the tools import it and
20
+ // register themselves as they load, so it has to be finished before any of them runs. Both of those are
21
+ // leaves - pane-shape.js is the shape, its rules and its store, and it holds no element and reaches no
22
+ // other module. Every rule about a shape lives there rather than here, so it can be run in a test; what is
23
+ // left here is the pane as a thing on the screen.
24
+
25
+ import {
26
+ grip,
27
+ pane,
28
+ panebar,
29
+ paneCollapse,
30
+ paneFull,
31
+ paneHalf,
32
+ spine,
33
+ spineBadge,
34
+ toolPages,
35
+ toolReviews,
36
+ toolSpecs,
37
+ toolTabs,
38
+ } from "./dom.js";
39
+ import {
40
+ DEFAULT_FRACTION,
41
+ defaultShape,
42
+ draggedTo,
43
+ isDefaultWidth,
44
+ mergeShape,
45
+ PAGES,
46
+ pruneShapes,
47
+ REVIEWS,
48
+ readShapes,
49
+ reopenedFrom,
50
+ SPECS,
51
+ } from "./pane-shape.js";
52
+
53
+ // The tools' ids come from the shape, which holds one of them. Re-exported here because this module is the
54
+ // pane, and a tool that asks whether it is open is asking the pane rather than the store.
55
+ export { PAGES, REVIEWS, SPECS };
21
56
 
22
57
  // The tools, in the order their tabs are drawn. A tool is a tab, a panel, and a render function it hands over
23
58
  // when it loads; the pane knows nothing else about any of them.
24
- export const PAGES = "pages";
25
- export const SPECS = "specs";
26
59
  const TOOLS = [
27
60
  { id: PAGES, label: "Pages", panel: toolPages },
28
61
  { id: SPECS, label: "Specs", panel: toolSpecs },
62
+ { id: REVIEWS, label: "Code Reviews", panel: toolReviews },
29
63
  ];
30
64
 
31
- // Narrower than this and the pane is not a pane any more, so it becomes the spine instead. The mock's
32
- // threshold, and the width the spine itself takes.
33
- const COLLAPSE_AT = 150;
34
- // What the pane opens at, in a fraction of the window, and the most of the window a drag may give it.
35
- const DEFAULT_FRACTION = 0.44;
36
- const MAX_FRACTION = 0.7;
37
- // The least of the window the pane may come back into. Reopening is not the same as resizing: whatever width
38
- // it was put away at, a pane you have just asked for has to be wide enough to read a page in.
39
- const MIN_OPEN_FRACTION = 1 / 3;
40
- // The window's own pane preferences: how wide, whether it is collapsed, whether it has the whole row, which
41
- // tool is open, and how big a page is drawn in it. Not the session's - a pane is furniture, and furniture
42
- // does not move because you looked at another tab.
43
- const GEOMETRY_KEY = "webterm-pane";
44
-
45
- let width = null;
46
- // null means nobody has said. An untouched pane keeps out of the way while the tool it is open on has nothing
47
- // in it, and is open once that tool has something - which is the first page of a session opening on arrival,
48
- // as it always did. A click on the spine or on the collapse button is an answer, and from then on it is the
49
- // only one that counts.
50
- let collapsed = null;
51
- // The far end: the pane has the whole row and the terminal is squeezed out.
52
- let full = false;
53
- let openTool = PAGES;
54
- // How big a page is drawn. The pane applies it, so it is stored here with the rest of the furniture, and the
55
- // Pages tool owns the control and the steps.
56
- let pageSize = null;
57
- // What the last render decided, for the two things that only need to know whether the pane is showing.
58
- let shut = true;
65
+ // The one shape this window is drawing, and the session it belongs to. One and not a map of them: the store
66
+ // is shared by every window of this origin, so a shape held here from a session this window left is a copy
67
+ // that has had time to go stale. It is read again on every move to another session instead.
68
+ let sid = null;
69
+ let current = defaultShape();
70
+ // Whether a frame from the server named that session. The id this window opens with comes from its own
71
+ // storage and is the session it is about to ask for, which is enough to draw in and not enough to write
72
+ // under: the server may hand this window a different one, or none.
73
+ let told = false;
59
74
 
60
75
  // id -> what that tool has to say for itself on its tab: how many things are waiting in it, and whether it
61
76
  // has anything at all. A tool nobody has heard from yet is empty and waiting on nothing.
62
- const EMPTY_MARK = { unread: 0, empty: true };
77
+ const EMPTY_MARK = { unread: 0, empty: true, counted: true };
63
78
  const marks = new Map();
64
79
  // id -> the tool's own render function, handed over as it loads.
65
80
  const renderers = new Map();
66
81
 
67
- // --- What the window remembers -----------------------------------------------------------------------------------------------------------
68
-
69
- try {
70
- const stored = JSON.parse(localStorage.getItem(GEOMETRY_KEY) ?? "{}");
71
- if (typeof stored.width === "number" && stored.width >= COLLAPSE_AT) width = stored.width;
72
- if (typeof stored.collapsed === "boolean") collapsed = stored.collapsed;
73
- if (typeof stored.full === "boolean") full = stored.full;
74
- if (TOOLS.some((tool) => tool.id === stored.tool)) openTool = stored.tool;
75
- // The multiplier is stored rather than its position, so a window that was left at a size this version no
76
- // longer offers falls back to the default instead of landing between two steps.
77
- if (typeof stored.size === "number") pageSize = stored.size;
78
- } catch {
79
- // A disabled or full store only means the pane opens at its default width, on its default tool.
80
- }
82
+ // --- What every session remembers --------------------------------------------------------------------------------------------------------
81
83
 
84
+ // Kept in the store, under the session this window is driving and no other. A window that has not been told
85
+ // its session yet writes nothing: the id it opened with may turn out to be another window's session, and the
86
+ // shape of that session is not this window's to set.
82
87
  function remember() {
83
- try {
84
- localStorage.setItem(GEOMETRY_KEY, JSON.stringify({ width, collapsed, full, tool: openTool, size: pageSize }));
85
- } catch {
86
- // The pane still works for this page's lifetime.
87
- }
88
+ if (!told) return;
89
+ mergeShape(localStorage, sid, current);
90
+ }
91
+
92
+ // The shape of one session, off the store. A window with no session draws the defaults and stores nothing,
93
+ // because there is nobody to store it under.
94
+ function shapeOf(next) {
95
+ if (next === null) return defaultShape();
96
+ return readShapes(localStorage).get(next) ?? defaultShape();
97
+ }
98
+
99
+ // Draw everything the shape decides: the pane's own chrome, and the tool open in it, which has a bar and a
100
+ // document to lay out at whatever width the pane now has.
101
+ function redraw() {
102
+ renderShell();
103
+ renderers.get(current.tool)?.();
104
+ }
105
+
106
+ // The shape just changed, so keep it under this session and draw it. Every act on the pane ends here.
107
+ function reshape() {
108
+ remember();
109
+ redraw();
110
+ }
111
+
112
+ /**
113
+ * The window is beside that session now, so the pane is drawn in that session's own shape.
114
+ *
115
+ * The shape is read again here rather than kept from the load, so a window coming back to a session sees
116
+ * what it was left at, whichever window left it there. Called before the tools are told, because what they
117
+ * lay out depends on how wide the pane now is and on which of them is open in it.
118
+ */
119
+ export function applySessionShape(next) {
120
+ const held = next ?? null;
121
+ // The first frame is worth acting on even where it names the session this window opened with: it is what
122
+ // makes the shape this window's to write, and the store may have moved since the page loaded.
123
+ if (held === sid && told) return;
124
+ // A drag in flight belongs to the session that is leaving. Ending it stores the width under that session
125
+ // rather than carrying the pointer into the next one's shape.
126
+ if (dragging) endDrag();
127
+ sid = held;
128
+ told = true;
129
+ current = shapeOf(held);
130
+ redraw();
88
131
  }
89
132
 
90
- /** How big a page was last drawn in this window, or null when nobody has said. */
133
+ /** A session that left the bar takes its pane shape with it, the way its unsent message and its page list do. */
134
+ export function prunePaneShapes(live) {
135
+ pruneShapes(localStorage, live);
136
+ }
137
+
138
+ /** How big a page was last drawn in this session, or null when nobody has said. */
91
139
  export function storedPageSize() {
92
- return pageSize;
140
+ return current.size;
93
141
  }
94
142
 
95
- /** The Pages tool's zoom, kept with the rest of the pane's furniture so one key has one writer. */
143
+ /** The Pages tool's zoom, kept with the rest of the session's shape so one key has one writer. */
96
144
  export function rememberPageSize(size) {
97
- pageSize = size;
145
+ current.size = size;
146
+ remember();
147
+ }
148
+
149
+ /** The reading theme this session was left on, or null for the system preference. */
150
+ export function storedPaneTheme() {
151
+ return current.theme;
152
+ }
153
+
154
+ /**
155
+ * The Specs tool's reading theme, kept with the rest of the session's shape.
156
+ *
157
+ * The tool's frame has an opaque origin and therefore no storage of its own, so the pane holds the choice
158
+ * and hands it back with the session. Anything but the two palettes means the system preference.
159
+ */
160
+ export function rememberPaneTheme(theme) {
161
+ current.theme = theme === "dark" || theme === "light" ? theme : null;
98
162
  remember();
99
163
  }
100
164
 
@@ -110,136 +174,147 @@ export function registerTool(id, render) {
110
174
 
111
175
  /** Whether the pane is open on that tool right now. */
112
176
  export function isToolOpen(id) {
113
- return openTool === id;
177
+ return current.tool === id;
114
178
  }
115
179
 
116
180
  /**
117
181
  * What a tool has to say on its tab: `unread` is what is waiting for the user in it, and `empty` is whether
118
182
  * it has anything to show at all - which is what an untouched pane reads to decide whether to stay out of
119
183
  * the way.
184
+ *
185
+ * `counted` is whether that number is worth showing. A tool that asks the user things counts them, because
186
+ * two questions waiting is a different day from one. A tool that only receives counts nothing the user
187
+ * would act on differently, so its tab says that something arrived and leaves the number out of it.
120
188
  */
121
- export function noteTool(id, { unread = 0, empty = false } = {}) {
122
- marks.set(id, { unread, empty });
189
+ export function noteTool(id, { unread = 0, empty = false, counted = true } = {}) {
190
+ marks.set(id, { unread, empty, counted });
123
191
  renderShell();
124
192
  }
125
193
 
126
- /** Open a tool, drawing its panel. A tool the user chose is the tool this window opens on next time. */
194
+ /** Open a tool, drawing its panel. A tool the user chose is the tool this session comes back on. */
127
195
  export function showTool(id) {
128
196
  if (!TOOLS.some((tool) => tool.id === id)) return;
129
- openTool = id;
130
- remember();
131
- renderShell();
132
- renderers.get(id)?.();
197
+ current.tool = id;
198
+ reshape();
133
199
  }
134
200
 
135
201
  function drawTabs() {
136
202
  const tabs = TOOLS.map((tool) => {
137
203
  const button = document.createElement("button");
138
204
  button.type = "button";
139
- button.className = tool.id === openTool ? "tool-tab on" : "tool-tab";
205
+ button.className = tool.id === current.tool ? "tool-tab on" : "tool-tab";
140
206
  button.dataset.tool = tool.id;
141
207
  button.append(tool.label);
142
208
  // What is waiting in a tool nobody is looking at. The one thing a tab says beyond its name, so a tool
143
209
  // never has to take the screen to be noticed.
144
- const waiting = (marks.get(tool.id) ?? EMPTY_MARK).unread;
145
- if (waiting > 0) {
210
+ const mark = marks.get(tool.id) ?? EMPTY_MARK;
211
+ if (mark.unread > 0) {
146
212
  const badge = document.createElement("span");
147
- badge.className = "tool-mark";
148
- badge.textContent = String(waiting);
213
+ badge.className = mark.counted ? "tool-mark" : "tool-mark dot";
214
+ if (mark.counted) badge.textContent = String(mark.unread);
215
+ else badge.setAttribute("aria-label", "something new");
149
216
  button.append(badge);
150
217
  }
151
218
  button.addEventListener("click", () => showTool(tool.id));
152
219
  return button;
153
220
  });
154
221
  toolTabs.replaceChildren(...tabs);
155
- for (const tool of TOOLS) tool.panel.hidden = tool.id !== openTool;
222
+ for (const tool of TOOLS) tool.panel.hidden = tool.id !== current.tool;
156
223
  }
157
224
 
158
225
  // --- How wide ----------------------------------------------------------------------------------------------------------------------------
159
226
 
160
227
  // How wide, put away, or the whole row. Its own function because a drag is the one thing that changes the
161
228
  // pane without changing anything in it: sixty of these a second are cheap, and sixty full redraws are not.
162
- function applyGeometry(collapsedNow = shut) {
163
- pane.classList.toggle("collapsed", collapsedNow);
229
+ function applyGeometry() {
230
+ const { width, full, collapsed } = current;
231
+ pane.classList.toggle("collapsed", collapsed);
164
232
  // Only one of the two can be true: a pane that is put away has no width to be full of.
165
- pane.classList.toggle("full", full && !collapsedNow);
233
+ pane.classList.toggle("full", full && !collapsed);
166
234
  // The width goes into a custom property rather than straight onto `width`, so the stylesheet keeps the
167
235
  // last word: the collapsed spine, the full row and the narrow-screen layout are rules, and an inline
168
236
  // width would beat all three.
169
237
  pane.style.setProperty("--pane-width", width === null ? `${DEFAULT_FRACTION * 100}%` : `${width}px`);
170
- paneFull.classList.toggle("on", full && !collapsedNow);
171
- const back = full && !collapsedNow;
238
+ paneFull.classList.toggle("on", full && !collapsed);
239
+ paneHalf.classList.toggle("on", isDefaultWidth(current));
240
+ const back = full && !collapsed;
172
241
  paneFull.title = back ? "Give the terminal its room back" : "Full width";
173
242
  paneFull.setAttribute("aria-label", paneFull.title);
174
243
  }
175
244
 
176
245
  /** Draw the pane's own chrome: the tabs, the width, and what the spine says while the pane is away. */
177
246
  export function renderShell() {
178
- const mark = marks.get(openTool) ?? EMPTY_MARK;
179
- shut = collapsed === null ? mark.empty : collapsed;
247
+ const mark = marks.get(current.tool) ?? EMPTY_MARK;
180
248
  pane.hidden = false;
181
249
  // There is no width worth choosing for a tool with nothing in it.
182
- grip.hidden = mark.empty && shut;
250
+ grip.hidden = mark.empty && current.collapsed;
183
251
 
184
- applyGeometry(shut);
252
+ applyGeometry();
185
253
  drawTabs();
186
254
 
187
255
  // The spine stands for the whole pane, so it carries what every tool is waiting on, not just the open one.
256
+ // It counts what the tools count, and falls back to the bare dot when all that arrived was uncounted:
257
+ // a number here has to mean the same thing it means on a tab.
188
258
  let waiting = 0;
189
- for (const entry of marks.values()) waiting += entry.unread;
259
+ let arrived = false;
260
+ for (const entry of marks.values()) {
261
+ if (entry.unread <= 0) continue;
262
+ if (entry.counted) waiting += entry.unread;
263
+ else arrived = true;
264
+ }
190
265
  spineBadge.textContent = waiting > 0 ? String(waiting) : "";
191
- spineBadge.hidden = waiting === 0;
266
+ spineBadge.classList.toggle("dot", waiting === 0 && arrived);
267
+ spineBadge.hidden = waiting === 0 && !arrived;
192
268
  }
193
269
 
194
270
  /**
195
271
  * Draw the pane for the first time, on the tool this window was left on. Called from the entry rather than
196
272
  * here, because the tools register as they load and there is no pane to draw until they all have.
197
273
  */
198
- export function mountPane() {
199
- renderShell();
200
- renderers.get(openTool)?.();
201
- }
202
-
203
- /** Whether the pane is drawn as the spine rather than as a pane. */
204
- export function isPaneShut() {
205
- return shut;
274
+ export function mountPane(startSid = null) {
275
+ sid = startSid ?? null;
276
+ current = shapeOf(sid);
277
+ redraw();
206
278
  }
207
279
 
208
- /** Whether the user put the pane away, as opposed to never having said. */
280
+ /** Whether the pane is put away in this session, which is whether it is drawn as the spine. */
209
281
  export function isPaneCollapsed() {
210
- return collapsed === true;
282
+ return current.collapsed;
211
283
  }
212
284
 
213
285
  // Away, and back. Two ways in and one way out, because the pane is put away far more often than it is
214
286
  // resized: the button in the bar is the obvious one, and dragging the grip past the threshold is the other.
215
287
  export function setPaneCollapsed(next) {
216
- collapsed = next;
217
- shut = next;
218
- if (collapsed) {
288
+ current.collapsed = next;
289
+ if (current.collapsed) {
219
290
  // Coming back to a pane that fills the window, with the terminal nowhere, is not what putting one
220
291
  // away asks for. The width it had is remembered; the far end is not.
221
- full = false;
292
+ current.full = false;
222
293
  } else {
223
- // Back at whatever width it had, unless that width would be a sliver - a pane dragged shut, or one
224
- // from a window remembered narrower than this one, has to open into something a page fits in.
225
- const floor = Math.min(window.innerWidth * MIN_OPEN_FRACTION, window.innerWidth * MAX_FRACTION);
226
- if (width !== null && width < floor) width = floor;
294
+ current.width = reopenedFrom(current, window.innerWidth);
227
295
  }
228
- remember();
229
- renderShell();
230
- renderers.get(openTool)?.();
296
+ reshape();
297
+ }
298
+
299
+ // Back to the width the pane opens at. Not a width of its own: the pane is left with none, and the
300
+ // stylesheet draws the default fraction, so a window resized afterwards leaves the pane at half rather
301
+ // than at the pixels half happened to be when the button was pressed.
302
+ function setHalf() {
303
+ current.width = null;
304
+ current.full = false;
305
+ current.collapsed = false;
306
+ reshape();
231
307
  }
232
308
 
233
309
  // The far end, and back. A press either way, and a drag of the grip is the other way back.
234
310
  function setFull(next) {
235
- full = next;
236
- if (full) collapsed = false;
237
- remember();
238
- renderShell();
239
- renderers.get(openTool)?.();
311
+ current.full = next;
312
+ if (current.full) current.collapsed = false;
313
+ reshape();
240
314
  }
241
315
 
242
- paneFull.addEventListener("click", () => setFull(!full));
316
+ paneFull.addEventListener("click", () => setFull(!current.full));
317
+ paneHalf.addEventListener("click", setHalf);
243
318
  paneCollapse.addEventListener("click", () => setPaneCollapsed(true));
244
319
  spine.addEventListener("click", () => setPaneCollapsed(false));
245
320
 
@@ -275,37 +350,27 @@ let widthBeforeDrag = null;
275
350
 
276
351
  grip.addEventListener("pointerdown", (event) => {
277
352
  dragging = true;
278
- widthBeforeDrag = width;
353
+ widthBeforeDrag = current.width;
279
354
  document.body.classList.add("dragging");
280
355
  grip.setPointerCapture(event.pointerId);
281
356
  });
282
357
 
283
358
  grip.addEventListener("pointermove", (event) => {
284
359
  if (!dragging) return;
285
- // A drag is a width, so it is the way out of the far end as well as the way into any other width.
286
- full = false;
287
360
  const wanted = Math.max(0, window.innerWidth - event.clientX - grip.offsetWidth / 2);
288
- if (wanted < COLLAPSE_AT) {
289
- collapsed = true;
290
- width = widthBeforeDrag;
291
- } else {
292
- collapsed = false;
293
- width = Math.min(wanted, window.innerWidth * MAX_FRACTION);
294
- }
295
- // A drag is an answer too, so what it decides is what the next render starts from.
296
- shut = collapsed;
297
- applyGeometry(shut);
361
+ // A drag is a width, so it is the way into every state the pane has and the way out of each of them.
362
+ // Where each of those states begins is pane-shape.js's rule; what is left here is the pointer.
363
+ Object.assign(current, draggedTo(wanted, window.innerWidth, widthBeforeDrag));
364
+ applyGeometry();
298
365
  });
299
366
 
300
367
  function endDrag() {
301
368
  if (!dragging) return;
302
369
  dragging = false;
303
370
  document.body.classList.remove("dragging");
304
- remember();
305
- // A drag that crossed the threshold changed which of the three states the pane is in, and the tool that
306
- // is open in it has a bar and a document to lay out again at the new width.
307
- renderShell();
308
- renderers.get(openTool)?.();
371
+ // The shape changed under the pointer, and only the geometry followed it. A drag that crossed either
372
+ // threshold also changed which of the three states the pane is in, which the tool has to hear about.
373
+ reshape();
309
374
  }
310
375
 
311
376
  grip.addEventListener("pointerup", endDrag);
@@ -31,7 +31,6 @@ import { dropPage, fetchPage, renderPage, submitFeedback } from "./pane-frame.js
31
31
  import {
32
32
  flashPane,
33
33
  isPaneCollapsed,
34
- isPaneShut,
35
34
  isToolOpen,
36
35
  noteTool,
37
36
  PAGES,
@@ -55,11 +54,14 @@ const AGE_TICK_MS = 30_000;
55
54
  // heard about, so switching back to a tab lands on the page it was left on.
56
55
  const panes = new Map();
57
56
 
58
- // Which of TEXT_SIZES the shown page is drawn at. The multiplier is what the pane stores, so a window that
59
- // was left at a size this version no longer offers falls back to the default instead of landing between two
60
- // steps.
61
- const storedStep = TEXT_SIZES.indexOf(storedPageSize());
62
- let sizeStep = storedStep === -1 ? DEFAULT_SIZE_STEP : storedStep;
57
+ // Which of TEXT_SIZES the shown page is drawn at. It belongs to the session, so it is asked of the pane each
58
+ // time rather than held here: a switch changes the answer. The multiplier is what the pane stores, so a
59
+ // session left at a size this version no longer offers falls back to the default instead of landing between
60
+ // two steps.
61
+ function sizeStep() {
62
+ const at = TEXT_SIZES.indexOf(storedPageSize());
63
+ return at === -1 ? DEFAULT_SIZE_STEP : at;
64
+ }
63
65
 
64
66
  // --- The tool's own state ----------------------------------------------------------------------------------------------------------------
65
67
 
@@ -91,9 +93,8 @@ export function applyPages(msg) {
91
93
  select(msg.sid, arrived.id, { user: false });
92
94
  if (isToolOpen(PAGES)) flashPane();
93
95
  }
94
- // A pane someone put away comes back for a page. Only for an answer they gave: the third state, nobody
95
- // has said, already opens the pane when its session stops being empty, and settling it here would open
96
- // the pane in every other empty session in this window too.
96
+ // A pane someone put away comes back for a page, in the session the page was published in - which is
97
+ // the session this frame is about, since the list only reaches the tool for the one it is driving.
97
98
  if (arrived !== null && isToolOpen(PAGES) && isPaneCollapsed()) setPaneCollapsed(false);
98
99
  else render();
99
100
  }
@@ -172,7 +173,7 @@ function introState() {
172
173
  const wrap = document.createElement("div");
173
174
  wrap.className = "pane-intro";
174
175
  const title = document.createElement("h2");
175
- title.textContent = "The web pane";
176
+ title.textContent = "Pages";
176
177
  const lede = document.createElement("p");
177
178
  lede.textContent = "Ask the agent for a page whenever the answer is longer or more structured than the terminal carries well:";
178
179
  const asks = document.createElement("ul");
@@ -230,38 +231,41 @@ function chipFor(sid, page, selected) {
230
231
  // would have to be put back on every one of those paths. Setting it out here costs nothing and cannot be
231
232
  // missed.
232
233
  function applyTextSize() {
233
- const size = TEXT_SIZES[sizeStep];
234
+ const step = sizeStep();
235
+ const size = TEXT_SIZES[step];
234
236
  toolPages.style.setProperty("--page-zoom", String(size));
235
237
  pageSizeVal.textContent = `${Math.round(size * 100)}%`;
236
238
  // A control that can do nothing says so, rather than swallowing the press.
237
- pageSmaller.disabled = sizeStep === 0;
238
- pageBigger.disabled = sizeStep === TEXT_SIZES.length - 1;
239
+ pageSmaller.disabled = step === 0;
240
+ pageBigger.disabled = step === TEXT_SIZES.length - 1;
239
241
  }
240
242
 
241
243
  function stepTextSize(by) {
242
- const next = Math.min(Math.max(sizeStep + by, 0), TEXT_SIZES.length - 1);
243
- if (next === sizeStep) return;
244
- sizeStep = next;
244
+ const step = sizeStep();
245
+ const next = Math.min(Math.max(step + by, 0), TEXT_SIZES.length - 1);
246
+ if (next === step) return;
247
+ rememberPageSize(TEXT_SIZES[next]);
245
248
  applyTextSize();
246
- rememberPageSize(TEXT_SIZES[sizeStep]);
247
249
  }
248
250
 
249
251
  pageSmaller.addEventListener("click", () => stepTextSize(-1));
250
252
  pageBigger.addEventListener("click", () => stepTextSize(1));
251
- applyTextSize();
252
253
 
253
254
  function render() {
255
+ // The zoom is the session's, so it is applied wherever this tool draws rather than once at load: the
256
+ // first draw is already in a session's shape, and every switch draws again.
257
+ applyTextSize();
254
258
  const sid = attachedSid;
255
259
  const entry = sid === null ? null : (panes.get(sid) ?? null);
256
260
  const pages = entry?.pages ?? [];
257
261
  const unread = pages.filter((page) => page.unread).length;
258
262
 
259
- // Nothing published in this session, and nothing to say about it. The tab stays - and the pane stays as
260
- // the spine, until someone opens it - because a pane that only appears once an agent has used it is one
261
- // nobody knows to ask for. Saying so is the shell's business: the tab's mark, and whether an untouched
262
- // pane keeps out of the way, are the pane's decisions to take from what its open tool has.
263
+ // Nothing published in this session, and nothing to say about it. The tab stays and the pane stays open,
264
+ // because a pane that only appears once an agent has used it is one nobody knows to ask for. What the
265
+ // empty pane says is the tool's own intro; the tab's mark is the shell's, from what the tool says here.
263
266
  const bare = pages.length === 0 && !entry?.notice;
264
- noteTool(PAGES, { unread, empty: bare });
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 });
265
269
 
266
270
  chips.replaceChildren(...pages.map((page) => chipFor(sid, page, page.id === entry?.selected)));
267
271
  const at = pages.findIndex((page) => page.id === entry?.selected);
@@ -291,6 +295,11 @@ function render() {
291
295
  pageDoc.append(emptyState(notice));
292
296
  return;
293
297
  }
298
+ // Fetching a page is what marks it read - the server owns that, and it answers with a fresh list. So a
299
+ // tool nobody is looking at must not fetch: the tab's own mark, set a few lines up, would be cleared by
300
+ // the frame that fetch provokes, before anyone had seen it. The document is drawn when the user opens
301
+ // the tool, by this same function.
302
+ if (!isToolOpen(PAGES)) return;
294
303
  show(sid, entry.selected);
295
304
  // A chip that scrolled out of the bar while the user was reading something else.
296
305
  chips.querySelector(".chip.sel")?.scrollIntoView({ block: "nearest", inline: "nearest" });
@@ -300,7 +309,7 @@ function render() {
300
309
  registerTool(PAGES, render);
301
310
 
302
311
  setInterval(() => {
303
- if (!isPaneShut() && isToolOpen(PAGES)) render();
312
+ if (!isPaneCollapsed() && isToolOpen(PAGES)) render();
304
313
  }, AGE_TICK_MS);
305
314
 
306
315
  // A bar that fits at one width may not at another, and the arrows only exist when it does not.
@@ -0,0 +1,15 @@
1
+ // reviews-host.js - the Code Reviews tool on the page.
2
+ //
3
+ // As the Specs host beside it: the tool's own name, its panel and its opening sentence, and the frame around
4
+ // them is `tool-host.js`.
5
+
6
+ import { toolReviews } from "./dom.js";
7
+ import { REVIEWS } from "./pane-shell.js";
8
+ import { createToolHost } from "./tool-host.js";
9
+
10
+ createToolHost({
11
+ id: REVIEWS,
12
+ label: "Code Reviews",
13
+ panel: toolReviews,
14
+ opening: "Opening the reviews in this repository.",
15
+ });