@backmatter/studio 0.0.16 → 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 (36) hide show
  1. package/dist/chunks/{UiKitApp-_EDqWID3.js → UiKitApp-D7vllRQE.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-B2PrFgpz.js → WorkspaceRoutes-DH1LD-2Q.js} +26374 -25887
  3. package/dist/chunks/{dashboard-BnlnMnnr.js → dashboard-Dsaeud2x.js} +2 -2
  4. package/dist/chunks/{define-studio-BYtYDGLX.js → define-studio-DlYjgrwr.js} +12 -16
  5. package/dist/chunks/{index-D_BHF_8q.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 +45 -1
  10. package/dist/site-widget.js +2799 -2791
  11. package/dist/studio.css +93 -1
  12. package/package.json +2 -2
  13. package/src/api.ts +19 -0
  14. package/src/chat/ChatProvider.tsx +6 -0
  15. package/src/chat/Composer.tsx +3 -1
  16. package/src/chrome/Toast.tsx +115 -0
  17. package/src/chrome/WorkspaceStatusBar.tsx +12 -3
  18. package/src/chrome/commit-error.ts +13 -0
  19. package/src/chrome/pull-failure.ts +61 -0
  20. package/src/fields/FieldControl.tsx +748 -0
  21. package/src/fields/choose-field-input.ts +64 -0
  22. package/src/fields/load-relation-documents.ts +35 -0
  23. package/src/fields/omit-field.ts +12 -0
  24. package/src/fields/relation-targets.ts +44 -0
  25. package/src/fields/resolve-object-fields.ts +21 -0
  26. package/src/pages/BlockSettingsDialog.tsx +19 -115
  27. package/src/pages/BlocksList.tsx +43 -35
  28. package/src/pages/BodyProse.tsx +16 -1
  29. package/src/pages/CodeSessionPage.tsx +180 -7
  30. package/src/pages/DocEditorPage.tsx +87 -92
  31. package/src/pages/RenderedBody.tsx +17 -2
  32. package/src/pages/branch-notice.ts +16 -0
  33. package/src/pages/code-session-context.tsx +2 -0
  34. package/src/shell-resets.css +15 -0
  35. package/src/ui/Input.tsx +178 -20
  36. package/src/ui/popup.ts +11 -2
@@ -14,7 +14,7 @@ import {
14
14
  IconGlobe,
15
15
  } from "@central-icons-react/round-outlined-radius-2-stroke-1.5";
16
16
  import { ChatPanel } from "../chat/ChatPanel";
17
- import { ChatProvider, ChatSessionScope } from "../chat/ChatProvider";
17
+ import { ChatProvider, ChatSessionScope, useChatBusy } from "../chat/ChatProvider";
18
18
  import { useReplicaState } from "../replica/useReplica";
19
19
  import { colors, radius, space, type } from "../styles/tokens.stylex";
20
20
  import { apiCredentials, apiHeaders, apiUrl } from "../studio-config";
@@ -35,7 +35,8 @@ import { CodeSessionFiles, type FilesView } from "./CodeSessionFiles";
35
35
  import { PreviewSizeSwitch, PreviewViewport } from "./PreviewSize";
36
36
  import { persistPreviewSize, readPreviewSize, type PreviewSize } from "./preview-size";
37
37
  import type { WorkspaceFile } from "./code-session-files";
38
- import { fetchCodeSessionEdits } from "../api";
38
+ import { clearCodeSessionEdits, fetchCodeSessionEdits, updateCodeSessionBranch } from "../api";
39
+ import { branchNoticeKind, type BranchNoticeKind } from "./branch-notice";
39
40
  import { usePublishCodeSession } from "./code-session-context";
40
41
  import { sessionDocument } from "./session-preview";
41
42
  import { useSessionPreview } from "./use-session-preview";
@@ -80,6 +81,10 @@ export function CodeSessionScreen({
80
81
  models = [],
81
82
  files,
82
83
  chat,
84
+ notice = null,
85
+ noticeBusy = false,
86
+ onUpdateBranch = () => {},
87
+ onClearEdits = () => {},
83
88
  }: {
84
89
  state: OpenState;
85
90
  host: HostView;
@@ -108,9 +113,16 @@ export function CodeSessionScreen({
108
113
  /** Models whose routes name the document under a pointed block. */
109
114
  models?: readonly RouteModel[];
110
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;
111
121
  }) {
112
122
  // The entry's ticket sets the host's cookie on first load; later loads ride that cookie.
113
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;
114
126
  const frameSrc = preview ? frameAddress(preview, reloads > 0 ? location : path, reloads) : null;
115
127
  const frameRef = useRef<HTMLIFrameElement>(null);
116
128
  const heldFrameRef = useRef<HTMLIFrameElement | null>(null);
@@ -201,6 +213,38 @@ export function CodeSessionScreen({
201
213
  </div>
202
214
  </div>
203
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
+
204
248
  <div {...stylex.props(styles.canvas)}>
205
249
  {tab === "files" ? (
206
250
  <CodeSessionFiles {...files} />
@@ -556,16 +600,26 @@ export default function CodeSessionPage() {
556
600
 
557
601
  const sessionId = state.kind === "ready" ? state.sessionId : null;
558
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);
559
606
  const publishCodeSession = usePublishCodeSession();
560
607
 
561
608
  const refreshEdits = useCallback(() => {
562
609
  if (!sessionId) {
563
610
  setEditCount(0);
611
+ setFoldPending(false);
564
612
  return;
565
613
  }
566
614
  void fetchCodeSessionEdits(workspace.id, sessionId)
567
- .then((list) => setEditCount(list.files.length))
568
- .catch(() => setEditCount(0));
615
+ .then((list) => {
616
+ setEditCount(list.files.length);
617
+ setFoldPending(list.foldPending);
618
+ })
619
+ .catch(() => {
620
+ setEditCount(0);
621
+ setFoldPending(false);
622
+ });
569
623
  }, [sessionId, workspace.id]);
570
624
 
571
625
  // The host's dev server does not push a reload through the preview proxy.
@@ -591,9 +645,9 @@ export default function CodeSessionPage() {
591
645
  publishCodeSession(null);
592
646
  return;
593
647
  }
594
- publishCodeSession({ sessionId, editCount, refresh: refreshEdits });
648
+ publishCodeSession({ sessionId, editCount, refresh: refreshEdits, updating });
595
649
  return () => publishCodeSession(null);
596
- }, [sessionId, editCount, refreshEdits, publishCodeSession]);
650
+ }, [sessionId, editCount, refreshEdits, publishCodeSession, updating]);
597
651
 
598
652
  // The agent and the dev server change the checkout, so read it fresh when the list is opened.
599
653
  const loadCheckout = useCallback(async () => {
@@ -666,6 +720,7 @@ export default function CodeSessionPage() {
666
720
  useEffect(() => {
667
721
  let cancelled = false;
668
722
  setState({ kind: "loading" });
723
+ setBaseSha(null);
669
724
  setHost({ phase: null, furthest: null, log: "", timedOut: false });
670
725
  setPreview(null);
671
726
  const stored = storedPreviewPath(workspace.id);
@@ -707,8 +762,9 @@ export default function CodeSessionPage() {
707
762
  setState({ kind: "fail", error: text || `request failed: ${res.status}` });
708
763
  return;
709
764
  }
710
- const body = (await res.json()) as { session?: { id?: string } };
765
+ const body = (await res.json()) as { session?: { id?: string; baseSha?: string } };
711
766
  const sessionId = body.session?.id;
767
+ setBaseSha(typeof body.session?.baseSha === "string" ? body.session.baseSha : null);
712
768
  if (!sessionId) {
713
769
  setState({ kind: "fail", error: "invalid response" });
714
770
  return;
@@ -752,6 +808,78 @@ export default function CodeSessionPage() {
752
808
  };
753
809
  }, [workspace.id, branch]);
754
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
+
755
883
  async function openInNewTab() {
756
884
  if (state.kind !== "ready") return;
757
885
  // The iframe's cookie is partitioned under Studio, so a new tab needs its own ticket.
@@ -803,6 +931,19 @@ export default function CodeSessionPage() {
803
931
  onSelect: (path) => void selectFile(path),
804
932
  }}
805
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}
806
947
  previewSize={previewSize}
807
948
  onPreviewSize={(size) => {
808
949
  setPreviewSize(size);
@@ -940,6 +1081,38 @@ const styles = stylex.create({
940
1081
  backgroundColor: colors.text,
941
1082
  opacity: { default: 1, ":disabled": 0.3 },
942
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
+ },
943
1116
  stage: {
944
1117
  display: "flex",
945
1118
  flex: 1,
@@ -5,6 +5,7 @@ import { useOutletContext, useParams, useSearchParams, Link, useNavigate } from
5
5
  import * as stylex from "@stylexjs/stylex";
6
6
  import {
7
7
  fetchDoc,
8
+ fetchDocs,
8
9
  notifyTreeChanged,
9
10
  type TreeChangeDetail,
10
11
  createPreviewTicket,
@@ -17,8 +18,11 @@ import {
17
18
  type SchemaPayload,
18
19
  type SchemaField,
19
20
  } from "../api";
20
- import { resolveFieldInput, useStudioDefinition } from "../define-studio";
21
- import { MediaFieldInput } from "./MediaFieldInput";
21
+ import { FieldControl } from "../fields/FieldControl";
22
+ import { loadRelationDocuments } from "../fields/load-relation-documents";
23
+ import { omitField } from "../fields/omit-field";
24
+ import { relationTargets } from "../fields/relation-targets";
25
+ import { useReplicaState } from "../replica/useReplica";
22
26
  import { useSetEditorActions } from "../chrome/EditorActionsContext";
23
27
  import {
24
28
  DOC_CHANGED_EVENT,
@@ -130,48 +134,6 @@ function emptyFrontmatter(model: SchemaPayload["models"][number] | undefined) {
130
134
  return out;
131
135
  }
132
136
 
133
- function JsonField({
134
- value,
135
- kind,
136
- readOnly,
137
- onValidChange,
138
- }: {
139
- value: unknown;
140
- kind: "object" | "array";
141
- readOnly?: boolean;
142
- onValidChange: (next: unknown) => void;
143
- }) {
144
- const serialized = JSON.stringify(value ?? (kind === "array" ? [] : {}), null, 2);
145
- const [draft, setDraft] = useState(serialized);
146
- const [focused, setFocused] = useState(false);
147
- const [invalid, setInvalid] = useState(false);
148
- useEffect(() => {
149
- if (!focused) {
150
- setDraft(serialized);
151
- setInvalid(false);
152
- }
153
- }, [serialized, focused]);
154
- return (
155
- <Textarea
156
- value={draft}
157
- readOnly={readOnly}
158
- aria-invalid={invalid || undefined}
159
- onFocus={() => setFocused(true)}
160
- onBlur={() => setFocused(false)}
161
- onChange={(e) => {
162
- const next = e.target.value;
163
- setDraft(next);
164
- try {
165
- onValidChange(JSON.parse(next || (kind === "array" ? "[]" : "{}")));
166
- setInvalid(false);
167
- } catch {
168
- setInvalid(true);
169
- }
170
- }}
171
- />
172
- );
173
- }
174
-
175
137
  /** Status-bar label for the doc's state against the branch HEAD. */
176
138
  export function docStatusLabel(status: DocStatus | null): string | null {
177
139
  if (status === null) return null;
@@ -202,7 +164,31 @@ export function DocEditorPage() {
202
164
  const [docFormat, setDocFormat] = useState<"mdx" | "page">("mdx");
203
165
  const [fieldKinds, setFieldKinds] = useState<Record<string, string>>({});
204
166
  const [fieldDefs, setFieldDefs] = useState<Record<string, SchemaField>>({});
205
- const studio = useStudioDefinition();
167
+ const replica = useReplicaState();
168
+ const objects = useMemo(
169
+ () => replica.checkout(workspace.id)?.schema?.payload.objects ?? [],
170
+ [replica, workspace.id],
171
+ );
172
+ const relationTargetTypes = useMemo(() => {
173
+ const targets = new Set<string>();
174
+ const groups: Record<string, SchemaField>[] = [];
175
+ if (model?.fields) groups.push(model.fields);
176
+ for (const block of blocks) {
177
+ if (block.fields) groups.push(block.fields);
178
+ }
179
+ for (const block of richtext) {
180
+ if (block.fields) groups.push(block.fields);
181
+ }
182
+ for (const fields of groups) {
183
+ for (const type of relationTargets(fields, models, objects)) {
184
+ targets.add(type);
185
+ }
186
+ }
187
+ return [...targets].sort();
188
+ }, [model, blocks, richtext, models, objects]);
189
+ const [documentLists, setDocumentLists] = useState<
190
+ Record<string, Array<{ slug: string; frontmatter: Record<string, unknown> }>> | undefined
191
+ >(undefined);
206
192
  const [parseFailed, setParseFailed] = useState(false);
207
193
  const [error, setError] = useState<string | null>(null);
208
194
  const [status, setStatus] = useState<string | null>(null);
@@ -305,6 +291,31 @@ export function DocEditorPage() {
305
291
  };
306
292
  }, [frameIdentity, previewPath, workspaceId, siteUrl, branch, tree]);
307
293
 
294
+ useEffect(() => {
295
+ if (relationTargetTypes.length === 0) {
296
+ setDocumentLists({});
297
+ return;
298
+ }
299
+ let cancelled = false;
300
+ setDocumentLists(undefined);
301
+ void loadRelationDocuments(
302
+ relationTargetTypes,
303
+ async () => (await fetchDocs(workspaceId)).docs,
304
+ async (relationType, relationSlug) => ({
305
+ frontmatter: (await fetchDoc(workspaceId, relationType, relationSlug)).doc.frontmatter,
306
+ }),
307
+ )
308
+ .then((loaded) => {
309
+ if (!cancelled) setDocumentLists(loaded);
310
+ })
311
+ .catch(() => {
312
+ if (!cancelled) setDocumentLists({});
313
+ });
314
+ return () => {
315
+ cancelled = true;
316
+ };
317
+ }, [workspaceId, type, slug, relationTargetTypes.join("\0")]);
318
+
308
319
  useEffect(() => {
309
320
  setPreviewLoading(true);
310
321
  }, [previewUrl]);
@@ -1103,55 +1114,33 @@ export function DocEditorPage() {
1103
1114
  </Text>
1104
1115
  ) : (
1105
1116
  detailNames.map((name) => {
1106
- const kind = fieldKinds[name];
1107
- const composite = kind === "object" || kind === "array";
1108
- const CustomInput = resolveFieldInput(studio, fieldDefs[name]);
1117
+ const field = fieldDefs[name]!;
1109
1118
  return (
1110
1119
  <Field key={name} label={fieldLabel(name)}>
1111
- {CustomInput ? (
1112
- <CustomInput
1113
- name={name}
1114
- field={fieldDefs[name]!}
1115
- value={frontmatter[name]}
1116
- readOnly={aiEditing || parseFailed}
1117
- onChange={(next) => {
1118
- markEdited();
1119
- setFrontmatter((prev) => ({ ...prev, [name]: next }));
1120
- }}
1121
- />
1122
- ) : kind === "media" ? (
1123
- <MediaFieldInput
1124
- name={name}
1125
- value={frontmatter[name]}
1126
- readOnly={aiEditing || parseFailed}
1127
- onChange={(next) => {
1128
- markEdited();
1129
- setFrontmatter((prev) => ({ ...prev, [name]: next }));
1130
- }}
1131
- />
1132
- ) : composite ? (
1133
- <JsonField
1134
- kind={kind === "array" ? "array" : "object"}
1135
- value={frontmatter[name]}
1136
- readOnly={aiEditing || parseFailed}
1137
- onValidChange={(next) => {
1138
- markEdited();
1139
- setFrontmatter((prev) => ({ ...prev, [name]: next }));
1140
- }}
1141
- />
1142
- ) : (
1143
- <Input
1144
- value={String(frontmatter[name] ?? "")}
1145
- readOnly={aiEditing || parseFailed}
1146
- onChange={(e) => {
1147
- markEdited();
1148
- setFrontmatter((prev) => ({
1149
- ...prev,
1150
- [name]: e.target.value,
1151
- }));
1152
- }}
1153
- />
1154
- )}
1120
+ <FieldControl
1121
+ name={name}
1122
+ field={field}
1123
+ value={frontmatter[name]}
1124
+ models={models}
1125
+ objects={objects}
1126
+ documentLists={documentLists}
1127
+ readOnly={aiEditing || parseFailed}
1128
+ blocksEditor={({ value, readOnly: blocksReadOnly, onChange: onBlocksChange }) => (
1129
+ <BlocksList
1130
+ blocks={blocksFromFrontmatter(value)}
1131
+ catalog={blocks}
1132
+ models={models}
1133
+ objects={objects}
1134
+ documentLists={documentLists}
1135
+ readOnly={blocksReadOnly}
1136
+ onChange={onBlocksChange}
1137
+ />
1138
+ )}
1139
+ onChange={(next) => {
1140
+ markEdited();
1141
+ setFrontmatter((prev) => omitField(prev, name, next));
1142
+ }}
1143
+ />
1155
1144
  </Field>
1156
1145
  );
1157
1146
  })
@@ -1160,6 +1149,9 @@ export function DocEditorPage() {
1160
1149
  <BlocksList
1161
1150
  blocks={blocksFromFrontmatter(frontmatter[blocksField])}
1162
1151
  catalog={blocks}
1152
+ models={models}
1153
+ objects={objects}
1154
+ documentLists={documentLists}
1163
1155
  readOnly={aiEditing || parseFailed}
1164
1156
  focusBlock={blockFocus}
1165
1157
  onChange={(next) => {
@@ -1171,6 +1163,9 @@ export function DocEditorPage() {
1171
1163
  <BodyField
1172
1164
  value={body}
1173
1165
  catalog={richtext}
1166
+ models={models}
1167
+ objects={objects}
1168
+ documentLists={documentLists}
1174
1169
  readOnly={aiEditing || parseFailed}
1175
1170
  onChange={(value) => {
1176
1171
  markEdited();
@@ -9,7 +9,7 @@ import {
9
9
  type ReactNode,
10
10
  } from "react";
11
11
  import * as stylex from "@stylexjs/stylex";
12
- import type { BlockSnapshot } from "../api";
12
+ import type { BlockSnapshot, SchemaField } from "../api";
13
13
  import { contentBlockFields, contentBlockLabel, contentBlockProps } from "../content-blocks";
14
14
  import { colors, radius, space } from "../styles/tokens.stylex";
15
15
  import {
@@ -40,16 +40,28 @@ export type RenderedBodyHandle = {
40
40
 
41
41
  const BODY_HOST = "body";
42
42
 
43
+ type ModelRef = { type: string; external?: boolean; fields?: Record<string, SchemaField> };
44
+ type RelationDocumentLists = Record<
45
+ string,
46
+ Array<{ slug: string; frontmatter: Record<string, unknown> }>
47
+ >;
48
+
43
49
  export const RenderedBody = forwardRef<
44
50
  RenderedBodyHandle,
45
51
  {
46
52
  value: string;
47
53
  catalog?: Array<BlockSnapshot & { label?: string }>;
54
+ models: ModelRef[];
55
+ objects?: Array<{ name: string; fields: Record<string, SchemaField> }>;
56
+ documentLists?: RelationDocumentLists;
48
57
  readOnly?: boolean;
49
58
  labelledBy: string;
50
59
  onChange: (value: string) => void;
51
60
  }
52
- >(function RenderedBody({ value, catalog = [], readOnly, labelledBy, onChange }, ref) {
61
+ >(function RenderedBody(
62
+ { value, catalog = [], models, objects = [], documentLists, readOnly, labelledBy, onChange },
63
+ ref,
64
+ ) {
53
65
  const local = useRef(value);
54
66
  const hosts = useRef(new Map<string, HTMLElement>());
55
67
  const linking = useRef(false);
@@ -218,6 +230,9 @@ export const RenderedBody = forwardRef<
218
230
  title={contentBlockLabel(settings.def.name)}
219
231
  fields={contentBlockFields(settings.def)}
220
232
  value={contentBlockProps(settings.block.markdown, settings.def.name)}
233
+ models={models}
234
+ objects={objects}
235
+ documentLists={documentLists}
221
236
  readOnly={readOnly}
222
237
  onOpenChange={(open) => {
223
238
  if (!open) setSettingsId(null);
@@ -0,0 +1,16 @@
1
+ // packages/studio/src/pages/branch-notice.ts
2
+
3
+ export type BranchNoticeKind = "update" | "edits" | "fold";
4
+
5
+ /** What to show when this session's commit is behind the branch tip. */
6
+ export function branchNoticeKind(input: {
7
+ baseSha: string | null;
8
+ headSha: string | null;
9
+ editCount: number;
10
+ foldPending: boolean;
11
+ }): BranchNoticeKind | null {
12
+ if (!input.baseSha || !input.headSha || input.baseSha === input.headSha) return null;
13
+ if (input.editCount > 0) return "edits";
14
+ if (input.foldPending) return "fold";
15
+ return "update";
16
+ }
@@ -13,6 +13,8 @@ export type CodeSessionContextValue = {
13
13
  sessionId: string;
14
14
  editCount: number;
15
15
  refresh: () => void;
16
+ /** The checkout is being replaced. Chat waits until the host boots. */
17
+ updating?: boolean;
16
18
  };
17
19
 
18
20
  const CodeSessionContext = createContext<CodeSessionContextValue | null>(null);
@@ -150,10 +150,20 @@ html[data-theme="dark"]:has(.backmatter-studio)
150
150
  outline-color: #fafafa;
151
151
  }
152
152
 
153
+ [data-no-ring]:focus,
153
154
  [data-no-ring]:focus-visible {
154
155
  outline: none;
156
+ outline-offset: 0;
157
+ box-shadow: none;
155
158
  }
156
159
 
160
+ :is(
161
+ .backmatter-studio,
162
+ [data-backmatter-panel],
163
+ [data-base-ui-portal],
164
+ [data-selection-bar]
165
+ )
166
+ [data-no-ring]:focus,
157
167
  :is(
158
168
  .backmatter-studio,
159
169
  [data-backmatter-panel],
@@ -161,8 +171,13 @@ html[data-theme="dark"]:has(.backmatter-studio)
161
171
  [data-selection-bar]
162
172
  )
163
173
  [data-no-ring]:focus-visible,
174
+ html[data-theme="dark"]:has(.backmatter-studio)
175
+ :is(.backmatter-studio, [data-base-ui-portal], [data-selection-bar])
176
+ [data-no-ring]:focus,
164
177
  html[data-theme="dark"]:has(.backmatter-studio)
165
178
  :is(.backmatter-studio, [data-base-ui-portal], [data-selection-bar])
166
179
  [data-no-ring]:focus-visible {
167
180
  outline: none;
181
+ outline-offset: 0;
182
+ box-shadow: none;
168
183
  }