bom-pi 0.0.0-stage → 0.0.2

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 (131) hide show
  1. package/README.md +33 -2
  2. package/apps/README.md +7 -0
  3. package/apps/web/README.md +21 -0
  4. package/apps/web/pocket-adapter.mjs +323 -0
  5. package/apps/web/public/LICENSE.pocket +21 -0
  6. package/apps/web/public/activity-fold.js +28 -0
  7. package/apps/web/public/agent-now.js +37 -0
  8. package/apps/web/public/agent-tile.js +79 -0
  9. package/apps/web/public/app.js +975 -0
  10. package/apps/web/public/apple-touch-icon.png +0 -0
  11. package/apps/web/public/artifact-bridge.js +687 -0
  12. package/apps/web/public/avatar.js +87 -0
  13. package/apps/web/public/back.js +554 -0
  14. package/apps/web/public/box-keys.js +216 -0
  15. package/apps/web/public/browser-panel.js +1097 -0
  16. package/apps/web/public/browser.js +68 -0
  17. package/apps/web/public/calls.js +139 -0
  18. package/apps/web/public/chat.js +919 -0
  19. package/apps/web/public/clock.js +34 -0
  20. package/apps/web/public/commands.js +578 -0
  21. package/apps/web/public/composer.js +2068 -0
  22. package/apps/web/public/diff-parse.js +672 -0
  23. package/apps/web/public/diff.js +1646 -0
  24. package/apps/web/public/dock.js +299 -0
  25. package/apps/web/public/extension-ui.js +2734 -0
  26. package/apps/web/public/files-panel.js +775 -0
  27. package/apps/web/public/files.js +368 -0
  28. package/apps/web/public/fonts/FiraCode-VF.woff2 +0 -0
  29. package/apps/web/public/fonts/Geist.woff2 +0 -0
  30. package/apps/web/public/fonts/IBMPlexMono-Variable-Italic.woff2 +0 -0
  31. package/apps/web/public/fonts/IBMPlexMono-Variable.woff2 +0 -0
  32. package/apps/web/public/fonts/InterVariable-Italic.woff2 +0 -0
  33. package/apps/web/public/fonts/InterVariable.woff2 +0 -0
  34. package/apps/web/public/fonts/Iosevka-Bold.woff2 +0 -0
  35. package/apps/web/public/fonts/Iosevka-BoldItalic.woff2 +0 -0
  36. package/apps/web/public/fonts/Iosevka-Italic.woff2 +0 -0
  37. package/apps/web/public/fonts/Iosevka-Regular.woff2 +0 -0
  38. package/apps/web/public/fonts/Iosevka-SemiBold.woff2 +0 -0
  39. package/apps/web/public/fonts/Iosevka-SemiBoldItalic.woff2 +0 -0
  40. package/apps/web/public/fonts/JetBrainsMono-Italic.woff2 +0 -0
  41. package/apps/web/public/fonts/JetBrainsMono.woff2 +0 -0
  42. package/apps/web/public/fonts/NerdFontsSymbolsMono.woff2 +0 -0
  43. package/apps/web/public/fonts/SOURCES.md +69 -0
  44. package/apps/web/public/gestures.js +610 -0
  45. package/apps/web/public/highlight.js +480 -0
  46. package/apps/web/public/history.js +70 -0
  47. package/apps/web/public/home.js +169 -0
  48. package/apps/web/public/icon-192.png +0 -0
  49. package/apps/web/public/icon-512.png +0 -0
  50. package/apps/web/public/icon-maskable-512.png +0 -0
  51. package/apps/web/public/icon.svg +16 -0
  52. package/apps/web/public/index.html +71 -0
  53. package/apps/web/public/launcher.js +1293 -0
  54. package/apps/web/public/linked-sessions.js +55 -0
  55. package/apps/web/public/live.js +39 -0
  56. package/apps/web/public/manifest.webmanifest +32 -0
  57. package/apps/web/public/model-refresh.js +73 -0
  58. package/apps/web/public/notify.js +251 -0
  59. package/apps/web/public/peeks.js +920 -0
  60. package/apps/web/public/pip-attachments.js +182 -0
  61. package/apps/web/public/pip-window.js +141 -0
  62. package/apps/web/public/pip.js +1217 -0
  63. package/apps/web/public/quote-reply.js +109 -0
  64. package/apps/web/public/quote-text.js +31 -0
  65. package/apps/web/public/rich.js +403 -0
  66. package/apps/web/public/run-frame.js +106 -0
  67. package/apps/web/public/safe-mode.js +71 -0
  68. package/apps/web/public/seen.js +169 -0
  69. package/apps/web/public/session-tree.js +117 -0
  70. package/apps/web/public/sessions.js +2695 -0
  71. package/apps/web/public/share.js +126 -0
  72. package/apps/web/public/sheets/appearance.js +820 -0
  73. package/apps/web/public/sheets/branch.js +353 -0
  74. package/apps/web/public/sheets/extensions.js +818 -0
  75. package/apps/web/public/sheets/file.js +318 -0
  76. package/apps/web/public/sheets/find.js +250 -0
  77. package/apps/web/public/sheets/folder.js +680 -0
  78. package/apps/web/public/sheets/gallery.js +2340 -0
  79. package/apps/web/public/sheets/hooks.js +350 -0
  80. package/apps/web/public/sheets/memory.js +531 -0
  81. package/apps/web/public/sheets/menu.js +580 -0
  82. package/apps/web/public/sheets/message.js +220 -0
  83. package/apps/web/public/sheets/model.js +412 -0
  84. package/apps/web/public/sheets/people.js +414 -0
  85. package/apps/web/public/sheets/pi-sessions.js +264 -0
  86. package/apps/web/public/sheets/providers.js +525 -0
  87. package/apps/web/public/sheets/running.js +130 -0
  88. package/apps/web/public/sheets/schedules.js +71 -0
  89. package/apps/web/public/sheets/session.js +249 -0
  90. package/apps/web/public/sheets/shortcuts.js +101 -0
  91. package/apps/web/public/sheets/spend.js +150 -0
  92. package/apps/web/public/sheets/text.js +53 -0
  93. package/apps/web/public/sheets/viewers.js +363 -0
  94. package/apps/web/public/sheets/worktree.js +50 -0
  95. package/apps/web/public/sheets.js +410 -0
  96. package/apps/web/public/signin.js +57 -0
  97. package/apps/web/public/slots.js +208 -0
  98. package/apps/web/public/store.js +1760 -0
  99. package/apps/web/public/style.css +18625 -0
  100. package/apps/web/public/subagents-board.js +879 -0
  101. package/apps/web/public/subagents.js +273 -0
  102. package/apps/web/public/sw.js +169 -0
  103. package/apps/web/public/taps.js +26 -0
  104. package/apps/web/public/theme-surface.d.ts +4 -0
  105. package/apps/web/public/theme-surface.js +266 -0
  106. package/apps/web/public/theme.js +845 -0
  107. package/apps/web/public/themes/lantern/colors.toml +25 -0
  108. package/apps/web/public/themes/lantern/direction.css +60 -0
  109. package/apps/web/public/themes/lantern/frame.css +254 -0
  110. package/apps/web/public/themes/lantern/theme.css +10 -0
  111. package/apps/web/public/themes/lantern/theme.json +7 -0
  112. package/apps/web/public/themes/pocket/theme.css +189 -0
  113. package/apps/web/public/themes/pocket/theme.json +6 -0
  114. package/apps/web/public/themes.js +556 -0
  115. package/apps/web/public/transcript.js +3348 -0
  116. package/apps/web/public/trust.js +295 -0
  117. package/apps/web/public/turns.js +507 -0
  118. package/apps/web/public/ui.js +1910 -0
  119. package/apps/web/public/voice.js +345 -0
  120. package/apps/web/server.mjs +774 -0
  121. package/package.json +43 -4
  122. package/packages/cli/src/bom-pi.mjs +452 -0
  123. package/packages/cli/templates/blank.factory.json +7 -0
  124. package/packages/cli/templates/dev.factory.json +11 -0
  125. package/packages/cli/templates/docs.factory.json +12 -0
  126. package/packages/cli/templates/ops.factory.json +14 -0
  127. package/packages/cli/templates/triage.factory.json +10 -0
  128. package/packages/core/src/daemon.mjs +2047 -0
  129. package/packages/core/src/limit-gate.mjs +192 -0
  130. package/packages/core/src/rpc-client.mjs +66 -0
  131. package/systemd/README.md +11 -0
