@backmatter/studio 0.0.12 → 0.0.14

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/studio",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
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.26",
59
+ "@backmatter/core": "^0.0.28",
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
@@ -1,5 +1,4 @@
1
1
  // packages/studio/src/api.ts
2
- import { encodeDocLocale } from "@backmatter/core";
3
2
  import { replicaQuery, type ReplicaCache } from "./replica/cache";
4
3
  import type { ReplicaBody } from "./replica/types";
5
4
  import { apiCredentials, apiHeaders, apiUrl, reportUnauthorized } from "./studio-config";
@@ -160,7 +159,6 @@ export type WorkspaceDetail = {
160
159
  githubDefaultBranch: string | null;
161
160
  contentRoot: string | null;
162
161
  githubDirectory: string | null;
163
- locales: string[];
164
162
  siteUrls: string[];
165
163
  /** Last `@backmatter/studio` version each site reported, keyed by origin. */
166
164
  studioVersions?: Record<string, { version: string; seenAt: string }>;
@@ -399,7 +397,6 @@ export type ChangeStatus = "modified" | "added" | "conflict" | "deleted";
399
397
  export type DocStatus = "clean" | ChangeStatus;
400
398
 
401
399
  export type GitChange = {
402
- locale: string;
403
400
  type: string;
404
401
  slug: string;
405
402
  status: ChangeStatus;
@@ -474,7 +471,7 @@ export function commitWorkspace(workspaceId: string, message?: string) {
474
471
  export function defaultCommitMessage(changes: GitChange[]) {
475
472
  if (changes.length === 1) {
476
473
  const f = changes[0]!;
477
- return `backmatter: update ${f.type}/${f.slug} (${f.locale})`;
474
+ return `backmatter: update ${f.type}/${f.slug}`;
478
475
  }
479
476
  return `backmatter: update ${changes.length} files`;
480
477
  }
@@ -486,7 +483,6 @@ export function notifyWorkspaceChanged() {
486
483
 
487
484
  export type TreeChangeDetail = {
488
485
  workspaceId: string;
489
- locale: string;
490
486
  type: string;
491
487
  slug: string;
492
488
  status?: DocStatus;
@@ -512,12 +508,11 @@ export function notifyHeadChanged() {
512
508
  */
513
509
  export function publishCommittedFiles(
514
510
  workspaceId: string,
515
- files: Array<{ locale: string; type: string; slug: string; githubSha: string }>,
511
+ files: Array<{ type: string; slug: string; githubSha: string }>,
516
512
  ) {
517
513
  for (const file of files) {
518
514
  notifyTreeChanged({
519
515
  workspaceId,
520
- locale: file.locale,
521
516
  type: file.type,
522
517
  slug: file.slug,
523
518
  ...(file.githubSha ? { status: "clean" } : { remove: true }),
@@ -561,22 +556,18 @@ export async function importContent(workspaceId: string) {
561
556
  export const pullFromGithub = importContent;
562
557
 
563
558
  export type DocListItem = {
564
- locale: string;
565
559
  type: string;
566
560
  slug: string;
567
561
  status: DocStatus;
568
562
  };
569
563
 
570
- export { encodeDocLocale };
571
-
572
564
  export function docApiPath(
573
565
  workspaceId: string,
574
- locale: string,
575
566
  type: string,
576
567
  slug: string,
577
568
  suffix = "",
578
569
  ) {
579
- return `/workspaces/${workspaceId}/docs/${encodeURIComponent(encodeDocLocale(locale))}/${encodeURIComponent(type)}/${encodeURIComponent(slug)}${suffix}`;
570
+ return `/workspaces/${workspaceId}/docs/${encodeURIComponent(type)}/${encodeURIComponent(slug)}${suffix}`;
580
571
  }
581
572
 
582
573
  export function fetchDocs(workspaceId: string) {
@@ -605,7 +596,6 @@ export function fetchDocNav(
605
596
  if (input.limit !== undefined) qs.set("limit", String(input.limit));
606
597
  if (input.offset !== undefined) qs.set("offset", String(input.offset));
607
598
  if (input.current) {
608
- qs.set("locale", input.current.locale);
609
599
  qs.set("type", input.current.type);
610
600
  qs.set("slug", input.current.slug);
611
601
  }
@@ -614,13 +604,11 @@ export function fetchDocNav(
614
604
 
615
605
  export function fetchDoc(
616
606
  workspaceId: string,
617
- locale: string,
618
607
  type: string,
619
608
  slug: string,
620
609
  ) {
621
610
  return api<{
622
611
  doc: {
623
- locale: string;
624
612
  type: string;
625
613
  slug: string;
626
614
  branch: string;
@@ -643,7 +631,7 @@ export function fetchDoc(
643
631
  } | null;
644
632
  parseError?: string | null;
645
633
  };
646
- }>(docApiPath(workspaceId, locale, type, slug));
634
+ }>(docApiPath(workspaceId, type, slug));
647
635
  }
648
636
 
649
637
  /** `head` = committed on the branch; `tree` = my uncommitted working copy. */
@@ -652,14 +640,12 @@ export type ContentSource = "head" | "tree";
652
640
  /** Save into my working tree (autosave). */
653
641
  export function saveDoc(
654
642
  workspaceId: string,
655
- locale: string,
656
643
  type: string,
657
644
  slug: string,
658
645
  input: { frontmatter: Record<string, unknown>; body: string },
659
646
  ) {
660
647
  return api<{
661
648
  doc: {
662
- locale: string;
663
649
  type: string;
664
650
  slug: string;
665
651
  source: "tree";
@@ -667,7 +653,7 @@ export function saveDoc(
667
653
  headSha: string | null;
668
654
  raw: string;
669
655
  };
670
- }>(docApiPath(workspaceId, locale, type, slug), {
656
+ }>(docApiPath(workspaceId, type, slug), {
671
657
  method: "PUT",
672
658
  body: JSON.stringify(input),
673
659
  });
@@ -676,12 +662,11 @@ export function saveDoc(
676
662
  /** Drop my uncommitted copy; the doc reads from HEAD again. */
677
663
  export function revertDoc(
678
664
  workspaceId: string,
679
- locale: string,
680
665
  type: string,
681
666
  slug: string,
682
667
  ) {
683
668
  return api<{ ok: true }>(
684
- docApiPath(workspaceId, locale, type, slug, "/tree"),
669
+ docApiPath(workspaceId, type, slug, "/tree"),
685
670
  { method: "DELETE" },
686
671
  );
687
672
  }
@@ -689,13 +674,12 @@ export function revertDoc(
689
674
  /** Resolve a conflict: keep mine (rebase onto the new HEAD) or take theirs (revert). */
690
675
  export function resolveDoc(
691
676
  workspaceId: string,
692
- locale: string,
693
677
  type: string,
694
678
  slug: string,
695
679
  strategy: "mine" | "theirs",
696
680
  ) {
697
681
  return api<{ ok: true; status: DocStatus; headSha: string | null }>(
698
- docApiPath(workspaceId, locale, type, slug, "/resolve"),
682
+ docApiPath(workspaceId, type, slug, "/resolve"),
699
683
  { method: "POST", body: JSON.stringify({ strategy }) },
700
684
  );
701
685
  }
@@ -729,6 +713,7 @@ export type ChatChangeDiff = {
729
713
  added: number;
730
714
  removed: number;
731
715
  renamedFrom?: string;
716
+ renamedFromLocale?: string;
732
717
  hunks: Array<{
733
718
  oldStart: number;
734
719
  oldLines: number;
@@ -28,7 +28,9 @@ export function ChangeCard({
28
28
  }) {
29
29
  const [expanded, setExpanded] = useState(false);
30
30
  const nav = useWorkspaceNav();
31
- const label = `${change.type}/${change.slug}`;
31
+ const label = change.locale
32
+ ? `${change.locale}/${change.type}/${change.slug}`
33
+ : `${change.type}/${change.slug}`;
32
34
  const to = nav
33
35
  ? contentDocPath(
34
36
  nav.workspaceSlug,
@@ -69,7 +71,9 @@ export function ChangeCard({
69
71
  )}
70
72
  {change.diff?.renamedFrom ? (
71
73
  <span {...stylex.props(styles.badge)}>
72
- {change.diff.renamedFrom} → {change.slug}
74
+ {change.diff.renamedFromLocale
75
+ ? `${change.diff.renamedFromLocale}/${change.diff.renamedFrom} → ${change.locale}/${change.slug}`
76
+ : `${change.diff.renamedFrom} → ${change.slug}`}
73
77
  </span>
74
78
  ) : change.op === "create" ? (
75
79
  <span {...stylex.props(styles.badge)}>new</span>
@@ -19,6 +19,7 @@ import {
19
19
  stackChanges,
20
20
  toolLabel,
21
21
  trimPartialPlaceholder,
22
+ changesInToolOutput,
22
23
  type WriteToolOutput,
23
24
  } from "./chat-helpers";
24
25
  import { CheckIcon, EyeIcon, FileIcon, SearchIcon, UndoIcon } from "./icons";
@@ -256,13 +257,7 @@ export function ChatMessage({
256
257
  const failed =
257
258
  tp.state === "output-error" ||
258
259
  (out !== null && (write || commit) && !out.ok);
259
- const outChange = write && out && out.ok ? out.change : null;
260
- const change = outChange
261
- ? {
262
- ...(session.changes[outChange.id] ?? outChange),
263
- summary: session.changes[outChange.id]?.summary ?? outChange.summary,
264
- }
265
- : null;
260
+ const written = write && out && out.ok ? changesInToolOutput(out) : [];
266
261
  // Doc lists placed inline via [[docs]] are not repeated here. While
267
262
  // streaming, the placeholder usually arrives a moment after the tool
268
263
  // result, so hold the fallback back until the stream has ended;
@@ -271,7 +266,14 @@ export function ChatMessage({
271
266
  tp.state === "output-available" && !inlined.has(i) && !streaming
272
267
  ? docListFromTool(name, tp.output)
273
268
  : null;
274
- const viewStatus = change ? presentedChangeStatus(change, stack) : null;
269
+ const cards = written.map((raw) => {
270
+ const change = {
271
+ ...(session.changes[raw.id] ?? raw),
272
+ summary: session.changes[raw.id]?.summary ?? raw.summary,
273
+ };
274
+ const viewStatus = presentedChangeStatus(change, stack);
275
+ return { change, viewStatus };
276
+ });
275
277
  return (
276
278
  <Reveal key={tp.toolCallId} active={streaming} origin="start">
277
279
  <div {...stylex.props(styles.toolBlock)}>
@@ -294,16 +296,19 @@ export function ChatMessage({
294
296
  emptyLabel={name === "searchDocs" ? "No matches" : "No documents"}
295
297
  />
296
298
  ) : null}
297
- {change && viewStatus ? (
298
- <ChangeCard
299
- change={{ ...change, status: viewStatus }}
300
- workspaceId={workspaceId}
301
- busy={busyId === change.id}
302
- canAct={canAct && !streaming}
303
- onKeep={() => void act(change.id, () => session.keep(change.id))}
304
- onUndo={() => void act(change.id, () => session.undo(change.id))}
305
- />
306
- ) : null}
299
+ {cards.map(({ change, viewStatus }) =>
300
+ viewStatus ? (
301
+ <ChangeCard
302
+ key={change.id}
303
+ change={{ ...change, status: viewStatus }}
304
+ workspaceId={workspaceId}
305
+ busy={busyId === change.id}
306
+ canAct={canAct && !streaming}
307
+ onKeep={() => void act(change.id, () => session.keep(change.id))}
308
+ onUndo={() => void act(change.id, () => session.undo(change.id))}
309
+ />
310
+ ) : null,
311
+ )}
307
312
  </div>
308
313
  </Reveal>
309
314
  );
@@ -56,9 +56,10 @@ import {
56
56
  import {
57
57
  changeIdsInMessage,
58
58
  isWriteTool,
59
+ changesInToolOutput,
60
+ detailForTreePatch,
59
61
  treeStatusPatches,
60
62
  type TreeStatusPatch,
61
- type WriteToolOutput,
62
63
  } from "./chat-helpers";
63
64
  import {
64
65
  initialPanelState,
@@ -158,13 +159,7 @@ const modelStorageKey = (ws: string) => `backmatter-chat-model:${ws}`;
158
159
  function publishTreePatches(workspaceId: string, patches: TreeStatusPatch[]) {
159
160
  let published = false;
160
161
  for (const patch of patches) {
161
- notifyTreeChanged({
162
- workspaceId,
163
- locale: patch.locale,
164
- type: patch.type,
165
- slug: patch.slug,
166
- ...(patch.absent ? { remove: true } : { status: patch.status }),
167
- });
162
+ notifyTreeChanged(detailForTreePatch(workspaceId, patch));
168
163
  // A published file stays on screen; head-changed reloads it once. A file
169
164
  // that disappeared still needs the open editor to drop it.
170
165
  if (patch.absent || patch.status !== "clean") {
@@ -173,6 +168,9 @@ function publishTreePatches(workspaceId: string, patches: TreeStatusPatch[]) {
173
168
  if (patch.status === "clean") published = true;
174
169
  }
175
170
  if (published) notifyHeadChanged();
171
+ // The per-file patch updates the tree immediately. A refetch picks up a
172
+ // file the patch missed, and drops one the server no longer lists.
173
+ notifyTreeChanged();
176
174
  }
177
175
 
178
176
  function refFromInput(input: unknown): DocRef | null {
@@ -636,9 +634,13 @@ function LiveSession({
636
634
  seen.add(part.toolCallId);
637
635
  const patches = treeStatusPatches(part.output);
638
636
  if (patches.length > 0) publishTreePatches(workspaceId, patches);
639
- const out = part.output as WriteToolOutput | undefined;
640
- if (write && out?.ok) {
641
- setChanges((prev) => ({ ...prev, [out.change.id]: out.change }));
637
+ const found = write ? changesInToolOutput(part.output) : [];
638
+ if (found.length > 0) {
639
+ setChanges((prev) => {
640
+ const next = { ...prev };
641
+ for (const change of found) next[change.id] = change;
642
+ return next;
643
+ });
642
644
  if (patches.length === 0) {
643
645
  if (ref) notifyDocChanged(ref);
644
646
  notifyTreeChanged();
@@ -7,11 +7,28 @@ import type { ChatChangeSummary, ChatThread, DocStatus, ChatChangeStatus } from
7
7
  import type { DocRef } from "../chrome/EditorFocusContext";
8
8
 
9
9
  export type WriteToolOutput =
10
- | { ok: true; change: ChatChangeSummary }
10
+ | { ok: true; change?: ChatChangeSummary; changes?: ChatChangeSummary[] }
11
11
  | { ok: false; error: string; message: string };
12
12
 
13
13
  export function isWriteTool(name: string) {
14
- return name === "writeDoc" || name === "createDoc" || name === "renameDoc";
14
+ return (
15
+ name === "writeDoc" ||
16
+ name === "createDoc" ||
17
+ name === "renameDoc" ||
18
+ name === "deleteDoc" ||
19
+ name === "deleteFolder" ||
20
+ name === "moveFolder"
21
+ );
22
+ }
23
+
24
+ /** One write tool may record several files, as a folder move or delete does. */
25
+ export function changesInToolOutput(output: unknown): ChatChangeSummary[] {
26
+ if (!output || typeof output !== "object") return [];
27
+ const body = output as { ok?: boolean; change?: ChatChangeSummary; changes?: ChatChangeSummary[] };
28
+ if (body.ok === false) return [];
29
+ const many = Array.isArray(body.changes) ? body.changes.filter((change) => change?.id) : [];
30
+ if (many.length > 0) return many;
31
+ return body.change?.id ? [body.change] : [];
15
32
  }
16
33
 
17
34
  export function isCommitTool(name: string) {
@@ -39,7 +56,6 @@ export function treeStatusPatches(output: unknown): TreeStatusPatch[] {
39
56
  const item = row as Partial<TreeStatusPatch>;
40
57
  if (
41
58
  typeof item.locale !== "string" ||
42
- !item.locale ||
43
59
  typeof item.type !== "string" ||
44
60
  !item.type ||
45
61
  typeof item.slug !== "string" ||
@@ -60,6 +76,28 @@ export function treeStatusPatches(output: unknown): TreeStatusPatch[] {
60
76
  return patches;
61
77
  }
62
78
 
79
+ /** A deleted or absent row leaves the content tree. Other rows update status. */
80
+ export function detailForTreePatch(
81
+ workspaceId: string,
82
+ patch: TreeStatusPatch,
83
+ ): {
84
+ workspaceId: string;
85
+ locale: string;
86
+ type: string;
87
+ slug: string;
88
+ status?: TreeStatusPatch["status"];
89
+ remove?: boolean;
90
+ } {
91
+ const gone = patch.absent === true || patch.status === "deleted";
92
+ return {
93
+ workspaceId,
94
+ locale: patch.locale,
95
+ type: patch.type,
96
+ slug: patch.slug,
97
+ ...(gone ? { remove: true } : { status: patch.status }),
98
+ };
99
+ }
100
+
63
101
  export function toolLabel(name: string, input: unknown, output: unknown, state: string): string {
64
102
  const ref = (input as { docRef?: { type?: string; slug?: string } } | undefined)?.docRef;
65
103
  const doc = ref?.type && ref.slug ? `${ref.type}/${ref.slug}` : "";
@@ -84,6 +122,17 @@ export function toolLabel(name: string, input: unknown, output: unknown, state:
84
122
  return running ? `Creating ${doc}…` : `Created ${doc}`;
85
123
  case "renameDoc":
86
124
  return running ? `Renaming ${doc}…` : `Renamed ${doc}`;
125
+ case "deleteDoc":
126
+ return running ? `Deleting ${doc}…` : `Deleted ${doc}`;
127
+ case "deleteFolder": {
128
+ const path = (input as { path?: string } | undefined)?.path ?? "folder";
129
+ return running ? `Deleting ${path}…` : `Deleted ${path}`;
130
+ }
131
+ case "moveFolder": {
132
+ const from = (input as { from?: string } | undefined)?.from ?? "folder";
133
+ const to = (input as { to?: string } | undefined)?.to ?? "";
134
+ return running ? `Moving ${from}…` : `Moved ${from} to ${to}`;
135
+ }
87
136
  case "commit": {
88
137
  if (running) return "Publishing…";
89
138
  const published = output as { ok?: boolean; files?: unknown[] } | undefined;
@@ -221,8 +270,7 @@ export function changeIdsInMessage(message: UIMessage): string[] {
221
270
  if (!isToolUIPart(part)) continue;
222
271
  if (!isWriteTool(getToolName(part))) continue;
223
272
  if (part.state !== "output-available") continue;
224
- const out = part.output as WriteToolOutput | undefined;
225
- if (out?.ok) ids.push(out.change.id);
273
+ for (const change of changesInToolOutput(part.output)) ids.push(change.id);
226
274
  }
227
275
  return ids;
228
276
  }
@@ -267,9 +315,9 @@ export function stackChanges(
267
315
  for (const part of message.parts) {
268
316
  if (!isToolUIPart(part) || part.state !== "output-available") continue;
269
317
  if (!isWriteTool(getToolName(part))) continue;
270
- const out = part.output as WriteToolOutput | undefined;
271
- if (!out?.ok) continue;
272
- byId.set(out.change.id, out.change);
318
+ for (const change of changesInToolOutput(part.output)) {
319
+ byId.set(change.id, change);
320
+ }
273
321
  }
274
322
  }
275
323
  for (const change of Object.values(overrides)) {