@backmatter/studio 0.0.14 → 0.0.16

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