chamba 0.5.1 → 0.6.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.
@@ -0,0 +1,36 @@
1
+ // pane-arrival.js - which page an incoming list opens by itself, if any.
2
+ //
3
+ // One question, kept apart from everything that draws so it can be run and checked where there is no browser -
4
+ // the same reason strip-format.js is its own file. It is the rule the whole of the pane's arrival behaviour
5
+ // rests on, and the one place where getting it subtly wrong shows up as the window walking through a session's
6
+ // pages rather than as anything that looks like an error.
7
+
8
+ /**
9
+ * The page a pages frame should open, or null when the pane should leave what it is showing alone.
10
+ *
11
+ * The list is oldest first, so this is the last one - and only while it is unread, which is the server's own
12
+ * answer to "did this arrive since we last looked". Two things follow from testing the newest page rather than
13
+ * any unread one, and both are the point:
14
+ *
15
+ * - Opening a page marks it read, which produces another pages frame. Under "any unread page" that frame
16
+ * answers with the page below the one just opened, and the window walks backwards through everything waiting
17
+ * until it comes to rest on the oldest. Under this rule the newest is read by then, so it stops.
18
+ * - A session with pages waiting in it - published while the user was on another tab - opens the newest when
19
+ * they arrive, and the rest keep their badges instead of being torn through and marked read unseen.
20
+ *
21
+ * A burst is the other case, and it is not this one: three publishes a moment apart are three frames, each
22
+ * carrying one more page, so the pages open in turn and the last of them is what stays on the screen. The ones
23
+ * before it are usually opened in passing - which is what marks a page read - and lose their badges without
24
+ * having been looked at, though one that lands while the page before it is still being fetched is skipped and
25
+ * keeps its badge. That is the cost of opening a page the instant it lands, and it was taken deliberately:
26
+ * waiting to see whether another follows would put a delay in front of every page, and let a timer overrule a
27
+ * chip the user clicked in the meantime.
28
+ *
29
+ * One thing this narrows, deliberately: a file copied into the pane directory by hand, numbered below the
30
+ * newest page, is unread but not last, so it badges its chip and waits to be clicked like any page did before
31
+ * this. Publishing always numbers last, so nothing an agent does lands there.
32
+ */
33
+ export function arrivingPage(pages) {
34
+ const newest = pages[pages.length - 1] ?? null;
35
+ return newest?.unread === true ? newest : null;
36
+ }
@@ -6,19 +6,38 @@
6
6
  // outside its frame.
7
7
  //
8
8
  // What the pane shows belongs to the attached session, and it follows the tabs: switching sessions switches
9
- // the list, the selection, and the page. What the pane *is* belongs to the window - how wide it is, and
10
- // whether it is collapsed - and that survives the switch, and a reload.
9
+ // the list, the selection, and the page. What the pane *is* belongs to the window - how wide it is, whether it
10
+ // is collapsed, and how big a page is drawn in it - and that survives the switch, and a reload.
11
11
  //
12
- // A page that arrives never steals the pane. It badges its chip (and ticks the spine's counter when the pane
13
- // is collapsed) and waits to be clicked. The one exception is a pane with nothing in it: the first page of a
14
- // session opens on arrival, because there is nothing to interrupt.
12
+ // A page that arrives opens itself, and says so with one pulse of the pane's edge. It never takes the
13
+ // keyboard: the caret stays in the composer mid-sentence, which is the price of the pane opening at all.
14
+ // "Arrives" is the server's own unread flag, not "new to this window" - the list here starts empty on every
15
+ // reload, and a page nobody has opened in a session nobody has visited is still an arrival when they get to
16
+ // it. A page for a session this window is not looking at touches nothing here; it marks that session's tab
17
+ // (see tabs.js), keeps its chip badge and its place in the spine's counter, and waits.
15
18
  //
16
19
  // The pane exists before any of that. A session that has published nothing still has its spine, and opening it
17
20
  // says what the pane is for and what to ask for - a feature nobody can see is a feature nobody uses, and this
18
21
  // one is asked for in words rather than found in a menu.
19
22
 
20
23
  import { sendFrame } from "./connection.js";
