@backmatter/studio 0.0.2 → 0.0.3

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.2",
3
+ "version": "0.0.3",
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": {
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@ai-sdk/react": "^4.0.100",
50
- "@backmatter/core": "^0.0.20",
50
+ "@backmatter/core": "^0.0.21",
51
51
  "@base-ui/react": "^1.6.0",
52
52
  "@dnd-kit/abstract": "^0.5.0",
53
53
  "@dnd-kit/react": "^0.5.0",
package/src/api.ts CHANGED
@@ -261,6 +261,10 @@ export function attachGithubInstallation(
261
261
  });
262
262
  }
263
263
 
264
+ export function fetchGithubRepositoryAccess(workspaceId: string) {
265
+ return api<{ url: string }>(`/workspaces/${workspaceId}/github/repository-access`);
266
+ }
267
+
264
268
  export function fetchGithubRepos(workspaceId: string) {
265
269
  return api<{
266
270
  repos: Array<{ fullName: string; defaultBranch: string }>;
@@ -55,7 +55,7 @@ export function ChatPanel({ fill = false }: { fill?: boolean } = {}) {
55
55
  // one row so toggling the open document doesn't shift the content.
56
56
  const contextRows = Math.max(
57
57
  1,
58
- (ui.focus && ui.includeFocus ? 1 : 0) + ui.mentions.length,
58
+ (ui.focus && ui.includeFocus ? 1 : 0) + ui.mentions.length + ui.pointers.length,
59
59
  );
60
60
  const contextReserve = contextRows * CONTEXT_ROW_HEIGHT + CONTEXT_BAR_PADDING + 12;
61
61
 
@@ -46,8 +46,11 @@ import {
46
46
  } from "../chrome/EditorFocusContext";
47
47
  import {
48
48
  addMention as addMentionTo,
49
+ addPointer as addPointerTo,
49
50
  buildChatContext,
50
51
  removeMention as removeMentionFrom,
52
+ removePointer as removePointerFrom,
53
+ type ChatPointer,
51
54
  type ChatRequestContext,
52
55
  } from "./chat-context";
53
56
  import {
@@ -85,6 +88,12 @@ export type ChatUIValue = {
85
88
  mentions: DocRef[];
86
89
  addMention: (ref: DocRef) => void;
87
90
  removeMention: (ref: DocRef) => void;
91
+ /** Elements pointed at on the host site. Only the dev bar chat sets `pointer`. */
92
+ pointer: boolean;
93
+ pointers: ChatPointer[];
94
+ addPointer: (pointer: ChatPointer) => void;
95
+ removePointer: (pointer: ChatPointer) => void;
96
+ clearPointers: () => void;
88
97
  includeFocus: boolean;
89
98
  setIncludeFocus: (v: boolean) => void;
90
99
  focus: EditorFocus | null;
@@ -171,10 +180,13 @@ function refFromInput(input: unknown): DocRef | null {
171
180
  export function ChatProvider({
172
181
  workspaceId,
173
182
  startOpen = false,
183
+ pointer = false,
174
184
  children,
175
185
  }: {
176
186
  workspaceId: string;
177
187
  startOpen?: boolean;
188
+ /** Dev bar chat can point at elements on the host page. */
189
+ pointer?: boolean;
178
190
  children: React.ReactNode;
179
191
  }) {
180
192
  const [panel, dispatch] = useReducer(
@@ -189,6 +201,7 @@ export function ChatProvider({
189
201
  const [freshThread, setFreshThread] = useState(false);
190
202
  const [pendingText, setPendingText] = useState<string | null>(null);
191
203
  const [mentions, setMentions] = useState<DocRef[]>([]);
204
+ const [pointers, setPointers] = useState<ChatPointer[]>([]);
192
205
  const [includeFocus, setIncludeFocus] = useState(true);
193
206
  const [threadsVersion, setThreadsVersion] = useState(0);
194
207
  const { focus } = useEditorFocus();
@@ -223,6 +236,7 @@ export function ChatProvider({
223
236
  setFreshThread(false);
224
237
  setPendingText(null);
225
238
  setMentions([]);
239
+ setPointers([]);
226
240
  setIncludeFocus(true);
227
241
  }, [workspaceId]);
228
242
 
@@ -246,6 +260,7 @@ export function ChatProvider({
246
260
  setFreshThread(false);
247
261
  setPendingText(null);
248
262
  setMentions([]);
263
+ setPointers([]);
249
264
  dispatch({ type: "showChat" });
250
265
  }, []);
251
266
 
@@ -255,6 +270,13 @@ export function ChatProvider({
255
270
  const removeMention = useCallback((ref: DocRef) => {
256
271
  setMentions((prev) => removeMentionFrom(prev, ref));
257
272
  }, []);
273
+ const addPointer = useCallback((next: ChatPointer) => {
274
+ setPointers((prev) => addPointerTo(prev, next));
275
+ }, []);
276
+ const removePointer = useCallback((next: ChatPointer) => {
277
+ setPointers((prev) => removePointerFrom(prev, next));
278
+ }, []);
279
+ const clearPointers = useCallback(() => setPointers([]), []);
258
280
  const bumpThreads = useCallback(() => setThreadsVersion((v) => v + 1), []);
259
281
 
260
282
  // Context getter kept in a ref so the session never re-renders because of it.
@@ -262,8 +284,9 @@ export function ChatProvider({
262
284
  focusDoc: null,
263
285
  selection: null,
264
286
  mentions: [],
287
+ pointers: [],
265
288
  }));
266
- contextRef.current = () => buildChatContext({ focus, includeFocus, mentions });
289
+ contextRef.current = () => buildChatContext({ focus, includeFocus, mentions, pointers });
267
290
  const modelRef = useRef<string | null>(model);
268
291
  modelRef.current = model;
269
292
 
@@ -295,6 +318,11 @@ export function ChatProvider({
295
318
  mentions,
296
319
  addMention,
297
320
  removeMention,
321
+ pointer,
322
+ pointers,
323
+ addPointer,
324
+ removePointer,
325
+ clearPointers,
298
326
  includeFocus,
299
327
  setIncludeFocus,
300
328
  focus,
@@ -315,6 +343,11 @@ export function ChatProvider({
315
343
  mentions,
316
344
  addMention,
317
345
  removeMention,
346
+ pointer,
347
+ pointers,
348
+ addPointer,
349
+ removePointer,
350
+ clearPointers,
318
351
  includeFocus,
319
352
  focus,
320
353
  threadsVersion,
@@ -7,7 +7,8 @@ import { Tooltip } from "../ui/Tooltip";
7
7
  import { useChatSession, useChatUI } from "./ChatProvider";
8
8
  import { MentionPopover, type MentionKey } from "./MentionPopover";
9
9
  import { ModelPicker } from "./ModelPicker";
10
- import { AtIcon, CloseIcon, FileIcon, FocusIcon, SendIcon, StopIcon } from "./icons";
10
+ import { AtIcon, CloseIcon, FileIcon, FocusIcon, PointerIcon, SendIcon, StopIcon } from "./icons";
11
+ import type { ChatPointer } from "./chat-context";
11
12
  import { mentionQueryAt } from "./chat-helpers";
12
13
 
13
14
  const hover = "@media (hover: hover)";
@@ -22,11 +23,29 @@ function isMentionKey(key: string): key is MentionKey {
22
23
  return MENTION_KEYS.has(key);
23
24
  }
24
25
 
26
+ function pointerKey(pointer: ChatPointer): string {
27
+ return `${pointer.locale}/${pointer.type}/${pointer.slug}/${pointer.blockId ?? ""}/${pointer.quote}`;
28
+ }
29
+
30
+ function pointerLabel(pointer: ChatPointer): string {
31
+ const quote = pointer.quote.replace(/\s+/g, " ").trim();
32
+ const short = quote.length > 42 ? `${quote.slice(0, 41)}…` : quote;
33
+ if (pointer.blockType && short) return `${pointer.blockType} · “${short}”`;
34
+ if (pointer.blockType) return pointer.blockType;
35
+ return short ? `“${short}”` : "element";
36
+ }
37
+
38
+ function postPointer(on: boolean) {
39
+ if (typeof window === "undefined" || window.parent === window) return;
40
+ window.parent.postMessage({ type: "backmatter:pointer", on }, location.origin);
41
+ }
42
+
25
43
  export function Composer({ autoFocus }: { autoFocus: boolean }) {
26
44
  const ui = useChatUI();
27
45
  const session = useChatSession();
28
46
  const [text, setText] = useState("");
29
47
  const [mention, setMention] = useState<{ start: number; query: string } | null>(null);
48
+ const [pointing, setPointing] = useState(false);
30
49
  const ref = useRef<HTMLTextAreaElement>(null);
31
50
  const boxRef = useRef<HTMLDivElement>(null);
32
51
  const mentionKeys = useRef<((key: MentionKey) => void) | null>(null);
@@ -71,13 +90,30 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
71
90
  return () => window.removeEventListener("backmatter:chat-prefill", onPrefill);
72
91
  }, []);
73
92
 
93
+ useEffect(() => {
94
+ if (!ui.pointer) return;
95
+ function onMessage(event: MessageEvent) {
96
+ if (event.source !== window.parent || event.origin !== location.origin) return;
97
+ const data = event.data as { type?: string; on?: boolean };
98
+ if (data?.type !== "backmatter:pointer" || data.on !== false) return;
99
+ setPointing(false);
100
+ }
101
+ window.addEventListener("message", onMessage);
102
+ return () => window.removeEventListener("message", onMessage);
103
+ }, [ui.pointer]);
104
+
74
105
  const submit = useCallback(() => {
75
106
  const value = text.trim();
76
107
  if (!value || busy) return;
77
108
  setText("");
78
109
  setMention(null);
110
+ if (pointing) {
111
+ setPointing(false);
112
+ postPointer(false);
113
+ }
79
114
  void session.send(value);
80
- }, [text, busy, session]);
115
+ ui.clearPointers();
116
+ }, [text, busy, session, ui, pointing]);
81
117
 
82
118
  function updateMention(el: HTMLTextAreaElement) {
83
119
  setMention(mentionQueryAt(el.value, el.selectionStart ?? el.value.length));
@@ -101,7 +137,7 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
101
137
  const focusChip = ui.focus && ui.includeFocus ? ui.focus.docRef : null;
102
138
  const selectionActive =
103
139
  ui.focus?.selection && ui.focus.selection.end > ui.focus.selection.start;
104
- const hasContext = Boolean(focusChip || ui.mentions.length > 0);
140
+ const hasContext = Boolean(focusChip || ui.mentions.length > 0 || ui.pointers.length > 0);
105
141
 
106
142
  return (
107
143
  <div {...stylex.props(styles.wrap)}>
@@ -138,6 +174,16 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
138
174
  onRemove={() => ui.removeMention(m)}
139
175
  />
140
176
  ))}
177
+ {ui.pointers.map((pointer) => (
178
+ <ContextRow
179
+ key={pointerKey(pointer)}
180
+ icon={<PointerIcon size={13} />}
181
+ label={pointerLabel(pointer)}
182
+ meta={`${pointer.type}/${pointer.slug}`}
183
+ removeLabel={`Remove ${pointerLabel(pointer)}`}
184
+ onRemove={() => ui.removePointer(pointer)}
185
+ />
186
+ ))}
141
187
  </div>
142
188
  ) : null}
143
189
  <div ref={boxRef} {...stylex.props(styles.box)}>
@@ -170,6 +216,11 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
170
216
  e.preventDefault();
171
217
  submit();
172
218
  } else if (e.key === "Escape") {
219
+ if (pointing) {
220
+ setPointing(false);
221
+ postPointer(false);
222
+ return;
223
+ }
173
224
  ui.dispatch({ type: "minimize" });
174
225
  }
175
226
  }}
@@ -222,6 +273,24 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
222
273
  </button>
223
274
  </Tooltip>
224
275
  ) : null}
276
+ {ui.pointer ? (
277
+ <Tooltip label={pointing ? "Stop pointing" : "Point at an element on the page"}>
278
+ <button
279
+ type="button"
280
+ aria-label="Point at an element on the page"
281
+ aria-pressed={pointing}
282
+ disabled={ui.aiAvailable !== true}
283
+ onClick={() => {
284
+ const next = !pointing;
285
+ setPointing(next);
286
+ postPointer(next);
287
+ }}
288
+ {...stylex.props(styles.iconBtn, pointing && styles.iconBtnActive)}
289
+ >
290
+ <PointerIcon />
291
+ </button>
292
+ </Tooltip>
293
+ ) : null}
225
294
  <ModelPicker />
226
295
  {busy ? (
227
296
  <Tooltip label="Stop generating">
@@ -257,13 +326,15 @@ function ContextRow({
257
326
  icon,
258
327
  type,
259
328
  slug,
329
+ label,
260
330
  meta,
261
331
  removeLabel,
262
332
  onRemove,
263
333
  }: {
264
334
  icon: React.ReactNode;
265
- type: string;
266
- slug: string;
335
+ type?: string;
336
+ slug?: string;
337
+ label?: string;
267
338
  meta?: string;
268
339
  removeLabel: string;
269
340
  onRemove: () => void;
@@ -272,8 +343,14 @@ function ContextRow({
272
343
  <div {...stylex.props(styles.row)}>
273
344
  <span {...stylex.props(styles.rowIcon)}>{icon}</span>
274
345
  <span {...stylex.props(styles.rowText)}>
275
- <span {...stylex.props(styles.rowType)}>{type}/</span>
276
- <span {...stylex.props(styles.rowSlug)}>{slug}</span>
346
+ {label ? (
347
+ <span {...stylex.props(styles.rowSlug)}>{label}</span>
348
+ ) : (
349
+ <>
350
+ <span {...stylex.props(styles.rowType)}>{type}/</span>
351
+ <span {...stylex.props(styles.rowSlug)}>{slug}</span>
352
+ </>
353
+ )}
277
354
  </span>
278
355
  {meta ? <span {...stylex.props(styles.rowMeta)}>{meta}</span> : null}
279
356
  <button
@@ -5,17 +5,77 @@ import {
5
5
  type EditorFocus,
6
6
  } from "../chrome/EditorFocusContext";
7
7
 
8
+ /** An element the user pointed at on the site. The agent edits that block, or the quote. */
9
+ export type ChatPointer = {
10
+ locale: string;
11
+ type: string;
12
+ slug: string;
13
+ blockId: string | null;
14
+ blockType: string | null;
15
+ blockIndex: number | null;
16
+ quote: string;
17
+ };
18
+
19
+ export const POINTER_CAP = 8;
20
+
8
21
  /** Request context sent with every chat message (mirrors the server's ChatRequestContext). */
9
22
  export type ChatRequestContext = {
10
23
  focusDoc: DocRef | null;
11
24
  selection: { start: number; end: number } | null;
12
25
  mentions: DocRef[];
26
+ pointers: ChatPointer[];
13
27
  };
14
28
 
29
+ function samePointer(a: ChatPointer, b: ChatPointer): boolean {
30
+ return (
31
+ a.locale === b.locale &&
32
+ a.type === b.type &&
33
+ a.slug === b.slug &&
34
+ a.blockId === b.blockId &&
35
+ a.quote === b.quote
36
+ );
37
+ }
38
+
39
+ export function addPointer(list: ChatPointer[], next: ChatPointer): ChatPointer[] {
40
+ if (list.some((item) => samePointer(item, next))) return list;
41
+ const added = [...list, next];
42
+ return added.length > POINTER_CAP ? added.slice(added.length - POINTER_CAP) : added;
43
+ }
44
+
45
+ export function removePointer(list: ChatPointer[], next: ChatPointer): ChatPointer[] {
46
+ return list.filter((item) => !samePointer(item, next));
47
+ }
48
+
49
+ export function chatPointerFromGrab(
50
+ doc: { locale?: unknown; type?: unknown; slug?: unknown },
51
+ grab: unknown,
52
+ ): ChatPointer | null {
53
+ if (!doc || typeof doc.locale !== "string" || typeof doc.type !== "string" || typeof doc.slug !== "string") {
54
+ return null;
55
+ }
56
+ if (!doc.locale || !doc.type || !doc.slug) return null;
57
+ if (!grab || typeof grab !== "object") return null;
58
+ const value = grab as Record<string, unknown>;
59
+ if (value.blockId !== null && typeof value.blockId !== "string") return null;
60
+ if (value.blockType !== null && typeof value.blockType !== "string") return null;
61
+ if (value.blockIndex !== null && typeof value.blockIndex !== "number") return null;
62
+ if (typeof value.quote !== "string") return null;
63
+ return {
64
+ locale: doc.locale,
65
+ type: doc.type,
66
+ slug: doc.slug,
67
+ blockId: value.blockId,
68
+ blockType: value.blockType,
69
+ blockIndex: value.blockIndex,
70
+ quote: value.quote.replace(/\s+/g, " ").trim().slice(0, 240),
71
+ };
72
+ }
73
+
15
74
  export function buildChatContext(input: {
16
75
  focus: EditorFocus | null;
17
76
  includeFocus: boolean;
18
77
  mentions: DocRef[];
78
+ pointers: ChatPointer[];
19
79
  }): ChatRequestContext {
20
80
  const focusDoc = input.includeFocus && input.focus ? input.focus.docRef : null;
21
81
  const selection =
@@ -28,7 +88,12 @@ export function buildChatContext(input: {
28
88
  if (mentions.some((x) => sameDocRef(x, m))) continue;
29
89
  mentions.push({ locale: m.locale, type: m.type, slug: m.slug });
30
90
  }
31
- return { focusDoc, selection, mentions };
91
+ const pointers: ChatPointer[] = [];
92
+ for (const pointer of input.pointers) {
93
+ if (pointers.some((item) => samePointer(item, pointer))) continue;
94
+ pointers.push(pointer);
95
+ }
96
+ return { focusDoc, selection, mentions, pointers: pointers.slice(-POINTER_CAP) };
32
97
  }
33
98
 
34
99
  export function addMention(list: DocRef[], ref: DocRef): DocRef[] {
@@ -158,6 +158,16 @@ export function EyeIcon({ size = 14 }: Props) {
158
158
  );
159
159
  }
160
160
 
161
+ /** Crosshair, used to point at an element on the site. */
162
+ export function PointerIcon({ size = 14 }: Props) {
163
+ return (
164
+ <svg {...base(size)}>
165
+ <circle cx="8" cy="8" r="2.2" />
166
+ <path d="M8 1.8v2.6M8 11.6v2.6M1.8 8h2.6M11.6 8h2.6" />
167
+ </svg>
168
+ );
169
+ }
170
+
161
171
  /** Viewfinder corners, used for the include-open-document toggle. */
162
172
  export function FocusIcon({ size = 14 }: Props) {
163
173
  return (
@@ -1,10 +1,15 @@
1
1
  // packages/studio/src/chat/session-expiry.ts
2
2
  // The chat send does not go through the Studio API helper, so a dead site
3
3
  // session would otherwise show up as a composer error. Tell the parent page,
4
- // which holds the bar, so it can sign the editor back in.
4
+ // which holds the bar, so it can drop the editor without navigating.
5
5
 
6
6
  export const SESSION_EXPIRED_MESSAGE = "backmatter:session-expired";
7
7
 
8
+ /** A framed panel tells its parent. The top window goes to the dashboard. */
9
+ export function lostSessionTarget(framed: boolean): "parent" | "dashboard" {
10
+ return framed ? "parent" : "dashboard";
11
+ }
12
+
8
13
  export function fetchNotifyingSessionExpiry(
9
14
  origin: string,
10
15
  parent: { postMessage: (message: unknown, targetOrigin: string) => void } | null,
package/src/index.css CHANGED
@@ -58,8 +58,16 @@ button[aria-disabled="true"] {
58
58
  cursor: grabbing;
59
59
  }
60
60
 
61
+ /* StyleX colors live in @layer priorityN, which the Vite config orders after
62
+ `reset`. An unlayered color on `a` would beat them, so a primary link
63
+ button inherits dark text on a dark fill and the label disappears. */
64
+ @layer reset {
65
+ a {
66
+ color: inherit;
67
+ }
68
+ }
69
+
61
70
  a {
62
- color: inherit;
63
71
  text-decoration: none;
64
72
  cursor: default;
65
73
  }
@@ -2,6 +2,7 @@
2
2
  // The CMS chat, alone, so a site can frame it. The parent posts the open document.
3
3
  import { useEffect, useRef } from "react";
4
4
  import { ChatPanel } from "../chat/ChatPanel";
5
+ import { chatPointerFromGrab } from "../chat/chat-context";
5
6
  import { ChatProvider, ChatSessionScope, useChatUI } from "../chat/ChatProvider";
6
7
  import { isPanelVisible } from "../chat/chat-state";
7
8
  import {
@@ -19,9 +20,10 @@ export function EmbedChatPage({ workspaceId }: { workspaceId: string }) {
19
20
  return (
20
21
  <TooltipProvider>
21
22
  <EditorFocusProvider>
22
- <ChatProvider workspaceId={workspaceId} startOpen>
23
+ <ChatProvider workspaceId={workspaceId} startOpen pointer>
23
24
  <ChatSessionScope>
24
25
  <FocusBridge />
26
+ <PointerBridge />
25
27
  <ChatVisibilityBridge />
26
28
  <ChatPanel fill />
27
29
  </ChatSessionScope>
@@ -77,6 +79,28 @@ function FocusBridge() {
77
79
  return null;
78
80
  }
79
81
 
82
+ function PointerBridge() {
83
+ const { addPointer } = useChatUI();
84
+
85
+ useEffect(() => {
86
+ const origin = parentOrigin();
87
+ function onMessage(event: MessageEvent) {
88
+ if (!origin || event.origin !== origin || event.source !== window.parent) return;
89
+ const data = event.data as { type?: string; grab?: unknown; doc?: unknown };
90
+ if (data?.type !== "backmatter:pointer-grab") return;
91
+ const pointer = chatPointerFromGrab(
92
+ (data.doc ?? {}) as { locale?: unknown; type?: unknown; slug?: unknown },
93
+ data.grab,
94
+ );
95
+ if (pointer) addPointer(pointer);
96
+ }
97
+ window.addEventListener("message", onMessage);
98
+ return () => window.removeEventListener("message", onMessage);
99
+ }, [addPointer]);
100
+
101
+ return null;
102
+ }
103
+
80
104
  function ChatVisibilityBridge() {
81
105
  const { panel, dispatch } = useChatUI();
82
106
  const mode = panel.mode;
@@ -2,6 +2,7 @@
2
2
  import { useEffect, useState } from "react";
3
3
  import { useNavigate, useOutletContext } from "react-router";
4
4
  import { bindGithub, fetchGithubRepos, type ApiError } from "../api";
5
+ import { SelectRepositoriesLink } from "./SelectRepositoriesLink";
5
6
  import { BackLink } from "../ui/BackLink";
6
7
  import { Button, ButtonLink } from "../ui/Button";
7
8
  import { Field } from "../ui/Field";
@@ -30,24 +31,43 @@ export function GithubConnectPage() {
30
31
 
31
32
  useEffect(() => {
32
33
  if (!workspaceId) return;
33
- void fetchGithubRepos(workspaceId)
34
- .then((data) => {
35
- setRepos(data.repos);
36
- setSelected((current) =>
37
- current && data.repos.some((repo) => repo.fullName === current)
38
- ? current
39
- : (data.repos[0]?.fullName ?? ""),
40
- );
41
- })
42
- .catch((err: Error) => {
43
- const code = err.message;
44
- setError(
45
- code === "github_not_installed" || code === "github_list_failed"
46
- ? "The saved GitHub installation is missing or this app cannot access it."
47
- : code,
48
- );
49
- })
50
- .finally(() => setLoading(false));
34
+ let cancelled = false;
35
+
36
+ function load() {
37
+ void fetchGithubRepos(workspaceId)
38
+ .then((data) => {
39
+ if (cancelled) return;
40
+ setRepos(data.repos);
41
+ setSelected((current) =>
42
+ current && data.repos.some((repo) => repo.fullName === current)
43
+ ? current
44
+ : (data.repos[0]?.fullName ?? ""),
45
+ );
46
+ setError(null);
47
+ })
48
+ .catch((err: Error) => {
49
+ if (cancelled) return;
50
+ const code = err.message;
51
+ setError(
52
+ code === "github_not_installed" || code === "github_list_failed"
53
+ ? "The saved GitHub installation is missing or this app cannot access it."
54
+ : code,
55
+ );
56
+ })
57
+ .finally(() => {
58
+ if (!cancelled) setLoading(false);
59
+ });
60
+ }
61
+
62
+ load();
63
+ function onVisible() {
64
+ if (document.visibilityState === "visible") load();
65
+ }
66
+ document.addEventListener("visibilitychange", onVisible);
67
+ return () => {
68
+ cancelled = true;
69
+ document.removeEventListener("visibilitychange", onVisible);
70
+ };
51
71
  }, [workspaceId]);
52
72
 
53
73
  async function onSubmit(e: React.FormEvent) {
@@ -100,6 +120,7 @@ export function GithubConnectPage() {
100
120
  ? "GitHub did not return repositories for the saved installation."
101
121
  : "No repositories are available for this installation."}
102
122
  </Text>
123
+ <SelectRepositoriesLink workspaceId={workspaceId} />
103
124
  <div>
104
125
  <ButtonLink to={settingsPath(workspace.slug, "github")} variant="secondary">
105
126
  Back to GitHub settings
@@ -109,7 +130,10 @@ export function GithubConnectPage() {
109
130
  ) : (
110
131
  <form onSubmit={onSubmit}>
111
132
  <Stack>
112
- <Field label="Repository">
133
+ <Field
134
+ label="Repository"
135
+ hint="Only repositories granted to the GitHub App are listed."
136
+ >
113
137
  <Select
114
138
  aria-label="Repository"
115
139
  value={selected}
@@ -120,6 +144,7 @@ export function GithubConnectPage() {
120
144
  }))}
121
145
  />
122
146
  </Field>
147
+ <SelectRepositoriesLink workspaceId={workspaceId} />
123
148
  <Field
124
149
  label="Directory"
125
150
  hint="Folder inside the repository that contains this site. Leave empty for the repository root."
@@ -0,0 +1,30 @@
1
+ // packages/studio/src/pages/SelectRepositoriesLink.tsx
2
+ import { useEffect, useState } from "react";
3
+ import { fetchGithubRepositoryAccess } from "../api";
4
+ import { ButtonLink } from "../ui/Button";
5
+
6
+ /** Opens the GitHub page where this installation's repository access is chosen. */
7
+ export function SelectRepositoriesLink({ workspaceId }: { workspaceId: string }) {
8
+ const [url, setUrl] = useState<string | null>(null);
9
+
10
+ useEffect(() => {
11
+ let cancelled = false;
12
+ void fetchGithubRepositoryAccess(workspaceId)
13
+ .then((data) => {
14
+ if (!cancelled) setUrl(data.url);
15
+ })
16
+ .catch(() => {
17
+ if (!cancelled) setUrl(null);
18
+ });
19
+ return () => {
20
+ cancelled = true;
21
+ };
22
+ }, [workspaceId]);
23
+
24
+ if (!url) return null;
25
+ return (
26
+ <ButtonLink href={url} variant="secondary">
27
+ Select repositories on GitHub
28
+ </ButtonLink>
29
+ );
30
+ }
@@ -18,6 +18,7 @@ import * as stylex from "@stylexjs/stylex";
18
18
  import { colors, space, type } from "../styles/tokens.stylex";
19
19
  import type { WorkspaceOutletContext } from "../workspace/types";
20
20
  import { githubConnectPath } from "../workspace-path";
21
+ import { SelectRepositoriesLink } from "./SelectRepositoriesLink";
21
22
 
22
23
  export function SettingsGithubPage() {
23
24
  const { workspace, reload } = useOutletContext<WorkspaceOutletContext>();
@@ -172,6 +173,7 @@ export function SettingsGithubPage() {
172
173
  >
173
174
  Change repository
174
175
  </ButtonLink>
176
+ <SelectRepositoriesLink workspaceId={workspace.id} />
175
177
  <Button
176
178
  variant="danger"
177
179
  disabled={disconnecting}
@@ -185,9 +187,9 @@ export function SettingsGithubPage() {
185
187
  ) : (
186
188
  <Stack>
187
189
  <Text tone="muted" size="sm">
188
- Connect finds the backmatter CMS installation on your GitHub
189
- account and opens the repository list here. Saving on GitHub does
190
- not return to Backmatter.
190
+ {workspace.githubInstallationId
191
+ ? "The list only includes repositories this GitHub App can access. Select repositories on GitHub to grant more. Saving there does not return to Backmatter."
192
+ : "Connect finds the backmatter CMS installation on your GitHub account and opens the repository list here. Saving on GitHub does not return to Backmatter."}
191
193
  </Text>
192
194
  {canEdit ? (
193
195
  <div {...stylex.props(styles.actions)}>
@@ -210,6 +212,9 @@ export function SettingsGithubPage() {
210
212
  {connecting ? "Connecting…" : "Connect GitHub"}
211
213
  </Button>
212
214
  )}
215
+ {workspace.githubInstallationId ? (
216
+ <SelectRepositoriesLink workspaceId={workspace.id} />
217
+ ) : null}
213
218
  {installUrl ? (
214
219
  <ButtonLink href={installUrl} newTab={false} variant="secondary">
215
220
  Install on GitHub
@@ -13,7 +13,7 @@ export function hostEnvSnippet(input: {
13
13
  return [
14
14
  `# backmatter.config.ts → workspaceId: "${input.workspaceId}"`,
15
15
  "# API defaults to https://api.backmatter.app in production",
16
- "# BACKMATTER_API_URL=http://localhost:3001 # local or self-hosted CMS",
16
+ line("BACKMATTER_WORKSPACE_ID", input.workspaceId),
17
17
  line("BACKMATTER_PREVIEW_TOKEN", input.previewToken),
18
18
  line("BACKMATTER_HOST_TOKEN", input.hostToken),
19
19
  line("BACKMATTER_CONTENT_TOKEN", input.contentToken),
package/src/site.tsx CHANGED
@@ -1,12 +1,14 @@
1
1
  // packages/studio/src/site.tsx
2
2
  // Root of a Studio mounted on a customer site. Same pages as the dashboard,
3
3
  // one workspace, authenticated by the site session through the host's
4
- // same-origin proxy. No login here: without a session the browser goes
5
- // top-level to the dashboard `enter` route and comes back with a ticket.
4
+ // same-origin proxy. A frame with no session tells the parent to drop the
5
+ // bar. The top window goes to the dashboard `enter` route and comes back
6
+ // with a ticket.
6
7
  import { useEffect, useState, type ReactNode } from "react";
7
8
  import * as stylex from "@stylexjs/stylex";
8
9
  import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router";
9
10
  import { studioEnterUrl } from "@backmatter/core";
11
+ import { lostSessionTarget, SESSION_EXPIRED_MESSAGE } from "./chat/session-expiry";
10
12
  import { fetchMe, fetchWorkspace } from "./api";
11
13
  import { Shell } from "./components/Shell";
12
14
  import { StudioDefinitionProvider, type StudioDefinition } from "./define-studio";
@@ -51,14 +53,16 @@ const ENTER_LOOP_MS = 10_000;
51
53
  */
52
54
  export function bounceToEnter(workspaceId: string, dashboardUrl: string): boolean {
53
55
  if (typeof window === "undefined") return false;
56
+ const framed = Boolean(window.top && window.top !== window);
57
+ if (lostSessionTarget(framed) === "parent") {
58
+ window.parent.postMessage({ type: SESSION_EXPIRED_MESSAGE }, window.location.origin);
59
+ return true;
60
+ }
54
61
  const last = Number(window.sessionStorage.getItem(ENTER_GUARD_KEY) ?? 0);
55
62
  if (Date.now() - last < ENTER_LOOP_MS) return false;
56
63
  window.sessionStorage.setItem(ENTER_GUARD_KEY, String(Date.now()));
57
- // A panel is a frame of this site. Send the top window, or the login form
58
- // appears inside the chat instead of taking the page.
59
- const nav = window.top && window.top !== window ? window.top : window;
60
- nav.location.assign(
61
- studioEnterUrl(dashboardUrl, { workspaceId, returnUrl: nav.location.href }),
64
+ window.location.assign(
65
+ studioEnterUrl(dashboardUrl, { workspaceId, returnUrl: window.location.href }),
62
66
  );
63
67
  return true;
64
68
  }