@@ -0,0 +1,87 @@
1
+ // People's avatars: their initials in a color of the theme, the same color for the same person everywhere; and the
2
+ // colors of folders, which mark their sessions the same way.
3
+
4
+ import { html } from "./ui.js";
5
+
6
+ // The theme's own colors, so people keep their colors in every theme and each one fits it.
7
+ const COLORS = [
8
+ "var(--o-blue)",
9
+ "var(--o-green)",
10
+ "var(--o-yellow)",
11
+ "var(--o-magenta)",
12
+ "var(--o-cyan)",
13
+ "var(--o-red)",
14
+ "var(--o-orange)",
15
+ "var(--o-fg-bright)",
16
+ ];
17
+
18
+ /** Folders' colors: the same, without yellow and red, which mark sessions waiting for you and subagents that failed. */
19
+ const FOLDER_COLORS = [
20
+ "var(--o-blue)",
21
+ "var(--o-green)",
22
+ "var(--o-magenta)",
23
+ "var(--o-cyan)",
24
+ "var(--o-orange)",
25
+ "var(--o-fg-bright)",
26
+ ];
27
+
28
+ function hashOf(text) {
29
+ let hash = 0;
30
+
31
+ for (const char of String(text)) {
32
+ hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
33
+ }
34
+
35
+ return hash;
36
+ }
37
+
38
+ /** A steady color per person, so the same person looks the same on every device. */
39
+ export function personColor(id) {
40
+ return COLORS[hashOf(id) % COLORS.length];
41
+ }
42
+
43
+ /** Each folder's color, by path: the list asks for every row as it draws, and a few folders hold most sessions. */
44
+ const folderColors = new Map();
45
+
46
+ /** A steady color per folder, from its last part, for the mark on its sessions' rows. */
47
+ export function folderColor(path) {
48
+ const key = String(path ?? "");
49
+ let color = folderColors.get(key);
50
+
51
+ if (color === undefined) {
52
+ const name = key.replace(/\/+$/, "").split("/").pop();
53
+
54
+ color = FOLDER_COLORS[hashOf(name) % FOLDER_COLORS.length];
55
+ folderColors.set(key, color);
56
+ }
57
+
58
+ return color;
59
+ }
60
+
61
+ /** "Tanner - Mac" → "TM", "alex" → "AL". */
62
+ export function initials(name) {
63
+ const words = String(name ?? "?")
64
+ .trim()
65
+ .split(/[\s\-_.]+/)
66
+ .filter(Boolean);
67
+ const first = words[0] ?? "?";
68
+
69
+ return (first[0] + (words.length > 1 ? words[1][0] : (first[1] ?? ""))).toUpperCase();
70
+ }
71
+
72
+ export function Avatar({ person, size = 24 }) {
73
+ const state = person.typing ? "typing" : person.away ? "away" : "";
74
+ const title = person.typing
75
+ ? `${person.name} is typing`
76
+ : person.away
77
+ ? `${person.name} (away)`
78
+ : person.name;
79
+
80
+ return html`<span
81
+ class=${`avatar ${state}`}
82
+ style=${`--who:${personColor(person.id)};width:${size}px;height:${size}px`}
83
+ title=${title}
84
+ >
85
+ ${initials(person.name)}
86
+ </span>`;
87
+ }
@@ -0,0 +1,554 @@
1
+ // The back stack: what back (a phone's swipe or back button, the browser's) closes, newest first.
2
+ //
3
+ // Each layer over the conversation (a sheet, the drawer, the launcher, the Files tile or Browser panel where they
4
+ // cover the screen, a file opened in the Files tile) is a history entry of its own, so back closes what opened last
5
+ // instead of leaving the session. Routes (`/s/12`) are history entries too, written here so that routes and the layers
6
+ // above them stay in order. This module knows nothing of the app's state: store.js asks for routes and hears when back
7
+ // changes one, and components register their layers with `useBack` (or `addLayer` and `removeLayer`).
8
+ //
9
+ // Each entry this module writes says how many layers it stands for (`history.state.pocket.layers`), and how many steps
10
+ // back the session list's entry is (`back`), when known. A home screen app keeps an entry of the list's under the list
11
+ // itself: the system's swipe back there has no browser page to land on, and would otherwise leave the app blank.
12
+ // The app is the truth and history follows it: a layer closed by a tap takes its entry back off; one opened adds an
13
+ // entry. Going back (the person's own) closes the layers above the entry it lands on.
14
+
15
+ import { useEffect, useLayoutEffect, useRef } from "preact/hooks";
16
+
17
+ /** The open layers, oldest first: `{ close, gone, shown }`. */
18
+ const layers = [];
19
+ /** How many layers the current history entry stands for. Entries from before this app wrote any count none. */
20
+ const depth = () => history.state?.pocket?.layers ?? 0;
21
+ /** An iPhone or iPad home screen app: no browser around it, so a back it takes must find something of the app's. */
22
+ const homeScreen = () => navigator.standalone === true;
23
+ /** How long to wait for layers that open and close together (one sheet swapped for another) before writing history. */
24
+ const SETTLE_MS = 40;
25
+ /** How long a traversal this module asked for may take to arrive; one that cannot happen never does. */
26
+ const TRAVERSE_MS = 500;
27
+
28
+ let routeListener = () => {};
29
+
30
+ /** Hear when going back or forward changes the route: store.js shows the conversation it names. */
31
+ export function onRoute(listener) {
32
+ routeListener = listener;
33
+ }
34
+
35
+ // ─── History, one step at a time ──────────────────────────────────────────────────
36
+
37
+ /** History's changes, in order: a traversal finishes before the next push, which would otherwise be undone by it. */
38
+ let chain = Promise.resolve();
39
+
40
+ function queue(step) {
41
+ chain = chain.then(step).catch((error) => console.error("back stack:", error));
42
+
43
+ return chain;
44
+ }
45
+
46
+ /** The traversal this module asked for: `done` when its popstate arrives. */
47
+ let expected = null;
48
+
49
+ /**
50
+ * Go `delta` entries through history and wait until there. With `follow`, the traversal should stay on the route the
51
+ * app shows, and should it land on another one, the app follows the address.
52
+ */
53
+ function traverse(delta, { follow = false } = {}) {
54
+ return new Promise((resolve) => {
55
+ const done = () => {
56
+ clearTimeout(timer);
57
+ expected = null;
58
+ resolve();
59
+ };
60
+
61
+ const timer = setTimeout(done, TRAVERSE_MS);
62
+
63
+ expected = { done, follow };
64
+ history.go(delta);
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Make history match the open layers: add an entry for each new one, or go back past those closed by a tap. New
70
+ * entries wait for the page's first tap or key: Chrome's back button skips entries added before anyone touched it.
71
+ */
72
+ async function reconcile() {
73
+ // A layer registered by a render that never showed (it threw, and a boundary caught it) was never open.
74
+ for (const layer of layers.filter((each) => !each.shown)) {
75
+ removeLayer(layer);
76
+ }
77
+
78
+ // Bounded: an entry that cannot be reached (history cut short) leaves history as it is rather than looping.
79
+ for (let tries = 0; tries < 4; tries++) {
80
+ const at = depth();
81
+
82
+ if (at > layers.length) {
83
+ await traverse(layers.length - at, { follow: true });
84
+ } else {
85
+ if (at < layers.length && !touched()) {
86
+ if (!waitingForTap) {
87
+ waitingForTap = true;
88
+ afterFirstTap(() => {
89
+ waitingForTap = false;
90
+ settle();
91
+ });
92
+ }
93
+
94
+ return;
95
+ }
96
+
97
+ for (let count = at + 1; count <= layers.length; count++) {
98
+ history.pushState(entry(count), "", location.href);
99
+ }
100
+
101
+ guardRoot();
102
+
103
+ return;
104
+ }
105
+ }
106
+ }
107
+
108
+ let waitingForTap = false;
109
+
110
+ /** Someone has tapped or typed on this page (where the browser says; elsewhere, assume so). */
111
+ const touched = () => navigator.userActivation?.hasBeenActive ?? true;
112
+
113
+ /**
114
+ * Run `then` at the page's first tap or key, before what the tap itself does. A finger's lift counts: a long press (a
115
+ * session row's) makes no click.
116
+ */
117
+ function afterFirstTap(then) {
118
+ const once = () => {
119
+ removeEventListener("click", once, true);
120
+ removeEventListener("pointerup", once, true);
121
+ removeEventListener("keydown", once, true);
122
+ then();
123
+ };
124
+
125
+ addEventListener("click", once, true);
126
+ addEventListener("pointerup", once, true);
127
+ addEventListener("keydown", once, true);
128
+ }
129
+
130
+ /** A layer's entry: its route's, standing for `count` layers. The list goes under the route itself only. */
131
+ function entry(count) {
132
+ const { listUnder, ...route } = history.state?.pocket ?? {};
133
+
134
+ return { pocket: { ...route, layers: count } };
135
+ }
136
+
137
+ let timer = 0;
138
+
139
+ function settle() {
140
+ clearTimeout(timer);
141
+ timer = setTimeout(() => queue(reconcile), SETTLE_MS);
142
+ }
143
+
144
+ addEventListener("popstate", (event) => {
145
+ if (expected) {
146
+ const { done, follow } = expected;
147
+
148
+ done();
149
+
150
+ if (follow && location.pathname !== shown) {
151
+ shown = location.pathname;
152
+ routeListener();
153
+ }
154
+
155
+ return;
156
+ }
157
+
158
+ // The browser showed back as it does (Safari's swipe slides the page before): what closes goes at once, not again.
159
+ if (event.hasUAVisualTransition) {
160
+ document.documentElement.dataset.backShown = "on";
161
+ setTimeout(() => delete document.documentElement.dataset.backShown, 500);
162
+ }
163
+
164
+ // The person went back or forward: the route first, then the layers above the entry it landed on. Forward goes
165
+ // further from the session list; anything else counts as back.
166
+ const back = event.state?.pocket?.back;
167
+
168
+ if (location.pathname !== shown) {
169
+ shown = location.pathname;
170
+ heading(
171
+ event.hasUAVisualTransition
172
+ ? "shown"
173
+ : typeof back === "number" && typeof shownBack === "number" && back > shownBack
174
+ ? "forward"
175
+ : "back",
176
+ );
177
+ }
178
+
179
+ shownBack = back;
180
+
181
+ routeListener();
182
+ const at = event.state?.pocket?.layers ?? 0;
183
+
184
+ while (layers.length > at) {
185
+ const layer = layers.pop();
186
+
187
+ layer.gone = true;
188
+ layer.close();
189
+ }
190
+
191
+ // Forward onto layers that are gone: back down to the ones open.
192
+ if (at > layers.length) {
193
+ settle();
194
+ }
195
+
196
+ // A home screen app has no browser page behind it: the swipe must find the app's own list, not a blank page.
197
+ if (homeScreen()) {
198
+ guardRoot();
199
+ }
200
+ });
201
+
202
+ // ─── Taps that land on what just moved ─────────────────────────────────────────────
203
+
204
+ /**
205
+ * When something last came or went at a tap: a layer opened or closed, or a route opened, right after a finger (or a
206
+ * mouse button) let go. A second tap that follows quickly (a double tap, an impatient one) would land on whatever slid
207
+ * in under the finger (a tile of the sheet that just rose, a row of the screen that just pushed in, the scrim that
208
+ * closes it) rather than on anything the person saw, so it is let go, focus and all. Only a finger's or a mouse's:
209
+ * keys and scripts are not held back. What moves on its own (a reply that opens a session, a sheet the server closes,
210
+ * Escape) is not at a tap, and holds nothing back.
211
+ */
212
+ let movedAt = -Infinity;
213
+ let liftedAt = -Infinity;
214
+ const SETTLING_MS = 280;
215
+ const AT_TAP_MS = 400;
216
+
217
+ const moved = () => {
218
+ if (performance.now() - liftedAt < AT_TAP_MS) {
219
+ movedAt = performance.now();
220
+ }
221
+ };
222
+
223
+ addEventListener("pointerup", () => (liftedAt = performance.now()), {
224
+ capture: true,
225
+ passive: true,
226
+ });
227
+
228
+ /** A finger's or a mouse's press or click, just after something moved under it. */
229
+ const tooSoon = (event) =>
230
+ event.isTrusted && event.detail > 0 && performance.now() - movedAt < SETTLING_MS;
231
+
232
+ // Held back from the press too: a field that slid in under the finger does not take the focus (and the keyboard).
233
+ addEventListener(
234
+ "mousedown",
235
+ (event) => {
236
+ if (tooSoon(event)) {
237
+ event.preventDefault();
238
+ event.stopPropagation();
239
+ }
240
+ },
241
+ true,
242
+ );
243
+ addEventListener(
244
+ "click",
245
+ (event) => {
246
+ if (tooSoon(event)) {
247
+ event.preventDefault();
248
+ event.stopPropagation();
249
+ }
250
+ },
251
+ true,
252
+ );
253
+
254
+ // ─── Layers ───────────────────────────────────────────────────────────────────────
255
+
256
+ /**
257
+ * A layer opened: back closes it with `close` until `removeLayer` says it closed some other way. `shown` false waits for
258
+ * the caller to say it showed (`useBack`, once its render is on screen).
259
+ */
260
+ export function addLayer(close, { shown = true } = {}) {
261
+ const layer = { close, gone: false, shown };
262
+
263
+ layers.push(layer);
264
+ moved();
265
+ settle();
266
+
267
+ return layer;
268
+ }
269
+
270
+ /** A layer closed by a tap, a key, or its owner: its history entry goes too. */
271
+ export function removeLayer(layer) {
272
+ const at = layers.indexOf(layer);
273
+
274
+ layer.gone = true;
275
+
276
+ if (at !== -1) {
277
+ layers.splice(at, 1);
278
+ moved();
279
+ settle();
280
+ }
281
+ }
282
+
283
+ /**
284
+ * A layer for as long as `open` is true: back calls `close`, which must make `open` false. Registered while rendering,
285
+ * so a panel and a layer inside it that show together (after a reload) stack in that order, the inner one on top.
286
+ */
287
+ export function useBack(open, close) {
288
+ const layer = useRef(null);
289
+
290
+ if (layer.current) {
291
+ layer.current.close = close;
292
+ }
293
+
294
+ if (open && (layer.current === null || layer.current.gone)) {
295
+ layer.current = addLayer(close, { shown: false });
296
+ }
297
+
298
+ useLayoutEffect(() => {
299
+ if (layer.current) {
300
+ layer.current.shown = true;
301
+ }
302
+ });
303
+ useEffect(() => {
304
+ if (!open && layer.current) {
305
+ removeLayer(layer.current);
306
+ layer.current = null;
307
+ }
308
+ });
309
+ useEffect(
310
+ () => () => {
311
+ if (layer.current) {
312
+ removeLayer(layer.current);
313
+ }
314
+ },
315
+ [],
316
+ );
317
+ }
318
+
319
+ /** Back has somewhere to go in the app: a layer to close, or a route opened from the session list's. */
320
+ export const canGoBack = () =>
321
+ layers.length > 0 || depth() > 0 || (history.state?.pocket?.back ?? 0) > 0;
322
+
323
+ /** Back, as the system's back does it: for gestures of the app's own. */
324
+ export const goBack = () => history.back();
325
+
326
+ // ─── Routes ───────────────────────────────────────────────────────────────────────
327
+
328
+ /** The session list's route. */
329
+ const HOME = "/";
330
+
331
+ /** The route the app shows, ahead of history while layers come off for a new one; how far back the list is from it. */
332
+ let shown = location.pathname;
333
+ let shownBack = history.state?.pocket?.back;
334
+ let routeTimer = 0;
335
+ let homeRequest = 0;
336
+
337
+ /**
338
+ * One entry of the list's under it, where the app is a home screen app's root: the system's swipe back then lands on
339
+ * the app, not on the blank page outside it. An entry already guarded adds none.
340
+ */
341
+ function guardRoot() {
342
+ if (
343
+ !homeScreen() ||
344
+ location.pathname !== HOME ||
345
+ layers.length > 0 ||
346
+ history.state?.pocket?.guard
347
+ ) {
348
+ return;
349
+ }
350
+
351
+ history.pushState({ pocket: { layers: 0, back: 0, guard: true } }, "", location.href);
352
+ shownBack = 0;
353
+ }
354
+
355
+ /** The window a route slides into (the conversation's, or the list's on phones), whose mark says which way. */
356
+ let routeWindow = null;
357
+
358
+ /** The route slide ended: its mark goes. A slide that never played (a wide screen, reduced motion) ends on a timer. */
359
+ function landed() {
360
+ clearTimeout(routeTimer);
361
+ routeWindow?.removeEventListener("animationend", slid);
362
+ delete routeWindow?.dataset.route;
363
+ routeWindow = null;
364
+ }
365
+
366
+ const slid = (event) => /^route-(forward|back)$/.test(event.animationName) && landed();
367
+
368
+ /**
369
+ * Which way the last route change went, for the screen it brings to slide in from that side (style.css): `forward` to
370
+ * a session, `back` to the one before or up to the list. Only while it slides. On the window, not the page's root: a
371
+ * mark there would restyle the whole page twice for each route.
372
+ */
373
+ function heading(way) {
374
+ landed();
375
+ routeWindow = document.querySelector(".pane");
376
+
377
+ if (routeWindow === null) {
378
+ return;
379
+ }
380
+
381
+ routeWindow.dataset.route = way;
382
+ routeWindow.addEventListener("animationend", slid);
383
+ routeTimer = setTimeout(landed, 600);
384
+ }
385
+
386
+ /**
387
+ * A route's slide plays now: what it brings in needs no entrance of its own (`transcript.js`). Only narrower than the
388
+ * sidebar shows (style.css).
389
+ */
390
+ export const routeSliding = () => routeWindow !== null && matchMedia("(max-width: 959px)").matches;
391
+
392
+ /**
393
+ * Show a route: its own entry, after the layers above this one, so back from it comes back here. With `replace`, it
394
+ * takes this route's place instead. The history entry follows the app a moment later while open layers come off.
395
+ */
396
+ export function pushRoute(path, { replace = false } = {}) {
397
+ // There already, or on the way (history lags while layers come off): nothing more to write.
398
+ if (path === shown && !replace) {
399
+ return;
400
+ }
401
+
402
+ homeRequest++;
403
+ heading(path === HOME ? "back" : "forward");
404
+ moved();
405
+ shown = path;
406
+ queue(async () => {
407
+ if (depth() > 0) {
408
+ await traverse(-depth());
409
+ }
410
+
411
+ const here = history.state?.pocket?.back;
412
+ const back =
413
+ path === HOME ? 0 : replace ? here : typeof here === "number" ? here + 1 : undefined;
414
+ const state = { pocket: { layers: 0, back } };
415
+
416
+ shownBack = back;
417
+
418
+ if (replace) {
419
+ history.replaceState(state, "", path);
420
+ } else {
421
+ history.pushState(state, "", path);
422
+ }
423
+ });
424
+ // Layers still open (a panel that stays) get their entries again, above the new route.
425
+ settle();
426
+ }
427
+
428
+ /**
429
+ * Go up to the session list: back to its entry when it is known, past the sessions opened from it, so back from the
430
+ * list leaves the app as it would from any first page. Otherwise `fallback`, which shows the list some other way.
431
+ */
432
+ export function goHome(fallback) {
433
+ const request = ++homeRequest;
434
+
435
+ return queue(() => {
436
+ if (request !== homeRequest) {
437
+ return;
438
+ }
439
+
440
+ const state = history.state;
441
+ const path = shown;
442
+ const back = state?.pocket?.back;
443
+ const steps = typeof back === "number" && back > 0 ? back + depth() : 0;
444
+
445
+ // Old entries can be dropped. The length also counts forward entries, so even a jump within it may fail.
446
+ if (steps <= 0 || steps > history.length - 1) {
447
+ fallback();
448
+
449
+ return;
450
+ }
451
+
452
+ return new Promise((resolve) => {
453
+ const done = () => {
454
+ clearTimeout(timer);
455
+ removeEventListener("popstate", done);
456
+ resolve();
457
+ };
458
+
459
+ const timer = setTimeout(() => {
460
+ done();
461
+
462
+ // A newer request or route must not be undone by recovery from this one.
463
+ if (request === homeRequest && shown === path && history.state === state) {
464
+ fallback();
465
+ }
466
+ }, TRAVERSE_MS);
467
+
468
+ // Keep normal popstate handling: Home closes layers as well as changing the route.
469
+ addEventListener("popstate", done);
470
+ history.go(-steps);
471
+ });
472
+ });
473
+ }
474
+
475
+ /**
476
+ * Start: on a fresh page (not a reload, which keeps its entries), mark the entry, and when it opens a conversation
477
+ * straight away (a link, a notification, a home screen icon that remembers) and `home` asks for it, put the session
478
+ * list under it, so back goes there rather than out of the app.
479
+ */
480
+ export function startHistory({ home }) {
481
+ const pocket = history.state?.pocket;
482
+
483
+ if (pocket && !pocket.listUnder) {
484
+ settle();
485
+
486
+ return;
487
+ }
488
+
489
+ if (location.pathname === HOME) {
490
+ history.replaceState({ pocket: { layers: 0, back: 0 } }, "", location.href);
491
+ guardRoot();
492
+ } else if (home) {
493
+ if (homeScreen()) {
494
+ listUnderNow();
495
+ } else {
496
+ listUnderOnFirstTap();
497
+ }
498
+ } else {
499
+ history.replaceState({ pocket: { layers: 0 } }, "", location.href);
500
+ }
501
+
502
+ shownBack = history.state.pocket.back;
503
+ settle();
504
+ }
505
+
506
+ /**
507
+ * Put the list's entry under a conversation opened in a home screen app at once, not at the first tap: a system swipe
508
+ * back there must find the list before anyone has touched the page. The list's own entry under it keeps the next one
509
+ * from leaving too (`guardRoot`).
510
+ */
511
+ function listUnderNow() {
512
+ const here = location.pathname + location.search + location.hash;
513
+
514
+ history.replaceState({ pocket: { layers: 0, back: 0 } }, "", HOME);
515
+ history.pushState({ pocket: { layers: 0, back: 0, guard: true } }, "", HOME);
516
+ history.pushState({ pocket: { layers: 0, back: 1 } }, "", here);
517
+ }
518
+
519
+ /**
520
+ * Put the session list's entry under this conversation's at the first tap or key, not before: Chrome's back button
521
+ * skips entries a page adds before anyone has touched it. Marked meanwhile, so a reload before then still does it.
522
+ */
523
+ function listUnderOnFirstTap() {
524
+ history.replaceState({ pocket: { layers: 0, listUnder: true } }, "", location.href);
525
+
526
+ const put = () => {
527
+ // A layer open above it (a notification opened the chat): at a tap once it has closed.
528
+ if (depth() > 0) {
529
+ return;
530
+ }
531
+
532
+ removeEventListener("click", put, true);
533
+ removeEventListener("keydown", put, true);
534
+
535
+ // Gone to another route already: history stays as it is.
536
+ if (!history.state?.pocket?.listUnder) {
537
+ return;
538
+ }
539
+
540
+ const here = location.pathname + location.search + location.hash;
541
+
542
+ history.replaceState({ pocket: { layers: 0, back: 0 } }, "", HOME);
543
+ history.pushState({ pocket: { layers: 0, back: 1 } }, "", here);
544
+ shownBack = 1;
545
+ };
546
+
547
+ addEventListener("click", put, true);
548
+ addEventListener("keydown", put, true);
549
+ }
550
+
551
+ /** Change the address without a new entry (dropping a query the app has read), keeping what this module wrote. */
552
+ export function replaceAddress(path) {
553
+ history.replaceState(history.state, "", path);
554
+ }