21
- import { chips, grip, pagebar, pageCount, pageDoc, pane, paneCollapse, paneNext, panePrev, spine, spineBadge } from "./dom.js";
24
+ import {
25
+ chips,
26
+ grip,
27
+ pageBigger,
28
+ pagebar,
29
+ pageCount,
30
+ pageDoc,
31
+ pageSizeVal,
32
+ pageSmaller,
33
+ pane,
34
+ paneCollapse,
35
+ paneNext,
36
+ panePrev,
37
+ spine,
38
+ spineBadge,
39
+ } from "./dom.js";
40
+ import { arrivingPage } from "./pane-arrival.js";
22
41
  import { dropPage, fetchPage, renderPage, submitFeedback } from "./pane-frame.js";
23
42
  import { attachedSid } from "./state.js";
24
43
 
@@ -31,9 +50,14 @@ const MAX_FRACTION = 0.7;
31
50
  // The least of the window the pane may come back into. Reopening is not the same as resizing: whatever width
32
51
  // it was put away at, a pane you have just asked for has to be wide enough to read a page in.
33
52
  const MIN_OPEN_FRACTION = 1 / 3;
34
- // The window's own pane preferences: how wide, and whether it is collapsed. Not the session's - a pane is
35
- // furniture, and furniture does not move because you looked at another tab.
53
+ // The window's own pane preferences: how wide, whether it is collapsed, and how big a page is drawn in it.
54
+ // Not the session's - a pane is furniture, and furniture does not move because you looked at another tab.
36
55
  const GEOMETRY_KEY = "webterm-pane";
56
+ // The sizes a page can be shown at, smallest first, and where an untouched window starts. The spacing is
57
+ // deliberately uneven: a correction near normal reading size wants a fine step, and someone going to 150%
58
+ // wants to get there in two presses.
59
+ const TEXT_SIZES = [0.8, 0.9, 1, 1.25, 1.5];
60
+ const DEFAULT_SIZE_STEP = 2;
37
61
  // Chips say how long ago a page arrived, so the bar goes stale sitting still. Cheap to redraw; nothing else
38
62
  // on the page depends on it.
39
63
  const AGE_TICK_MS = 30_000;
@@ -47,6 +71,8 @@ let width = null;
47
71
  // is open once it has - which is the first page of a session opening on arrival, as it always did. A click on
48
72
  // the spine or on the collapse button is an answer, and from then on it is the only one that counts.
49
73
  let collapsed = null;
74
+ // Which of TEXT_SIZES the shown page is drawn at.
75
+ let sizeStep = DEFAULT_SIZE_STEP;
50
76
  // What the last render decided, for the two things that only need to know whether the pane is showing.
51
77
  let shut = true;
52
78
 
@@ -56,13 +82,17 @@ try {
56
82
  const stored = JSON.parse(localStorage.getItem(GEOMETRY_KEY) ?? "{}");
57
83
  if (typeof stored.width === "number" && stored.width >= COLLAPSE_AT) width = stored.width;
58
84
  if (typeof stored.collapsed === "boolean") collapsed = stored.collapsed;
85
+ // The multiplier is stored rather than its position, so a window that was left at a size this version no
86
+ // longer offers falls back to the default instead of landing between two steps.
87
+ const step = TEXT_SIZES.indexOf(stored.size);
88
+ if (step !== -1) sizeStep = step;
59
89
  } catch {
60
90
  // A disabled or full store only means the pane opens at its default width.
61
91
  }
62
92
 
