@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 +2 -2
- package/src/api.ts +4 -0
- package/src/chat/ChatPanel.tsx +1 -1
- package/src/chat/ChatProvider.tsx +34 -1
- package/src/chat/Composer.tsx +84 -7
- package/src/chat/chat-context.ts +66 -1
- package/src/chat/icons.tsx +10 -0
- package/src/chat/session-expiry.ts +6 -1
- package/src/index.css +9 -1
- package/src/pages/EmbedChatPage.tsx +25 -1
- package/src/pages/GithubConnectPage.tsx +44 -19
- package/src/pages/SelectRepositoriesLink.tsx +30 -0
- package/src/pages/SettingsGithubPage.tsx +8 -3
- package/src/pages/host-env.ts +1 -1
- package/src/site.tsx +11 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backmatter/studio",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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 }>;
|
package/src/chat/ChatPanel.tsx
CHANGED
|
@@ -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,
|
package/src/chat/Composer.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
266
|
-
slug
|
|
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
|
-
|
|
276
|
-
|
|
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
|
package/src/chat/chat-context.ts
CHANGED
|
@@ -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
|
-
|
|
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[] {
|
package/src/chat/icons.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
package/src/pages/host-env.ts
CHANGED
|
@@ -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
|
-
"
|
|
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.
|
|
5
|
-
// top
|
|
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
|
-
|
|
58
|
-
|
|
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
|
}
|