@backmatter/studio 0.0.15 → 0.0.17

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 (80) hide show
  1. package/dist/chunks/{UiKitApp-BDsrXN12.js → UiKitApp-D7vllRQE.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-BTZ1Ah4F.js → WorkspaceRoutes-DH1LD-2Q.js} +30013 -27965
  3. package/dist/chunks/{dashboard-FT7dm5wi.js → dashboard-Dsaeud2x.js} +55 -54
  4. package/dist/chunks/{define-studio-BYtYDGLX.js → define-studio-DlYjgrwr.js} +12 -16
  5. package/dist/chunks/{index-DHJvh5Uy.js → index-CGMqw2NH.js} +1 -1
  6. package/dist/dashboard.js +2 -2
  7. package/dist/define.js +1 -1
  8. package/dist/index.js +4 -4
  9. package/dist/site-widget.css +49 -1
  10. package/dist/site-widget.js +8341 -8058
  11. package/dist/studio.css +279 -13
  12. package/package.json +2 -2
  13. package/src/WorkspaceRoutes.tsx +5 -2
  14. package/src/api.ts +82 -7
  15. package/src/chat/ChangeCard.tsx +8 -2
  16. package/src/chat/ChatMessage.tsx +171 -102
  17. package/src/chat/ChatPanel.tsx +54 -34
  18. package/src/chat/ChatProvider.tsx +122 -20
  19. package/src/chat/Composer.tsx +51 -8
  20. package/src/chat/PointerChip.tsx +9 -5
  21. package/src/chat/ThreadHistory.tsx +3 -3
  22. package/src/chat/chat-context.ts +67 -3
  23. package/src/chat/chat-helpers.ts +12 -0
  24. package/src/chat/chat-state.ts +11 -0
  25. package/src/chrome/AppShell.tsx +16 -5
  26. package/src/chrome/CommandPalette.tsx +12 -0
  27. package/src/chrome/ContentExplorerSidebar.tsx +23 -3
  28. package/src/chrome/Toast.tsx +115 -0
  29. package/src/chrome/UserMenu.tsx +34 -3
  30. package/src/chrome/WorkspaceStatusBar.tsx +221 -30
  31. package/src/chrome/commit-error.ts +13 -0
  32. package/src/chrome/content-api-url.ts +0 -78
  33. package/src/chrome/content-tree-visibility.ts +13 -3
  34. package/src/chrome/explorer-icons.tsx +5 -0
  35. package/src/chrome/pull-failure.ts +61 -0
  36. package/src/code-session-entry.ts +9 -0
  37. package/src/components/WorkspaceSwitcher.tsx +2 -2
  38. package/src/element-origin.ts +149 -0
  39. package/src/fields/FieldControl.tsx +748 -0
  40. package/src/fields/choose-field-input.ts +64 -0
  41. package/src/fields/load-relation-documents.ts +35 -0
  42. package/src/fields/omit-field.ts +12 -0
  43. package/src/fields/relation-targets.ts +44 -0
  44. package/src/fields/resolve-object-fields.ts +21 -0
  45. package/src/host-pointer-bootstrap.ts +11 -0
  46. package/src/host-pointer.ts +147 -0
  47. package/src/install-preview-pointer.ts +19 -3
  48. package/src/pages/BlockSettingsDialog.tsx +19 -115
  49. package/src/pages/BlocksList.tsx +43 -35
  50. package/src/pages/BodyProse.tsx +16 -1
  51. package/src/pages/CodeSessionFiles.tsx +221 -0
  52. package/src/pages/CodeSessionPage.tsx +1361 -0
  53. package/src/pages/DocEditorPage.tsx +120 -107
  54. package/src/pages/PreviewSize.tsx +181 -0
  55. package/src/pages/RenderedBody.tsx +17 -2
  56. package/src/pages/SettingsGeneralPage.tsx +67 -2
  57. package/src/pages/WorkspaceHomePage.tsx +313 -0
  58. package/src/pages/WorkspaceLayout.tsx +10 -1
  59. package/src/pages/branch-notice.ts +16 -0
  60. package/src/pages/code-session-boot.ts +43 -0
  61. package/src/pages/code-session-context.tsx +53 -0
  62. package/src/pages/code-session-files.ts +42 -0
  63. package/src/pages/code-session-frame.ts +52 -0
  64. package/src/pages/code-session-routes.ts +42 -0
  65. package/src/pages/home-rows.ts +140 -0
  66. package/src/pages/preview-size.ts +33 -0
  67. package/src/pages/session-preview.ts +109 -0
  68. package/src/pages/use-session-preview.ts +70 -0
  69. package/src/replica/store.ts +11 -0
  70. package/src/replica/types.ts +6 -0
  71. package/src/shell-resets.css +15 -0
  72. package/src/site-pointer.ts +24 -5
  73. package/src/studio-config.ts +1 -1
  74. package/src/ui/Input.tsx +178 -20
  75. package/src/ui/ListRow.tsx +12 -3
  76. package/src/ui/Page.tsx +12 -1
  77. package/src/ui/popup.ts +11 -2
  78. package/src/workspace-path.ts +35 -3
  79. package/src/chrome/ContentApiPopover.tsx +0 -215
  80. package/src/pages/WorkspaceHomeRedirect.tsx +0 -17
