@backmatter/studio 0.0.2 → 0.0.4
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/dist/chunks/EmbedPublishPage-hx1xoAhI.js +149 -0
- package/dist/chunks/{UiKitPage-CTZtLwJ0.js → UiKitPage-BOunq6mr.js} +12 -11
- package/dist/chunks/WorkspaceRoutes-CTFTWu3L.js +57731 -0
- package/dist/chunks/{dashboard-nvFBzpZv.js → dashboard-upmiawx5.js} +216 -215
- package/dist/chunks/{index-Cr-IOgdC.js → index-CBvevwfT.js} +9 -8
- package/dist/chunks/status-bar-action-DiXhfcj3.js +30254 -0
- package/dist/dashboard.js +2 -2
- package/dist/index.js +139 -228
- package/dist/site-widget.js +292 -0
- package/dist/studio.css +46 -6
- package/package.json +7 -2
- package/src/api.ts +4 -0
- package/src/chat/ChangeCard.tsx +11 -6
- package/src/chat/ChatMessage.tsx +27 -2
- package/src/chat/ChatPanel.tsx +12 -2
- package/src/chat/ChatProvider.tsx +40 -3
- package/src/chat/Composer.tsx +92 -6
- package/src/chat/DocLink.tsx +31 -0
- package/src/chat/DocListCard.tsx +3 -3
- package/src/chat/PointerChip.tsx +139 -0
- package/src/chat/chat-context.ts +80 -1
- package/src/chat/icons.tsx +20 -0
- package/src/chat/session-expiry.ts +13 -5
- package/src/chrome/OpenSiteButton.tsx +33 -25
- package/src/index.css +9 -1
- package/src/pages/EmbedChatPage.tsx +25 -1
- package/src/pages/EmbedPublishPage.tsx +279 -84
- package/src/pages/GithubConnectPage.tsx +44 -19
- package/src/pages/SelectRepositoriesLink.tsx +30 -0
- package/src/pages/SettingsGeneralPage.tsx +2 -0
- package/src/pages/SettingsGithubPage.tsx +8 -3
- package/src/pages/host-env.ts +7 -2
- package/src/site-pointer.ts +271 -0
- package/src/site-widget.tsx +391 -0
- package/src/site.tsx +11 -7
- package/dist/chunks/WorkspaceRoutes-DyRVES9t.js +0 -87308
package/src/chat/chat-context.ts
CHANGED
|
@@ -5,17 +5,91 @@ 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
|
+
/** Pointers stored on a sent user message, so the chip can be shown again later. */
|
|
40
|
+
export function pointersFromMetadata(metadata: unknown): ChatPointer[] {
|
|
41
|
+
if (!metadata || typeof metadata !== "object") return [];
|
|
42
|
+
const raw = (metadata as { pointers?: unknown }).pointers;
|
|
43
|
+
if (!Array.isArray(raw)) return [];
|
|
44
|
+
const pointers: ChatPointer[] = [];
|
|
45
|
+
for (const item of raw) {
|
|
46
|
+
if (!item || typeof item !== "object") continue;
|
|
47
|
+
const pointer = chatPointerFromGrab(item as ChatPointer, item);
|
|
48
|
+
if (pointer) pointers.push(pointer);
|
|
49
|
+
}
|
|
50
|
+
return pointers.slice(0, POINTER_CAP);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function addPointer(list: ChatPointer[], next: ChatPointer): ChatPointer[] {
|
|
54
|
+
if (list.some((item) => samePointer(item, next))) return list;
|
|
55
|
+
const added = [...list, next];
|
|
56
|
+
return added.length > POINTER_CAP ? added.slice(added.length - POINTER_CAP) : added;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function removePointer(list: ChatPointer[], next: ChatPointer): ChatPointer[] {
|
|
60
|
+
return list.filter((item) => !samePointer(item, next));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function chatPointerFromGrab(
|
|
64
|
+
doc: { locale?: unknown; type?: unknown; slug?: unknown },
|
|
65
|
+
grab: unknown,
|
|
66
|
+
): ChatPointer | null {
|
|
67
|
+
if (!doc || typeof doc.locale !== "string" || typeof doc.type !== "string" || typeof doc.slug !== "string") {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (!doc.locale || !doc.type || !doc.slug) return null;
|
|
71
|
+
if (!grab || typeof grab !== "object") return null;
|
|
72
|
+
const value = grab as Record<string, unknown>;
|
|
73
|
+
if (value.blockId !== null && typeof value.blockId !== "string") return null;
|
|
74
|
+
if (value.blockType !== null && typeof value.blockType !== "string") return null;
|
|
75
|
+
if (value.blockIndex !== null && typeof value.blockIndex !== "number") return null;
|
|
76
|
+
if (typeof value.quote !== "string") return null;
|
|
77
|
+
return {
|
|
78
|
+
locale: doc.locale,
|
|
79
|
+
type: doc.type,
|
|
80
|
+
slug: doc.slug,
|
|
81
|
+
blockId: value.blockId,
|
|
82
|
+
blockType: value.blockType,
|
|
83
|
+
blockIndex: value.blockIndex,
|
|
84
|
+
quote: value.quote.replace(/\s+/g, " ").trim().slice(0, 240),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
15
88
|
export function buildChatContext(input: {
|
|
16
89
|
focus: EditorFocus | null;
|
|
17
90
|
includeFocus: boolean;
|
|
18
91
|
mentions: DocRef[];
|
|
92
|
+
pointers: ChatPointer[];
|
|
19
93
|
}): ChatRequestContext {
|
|
20
94
|
const focusDoc = input.includeFocus && input.focus ? input.focus.docRef : null;
|
|
21
95
|
const selection =
|
|
@@ -28,7 +102,12 @@ export function buildChatContext(input: {
|
|
|
28
102
|
if (mentions.some((x) => sameDocRef(x, m))) continue;
|
|
29
103
|
mentions.push({ locale: m.locale, type: m.type, slug: m.slug });
|
|
30
104
|
}
|
|
31
|
-
|
|
105
|
+
const pointers: ChatPointer[] = [];
|
|
106
|
+
for (const pointer of input.pointers) {
|
|
107
|
+
if (pointers.some((item) => samePointer(item, pointer))) continue;
|
|
108
|
+
pointers.push(pointer);
|
|
109
|
+
}
|
|
110
|
+
return { focusDoc, selection, mentions, pointers: pointers.slice(-POINTER_CAP) };
|
|
32
111
|
}
|
|
33
112
|
|
|
34
113
|
export function addMention(list: DocRef[], ref: DocRef): DocRef[] {
|
package/src/chat/icons.tsx
CHANGED
|
@@ -15,6 +15,16 @@ function base(size: number) {
|
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export function PublishIcon({ size = 14 }: Props) {
|
|
19
|
+
return (
|
|
20
|
+
<svg {...base(size)}>
|
|
21
|
+
<path d="M3.25 10.25V13h9.5v-2.75" />
|
|
22
|
+
<path d="M8 10.25V3" />
|
|
23
|
+
<path d="M5.25 5.75 8 3l2.75 2.75" />
|
|
24
|
+
</svg>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
export function SparkIcon({ size = 14 }: Props) {
|
|
19
29
|
return (
|
|
20
30
|
<svg {...base(size)}>
|
|
@@ -158,6 +168,16 @@ export function EyeIcon({ size = 14 }: Props) {
|
|
|
158
168
|
);
|
|
159
169
|
}
|
|
160
170
|
|
|
171
|
+
/** Crosshair, used to point at an element on the site. */
|
|
172
|
+
export function PointerIcon({ size = 14 }: Props) {
|
|
173
|
+
return (
|
|
174
|
+
<svg {...base(size)}>
|
|
175
|
+
<circle cx="8" cy="8" r="2.2" />
|
|
176
|
+
<path d="M8 1.8v2.6M8 11.6v2.6M1.8 8h2.6M11.6 8h2.6" />
|
|
177
|
+
</svg>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
161
181
|
/** Viewfinder corners, used for the include-open-document toggle. */
|
|
162
182
|
export function FocusIcon({ size = 14 }: Props) {
|
|
163
183
|
return (
|
|
@@ -1,10 +1,18 @@
|
|
|
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
|
-
// session would otherwise show up as a composer error.
|
|
4
|
-
//
|
|
3
|
+
// session would otherwise show up as a composer error. A framed panel tells
|
|
4
|
+
// its parent. The in-page widget is the top window, so it reports through the
|
|
5
|
+
// Studio config callback the host used to drop the bar.
|
|
6
|
+
|
|
7
|
+
import { reportUnauthorized } from "../studio-config";
|
|
5
8
|
|
|
6
9
|
export const SESSION_EXPIRED_MESSAGE = "backmatter:session-expired";
|
|
7
10
|
|
|
11
|
+
/** A framed panel tells its parent. The top window goes to the dashboard. */
|
|
12
|
+
export function lostSessionTarget(framed: boolean): "parent" | "dashboard" {
|
|
13
|
+
return framed ? "parent" : "dashboard";
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
export function fetchNotifyingSessionExpiry(
|
|
9
17
|
origin: string,
|
|
10
18
|
parent: { postMessage: (message: unknown, targetOrigin: string) => void } | null,
|
|
@@ -12,9 +20,9 @@ export function fetchNotifyingSessionExpiry(
|
|
|
12
20
|
): typeof fetch {
|
|
13
21
|
return async (input, init) => {
|
|
14
22
|
const res = await base(input, init);
|
|
15
|
-
if (res.status
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
if (res.status !== 401) return res;
|
|
24
|
+
if (parent && origin) parent.postMessage({ type: SESSION_EXPIRED_MESSAGE }, origin);
|
|
25
|
+
else reportUnauthorized(401);
|
|
18
26
|
return res;
|
|
19
27
|
};
|
|
20
28
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// packages/studio/src/chrome/OpenSiteButton.tsx
|
|
2
|
-
// Dashboard-only: mint a site session for the workspace's site and
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Dashboard-only: mint a site session for the workspace's site and open it
|
|
3
|
+
// in a new tab. The tab is opened on the click, then pointed at the site
|
|
4
|
+
// once the ticket exists, so the Studio stays where it is.
|
|
5
5
|
// One site URL opens on click; several show a picker.
|
|
6
|
-
import { useState } from "react";
|
|
6
|
+
import { useRef, useState } from "react";
|
|
7
7
|
import { Menu } from "@base-ui/react/menu";
|
|
8
8
|
import * as stylex from "@stylexjs/stylex";
|
|
9
9
|
import { previewEntryUrl, resolveEnterTarget } from "@backmatter/core";
|
|
@@ -30,46 +30,55 @@ export function OpenSiteButton({
|
|
|
30
30
|
userId: string;
|
|
31
31
|
siteUrls: readonly string[];
|
|
32
32
|
}) {
|
|
33
|
-
const [busy, setBusy] = useState(false);
|
|
34
33
|
const [error, setError] = useState<string | null>(null);
|
|
34
|
+
const opening = useRef(false);
|
|
35
35
|
const sites = openableSites(siteUrls);
|
|
36
36
|
if (sites.length === 0 || !showsOpenSite()) return null;
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
function open(site: string) {
|
|
39
|
+
if (opening.current) return;
|
|
39
40
|
const target = resolveEnterTarget(site, siteUrls);
|
|
40
41
|
if (!target) {
|
|
41
42
|
setError("Site URL is not valid");
|
|
42
43
|
return;
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
tree: userId,
|
|
50
|
-
});
|
|
51
|
-
window.location.assign(previewEntryUrl(target.origin, ticket));
|
|
52
|
-
} catch (err) {
|
|
53
|
-
setBusy(false);
|
|
54
|
-
setError(err instanceof Error ? err.message : "Could not open the site");
|
|
45
|
+
// Open during the click. A tab created after the ticket request is blocked.
|
|
46
|
+
const tab = window.open("about:blank", "_blank");
|
|
47
|
+
if (!tab) {
|
|
48
|
+
setError("Allow pop-ups to open the site");
|
|
49
|
+
return;
|
|
55
50
|
}
|
|
51
|
+
tab.opener = null;
|
|
52
|
+
opening.current = true;
|
|
53
|
+
setError(null);
|
|
54
|
+
void (async () => {
|
|
55
|
+
try {
|
|
56
|
+
const { ticket } = await createPreviewTicket(workspaceId, {
|
|
57
|
+
redirect: target.redirect,
|
|
58
|
+
tree: userId,
|
|
59
|
+
});
|
|
60
|
+
tab.location.replace(previewEntryUrl(target.origin, ticket));
|
|
61
|
+
} catch (err) {
|
|
62
|
+
tab.close();
|
|
63
|
+
setError(err instanceof Error ? err.message : "Could not open the site");
|
|
64
|
+
} finally {
|
|
65
|
+
opening.current = false;
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
const label = <span>{busy ? "Opening…" : "Open site"}</span>;
|
|
59
|
-
|
|
60
70
|
if (sites.length === 1) {
|
|
61
71
|
const site = sites[0]!;
|
|
62
72
|
return (
|
|
63
73
|
<button
|
|
64
74
|
type="button"
|
|
65
|
-
|
|
66
|
-
onClick={() => void open(site)}
|
|
75
|
+
onClick={() => open(site)}
|
|
67
76
|
title={error ?? `Open ${site} with editing on`}
|
|
68
77
|
aria-label="Open site"
|
|
69
78
|
className={cx(styles.trigger)}
|
|
70
79
|
>
|
|
71
80
|
<ExternalIcon />
|
|
72
|
-
|
|
81
|
+
<span>Open site</span>
|
|
73
82
|
</button>
|
|
74
83
|
);
|
|
75
84
|
}
|
|
@@ -77,13 +86,12 @@ export function OpenSiteButton({
|
|
|
77
86
|
return (
|
|
78
87
|
<Menu.Root>
|
|
79
88
|
<Menu.Trigger
|
|
80
|
-
disabled={busy}
|
|
81
89
|
title={error ?? "Open a site with editing on"}
|
|
82
90
|
aria-label="Open site"
|
|
83
91
|
className={cx(styles.trigger)}
|
|
84
92
|
>
|
|
85
93
|
<ExternalIcon />
|
|
86
|
-
|
|
94
|
+
<span>Open site</span>
|
|
87
95
|
</Menu.Trigger>
|
|
88
96
|
<Menu.Portal>
|
|
89
97
|
<Menu.Positioner side="bottom" align="start" sideOffset={6} className={cx(popup.positioner)}>
|
|
@@ -92,7 +100,7 @@ export function OpenSiteButton({
|
|
|
92
100
|
<Menu.Item
|
|
93
101
|
key={site}
|
|
94
102
|
label={siteLabel(site)}
|
|
95
|
-
onClick={() =>
|
|
103
|
+
onClick={() => open(site)}
|
|
96
104
|
className={(state) =>
|
|
97
105
|
cx(popup.item, styles.item, state.highlighted && popup.itemHighlighted)
|
|
98
106
|
}
|
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;
|