63
93
  function rememberGeometry() {
64
94
  try {
65
- localStorage.setItem(GEOMETRY_KEY, JSON.stringify({ width, collapsed }));
95
+ localStorage.setItem(GEOMETRY_KEY, JSON.stringify({ width, collapsed, size: TEXT_SIZES[sizeStep] }));
66
96
  } catch {
67
97
  // The pane still works for this page's lifetime.
68
98
  }
@@ -88,10 +118,19 @@ export function applyPages(msg) {
88
118
  // A page the user was reading can go: the file was moved, or the pane was adopted into another
89
119
  // conversation's directory while this window was looking at it.
90
120
  if (entry.selected !== null && !entry.pages.some((page) => page.id === entry.selected)) entry.selected = null;
91
- // The one time a page selects itself. An empty pane has nothing to interrupt, so the first page to
92
- // arrive opens; from then on an arrival is a badge and a click.
93
- if (entry.selected === null && entry.pages.length === 1) select(msg.sid, entry.pages[0].id, { user: false });
94
- if (msg.sid === attachedSid) render();
121
+ if (msg.sid !== attachedSid) return;
122
+ // The page this frame opens, if it opens one - the rule itself is in pane-arrival.js, where it can be run,
123
+ // and so is what a burst of publishes does under it.
124
+ const arrived = arrivingPage(entry.pages);
125
+ if (arrived !== null) {
126
+ select(msg.sid, arrived.id, { user: false });
127
+ flashArrival();
128
+ }
129
+ // A pane someone put away comes back for a page. Only for an answer they gave: the third state, nobody
130
+ // has said, already opens the pane when its session stops being empty, and settling it here would open
131
+ // the pane in every other empty session in this window too.
132
+ if (arrived !== null && collapsed === true) setCollapsed(false);
133
+ else render();
95
134
  }
96
135
 
97
136
  /** Sessions that are gone take their pane state with them. The files on disk are untouched; this is a window. */
@@ -228,6 +267,52 @@ function applyGeometry(collapsedNow = shut) {
228
267
  pane.style.setProperty("--pane-width", width === null ? `${DEFAULT_FRACTION * 100}%` : `${width}px`);
229
268
  }
230
269
 
270
+ // --- How big a page is drawn -------------------------------------------------------------------------------------------------------------
271
+
272
+ // The size is a property on the pane, and the stylesheet zooms the frame from it. Two reasons, both structural:
273
+ // the frame's document has an opaque origin and the shell cannot reach into it, and renderPage() throws the
274
+ // iframe away and builds a fresh one on every chip click, so anything set on the frame itself would have to be
275
+ // put back on every one of those paths. Setting it out here costs nothing and cannot be missed.
276
+ function applyTextSize() {
277
+ const size = TEXT_SIZES[sizeStep];
278
+ pane.style.setProperty("--page-zoom", String(size));
279
+ pageSizeVal.textContent = `${Math.round(size * 100)}%`;
280
+ // A control that can do nothing says so, rather than swallowing the press.
281
+ pageSmaller.disabled = sizeStep === 0;
282
+ pageBigger.disabled = sizeStep === TEXT_SIZES.length - 1;
283
+ }
284
+
285
+ function stepTextSize(by) {
286
+ const next = Math.min(Math.max(sizeStep + by, 0), TEXT_SIZES.length - 1);
287
+ if (next === sizeStep) return;
288
+ sizeStep = next;
289
+ applyTextSize();
290
+ rememberGeometry();
291
+ }
292
+
293
+ pageSmaller.addEventListener("click", () => stepTextSize(-1));
294
+ pageBigger.addEventListener("click", () => stepTextSize(1));
295
+ applyTextSize();
296
+
297
+ // --- Saying that a page arrived ----------------------------------------------------------------------------------------------------------
298
+
299
+ // One pulse of the pane's edge, in the workspace colour. ARRIVAL_MS must match the keyframes in styles.css.
300
+ const ARRIVAL_MS = 1_000;
301
+ let arrivalTimer = null;
302
+
303
+ // The class comes off on a timer rather than on animationend, because with a reduced-motion preference there
304
+ // is no animation to end - the stylesheet holds the highlight instead of pulsing it, and this is what makes
305
+ // the two last the same moment.
306
+ function flashArrival() {
307
+ clearTimeout(arrivalTimer);
308
+ // A pulse still running when the next page lands restarts rather than stacking: off, force the browser to
309
+ // notice, on again. Reading a layout property is what makes the restart real.
310
+ pane.classList.remove("arriving");
311
+ void pane.offsetWidth;
312
+ pane.classList.add("arriving");
313
+ arrivalTimer = setTimeout(() => pane.classList.remove("arriving"), ARRIVAL_MS);
314
+ }
315
+
231
316
  function render() {
232
317
  const sid = attachedSid;
233
318
  const entry = sid === null ? null : (panes.get(sid) ?? null);
@@ -13,6 +13,7 @@
13
13
  import { sendFrame } from "./connection.js";
14
14
  import { strip } from "./dom.js";
15
15
  import { attachedSid } from "./state.js";
16
+ import { short, until } from "./strip-format.js";
16
17
 
17
18
  // The countdown to the quota recharge goes stale sitting still, and so does nothing else here. A redraw is
18
19
  // a handful of spans, so the whole strip is rebuilt rather than the one number patched.
@@ -60,29 +61,6 @@ export function refreshStrip() {
60
61
  render();
61
62
  }
62
63
 
63
- // --- Saying a number ---------------------------------------------------------------------------------------------------------------------
64
-
65
- /** 45.0k, 174k, 1M - a token count at a glance, never to the token. */
66
- function short(value) {
67
- if (value >= 1_000_000) return `${trimZero(value / 1_000_000)}M`;
68
- if (value >= 100_000) return `${Math.round(value / 1000)}k`;
69
- if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
70
- return String(Math.round(value));
71
- }
72
-
73
- function trimZero(value) {
74
- const text = value.toFixed(1);
75
- return text.endsWith(".0") ? text.slice(0, -2) : text;
76
- }
77
-
78
- /** How long until a moment: "2h 15m", "15m", or "now" once it has passed. */
79
- function until(epochSeconds) {
80
- const seconds = Math.round(epochSeconds - Date.now() / 1000);
81
- if (seconds <= 0) return "now";
82
- if (seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))}m`;
83
- return `${Math.floor(seconds / 3600)}h ${Math.round((seconds % 3600) / 60)}m`;
84
- }
85
-
86
64
  // --- Drawing -----------------------------------------------------------------------------------------------------------------------------
87
65
 
88
66
  // The icons and the separator the mock draws, written as escapes so this file stays plain ASCII: an emoji
@@ -107,9 +85,12 @@ function segment(...parts) {
107
85
  return seg;
108
86
  }
109
87
 
110
- /** A meter, filled to a percentage of itself. `tone` is what the fill says about that number. */
111
- function bar(percent, tone) {
112
- const el = span(tone ? `bar ${tone}` : "bar");
88
+ /**
89
+ * A meter, filled to a percentage of itself. `tone` is what the fill says about that number, and `variant` its
90
+ * width: the quota meters are narrow, because there are two of them and one line to fit on.
91
+ */
92
+ function bar(percent, tone, variant) {
93
+ const el = span(["bar", tone, variant].filter(Boolean).join(" "));
113
94
  const fill = span("fill");
114
95
  fill.style.width = `${Math.max(0, Math.min(100, percent))}%`;
115
96
  el.append(fill);
@@ -136,13 +117,15 @@ function contextSegment(status) {
136
117
  return segment(...parts);
137
118
  }
138
119
 
139
- // What is left of the five-hour window, and when it comes back. Absent on a free account and before the first
140
- // answer of a session, which is why the whole segment goes rather than showing a full meter that is a guess.
141
- function quotaSegment(status) {
142
- if (status.quotaPct === null) return null;
143
- const tone = status.quotaPct <= QUOTA_LOW_AT ? "low" : status.quotaPct <= QUOTA_WARN_AT ? "warn" : "";
144
- const parts = [span("icon", BOLT), bar(status.quotaPct, tone), span("name", `${Math.round(status.quotaPct)}%`)];
145
- if (status.quotaResetsAt !== null) parts.push(span("dim", `(${PLUG} ${until(status.quotaResetsAt)})`));
120
+ // What is left of one rate-limit window, and when it comes back. Drawn once per window the account has, with a
121
+ // tag - "5h", "7d" - so the two are told apart without reading the percentages. A window the account does not
122
+ // have is absent from the snapshot, and so is its whole segment: an empty meter would be a guess. So is one
123
+ // before the first answer of a session, and on an account that has no limits at all.
124
+ function quotaSegment(percent, resetsAt, tag) {
125
+ if (percent === null) return null;
126
+ const tone = percent <= QUOTA_LOW_AT ? "low" : percent <= QUOTA_WARN_AT ? "warn" : "";
127
+ const parts = [span("icon", BOLT), span("tag", tag), bar(percent, tone, "narrow"), span("name", `${Math.round(percent)}%`)];
128
+ if (resetsAt !== null) parts.push(span("dim", `(${PLUG} ${until(resetsAt)})`));
146
129
  return segment(...parts);
147
130
  }
148
131
 
@@ -152,7 +135,16 @@ function versionSegment(status) {
152
135
 
153
136
  function render() {
154
137
  const status = attachedSid === null ? null : (statuses.get(attachedSid) ?? null);
155
- const segments = status === null ? [] : [modelSegment(status), contextSegment(status), quotaSegment(status), versionSegment(status)];
138
+ const segments =
139
+ status === null
140
+ ? []
141
+ : [
142
+ modelSegment(status),
143
+ contextSegment(status),
144
+ quotaSegment(status.quotaPct, status.quotaResetsAt, "5h"),
145
+ quotaSegment(status.quotaWeekPct, status.quotaWeekResetsAt, "7d"),
146
+ versionSegment(status),
147
+ ];
156
148
  const shown = segments.filter((seg) => seg !== null);
157
149
  // A snapshot every field of which was missing says nothing, and an empty bar above the composer is worse
158
150
  // than no bar: it looks like something failed to load.
@@ -0,0 +1,36 @@
1
+ // strip-format.js - saying the strip's numbers in words, and nothing else.
2
+ //
3
+ // These three are the whole of the arithmetic behind the strip: a token count at a glance, and how long until a
4
+ // rate-limit window comes back. They are here rather than in status-strip.js because they touch nothing on the
5
+ // page - no element, no state, no socket - which is what lets them be run and checked where there is no browser,
6
+ // the way the shell scripts beside them are. Everything else the strip does is drawing, and is read as source.
7
+
8
+ /** 45.0k, 174k, 1M - a token count at a glance, never to the token. */
9
+ export function short(value) {
10
+ if (value >= 1_000_000) return `${trimZero(value / 1_000_000)}M`;
11
+ if (value >= 100_000) return `${Math.round(value / 1000)}k`;
12
+ if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
13
+ return String(Math.round(value));
14
+ }
15
+
16
+ function trimZero(value) {
17
+ const text = value.toFixed(1);
18
+ return text.endsWith(".0") ? text.slice(0, -2) : text;
19
+ }
20
+
21
+ const DAY_SECONDS = 86_400;
22
+
23
+ /**
24
+ * How long until a moment: "4d 6h", "2h 15m", "15m", or "now" once it has passed.
25
+ *
26
+ * The day unit is here for the seven-day rate-limit window, which is days away for most of its life. One
27
+ * countdown draws both quota meters, so the five-hour one has it too; there it simply never fires.
28
+ */
29
+ export function until(epochSeconds, nowSeconds = Date.now() / 1000) {
30
+ const seconds = Math.round(epochSeconds - nowSeconds);
31
+ if (seconds <= 0) return "now";
32
+ if (seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))}m`;
33
+ if (seconds < DAY_SECONDS) return `${Math.floor(seconds / 3600)}h ${Math.round((seconds % 3600) / 60)}m`;
34
+ // The hours are floored rather than rounded, so a reset 4 days and 23h50m away never reads "4d 24h".
35
+ return `${Math.floor(seconds / DAY_SECONDS)}d ${Math.floor((seconds % DAY_SECONDS) / 3600)}h`;
36
+ }
@@ -3,8 +3,9 @@
3
3
  // Every live agent in the container is a tab, this window drives one of them at a time, and none of them ends
