@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/studio",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "description": "The Backmatter editing surface: document editor, AI chat, publish. Mounted on your site or hosted on the dashboard.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@ai-sdk/react": "^4.0.100",
59
- "@backmatter/core": "^0.0.33",
59
+ "@backmatter/core": "^0.0.34",
60
60
  "@base-ui/react": "^1.6.0",
61
61
  "@central-icons-react/round-outlined-radius-2-stroke-1.5": "1.2.15",
62
62
  "@dnd-kit/abstract": "^0.5.0",
package/src/api.ts CHANGED
@@ -356,6 +356,11 @@ export type SchemaField = {
356
356
  fields?: Record<string, SchemaField>;
357
357
  /** Studio input name from `field.text({ input })`; see `defineStudio`. */
358
358
  input?: string;
359
+ options?: string[];
360
+ min?: number;
361
+ max?: number;
362
+ step?: number;
363
+ where?: Record<string, string | number | boolean>;
359
364
  };
360
365
 
361
366
  export type BlockSnapshot = {
@@ -496,6 +501,20 @@ export function fetchCodeSessionEdits(workspaceId: string, sessionId: string) {
496
501
  );
497
502
  }
498
503
 
504
+ export function updateCodeSessionBranch(workspaceId: string, sessionId: string) {
505
+ return api<{ ok: true; baseSha: string }>(
506
+ `/workspaces/${workspaceId}/code-sessions/${sessionId}/update`,
507
+ { method: "POST" },
508
+ );
509
+ }
510
+
511
+ export function clearCodeSessionEdits(workspaceId: string, sessionId: string) {
512
+ return api<{ ok: true; baseSha: string }>(
513
+ `/workspaces/${workspaceId}/code-sessions/${sessionId}/clear-edits`,
514
+ { method: "POST" },
515
+ );
516
+ }
517
+
499
518
  export function commitCodeSessionEdits(
500
519
  workspaceId: string,
501
520
  sessionId: string,
@@ -163,6 +163,12 @@ export function useChatSession(): ChatSessionValue {
163
163
  return ctx;
164
164
  }
165
165
 
166
+ /** False outside a chat provider. True while a turn is submitted or streaming. */
167
+ export function useChatBusy(): boolean {
168
+ const ctx = useContext(ChatSessionContext);
169
+ return ctx?.status === "streaming" || ctx?.status === "submitted";
170
+ }
171
+
166
172
  // ---------------------------------------------------------------------------
167
173
 
168
174
  const modelStorageKey = (ws: string) => `backmatter-chat-model:${ws}`;
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
3
3
  import * as stylex from "@stylexjs/stylex";
4
4
  import { docKey, type DocRef } from "../chrome/EditorFocusContext";
5
5
  import { colors, radius, space, type } from "../styles/tokens.stylex";
6
+ import { useCodeSessionContext } from "../pages/code-session-context";
6
7
  import { Tooltip } from "../ui/Tooltip";
7
8
  import { useChatSession, useChatUI } from "./ChatProvider";
8
9
  import { MentionPopover, type MentionKey } from "./MentionPopover";
@@ -64,7 +65,8 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
64
65
  const ref = useRef<HTMLTextAreaElement>(null);
65
66
  const boxRef = useRef<HTMLDivElement>(null);
66
67
  const mentionKeys = useRef<((key: MentionKey) => void) | null>(null);
67
- const busy = session.status === "streaming" || session.status === "submitted";
68
+ const updating = useCodeSessionContext()?.updating === true;
69
+ const busy = session.status === "streaming" || session.status === "submitted" || updating;
68
70
  const canSend = text.trim().length > 0 && !busy && ui.aiAvailable === true;
69
71
 
70
72
  // Auto-grow. Measuring must not leave a 1px swing: that resizes the thread
@@ -0,0 +1,115 @@
1
+ // packages/studio/src/chrome/Toast.tsx
2
+ import * as stylex from "@stylexjs/stylex";
3
+ import { colors, radius, space, type, z } from "../styles/tokens.stylex";
4
+
5
+ const hover = "@media (hover: hover)";
6
+
7
+ export type ToastProblem = {
8
+ file: string;
9
+ text: string;
10
+ };
11
+
12
+ /** A dismissible notice that sits over the page, not in the status bar. */
13
+ export function Toast({
14
+ title,
15
+ problems,
16
+ onDismiss,
17
+ }: {
18
+ title: string;
19
+ problems: ToastProblem[];
20
+ onDismiss: () => void;
21
+ }) {
22
+ return (
23
+ <div role="alert" {...stylex.props(styles.toast)}>
24
+ <div {...stylex.props(styles.head)}>
25
+ <p {...stylex.props(styles.title)}>{title}</p>
26
+ <button type="button" onClick={onDismiss} {...stylex.props(styles.dismiss)}>
27
+ Dismiss
28
+ </button>
29
+ </div>
30
+ <ul {...stylex.props(styles.list)}>
31
+ {problems.map((problem, index) => (
32
+ <li key={`${problem.file}-${index}`} {...stylex.props(styles.item)}>
33
+ {problem.file ? <span {...stylex.props(styles.file)}>{problem.file}</span> : null}
34
+ <span {...stylex.props(styles.text)}>{problem.text}</span>
35
+ </li>
36
+ ))}
37
+ </ul>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ const styles = stylex.create({
43
+ toast: {
44
+ position: "fixed",
45
+ zIndex: z.tooltip,
46
+ top: "44px",
47
+ right: space.xl,
48
+ boxSizing: "border-box",
49
+ display: "flex",
50
+ flexDirection: "column",
51
+ gap: space.md,
52
+ width: "min(380px, calc(100vw - 32px))",
53
+ maxHeight: "min(420px, calc(100vh - 64px))",
54
+ padding: space.lg,
55
+ borderWidth: 1,
56
+ borderStyle: "solid",
57
+ borderColor: colors.border,
58
+ borderRadius: radius.md,
59
+ backgroundColor: colors.bgSurface,
60
+ boxShadow: "0 8px 24px rgba(24, 24, 27, 0.12)",
61
+ color: colors.text,
62
+ },
63
+ head: {
64
+ display: "flex",
65
+ alignItems: "center",
66
+ justifyContent: "space-between",
67
+ gap: space.lg,
68
+ },
69
+ title: {
70
+ margin: 0,
71
+ fontSize: type.sm,
72
+ fontWeight: 600,
73
+ color: colors.danger,
74
+ },
75
+ list: {
76
+ margin: 0,
77
+ padding: 0,
78
+ overflow: "auto",
79
+ listStyle: "none",
80
+ display: "flex",
81
+ flexDirection: "column",
82
+ gap: space.md,
83
+ },
84
+ item: {
85
+ display: "flex",
86
+ flexDirection: "column",
87
+ gap: "2px",
88
+ minWidth: 0,
89
+ },
90
+ file: {
91
+ fontFamily: type.mono,
92
+ fontSize: type.xs,
93
+ color: colors.text,
94
+ },
95
+ text: {
96
+ fontSize: type.sm,
97
+ lineHeight: 1.45,
98
+ color: colors.textMuted,
99
+ },
100
+ dismiss: {
101
+ flexShrink: 0,
102
+ margin: 0,
103
+ padding: 0,
104
+ borderWidth: 0,
105
+ backgroundColor: "transparent",
106
+ color: colors.textMuted,
107
+ fontSize: type.xs,
108
+ cursor: "pointer",
109
+ [hover]: {
110
+ ":hover": {
111
+ color: colors.text,
112
+ },
113
+ },
114
+ },
115
+ });
@@ -26,7 +26,6 @@ import {
26
26
  import { contentDocPath, isWorkspaceCodePath } from "../workspace-path";
27
27
  import { useCodeSessionContext } from "../pages/code-session-context";
28
28
  import {
29
- commitErrorSummary,
30
29
  explainCommitError,
31
30
  problemsForRef,
32
31
  type CommitErrorView,
@@ -40,7 +39,9 @@ import { useEditorStatus } from "./EditorStatusContext";
40
39
  import { openableSites } from "./open-site";
41
40
  import { OpenSiteButton, showsOpenSite } from "./OpenSiteButton";
42
41
  import { GitBranchIcon } from "./icons";
42
+ import { pullFailureProblems, type PullProblem } from "./pull-failure";
43
43
  import { StatusBar, StatusItem, StatusSpacer } from "./StatusBar";
44
+ import { Toast } from "./Toast";
44
45
  import { replicaStore } from "../replica/instance";
45
46
  import { useReplicaSession } from "../replica/ReplicaProvider";
46
47
  import { useReplicaState } from "../replica/useReplica";
@@ -172,6 +173,7 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
172
173
  const [resolvingKey, setResolvingKey] = useState<string | null>(null);
173
174
  const [message, setMessage] = useState("");
174
175
  const [error, setError] = useState<string | null>(null);
176
+ const [pullProblems, setPullProblems] = useState<PullProblem[] | null>(null);
175
177
  const [commitFailure, setCommitFailure] = useState<CommitErrorView | null>(null);
176
178
  const onCodePage = isWorkspaceCodePath(location.pathname);
177
179
  const codeSession = useCodeSessionContext();
@@ -306,13 +308,15 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
306
308
  async function onPull() {
307
309
  setPulling(true);
308
310
  setError(null);
311
+ setPullProblems(null);
309
312
  try {
310
313
  await pullFromGithub(workspaceId);
311
314
  notifyHeadChanged();
312
315
  await reloadGit();
313
316
  setOpen(false);
314
317
  } catch (err) {
315
- setError(err instanceof Error ? err.message : "Pull failed");
318
+ setOpen(false);
319
+ setPullProblems(pullFailureProblems(err));
316
320
  } finally {
317
321
  setPulling(false);
318
322
  }
@@ -436,6 +440,10 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
436
440
  : "Commit";
437
441
 
438
442
  return (
443
+ <>
444
+ {pullProblems ? (
445
+ <Toast title="Pull failed" problems={pullProblems} onDismiss={() => setPullProblems(null)} />
446
+ ) : null}
439
447
  <StatusBar>
440
448
  <div {...stylex.props(styles.identity)}>
441
449
  {workspaceNav ? (
@@ -595,7 +603,7 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
595
603
  {error ? (
596
604
  <StatusItem tone="danger">{error}</StatusItem>
597
605
  ) : commitFailure ? (
598
- <StatusItem tone="danger">{commitErrorSummary(commitFailure)}</StatusItem>
606
+ <StatusItem tone="danger">Can't commit</StatusItem>
599
607
  ) : refreshError ? (
600
608
  <StatusItem tone="danger">{refreshError}</StatusItem>
601
609
  ) : null}
@@ -810,6 +818,7 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
810
818
  </Popover.Root>
811
819
  ) : null}
812
820
  </StatusBar>
821
+ </>
813
822
  );
814
823
  }
815
824
 
@@ -27,6 +27,9 @@ export function explainCommitError(err: unknown): CommitErrorView {
27
27
  if (apiErr.code === "github_not_bound") {
28
28
  return { kind: "message", text: "Connect GitHub first" };
29
29
  }
30
+ if (apiErr.code === "unknown_type") {
31
+ return { kind: "message", text: unknownTypeMessage(apiErr.details ?? []) };
32
+ }
30
33
  if (apiErr.code === "validation") {
31
34
  return { kind: "validation", files: groupValidation(validationLines(apiErr)) };
32
35
  }
@@ -52,6 +55,16 @@ export function problemsForRef(
52
55
  return view.files.find((file) => file.ref === ref)?.problems ?? [];
53
56
  }
54
57
 
58
+ function unknownTypeMessage(types: string[]): string {
59
+ if (types.length === 0) {
60
+ return "A file uses a model this workspace doesn't have, so it can't be committed.";
61
+ }
62
+ if (types.length === 1) {
63
+ return `${types[0]} isn't a model in this workspace. Pull couldn't load that model file, so this draft can't be committed.`;
64
+ }
65
+ return `${types.join(", ")} aren't models in this workspace. Pull couldn't load those model files, so these drafts can't be committed.`;
66
+ }
67
+
55
68
  function validationLines(err: ApiError): string[] {
56
69
  if (err.details && err.details.length > 0) return err.details;
57
70
  const body = err.message.replace(/^validation:\s*/, "");
@@ -0,0 +1,61 @@
1
+ // packages/studio/src/chrome/pull-failure.ts
2
+ import type { ApiError } from "../api";
3
+
4
+ export type PullProblem = {
5
+ /** Repo path, when the failure names a file. */
6
+ file: string;
7
+ text: string;
8
+ };
9
+
10
+ /** What the pull-failed notice says. One problem per file, in words. */
11
+ export function pullFailureProblems(err: unknown): PullProblem[] {
12
+ const lines = rawLines(err);
13
+ if (lines.length === 0) return [{ file: "", text: "Pull failed." }];
14
+ return lines.map(explainPullLine);
15
+ }
16
+
17
+ function rawLines(err: unknown): string[] {
18
+ const details = (err as ApiError).details;
19
+ if (Array.isArray(details) && details.length > 0 && details.every((line) => typeof line === "string")) {
20
+ return details;
21
+ }
22
+ if (err instanceof Error) {
23
+ const message = err.message.replace(/^github_import_failed:\s*/, "");
24
+ if (message && message !== "github_import_failed") return [message];
25
+ }
26
+ return [];
27
+ }
28
+
29
+ function explainPullLine(line: string): PullProblem {
30
+ const split = line.indexOf(": ");
31
+ const file = split === -1 ? "" : line.slice(0, split);
32
+ const rest = split === -1 ? line : line.slice(split + 2);
33
+
34
+ const renamed = rest.match(/defines name "([^"]+)" but the file is named "([^"]+)"/);
35
+ if (renamed) {
36
+ return {
37
+ file,
38
+ text: `The name is ${renamed[1]}, but the file is ${renamed[2]}. Rename one so they match.`,
39
+ };
40
+ }
41
+ if (/field\.locale/.test(rest)) {
42
+ return {
43
+ file,
44
+ text: "This file calls field.locale(), which was removed, so it didn't load.",
45
+ };
46
+ }
47
+ if (/import is not allowed/.test(rest)) {
48
+ return {
49
+ file,
50
+ text: "This file imports something other than defineModel, field, or an object, so it didn't load.",
51
+ };
52
+ }
53
+
54
+ const cleaned = rest
55
+ .replace(/^model "[^"]+" failed to evaluate:\s*/i, "")
56
+ .replace(/^object "[^"]+" failed to evaluate:\s*/i, "")
57
+ .replace(/TypeError:\s*/g, "")
58
+ .replace(/\s*\([^)]*\)/g, "")
59
+ .trim();
60
+ return { file, text: cleaned || "This file didn't load." };
61
+ }