@@ -0,0 +1,1361 @@
1
+ // packages/studio/src/pages/CodeSessionPage.tsx
2
+ import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
3
+ import type { RouteModel } from "@backmatter/core";
4
+ import { useOutletContext } from "react-router";
5
+ import * as stylex from "@stylexjs/stylex";
6
+ import { Combobox } from "@base-ui/react/combobox";
7
+ import {
8
+ IconArrowRotateClockwise,
9
+ IconArrowUpRight,
10
+ IconCheckmark1Small,
11
+ IconCrossSmall,
12
+ IconEyeOpen,
13
+ IconFiles,
14
+ IconGlobe,
15
+ } from "@central-icons-react/round-outlined-radius-2-stroke-1.5";
16
+ import { ChatPanel } from "../chat/ChatPanel";
17
+ import { ChatProvider, ChatSessionScope, useChatBusy } from "../chat/ChatProvider";
18
+ import { useReplicaState } from "../replica/useReplica";
19
+ import { colors, radius, space, type } from "../styles/tokens.stylex";
20
+ import { apiCredentials, apiHeaders, apiUrl } from "../studio-config";
21
+ import { Text } from "../ui/Text";
22
+ import { cx, popup, popupClassName } from "../ui/popup";
23
+ import type { WorkspaceOutletContext } from "../workspace/types";
24
+ import { bootSteps, hostPath, type BootStep, type HostPhase } from "./code-session-boot";
25
+ import {
26
+ frameAddress,
27
+ framedLocation,
28
+ rememberPath,
29
+ rememberedPath,
30
+ previewUrl,
31
+ type HostPreview,
32
+ } from "./code-session-frame";
33
+ import { contentRoutes, matchingRoutes } from "./code-session-routes";
34
+ import { CodeSessionFiles, type FilesView } from "./CodeSessionFiles";
35
+ import { PreviewSizeSwitch, PreviewViewport } from "./PreviewSize";
36
+ import { persistPreviewSize, readPreviewSize, type PreviewSize } from "./preview-size";
37
+ import type { WorkspaceFile } from "./code-session-files";
38
+ import { clearCodeSessionEdits, fetchCodeSessionEdits, updateCodeSessionBranch } from "../api";
39
+ import { branchNoticeKind, type BranchNoticeKind } from "./branch-notice";
40
+ import { usePublishCodeSession } from "./code-session-context";
41
+ import { sessionDocument } from "./session-preview";
42
+ import { useSessionPreview } from "./use-session-preview";
43
+
44
+ type OpenState =
45
+ | { kind: "loading" }
46
+ | { kind: "forbidden" }
47
+ | { kind: "fail"; error: string }
48
+ | { kind: "ready"; sessionId: string };
49
+
50
+ export type HostView = {
51
+ phase: HostPhase | null;
52
+ furthest: "installing" | "starting" | null;
53
+ log: string;
54
+ timedOut: boolean;
55
+ };
56
+
57
+ export type SessionTab = "preview" | "files";
58
+
59
+ // A first install of a customer's dependencies takes minutes in a fresh container.
60
+ const HOST_WAIT_MS = 10 * 60 * 1000;
61
+ const STATUS_POLL_MS = 2000;
62
+
63
+ export function CodeSessionScreen({
64
+ state,
65
+ host,
66
+ tab,
67
+ onTab,
68
+ preview,
69
+ path,
70
+ location,
71
+ onPath,
72
+ reloads,
73
+ live = false,
74
+ onReload,
75
+ onOpen,
76
+ routes = [],
77
+ routesPending = false,
78
+ onOpenRoutes,
79
+ previewSize = "desktop",
80
+ onPreviewSize = () => {},
81
+ models = [],
82
+ files,
83
+ chat,
84
+ notice = null,
85
+ noticeBusy = false,
86
+ onUpdateBranch = () => {},
87
+ onClearEdits = () => {},
88
+ }: {
89
+ state: OpenState;
90
+ host: HostView;
91
+ tab: SessionTab;
92
+ onTab: (tab: SessionTab) => void;
93
+ files: FilesView;
94
+ /** The host's own origin and its ticketed entry, or null until the host is up. */
95
+ preview: HostPreview | null;
96
+ /** Where the frame was sent. */
97
+ path: string;
98
+ /** Where the frame's page says it is now, after its own navigation. */
99
+ location: string;
100
+ onPath: (path: string) => void;
101
+ reloads: number;
102
+ /** A file edit. The open page stays up until the next document has loaded. */
103
+ live?: boolean;
104
+ onReload: () => void;
105
+ onOpen: () => void;
106
+ /** Public routes of the content files in this checkout. */
107
+ routes?: readonly string[];
108
+ /** The checkout file list has not come back yet. */
109
+ routesPending?: boolean;
110
+ onOpenRoutes?: () => void;
111
+ previewSize?: PreviewSize;
112
+ onPreviewSize?: (size: PreviewSize) => void;
113
+ /** Models whose routes name the document under a pointed block. */
114
+ models?: readonly RouteModel[];
115
+ chat: React.ReactNode;
116
+ notice?: BranchNoticeKind | null;
117
+ /** Chat is writing, or the checkout is already being replaced. */
118
+ noticeBusy?: boolean;
119
+ onUpdateBranch?: () => void;
120
+ onClearEdits?: () => void;
121
+ }) {
122
+ // The entry's ticket sets the host's cookie on first load; later loads ride that cookie.
123
+ // A reload uses the route the frame is on, which can differ from the last address typed.
124
+ const chatBusy = useChatBusy();
125
+ const actionBusy = noticeBusy || chatBusy;
126
+ const frameSrc = preview ? frameAddress(preview, reloads > 0 ? location : path, reloads) : null;
127
+ const frameRef = useRef<HTMLIFrameElement>(null);
128
+ const heldFrameRef = useRef<HTMLIFrameElement | null>(null);
129
+ const [pointFrame, setPointFrame] = useState<HTMLIFrameElement | null>(null);
130
+ const frameWatch = `${tab}:${reloads}:${frameSrc ?? ""}`;
131
+ const [frameSeen, setFrameSeen] = useState(frameWatch);
132
+ const [frameLoaded, setFrameLoaded] = useState<string | null>(null);
133
+ const [held, setHeld] = useState<{ reload: number; src: string } | null>(null);
134
+ if (frameWatch !== frameSeen) {
135
+ setFrameSeen(frameWatch);
136
+ setFrameLoaded(null);
137
+ }
138
+ // A live edit keeps the document that is already up. The next one loads behind it.
139
+ const holding = live && held !== null && frameSrc !== null && held.reload !== reloads;
140
+ const framePending = !holding && frameSrc !== null && tab === "preview" && frameLoaded !== frameWatch;
141
+ let pointOrigin: string | null = null;
142
+ if (preview) {
143
+ try {
144
+ pointOrigin = new URL(preview.origin).origin;
145
+ } catch {
146
+ pointOrigin = null;
147
+ }
148
+ }
149
+ useSessionPreview({
150
+ frame: tab === "preview" ? pointFrame : null,
151
+ origin: pointOrigin,
152
+ models,
153
+ enabled: state.kind === "ready",
154
+ });
155
+
156
+ useEffect(() => {
157
+ if (tab !== "preview") {
158
+ setPointFrame(null);
159
+ return;
160
+ }
161
+ setPointFrame(holding ? heldFrameRef.current : frameRef.current);
162
+ }, [tab, holding, reloads, frameSrc]);
163
+
164
+ function revealFrame() {
165
+ if (!frameSrc) return;
166
+ try {
167
+ const href = frameRef.current?.contentWindow?.location.href;
168
+ if (!href || href === "about:blank") return;
169
+ } catch {
170
+ // A cross-origin document means the host answered.
171
+ }
172
+ setFrameLoaded(frameWatch);
173
+ setHeld({ reload: reloads, src: frameSrc });
174
+ }
175
+
176
+ if (state.kind === "forbidden") return null;
177
+
178
+ return (
179
+ <div {...stylex.props(styles.shell)}>
180
+ <aside {...stylex.props(styles.chat)} aria-label="Code session chat">
181
+ {chat}
182
+ </aside>
183
+
184
+ <section {...stylex.props(styles.stage)} aria-label="Host">
185
+ <div {...stylex.props(styles.toolbar)}>
186
+ <div role="tablist" aria-label="View" {...stylex.props(styles.tabs)}>
187
+ <TabButton active={tab === "preview"} onClick={() => onTab("preview")}>
188
+ <IconEyeOpen size={14} ariaHidden />
189
+ Preview
190
+ </TabButton>
191
+ <TabButton active={tab === "files"} onClick={() => onTab("files")}>
192
+ <IconFiles size={14} ariaHidden />
193
+ Files
194
+ </TabButton>
195
+ </div>
196
+
197
+ <AddressBar
198
+ path={location}
199
+ enabled={frameSrc !== null && tab === "preview"}
200
+ routes={routes}
201
+ routesPending={routesPending}
202
+ onOpenRoutes={onOpenRoutes}
203
+ onPath={onPath}
204
+ onReload={onReload}
205
+ onOpen={onOpen}
206
+ />
207
+ <div {...stylex.props(styles.sizeSwitch)}>
208
+ <PreviewSizeSwitch
209
+ size={previewSize}
210
+ onSize={onPreviewSize}
211
+ disabled={frameSrc === null || tab !== "preview"}
212
+ />
213
+ </div>
214
+ </div>
215
+
216
+ {notice ? (
217
+ <div {...stylex.props(styles.notice)} role="status">
218
+ <span {...stylex.props(styles.noticeText)}>
219
+ {notice === "edits"
220
+ ? "This branch has new commits. Commit them, or clear the edit set, before updating."
221
+ : notice === "fold"
222
+ ? "This branch has new commits. The last commit is still folding."
223
+ : "This branch has new commits."}
224
+ </span>
225
+ {notice === "update" ? (
226
+ <button
227
+ type="button"
228
+ {...stylex.props(styles.noticeButton)}
229
+ disabled={actionBusy}
230
+ onClick={onUpdateBranch}
231
+ >
232
+ Update branch
233
+ </button>
234
+ ) : null}
235
+ {notice === "edits" ? (
236
+ <button
237
+ type="button"
238
+ {...stylex.props(styles.noticeButton)}
239
+ disabled={actionBusy}
240
+ onClick={onClearEdits}
241
+ >
242
+ Clear edit set
243
+ </button>
244
+ ) : null}
245
+ </div>
246
+ ) : null}
247
+
248
+ <div {...stylex.props(styles.canvas)}>
249
+ {tab === "files" ? (
250
+ <CodeSessionFiles {...files} />
251
+ ) : state.kind === "fail" ? (
252
+ <BootCard title="This branch cannot open a code session" error={state.error} />
253
+ ) : frameSrc ? (
254
+ <PreviewViewport size={previewSize}>
255
+ {holding && held ? (
256
+ <iframe
257
+ ref={heldFrameRef}
258
+ key={held.reload}
259
+ title="Code session"
260
+ src={held.src}
261
+ sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads"
262
+ {...stylex.props(styles.frame)}
263
+ />
264
+ ) : null}
265
+ <iframe
266
+ ref={frameRef}
267
+ key={reloads}
268
+ title={holding ? "" : "Code session"}
269
+ aria-hidden={holding || undefined}
270
+ src={frameSrc}
271
+ sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads"
272
+ onLoad={revealFrame}
273
+ {...stylex.props(styles.frame, holding && styles.frameIncoming)}
274
+ />
275
+ {framePending ? (
276
+ <div {...stylex.props(styles.pending)} role="status">
277
+ <span {...stylex.props(styles.mark, styles.spinner)} />
278
+ Loading the page
279
+ </div>
280
+ ) : null}
281
+ </PreviewViewport>
282
+ ) : (
283
+ <BootCard
284
+ title={
285
+ host.timedOut
286
+ ? "The dev server has not opened its port yet"
287
+ : host.phase === "failed" || host.phase === "stopped"
288
+ ? "The host stopped while booting"
289
+ : "Booting the host"
290
+ }
291
+ steps={bootSteps({
292
+ opening: state.kind === "loading",
293
+ phase: host.phase,
294
+ furthest: host.furthest,
295
+ })}
296
+ log={host.log}
297
+ />
298
+ )}
299
+ </div>
300
+ </section>
301
+ </div>
302
+ );
303
+ }
304
+
305
+ function TabButton({
306
+ active,
307
+ onClick,
308
+ children,
309
+ }: {
310
+ active: boolean;
311
+ onClick: () => void;
312
+ children: React.ReactNode;
313
+ }) {
314
+ return (
315
+ <button
316
+ type="button"
317
+ role="tab"
318
+ aria-selected={active}
319
+ onClick={onClick}
320
+ {...stylex.props(styles.tab, active && styles.tabActive)}
321
+ >
322
+ {children}
323
+ </button>
324
+ );
325
+ }
326
+
327
+ function AddressBar({
328
+ path,
329
+ enabled,
330
+ routes,
331
+ routesPending,
332
+ onOpenRoutes,
333
+ onPath,
334
+ onReload,
335
+ onOpen,
336
+ }: {
337
+ path: string;
338
+ enabled: boolean;
339
+ routes: readonly string[];
340
+ routesPending: boolean;
341
+ onOpenRoutes?: () => void;
342
+ onPath: (path: string) => void;
343
+ onReload: () => void;
344
+ onOpen: () => void;
345
+ }) {
346
+ const [query, setQuery] = useState<string | null>(null);
347
+ const [open, setOpen] = useState(false);
348
+ const picked = useRef(false);
349
+ const fieldRef = useRef<HTMLButtonElement>(null);
350
+ const inputRef = useRef<HTMLInputElement>(null);
351
+ const shown = query ? matchingRoutes(routes, query) : [...routes];
352
+
353
+ useEffect(() => {
354
+ if (!open) return;
355
+ const input = inputRef.current;
356
+ input?.focus();
357
+ input?.select();
358
+ }, [open]);
359
+
360
+ return (
361
+ <div {...stylex.props(styles.address)}>
362
+ <button
363
+ type="button"
364
+ aria-label="Reload preview"
365
+ disabled={!enabled}
366
+ onClick={onReload}
367
+ {...stylex.props(styles.iconButton)}
368
+ >
369
+ <IconArrowRotateClockwise size={14} ariaHidden />
370
+ </button>
371
+ <Combobox.Root
372
+ items={shown}
373
+ filteredItems={shown}
374
+ inputValue={query ?? ""}
375
+ disabled={!enabled}
376
+ open={open}
377
+ value={routes.includes(path) ? path : null}
378
+ onOpenChange={(next) => {
379
+ if (!enabled && next) return;
380
+ setOpen(next);
381
+ if (next) onOpenRoutes?.();
382
+ else setQuery(null);
383
+ }}
384
+ onInputValueChange={(value, details) => {
385
+ if (details.reason === "input-change" || details.reason === "input-clear") {
386
+ setQuery(value);
387
+ }
388
+ }}
389
+ onValueChange={(value) => {
390
+ if (typeof value !== "string") return;
391
+ picked.current = true;
392
+ setQuery(null);
393
+ onPath(hostPath(value));
394
+ queueMicrotask(() => {
395
+ picked.current = false;
396
+ });
397
+ }}
398
+ >
399
+ <Combobox.Trigger
400
+ ref={fieldRef}
401
+ aria-label="Preview path"
402
+ disabled={!enabled}
403
+ data-no-ring
404
+ className={cx(styles.addressTrigger)}
405
+ >
406
+ <IconGlobe size={13} ariaHidden />
407
+ <span {...stylex.props(styles.addressPath)}>{path}</span>
408
+ </Combobox.Trigger>
409
+ <Combobox.Portal>
410
+ <Combobox.Positioner
411
+ anchor={fieldRef}
412
+ side="bottom"
413
+ align="start"
414
+ sideOffset={6}
415
+ className={cx(popup.positioner)}
416
+ >
417
+ <Combobox.Popup
418
+ aria-label="Pages"
419
+ className={(state) => popupClassName(state, styles.routePopup)}
420
+ >
421
+ <form
422
+ onSubmit={(event: FormEvent<HTMLFormElement>) => {
423
+ event.preventDefault();
424
+ const fromList = picked.current;
425
+ picked.current = false;
426
+ if (fromList) return;
427
+ const typed = event.currentTarget.querySelector("input")?.value;
428
+ if (typed?.trim()) onPath(hostPath(typed));
429
+ setOpen(false);
430
+ setQuery(null);
431
+ }}
432
+ >
433
+ <Combobox.Input
434
+ ref={inputRef}
435
+ placeholder="Find a page"
436
+ aria-label="Find a page"
437
+ spellCheck={false}
438
+ autoComplete="off"
439
+ data-no-ring
440
+ className={cx(popup.quietInput, styles.routeSearch)}
441
+ onKeyDown={(event) => {
442
+ if (event.key !== "Enter") return;
443
+ // A highlighted row is chosen by the combobox. A bare Enter goes to the typed path.
444
+ if (event.currentTarget.getAttribute("aria-activedescendant")) return;
445
+ const typed = event.currentTarget.value.trim();
446
+ if (!typed) return;
447
+ picked.current = true;
448
+ onPath(hostPath(typed));
449
+ }}
450
+ />
451
+ </form>
452
+ <div {...stylex.props(popup.separator)} />
453
+ <Combobox.Empty className={cx(popup.empty)}>
454
+ {routesPending ? "Loading pages" : "No pages"}
455
+ </Combobox.Empty>
456
+ <Combobox.List className={cx(styles.routeList)}>
457
+ {(route: string) => (
458
+ <Combobox.Item
459
+ key={route}
460
+ value={route}
461
+ className={(state) =>
462
+ cx(
463
+ popup.item,
464
+ styles.routeItem,
465
+ state.highlighted && popup.itemHighlighted,
466
+ state.selected && popup.itemSelected,
467
+ )
468
+ }
469
+ >
470
+ {route}
471
+ </Combobox.Item>
472
+ )}
473
+ </Combobox.List>
474
+ </Combobox.Popup>
475
+ </Combobox.Positioner>
476
+ </Combobox.Portal>
477
+ </Combobox.Root>
478
+ <button
479
+ type="button"
480
+ aria-label="Open preview in a new tab"
481
+ disabled={!enabled}
482
+ onClick={onOpen}
483
+ {...stylex.props(styles.iconButton)}
484
+ >
485
+ <IconArrowUpRight size={14} ariaHidden />
486
+ </button>
487
+ </div>
488
+ );
489
+ }
490
+
491
+ function BootCard({
492
+ title,
493
+ steps,
494
+ log,
495
+ error,
496
+ }: {
497
+ title: string;
498
+ steps?: BootStep[];
499
+ log?: string;
500
+ error?: string;
501
+ }) {
502
+ const tail = (log ?? "").trimEnd().split("\n").slice(-12).join("\n");
503
+ return (
504
+ <div {...stylex.props(styles.bootWrap)}>
505
+ <div {...stylex.props(styles.bootCard)} role="status" aria-live="polite">
506
+ <Text size="md" weight="medium">
507
+ {title}
508
+ </Text>
509
+ {steps ? (
510
+ <ol {...stylex.props(styles.steps)}>
511
+ {steps.map((step) => (
512
+ <li key={step.label} {...stylex.props(styles.step)}>
513
+ <StepMark state={step.state} />
514
+ <span
515
+ {...stylex.props(
516
+ styles.stepLabel,
517
+ step.state === "pending" && styles.stepPending,
518
+ step.state === "failed" && styles.stepFailed,
519
+ )}
520
+ >
521
+ {step.label}
522
+ </span>
523
+ </li>
524
+ ))}
525
+ </ol>
526
+ ) : null}
527
+ {error ? <p {...stylex.props(styles.error)}>{error}</p> : null}
528
+ {tail ? <pre {...stylex.props(styles.log)}>{tail}</pre> : null}
529
+ </div>
530
+ </div>
531
+ );
532
+ }
533
+
534
+ function StepMark({ state }: { state: BootStep["state"] }) {
535
+ if (state === "done") {
536
+ return (
537
+ <span {...stylex.props(styles.mark, styles.markDone)}>
538
+ <IconCheckmark1Small size={14} ariaHidden />
539
+ </span>
540
+ );
541
+ }
542
+ if (state === "failed") {
543
+ return (
544
+ <span {...stylex.props(styles.mark, styles.markFailed)}>
545
+ <IconCrossSmall size={14} ariaHidden />
546
+ </span>
547
+ );
548
+ }
549
+ if (state === "active") {
550
+ return <span {...stylex.props(styles.mark, styles.spinner)} />;
551
+ }
552
+ return <span {...stylex.props(styles.mark, styles.markPending)} />;
553
+ }
554
+
555
+ function storedPreviewPath(workspaceId: string): string {
556
+ try {
557
+ return rememberedPath(window.sessionStorage, workspaceId);
558
+ } catch {
559
+ return "/";
560
+ }
561
+ }
562
+
563
+ async function requestPreview(workspaceId: string, sessionId: string): Promise<HostPreview | null> {
564
+ const res = await fetch(
565
+ apiUrl(`/workspaces/${workspaceId}/code-sessions/${sessionId}/preview`),
566
+ { method: "POST", credentials: apiCredentials(), headers: apiHeaders() },
567
+ ).catch(() => null);
568
+ if (!res?.ok) return null;
569
+ const body = (await res.json().catch(() => null)) as Partial<HostPreview> | null;
570
+ return typeof body?.origin === "string" && typeof body.entry === "string"
571
+ ? { origin: body.origin, entry: body.entry }
572
+ : null;
573
+ }
574
+
575
+ export default function CodeSessionPage() {
576
+ const { workspace, models } = useOutletContext<WorkspaceOutletContext>();
577
+ const git = useReplicaState().git(workspace.id);
578
+ const branch = git?.currentBranch ?? "main";
579
+ const [state, setState] = useState<OpenState>({ kind: "loading" });
580
+ const [host, setHost] = useState<HostView>({
581
+ phase: null,
582
+ furthest: null,
583
+ log: "",
584
+ timedOut: false,
585
+ });
586
+ const [tab, setTab] = useState<SessionTab>("preview");
587
+ const [preview, setPreview] = useState<HostPreview | null>(null);
588
+ const [path, setPath] = useState(() => storedPreviewPath(workspace.id));
589
+ const [location, setLocation] = useState(() => storedPreviewPath(workspace.id));
590
+ const [reloads, setReloads] = useState(0);
591
+ const [previewSize, setPreviewSize] = useState(readPreviewSize);
592
+ const [live, setLive] = useState(false);
593
+ const [dismissedRoute, setDismissedRoute] = useState<string | null>(null);
594
+ const [fileList, setFileList] = useState<string[] | null>(null);
595
+ const [routesPending, setRoutesPending] = useState(false);
596
+ const [filesError, setFilesError] = useState<string | null>(null);
597
+ const [selectedFile, setSelectedFile] = useState<string | null>(null);
598
+ const [openedFile, setOpenedFile] = useState<WorkspaceFile | "loading" | null>(null);
599
+ const latestFile = useRef<string | null>(null);
600
+
601
+ const sessionId = state.kind === "ready" ? state.sessionId : null;
602
+ const [editCount, setEditCount] = useState(0);
603
+ const [foldPending, setFoldPending] = useState(false);
604
+ const [baseSha, setBaseSha] = useState<string | null>(null);
605
+ const [updating, setUpdating] = useState(false);
606
+ const publishCodeSession = usePublishCodeSession();
607
+
608
+ const refreshEdits = useCallback(() => {
609
+ if (!sessionId) {
610
+ setEditCount(0);
611
+ setFoldPending(false);
612
+ return;
613
+ }
614
+ void fetchCodeSessionEdits(workspace.id, sessionId)
615
+ .then((list) => {
616
+ setEditCount(list.files.length);
617
+ setFoldPending(list.foldPending);
618
+ })
619
+ .catch(() => {
620
+ setEditCount(0);
621
+ setFoldPending(false);
622
+ });
623
+ }, [sessionId, workspace.id]);
624
+
625
+ // The host's dev server does not push a reload through the preview proxy.
626
+ // The write tool has already asked the server to compile this page. The open
627
+ // page stays up while that next document loads.
628
+ const reloadPreview = useCallback(() => {
629
+ setLive(true);
630
+ setReloads((n) => n + 1);
631
+ }, []);
632
+ const settleEdits = useCallback(() => {
633
+ refreshEdits();
634
+ window.setTimeout(() => refreshEdits(), 800);
635
+ }, [refreshEdits]);
636
+
637
+ useEffect(() => {
638
+ refreshEdits();
639
+ const timer = window.setInterval(refreshEdits, 8000);
640
+ return () => window.clearInterval(timer);
641
+ }, [refreshEdits]);
642
+
643
+ useEffect(() => {
644
+ if (!sessionId) {
645
+ publishCodeSession(null);
646
+ return;
647
+ }
648
+ publishCodeSession({ sessionId, editCount, refresh: refreshEdits, updating });
649
+ return () => publishCodeSession(null);
650
+ }, [sessionId, editCount, refreshEdits, publishCodeSession, updating]);
651
+
652
+ // The agent and the dev server change the checkout, so read it fresh when the list is opened.
653
+ const loadCheckout = useCallback(async () => {
654
+ if (!sessionId) return;
655
+ const res = await fetch(
656
+ apiUrl(`/workspaces/${workspace.id}/code-sessions/${sessionId}/files`),
657
+ { credentials: apiCredentials(), headers: apiHeaders() },
658
+ ).catch(() => null);
659
+ const body = res?.ok ? ((await res.json().catch(() => null)) as { files?: string[] } | null) : null;
660
+ if (body?.files) {
661
+ setFileList(body.files);
662
+ setFilesError(null);
663
+ } else {
664
+ setFilesError("The checkout could not be read. Is the host still running?");
665
+ }
666
+ }, [sessionId, workspace.id]);
667
+
668
+ useEffect(() => {
669
+ if (tab !== "files" || !sessionId) return;
670
+ setFilesError(null);
671
+ void loadCheckout();
672
+ }, [tab, sessionId, loadCheckout]);
673
+
674
+ function openRoutes() {
675
+ setRoutesPending(fileList === null);
676
+ void loadCheckout().finally(() => setRoutesPending(false));
677
+ }
678
+
679
+ useEffect(() => {
680
+ try {
681
+ rememberPath(window.sessionStorage, workspace.id, location);
682
+ } catch {
683
+ // A render without browser storage has nothing to restore on the next load.
684
+ }
685
+ }, [workspace.id, location]);
686
+
687
+ useEffect(() => {
688
+ if (!preview) return;
689
+ const onMessage = (event: MessageEvent) => {
690
+ const next = framedLocation(event, preview.origin);
691
+ if (next !== null) setLocation(next);
692
+ };
693
+ window.addEventListener("message", onMessage);
694
+ return () => window.removeEventListener("message", onMessage);
695
+ }, [preview]);
696
+
697
+ function navigate(next: string) {
698
+ setPath(next);
699
+ setLocation(next);
700
+ setLive(false);
701
+ // A fresh frame, so asking for the route it is already on still loads it.
702
+ setReloads((n) => n + 1);
703
+ }
704
+
705
+ async function selectFile(path: string) {
706
+ if (!sessionId) return;
707
+ latestFile.current = path;
708
+ setSelectedFile(path);
709
+ setOpenedFile("loading");
710
+ const res = await fetch(
711
+ apiUrl(
712
+ `/workspaces/${workspace.id}/code-sessions/${sessionId}/files/content?path=${encodeURIComponent(path)}`,
713
+ ),
714
+ { credentials: apiCredentials(), headers: apiHeaders() },
715
+ ).catch(() => null);
716
+ const body = res?.ok ? ((await res.json().catch(() => null)) as WorkspaceFile | null) : null;
717
+ if (latestFile.current === path) setOpenedFile(body);
718
+ }
719
+
720
+ useEffect(() => {
721
+ let cancelled = false;
722
+ setState({ kind: "loading" });
723
+ setBaseSha(null);
724
+ setHost({ phase: null, furthest: null, log: "", timedOut: false });
725
+ setPreview(null);
726
+ const stored = storedPreviewPath(workspace.id);
727
+ setPath(stored);
728
+ setLocation(stored);
729
+ setReloads(0);
730
+ setLive(false);
731
+ setDismissedRoute(null);
732
+ setFileList(null);
733
+ setRoutesPending(false);
734
+ setSelectedFile(null);
735
+ setOpenedFile(null);
736
+ latestFile.current = null;
737
+ void (async () => {
738
+ const res = await fetch(apiUrl(`/workspaces/${workspace.id}/code-sessions`), {
739
+ method: "POST",
740
+ credentials: apiCredentials(),
741
+ headers: {
742
+ "Content-Type": "application/json",
743
+ ...apiHeaders(),
744
+ },
745
+ body: JSON.stringify({ branch }),
746
+ });
747
+ if (cancelled) return;
748
+ if (res.status === 403) {
749
+ setState({ kind: "forbidden" });
750
+ return;
751
+ }
752
+ if (res.status === 422) {
753
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
754
+ setState({
755
+ kind: "fail",
756
+ error: typeof body.error === "string" ? body.error : "unknown",
757
+ });
758
+ return;
759
+ }
760
+ if (!res.ok) {
761
+ const text = await res.text().catch(() => "");
762
+ setState({ kind: "fail", error: text || `request failed: ${res.status}` });
763
+ return;
764
+ }
765
+ const body = (await res.json()) as { session?: { id?: string; baseSha?: string } };
766
+ const sessionId = body.session?.id;
767
+ setBaseSha(typeof body.session?.baseSha === "string" ? body.session.baseSha : null);
768
+ if (!sessionId) {
769
+ setState({ kind: "fail", error: "invalid response" });
770
+ return;
771
+ }
772
+ setState({ kind: "ready", sessionId });
773
+
774
+ const statusUrl = apiUrl(`/workspaces/${workspace.id}/code-sessions/${sessionId}/status`);
775
+ const deadline = Date.now() + HOST_WAIT_MS;
776
+ while (!cancelled && Date.now() < deadline) {
777
+ const polled = await fetch(statusUrl, {
778
+ credentials: apiCredentials(),
779
+ headers: apiHeaders(),
780
+ }).catch(() => null);
781
+ if (cancelled) return;
782
+ if (polled?.ok) {
783
+ const status = (await polled.json()) as { phase: HostPhase; log: string };
784
+ setHost((current) => ({
785
+ phase: status.phase,
786
+ furthest:
787
+ status.phase === "installing" || status.phase === "starting"
788
+ ? status.phase
789
+ : current.furthest,
790
+ log: status.log,
791
+ timedOut: false,
792
+ }));
793
+ if (status.phase === "ready") {
794
+ const entry = await requestPreview(workspace.id, sessionId);
795
+ if (cancelled) return;
796
+ if (entry) setPreview(entry);
797
+ else setState({ kind: "fail", error: "The preview could not be opened." });
798
+ return;
799
+ }
800
+ if (status.phase === "failed" || status.phase === "stopped") return;
801
+ }
802
+ await new Promise((resolve) => setTimeout(resolve, STATUS_POLL_MS));
803
+ }
804
+ if (!cancelled) setHost((current) => ({ ...current, timedOut: true }));
805
+ })();
806
+ return () => {
807
+ cancelled = true;
808
+ };
809
+ }, [workspace.id, branch]);
810
+
811
+ async function bootAgain(id: string, openPath: string) {
812
+ setPath(openPath);
813
+ setLocation(openPath);
814
+ setReloads(0);
815
+ setPreview(null);
816
+ setHost({ phase: null, furthest: null, log: "", timedOut: false });
817
+ const statusUrl = apiUrl(`/workspaces/${workspace.id}/code-sessions/${id}/status`);
818
+ const deadline = Date.now() + HOST_WAIT_MS;
819
+ while (Date.now() < deadline) {
820
+ const polled = await fetch(statusUrl, {
821
+ credentials: apiCredentials(),
822
+ headers: apiHeaders(),
823
+ }).catch(() => null);
824
+ if (polled?.ok) {
825
+ const status = (await polled.json()) as { phase: HostPhase; log: string };
826
+ setHost((current) => ({
827
+ phase: status.phase,
828
+ furthest:
829
+ status.phase === "installing" || status.phase === "starting"
830
+ ? status.phase
831
+ : current.furthest,
832
+ log: status.log,
833
+ timedOut: false,
834
+ }));
835
+ if (status.phase === "ready") {
836
+ const entry = await requestPreview(workspace.id, id);
837
+ if (entry) setPreview(entry);
838
+ else setState({ kind: "fail", error: "The preview could not be opened." });
839
+ return;
840
+ }
841
+ if (status.phase === "failed" || status.phase === "stopped") return;
842
+ }
843
+ await new Promise((resolve) => setTimeout(resolve, STATUS_POLL_MS));
844
+ }
845
+ setHost((current) => ({ ...current, timedOut: true }));
846
+ }
847
+
848
+ async function onUpdateBranch() {
849
+ if (!sessionId || updating) return;
850
+ setUpdating(true);
851
+ try {
852
+ const result = await updateCodeSessionBranch(workspace.id, sessionId);
853
+ setBaseSha(result.baseSha);
854
+ await bootAgain(sessionId, location);
855
+ } catch {
856
+ // The checkout stayed on its commit, and the preview that is up stays up.
857
+ } finally {
858
+ setUpdating(false);
859
+ refreshEdits();
860
+ }
861
+ }
862
+
863
+ function onClearEdits() {
864
+ if (!sessionId || updating) return;
865
+ const confirmed = window.confirm(
866
+ "Clear the edit set? The files go back to this session's commit. The chat stays.",
867
+ );
868
+ if (!confirmed) return;
869
+ setUpdating(true);
870
+ void (async () => {
871
+ try {
872
+ await clearCodeSessionEdits(workspace.id, sessionId);
873
+ await bootAgain(sessionId, location);
874
+ } catch {
875
+ // The edit set is still there.
876
+ } finally {
877
+ setUpdating(false);
878
+ refreshEdits();
879
+ }
880
+ })();
881
+ }
882
+
883
+ async function openInNewTab() {
884
+ if (state.kind !== "ready") return;
885
+ // The iframe's cookie is partitioned under Studio, so a new tab needs its own ticket.
886
+ const tab = window.open("about:blank", "_blank");
887
+ const entry = await requestPreview(workspace.id, state.sessionId);
888
+ if (!tab) return;
889
+ if (!entry) {
890
+ tab.close();
891
+ return;
892
+ }
893
+ tab.opener = null;
894
+ tab.location.href = previewUrl(entry.entry, location);
895
+ }
896
+
897
+ const chatRoute = dismissedRoute === location ? null : location;
898
+ const viewed = chatRoute ? sessionDocument({ type: "route", slug: chatRoute }, models) : null;
899
+ const chatColumn =
900
+ state.kind === "ready" ? (
901
+ <div {...stylex.props(styles.chatHost)}>
902
+ <ChatSessionScope>
903
+ <ChatPanel fill />
904
+ </ChatSessionScope>
905
+ </div>
906
+ ) : null;
907
+
908
+ const screen = (
909
+ <CodeSessionScreen
910
+ models={models}
911
+ state={state}
912
+ host={host}
913
+ tab={tab}
914
+ onTab={setTab}
915
+ preview={preview}
916
+ path={path}
917
+ location={location}
918
+ onPath={navigate}
919
+ reloads={reloads}
920
+ live={live}
921
+ onReload={() => navigate(location)}
922
+ onOpen={() => void openInNewTab()}
923
+ routes={contentRoutes(models, fileList ?? [])}
924
+ routesPending={routesPending}
925
+ onOpenRoutes={openRoutes}
926
+ files={{
927
+ list: fileList,
928
+ error: filesError,
929
+ selected: selectedFile,
930
+ opened: openedFile,
931
+ onSelect: (path) => void selectFile(path),
932
+ }}
933
+ chat={chatColumn}
934
+ notice={
935
+ updating
936
+ ? null
937
+ : branchNoticeKind({
938
+ baseSha,
939
+ headSha: git?.head?.sha ?? null,
940
+ editCount,
941
+ foldPending,
942
+ })
943
+ }
944
+ noticeBusy={updating}
945
+ onUpdateBranch={() => void onUpdateBranch()}
946
+ onClearEdits={onClearEdits}
947
+ previewSize={previewSize}
948
+ onPreviewSize={(size) => {
949
+ setPreviewSize(size);
950
+ persistPreviewSize(size);
951
+ }}
952
+ />
953
+ );
954
+
955
+ if (state.kind !== "ready") return screen;
956
+ return (
957
+ <ChatProvider
958
+ workspaceId={workspace.id}
959
+ codeSessionId={state.sessionId}
960
+ route={chatRoute}
961
+ viewed={viewed}
962
+ onRouteDismiss={setDismissedRoute}
963
+ onCheckoutWrite={reloadPreview}
964
+ onTurnSettled={settleEdits}
965
+ startOpen
966
+ >
967
+ {screen}
968
+ </ChatProvider>
969
+ );
970
+ }
971
+
972
+ const hover = "@media (hover: hover)";
973
+
974
+ const spin = stylex.keyframes({
975
+ from: { transform: "rotate(0deg)" },
976
+ to: { transform: "rotate(360deg)" },
977
+ });
978
+
979
+ const styles = stylex.create({
980
+ shell: {
981
+ display: "flex",
982
+ flex: 1,
983
+ minHeight: 0,
984
+ minWidth: 0,
985
+ backgroundColor: colors.bgApp,
986
+ },
987
+ chat: {
988
+ width: "360px",
989
+ flexShrink: 0,
990
+ display: "flex",
991
+ flexDirection: "column",
992
+ minHeight: 0,
993
+ borderRightWidth: 1,
994
+ borderRightStyle: "solid",
995
+ borderRightColor: colors.border,
996
+ backgroundColor: colors.bgSurface,
997
+ overflow: "hidden",
998
+ },
999
+ chatHost: {
1000
+ display: "flex",
1001
+ flex: 1,
1002
+ flexDirection: "column",
1003
+ minHeight: 0,
1004
+ },
1005
+ chatHeader: {
1006
+ display: "flex",
1007
+ alignItems: "center",
1008
+ height: "44px",
1009
+ boxSizing: "border-box",
1010
+ paddingInline: space.lg,
1011
+ borderBottomWidth: 1,
1012
+ borderBottomStyle: "solid",
1013
+ borderBottomColor: colors.border,
1014
+ },
1015
+ messages: {
1016
+ flex: 1,
1017
+ minHeight: 0,
1018
+ overflow: "auto",
1019
+ padding: space.lg,
1020
+ },
1021
+ lineList: {
1022
+ display: "flex",
1023
+ flexDirection: "column",
1024
+ gap: space.sm,
1025
+ margin: 0,
1026
+ padding: 0,
1027
+ listStyle: "none",
1028
+ },
1029
+ line: {
1030
+ fontSize: type.sm,
1031
+ lineHeight: 1.45,
1032
+ color: colors.text,
1033
+ },
1034
+ userLine: {
1035
+ alignSelf: "flex-end",
1036
+ maxWidth: "85%",
1037
+ paddingBlock: space.xs,
1038
+ paddingInline: space.sm,
1039
+ borderRadius: radius.md,
1040
+ backgroundColor: colors.bgPanelInset,
1041
+ },
1042
+ composer: {
1043
+ padding: space.md,
1044
+ },
1045
+ inputWrap: {
1046
+ position: "relative",
1047
+ borderWidth: 1,
1048
+ borderStyle: "solid",
1049
+ borderColor: colors.border,
1050
+ borderRadius: radius.md,
1051
+ backgroundColor: colors.bgInput,
1052
+ },
1053
+ input: {
1054
+ display: "block",
1055
+ width: "100%",
1056
+ minHeight: "84px",
1057
+ resize: "none",
1058
+ boxSizing: "border-box",
1059
+ font: "inherit",
1060
+ fontSize: type.sm,
1061
+ color: colors.text,
1062
+ backgroundColor: "transparent",
1063
+ borderWidth: 0,
1064
+ outline: "none",
1065
+ padding: space.sm,
1066
+ paddingBottom: "36px",
1067
+ },
1068
+ send: {
1069
+ position: "absolute",
1070
+ right: space.sm,
1071
+ bottom: space.sm,
1072
+ display: "inline-flex",
1073
+ alignItems: "center",
1074
+ justifyContent: "center",
1075
+ width: "26px",
1076
+ height: "26px",
1077
+ borderWidth: 0,
1078
+ borderRadius: "999px",
1079
+ cursor: { default: "pointer", ":disabled": "default" },
1080
+ color: colors.textInverse,
1081
+ backgroundColor: colors.text,
1082
+ opacity: { default: 1, ":disabled": 0.3 },
1083
+ },
1084
+ notice: {
1085
+ display: "flex",
1086
+ alignItems: "center",
1087
+ justifyContent: "space-between",
1088
+ gap: space.md,
1089
+ paddingInline: space.md,
1090
+ paddingBlock: space.sm,
1091
+ borderBottomWidth: 1,
1092
+ borderBottomStyle: "solid",
1093
+ borderBottomColor: colors.border,
1094
+ backgroundColor: colors.bgSurface,
1095
+ },
1096
+ noticeText: {
1097
+ fontSize: type.sm,
1098
+ color: colors.text,
1099
+ },
1100
+ noticeButton: {
1101
+ flexShrink: 0,
1102
+ font: "inherit",
1103
+ fontSize: type.xs,
1104
+ fontWeight: 500,
1105
+ cursor: { default: "pointer", ":disabled": "default" },
1106
+ paddingBlock: "4px",
1107
+ paddingInline: space.sm,
1108
+ borderWidth: 1,
1109
+ borderStyle: "solid",
1110
+ borderColor: colors.border,
1111
+ borderRadius: radius.sm,
1112
+ color: colors.text,
1113
+ backgroundColor: colors.bgPanelInset,
1114
+ opacity: { default: 1, ":disabled": 0.4 },
1115
+ },
1116
+ stage: {
1117
+ display: "flex",
1118
+ flex: 1,
1119
+ flexDirection: "column",
1120
+ minWidth: 0,
1121
+ minHeight: 0,
1122
+ },
1123
+ toolbar: {
1124
+ display: "grid",
1125
+ gridTemplateColumns: "1fr minmax(0, 520px) 1fr",
1126
+ alignItems: "center",
1127
+ gap: space.md,
1128
+ height: "44px",
1129
+ boxSizing: "border-box",
1130
+ paddingInline: space.md,
1131
+ borderBottomWidth: 1,
1132
+ borderBottomStyle: "solid",
1133
+ borderBottomColor: colors.border,
1134
+ backgroundColor: colors.bgSurface,
1135
+ },
1136
+ tabs: {
1137
+ display: "inline-flex",
1138
+ justifySelf: "start",
1139
+ gap: "2px",
1140
+ padding: "2px",
1141
+ borderRadius: radius.md,
1142
+ backgroundColor: colors.bgPanelInset,
1143
+ },
1144
+ tab: {
1145
+ display: "inline-flex",
1146
+ alignItems: "center",
1147
+ gap: space.xs,
1148
+ font: "inherit",
1149
+ fontSize: type.xs,
1150
+ fontWeight: 500,
1151
+ cursor: "pointer",
1152
+ paddingBlock: "4px",
1153
+ paddingInline: space.sm,
1154
+ borderWidth: 0,
1155
+ borderRadius: radius.sm,
1156
+ color: { default: colors.textMuted, [hover]: { default: null, ":hover": colors.text } },
1157
+ backgroundColor: "transparent",
1158
+ },
1159
+ tabActive: {
1160
+ color: colors.text,
1161
+ backgroundColor: colors.bgSurface,
1162
+ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.08)",
1163
+ },
1164
+ address: {
1165
+ display: "flex",
1166
+ alignItems: "center",
1167
+ gap: space.xs,
1168
+ minWidth: 0,
1169
+ },
1170
+ sizeSwitch: {
1171
+ justifySelf: "end",
1172
+ },
1173
+ addressTrigger: {
1174
+ display: "flex",
1175
+ flex: 1,
1176
+ alignItems: "center",
1177
+ gap: space.xs,
1178
+ minWidth: 0,
1179
+ height: "28px",
1180
+ paddingInline: space.sm,
1181
+ borderWidth: 0,
1182
+ borderRadius: "999px",
1183
+ font: "inherit",
1184
+ textAlign: "left",
1185
+ cursor: { default: "pointer", ":disabled": "default" },
1186
+ color: { default: colors.textMuted, ":disabled": colors.textMuted },
1187
+ backgroundColor: colors.bgPanelInset,
1188
+ },
1189
+ addressPath: {
1190
+ flex: 1,
1191
+ minWidth: 0,
1192
+ overflow: "hidden",
1193
+ textOverflow: "ellipsis",
1194
+ whiteSpace: "nowrap",
1195
+ fontFamily: type.mono,
1196
+ fontSize: type.xs,
1197
+ color: colors.text,
1198
+ },
1199
+ routeSearch: {
1200
+ fontFamily: type.mono,
1201
+ fontSize: type.xs,
1202
+ },
1203
+ routePopup: {
1204
+ width: "var(--anchor-width)",
1205
+ minWidth: "220px",
1206
+ maxHeight: "min(320px, var(--available-height))",
1207
+ display: "flex",
1208
+ flexDirection: "column",
1209
+ overflowY: "hidden",
1210
+ },
1211
+ routeList: {
1212
+ minHeight: 0,
1213
+ overflowY: "auto",
1214
+ },
1215
+ routeItem: {
1216
+ fontFamily: type.mono,
1217
+ },
1218
+ iconButton: {
1219
+ display: "inline-flex",
1220
+ alignItems: "center",
1221
+ justifyContent: "center",
1222
+ flexShrink: 0,
1223
+ width: "28px",
1224
+ height: "28px",
1225
+ borderWidth: 0,
1226
+ borderRadius: radius.sm,
1227
+ cursor: { default: "pointer", ":disabled": "default" },
1228
+ color: { default: colors.textMuted, ":disabled": colors.border },
1229
+ backgroundColor: {
1230
+ default: "transparent",
1231
+ [hover]: { default: null, ":hover:not(:disabled)": colors.bgHover },
1232
+ },
1233
+ },
1234
+ canvas: {
1235
+ position: "relative",
1236
+ display: "flex",
1237
+ flex: 1,
1238
+ minWidth: 0,
1239
+ minHeight: 0,
1240
+ backgroundColor: colors.bgApp,
1241
+ },
1242
+ frame: {
1243
+ flex: 1,
1244
+ width: "100%",
1245
+ minHeight: 0,
1246
+ borderWidth: 0,
1247
+ backgroundColor: "#fff",
1248
+ },
1249
+ frameIncoming: {
1250
+ position: "absolute",
1251
+ inset: 0,
1252
+ opacity: 0,
1253
+ pointerEvents: "none",
1254
+ },
1255
+ pending: {
1256
+ position: "absolute",
1257
+ inset: 0,
1258
+ display: "flex",
1259
+ alignItems: "center",
1260
+ justifyContent: "center",
1261
+ gap: space.sm,
1262
+ fontSize: type.sm,
1263
+ color: colors.textMuted,
1264
+ backgroundColor: colors.bgApp,
1265
+ },
1266
+ bootWrap: {
1267
+ display: "flex",
1268
+ flex: 1,
1269
+ alignItems: "center",
1270
+ justifyContent: "center",
1271
+ padding: space.xl,
1272
+ },
1273
+ bootCard: {
1274
+ display: "flex",
1275
+ flexDirection: "column",
1276
+ gap: space.md,
1277
+ width: "100%",
1278
+ maxWidth: "460px",
1279
+ padding: space.xl,
1280
+ borderWidth: 1,
1281
+ borderStyle: "solid",
1282
+ borderColor: colors.border,
1283
+ borderRadius: radius.md,
1284
+ backgroundColor: colors.bgSurface,
1285
+ },
1286
+ steps: {
1287
+ display: "flex",
1288
+ flexDirection: "column",
1289
+ gap: space.sm,
1290
+ margin: 0,
1291
+ padding: 0,
1292
+ listStyle: "none",
1293
+ },
1294
+ step: {
1295
+ display: "flex",
1296
+ alignItems: "center",
1297
+ gap: space.sm,
1298
+ },
1299
+ stepLabel: {
1300
+ fontSize: type.sm,
1301
+ color: colors.text,
1302
+ },
1303
+ stepPending: {
1304
+ color: colors.textMuted,
1305
+ },
1306
+ stepFailed: {
1307
+ color: colors.danger,
1308
+ },
1309
+ mark: {
1310
+ display: "inline-flex",
1311
+ alignItems: "center",
1312
+ justifyContent: "center",
1313
+ flexShrink: 0,
1314
+ width: "18px",
1315
+ height: "18px",
1316
+ boxSizing: "border-box",
1317
+ borderRadius: "999px",
1318
+ },
1319
+ markDone: {
1320
+ color: colors.textInverse,
1321
+ backgroundColor: colors.success,
1322
+ },
1323
+ markFailed: {
1324
+ color: colors.textInverse,
1325
+ backgroundColor: colors.danger,
1326
+ },
1327
+ markPending: {
1328
+ borderWidth: 1.5,
1329
+ borderStyle: "solid",
1330
+ borderColor: colors.border,
1331
+ },
1332
+ spinner: {
1333
+ borderWidth: 2,
1334
+ borderStyle: "solid",
1335
+ borderColor: colors.border,
1336
+ borderTopColor: colors.accent,
1337
+ animationName: spin,
1338
+ animationDuration: { default: "0.8s", "@media (prefers-reduced-motion: reduce)": "2.4s" },
1339
+ animationTimingFunction: "linear",
1340
+ animationIterationCount: "infinite",
1341
+ },
1342
+ log: {
1343
+ margin: 0,
1344
+ maxHeight: "200px",
1345
+ overflow: "auto",
1346
+ padding: space.sm,
1347
+ borderRadius: radius.sm,
1348
+ fontFamily: type.mono,
1349
+ fontSize: "11px",
1350
+ lineHeight: 1.5,
1351
+ whiteSpace: "pre-wrap",
1352
+ wordBreak: "break-word",
1353
+ color: colors.textMuted,
1354
+ backgroundColor: colors.bgPanelInset,
1355
+ },
1356
+ error: {
1357
+ margin: 0,
1358
+ color: colors.danger,
1359
+ fontSize: type.sm,
1360
+ },
1361
+ });