4
4
  // because a window closed or a laptop slept. Tabs are dragged to reorder them, and the order is the registry's,
5
5
  // so every window agrees on it. A tab also says what its session is doing without being opened: a light travels
6
- // round it while the agent works, and it holds lit until visited when an agent finished something nobody was
7
- // there to see.
6
+ // round it while the agent works, it holds lit until visited when an agent finished something nobody was there
7
+ // to see, and it takes its session's colour and a small page beside its age when a page was published into a
8
+ // pane this window is not looking at.
8
9
  //
9
10
  // The bar is rebuilt from scratch on every frame the server sends and on the age tick, so nothing in it is
10
11
  // updated in place and nothing outside it may keep a reference to a tab.
@@ -54,9 +55,11 @@ export function ageLabel(createdAt) {
54
55
  // worse, be cut off half way and never seen. Both are fixed the same way: work out how far in the animation
55
56
  // should already be and hand that to the browser as a negative delay, so a fresh element carries on mid-stride.
56
57
 
57
- // One lap of the travelling light, and how long the arrival flash lasts. Both must match styles.css.
58
+ // One lap of the travelling light, how long the arrival flash lasts, and how long a published page flashes
59
+ // the tab it landed in. All three must match styles.css.
58
60
  const TRACE_MS = 4_000;
59
61
  const ARRIVAL_MS = 1_400;
62
+ const PAGE_ARRIVAL_MS = 1_400;
60
63
 
61
64
  // The travelling light, in its own clipped layer: the light rides the layer's edge and turns the corners, so
62
65
  // the part of it that hangs past a corner has to be cut off rather than drawn over the neighbouring tab.
@@ -98,6 +101,51 @@ function startArrival(tab, sid) {
98
101
  tab.style.animationDelay = `-${elapsed}ms`;
99
102
  }
100
103
 
104
+ // The same idea for the pane: a page published in a session this window is not looking at. The session list is
105
+ // the only frame that says anything about such a session, so the count of unopened pages rides on it, and this
106
+ // is where a rise in that count becomes a single flash rather than one per frame.
107
+ const pagesArrivedAt = new Map();
108
+
109
+ export function notePageArrivals() {
110
+ for (const entry of sessions) {
111
+ if (entry.unreadPages > 0 && !pagesArrivedAt.has(entry.id)) pagesArrivedAt.set(entry.id, Date.now());
112
+ }
113
+ // Read, or gone from the bar: the next page published there is a new arrival and flashes again.
114
+ for (const sid of [...pagesArrivedAt.keys()]) {
115
+ if (sessions.some((entry) => entry.id === sid && entry.unreadPages > 0)) continue;
116
+ pagesArrivedAt.delete(sid);
117
+ }
118
+ }
119
+
120
+ function startPageArrival(tab, sid) {
121
+ const elapsed = Date.now() - (pagesArrivedAt.get(sid) ?? Date.now());
122
+ if (elapsed >= PAGE_ARRIVAL_MS) return;
123
+ tab.classList.add("page-arriving");
124
+ tab.style.animationDelay = `-${elapsed}ms`;
125
+ }
126
+
127
+ // A page waiting in this session's pane, drawn rather than shipped in the page like every other icon the bar
128
+ // makes, because the bar is rebuilt from scratch on every frame. It says "a page", where a dot in the corner
129
+ // would only say "something" - and the corner is taken anyway, by the button that ends the session.
130
+ function pageMark() {
131
+ const mark = document.createElement("span");
132
+ mark.className = "pagemark";
133
+ const svg = document.createElementNS(SVG_NS, "svg");
134
+ svg.setAttribute("viewBox", "0 0 24 24");
135
+ svg.setAttribute("width", "11");
136
+ svg.setAttribute("height", "11");
137
+ svg.setAttribute("fill", "none");
138
+ svg.setAttribute("stroke", "currentColor");
139
+ svg.setAttribute("stroke-width", "2");
140
+ svg.setAttribute("stroke-linecap", "round");
141
+ svg.setAttribute("stroke-linejoin", "round");
142
+ const sheet = document.createElementNS(SVG_NS, "path");
143
+ sheet.setAttribute("d", "M6 3h12a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2M8 9h8M8 13h8M8 17h5");
144
+ svg.append(sheet);
145
+ mark.append(svg);
146
+ return mark;
147
+ }
148
+
101
149
  // --- One tab -----------------------------------------------------------------------------------------------------------------------------
102
150
 
103
151
  // The tab's name is either a static label or, while this tab is being renamed, an inline editor.
@@ -178,6 +226,12 @@ function stateNote(entry) {
178
226
  return "";
179
227
  }
180
228
 
229
+ // Said alongside whatever else the tab is saying, because a page waiting is not a state the session is in.
230
+ function pagesNote(entry, waiting) {
231
+ if (!waiting) return "";
232
+ return entry.unreadPages === 1 ? " - a page is waiting in its web pane" : ` - ${entry.unreadPages} pages are waiting in its web pane`;
233
+ }
234
+
181
235
  // How a directory reads in a sentence. The root has no path worth printing, so it gets a name.
182
236
  function whereLabel(cwd) {
183
237
  return cwd === "" ? "the workspace root" : cwd;
@@ -199,13 +253,20 @@ function tabFor(entry) {
199
253
  if (entry.unread) tab.classList.add("unread");
200
254
  if (entry.working) tab.classList.add("working");
201
255
  if (entry.attention) tab.classList.add("attention");
256
+ // A page nobody has opened, in a session this window is not looking at. The attached session says it in
257
+ // the pane instead, where the page has already opened itself.
258
+ const pagesWaiting = entry.unreadPages > 0 && entry.id !== attachedSid;
259
+ if (pagesWaiting) tab.classList.add("pages");
202
260
  tab.style.setProperty("--sc", colorFor(entry));
203
261
  // The tooltip keeps the default "agent N" even when a custom name is shown, so the number stays findable.
204
262
  const where = entry.cwd ? ` in ${entry.cwd}` : "";
205
- tab.title = `${entry.label}${where} - running ${ageLabel(entry.createdAt)}${stateNote(entry)} - double-click the name to rename`;
263
+ const state = `${stateNote(entry)}${pagesNote(entry, pagesWaiting)}`;
264
+ tab.title = `${entry.label}${where} - running ${ageLabel(entry.createdAt)}${state} - double-click the name to rename`;
206
265
 
207
266
  if (entry.working) tab.append(traceLayer());
267
+ // One flash at a time: a tab has one animation-delay, and the alert is the louder of the two.
208
268
  if (entry.attention) startArrival(tab, entry.id);
269
+ else if (pagesWaiting) startPageArrival(tab, entry.id);
209
270
 
210
271
  const dot = document.createElement("span");
211
272
  dot.className = "dot";
@@ -214,6 +275,7 @@ function tabFor(entry) {
214
275
  age.className = "age";
215
276
  age.textContent = ageLabel(entry.createdAt);
216
277
  tab.append(dot, name, age);
278
+ if (pagesWaiting) tab.append(pageMark());
217
279
  if (entry.cwd) tab.append(dirChip(entry));
218
280
 
219
281
  // Another window is driving this one, so a takeover prompt on click is not a surprise.
@@ -37,6 +37,13 @@
37
37
  <div id="chips"></div>
38
38
  <button type="button" id="pane-next" class="pane-nav" title="Newer pages" aria-label="Newer pages" hidden>&#8250;</button>
39
39
  <span id="page-count"></span>
40
+ <!-- How big the shown page is drawn, for this browser. Interface furniture: no agent knows it
41
+ exists, and a page written before it scales like any other. -->
42
+ <span id="page-size">
43
+ <button type="button" id="page-smaller" title="Smaller text" aria-label="Smaller text">&#8722;</button>
44
+ <span id="page-size-val"></span>
45
+ <button type="button" id="page-bigger" title="Larger text" aria-label="Larger text">+</button>
46
+ </span>
40
47
  <!-- An arrow into the pane's own edge: put it away, that way. Inline because this bar is static
41
48
  chrome, unlike the session bar's icons, which are drawn per frame. -->
42
49
  <button type="button" id="pane-collapse" class="pane-nav" title="Collapse the web pane" aria-label="Collapse the web pane">
@@ -55,9 +62,9 @@
55
62
  </div>
56
63
 
57
64
  <!-- The status strip, drawn by app/status-strip.js from the snapshot the attached session's status line
58
- wrote: model and effort, context against the window, quota left with its recharge countdown, and the
59
- Claude Code version. Hidden until that session has a snapshot, and no session but a claude one ever
60
- does. -->
65
+ wrote: model and effort, context against the window, what is left of each rate-limit window with its
66
+ recharge countdown, and the Claude Code version. Hidden until that session has a snapshot, and no
67
+ session but a claude one ever does. -->
61
68
  <div id="strip" hidden></div>
62
69
 
63
70
  <section id="composer">
@@ -70,6 +77,19 @@
70
77
  <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
71
78
  </svg>
72
79
  </button>
80
+ <!-- The one button that sends without being written: it types a fixed sentence into the
81
+ session, through the same paste path as anything else, and leaves the box alone. Its
82
+ place in the markup is what puts it above Send: the block fills row by row. -->
83
+ <button type="button" id="ask-page" class="icon-btn" title="Put the last answer in the web pane"
84
+ aria-label="Put the last answer in the web pane">
85
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
86
+ <title>Put the last answer in the web pane</title>
87
+ <rect x="3" y="4" width="18" height="16" rx="2" />
88
+ <path d="M14 4v16" />
89
+ <path d="M17 9h1" />
90
+ <path d="M17 12h1" />
91
+ </svg>
92
+ </button>
73
93
  <button type="button" id="mic" class="icon-btn" title="Dictate" aria-label="Dictate">
74
94
  <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
75
95
  <title>Dictate</title>