@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.
Files changed (36) hide show
  1. package/dist/chunks/EmbedPublishPage-hx1xoAhI.js +149 -0
  2. package/dist/chunks/{UiKitPage-CTZtLwJ0.js → UiKitPage-BOunq6mr.js} +12 -11
  3. package/dist/chunks/WorkspaceRoutes-CTFTWu3L.js +57731 -0
  4. package/dist/chunks/{dashboard-nvFBzpZv.js → dashboard-upmiawx5.js} +216 -215
  5. package/dist/chunks/{index-Cr-IOgdC.js → index-CBvevwfT.js} +9 -8
  6. package/dist/chunks/status-bar-action-DiXhfcj3.js +30254 -0
  7. package/dist/dashboard.js +2 -2
  8. package/dist/index.js +139 -228
  9. package/dist/site-widget.js +292 -0
  10. package/dist/studio.css +46 -6
  11. package/package.json +7 -2
  12. package/src/api.ts +4 -0
  13. package/src/chat/ChangeCard.tsx +11 -6
  14. package/src/chat/ChatMessage.tsx +27 -2
  15. package/src/chat/ChatPanel.tsx +12 -2
  16. package/src/chat/ChatProvider.tsx +40 -3
  17. package/src/chat/Composer.tsx +92 -6
  18. package/src/chat/DocLink.tsx +31 -0
  19. package/src/chat/DocListCard.tsx +3 -3
  20. package/src/chat/PointerChip.tsx +139 -0
  21. package/src/chat/chat-context.ts +80 -1
  22. package/src/chat/icons.tsx +20 -0
  23. package/src/chat/session-expiry.ts +13 -5
  24. package/src/chrome/OpenSiteButton.tsx +33 -25
  25. package/src/index.css +9 -1
  26. package/src/pages/EmbedChatPage.tsx +25 -1
  27. package/src/pages/EmbedPublishPage.tsx +279 -84
  28. package/src/pages/GithubConnectPage.tsx +44 -19
  29. package/src/pages/SelectRepositoriesLink.tsx +30 -0
  30. package/src/pages/SettingsGeneralPage.tsx +2 -0
  31. package/src/pages/SettingsGithubPage.tsx +8 -3
  32. package/src/pages/host-env.ts +7 -2
  33. package/src/site-pointer.ts +271 -0
  34. package/src/site-widget.tsx +391 -0
  35. package/src/site.tsx +11 -7
  36. package/dist/chunks/WorkspaceRoutes-DyRVES9t.js +0 -87308
@@ -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
- return { focusDoc, selection, mentions };
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[] {
@@ -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. Tell the parent page,
4
- // which holds the bar, so it can sign the editor back in.
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 === 401 && parent && origin) {
16
- parent.postMessage({ type: SESSION_EXPIRED_MESSAGE }, origin);
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 go there.
3
- // The site then shows the editing bar and its mounted Studio, with no
4
- // dashboard cookie involved. Same bounce `/enter` runs for a site that asks.
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
- async function open(site: string) {
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
- setBusy(true);
45
- setError(null);
46
- try {
47
- const { ticket } = await createPreviewTicket(workspaceId, {
48
- redirect: target.redirect,
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
- disabled={busy}
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
- {label}
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
- {label}
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={() => void open(site)}
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;