@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/studio",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
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": {
@@ -28,6 +28,11 @@
28
28
  "default": "./dist/define.js"
29
29
  },
30
30
  "./styles": "./src/styles.ts",
31
+ "./widget": {
32
+ "types": "./src/site-widget.tsx",
33
+ "source": "./src/site-widget.tsx",
34
+ "default": "./dist/site-widget.js"
35
+ },
31
36
  "./studio.css": "./dist/studio.css",
32
37
  "./prose-ui-node-shim": "./prose-ui-node-shim.ts",
33
38
  "./package.json": "./package.json",
@@ -47,7 +52,7 @@
47
52
  },
48
53
  "dependencies": {
49
54
  "@ai-sdk/react": "^4.0.100",
50
- "@backmatter/core": "^0.0.20",
55
+ "@backmatter/core": "^0.0.21",
51
56
  "@base-ui/react": "^1.6.0",
52
57
  "@dnd-kit/abstract": "^0.5.0",
53
58
  "@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 }>;
@@ -1,6 +1,5 @@
1
1
  // packages/studio/src/chat/ChangeCard.tsx
2
2
  import { useState } from "react";
3
- import { Link } from "react-router";
4
3
  import * as stylex from "@stylexjs/stylex";
5
4
  import type { ChatChangeSummary } from "../api";
6
5
  import { colors, radius, space, type } from "../styles/tokens.stylex";
@@ -8,6 +7,7 @@ import { Tooltip } from "../ui/Tooltip";
8
7
  import { contentDocPath } from "../workspace-path";
9
8
  import { useWorkspaceNav } from "../chrome/WorkspaceNavContext";
10
9
  import { CheckIcon, ChevronDownIcon, FileIcon, UndoIcon } from "./icons";
10
+ import { DocLink } from "./DocLink";
11
11
 
12
12
  const hover = "@media (hover: hover)";
13
13
 
@@ -56,12 +56,17 @@ export function ChangeCard({
56
56
  <span {...stylex.props(styles.fileIcon)}>
57
57
  <FileIcon />
58
58
  </span>
59
- <Tooltip label={`Open ${label} in the editor`}>
60
- <Link to={to} {...stylex.props(styles.name)}>
59
+ {nav ? (
60
+ <Tooltip label={`Open ${label} in the editor`}>
61
+ <DocLink to={to} {...stylex.props(styles.name)}>
62
+ {label}
63
+ </DocLink>
64
+ </Tooltip>
65
+ ) : (
66
+ <DocLink to={to} {...stylex.props(styles.name)}>
61
67
  {label}
62
- </Link>
63
- </Tooltip>
64
- <span {...stylex.props(styles.locale)}>{change.locale}</span>
68
+ </DocLink>
69
+ )}
65
70
  {change.diff?.renamedFrom ? (
66
71
  <span {...stylex.props(styles.badge)}>
67
72
  {change.diff.renamedFrom} → {change.slug}
@@ -19,6 +19,8 @@ import {
19
19
  type WriteToolOutput,
20
20
  } from "./chat-helpers";
21
21
  import { CheckIcon, EyeIcon, FileIcon, SearchIcon, UndoIcon } from "./icons";
22
+ import { pointersFromMetadata } from "./chat-context";
23
+ import { PointerChip, pointerKey } from "./PointerChip";
22
24
 
23
25
  const hover = "@media (hover: hover)";
24
26
 
@@ -117,9 +119,19 @@ export function ChatMessage({
117
119
  .filter((p): p is { type: "text"; text: string } => p.type === "text")
118
120
  .map((p) => p.text)
119
121
  .join("\n");
122
+ const pointers = pointersFromMetadata(message.metadata);
120
123
  return (
121
124
  <div {...stylex.props(styles.userRow, entering && styles.enter)}>
122
- <div {...stylex.props(styles.userBubble)}>{text}</div>
125
+ <div {...stylex.props(styles.userStack)}>
126
+ {pointers.length > 0 ? (
127
+ <div {...stylex.props(styles.userChips)} aria-label="Pointed elements">
128
+ {pointers.map((pointer) => (
129
+ <PointerChip key={pointerKey(pointer)} pointer={pointer} />
130
+ ))}
131
+ </div>
132
+ ) : null}
133
+ {text ? <div {...stylex.props(styles.userBubble)}>{text}</div> : null}
134
+ </div>
123
135
  </div>
124
136
  );
125
137
  }
@@ -312,8 +324,21 @@ const styles = stylex.create({
312
324
  display: "flex",
313
325
  justifyContent: "flex-end",
314
326
  },
315
- userBubble: {
327
+ userStack: {
328
+ display: "flex",
329
+ flexDirection: "column",
330
+ alignItems: "flex-end",
331
+ gap: space.sm,
316
332
  maxWidth: "85%",
333
+ },
334
+ userChips: {
335
+ display: "flex",
336
+ flexWrap: "wrap",
337
+ justifyContent: "flex-end",
338
+ gap: space.sm,
339
+ },
340
+ userBubble: {
341
+ maxWidth: "100%",
317
342
  paddingInline: space.lg,
318
343
  paddingBlock: space.md,
319
344
  borderRadius: 10,
@@ -26,7 +26,7 @@ import { isPanelVisible } from "./chat-state";
26
26
 
27
27
  const hover = "@media (hover: hover)";
28
28
 
29
- export function ChatPanel({ fill = false }: { fill?: boolean } = {}) {
29
+ export function ChatPanel({ fill = false, site = false }: { fill?: boolean; site?: boolean } = {}) {
30
30
  const ui = useChatUI();
31
31
  const session = useChatSession();
32
32
  const visible = isPanelVisible(ui.panel.mode);
@@ -70,7 +70,12 @@ export function ChatPanel({ fill = false }: { fill?: boolean } = {}) {
70
70
  <section
71
71
  role="dialog"
72
72
  aria-label="AI chat"
73
- {...stylex.props(fill ? styles.panelFill : styles.panel, !fill && fullscreen && styles.panelFull)}
73
+ data-backmatter-panel=""
74
+ {...stylex.props(
75
+ fill ? styles.panelFill : styles.panel,
76
+ site && styles.panelSite,
77
+ !fill && fullscreen && styles.panelFull,
78
+ )}
74
79
  >
75
80
  <header {...stylex.props(styles.header)}>
76
81
  <h2 {...stylex.props(styles.title)}>{title}</h2>
@@ -245,6 +250,11 @@ const styles = stylex.create({
245
250
  width: "auto",
246
251
  height: "auto",
247
252
  },
253
+ panelSite: {
254
+ right: 16,
255
+ bottom: 64,
256
+ zIndex: 2147483001,
257
+ },
248
258
  panelFill: {
249
259
  position: "fixed",
250
260
  top: 0,
@@ -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,
@@ -539,9 +572,13 @@ function LiveSession({
539
572
 
540
573
  const send = useCallback(
541
574
  async (text: string) => {
575
+ const context = getContext();
542
576
  await sendMessage(
543
- { text },
544
- { body: { model: getModel(), context: getContext() } },
577
+ {
578
+ text,
579
+ metadata: context.pointers.length ? { pointers: context.pointers } : undefined,
580
+ },
581
+ { body: { model: getModel(), context } },
545
582
  );
546
583
  },
547
584
  [sendMessage, getModel, getContext],
@@ -7,8 +7,9 @@ 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
11
  import { mentionQueryAt } from "./chat-helpers";
12
+ import { PointerChip, pointerKey } from "./PointerChip";
12
13
 
13
14
  const hover = "@media (hover: hover)";
14
15
 
@@ -22,11 +23,22 @@ function isMentionKey(key: string): key is MentionKey {
22
23
  return MENTION_KEYS.has(key);
23
24
  }
24
25
 
26
+ export const POINTER_ARM_EVENT = "backmatter:pointer-arm";
27
+
28
+ function postPointer(on: boolean) {
29
+ if (typeof window === "undefined") return;
30
+ window.dispatchEvent(new CustomEvent(POINTER_ARM_EVENT, { detail: { on } }));
31
+ if (window.parent !== window) {
32
+ window.parent.postMessage({ type: "backmatter:pointer", on }, location.origin);
33
+ }
34
+ }
35
+
25
36
  export function Composer({ autoFocus }: { autoFocus: boolean }) {
26
37
  const ui = useChatUI();
27
38
  const session = useChatSession();
28
39
  const [text, setText] = useState("");
29
40
  const [mention, setMention] = useState<{ start: number; query: string } | null>(null);
41
+ const [pointing, setPointing] = useState(false);
30
42
  const ref = useRef<HTMLTextAreaElement>(null);
31
43
  const boxRef = useRef<HTMLDivElement>(null);
32
44
  const mentionKeys = useRef<((key: MentionKey) => void) | null>(null);
@@ -71,13 +83,40 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
71
83
  return () => window.removeEventListener("backmatter:chat-prefill", onPrefill);
72
84
  }, []);
73
85
 
86
+ useEffect(() => {
87
+ if (!ui.pointer) return;
88
+ function disarm() {
89
+ setPointing(false);
90
+ }
91
+ function onArm(event: Event) {
92
+ if ((event as CustomEvent<{ on?: boolean }>).detail?.on === false) disarm();
93
+ }
94
+ function onMessage(event: MessageEvent) {
95
+ if (event.source !== window.parent || event.origin !== location.origin) return;
96
+ const data = event.data as { type?: string; on?: boolean };
97
+ if (data?.type !== "backmatter:pointer" || data.on !== false) return;
98
+ disarm();
99
+ }
100
+ window.addEventListener(POINTER_ARM_EVENT, onArm);
101
+ window.addEventListener("message", onMessage);
102
+ return () => {
103
+ window.removeEventListener(POINTER_ARM_EVENT, onArm);
104
+ window.removeEventListener("message", onMessage);
105
+ };
106
+ }, [ui.pointer]);
107
+
74
108
  const submit = useCallback(() => {
75
109
  const value = text.trim();
76
110
  if (!value || busy) return;
77
111
  setText("");
78
112
  setMention(null);
113
+ if (pointing) {
114
+ setPointing(false);
115
+ postPointer(false);
116
+ }
79
117
  void session.send(value);
80
- }, [text, busy, session]);
118
+ ui.clearPointers();
119
+ }, [text, busy, session, ui, pointing]);
81
120
 
82
121
  function updateMention(el: HTMLTextAreaElement) {
83
122
  setMention(mentionQueryAt(el.value, el.selectionStart ?? el.value.length));
@@ -141,6 +180,17 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
141
180
  </div>
142
181
  ) : null}
143
182
  <div ref={boxRef} {...stylex.props(styles.box)}>
183
+ {ui.pointers.length > 0 ? (
184
+ <div {...stylex.props(styles.chips)} aria-label="Pointed elements">
185
+ {ui.pointers.map((pointer) => (
186
+ <PointerChip
187
+ key={pointerKey(pointer)}
188
+ pointer={pointer}
189
+ onRemove={() => ui.removePointer(pointer)}
190
+ />
191
+ ))}
192
+ </div>
193
+ ) : null}
144
194
  <textarea
145
195
  ref={ref}
146
196
  value={text}
@@ -170,6 +220,11 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
170
220
  e.preventDefault();
171
221
  submit();
172
222
  } else if (e.key === "Escape") {
223
+ if (pointing) {
224
+ setPointing(false);
225
+ postPointer(false);
226
+ return;
227
+ }
173
228
  ui.dispatch({ type: "minimize" });
174
229
  }
175
230
  }}
@@ -222,6 +277,24 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
222
277
  </button>
223
278
  </Tooltip>
224
279
  ) : null}
280
+ {ui.pointer ? (
281
+ <Tooltip label={pointing ? "Stop pointing" : "Point at an element on the page"}>
282
+ <button
283
+ type="button"
284
+ aria-label="Point at an element on the page"
285
+ aria-pressed={pointing}
286
+ disabled={ui.aiAvailable !== true}
287
+ onClick={() => {
288
+ const next = !pointing;
289
+ setPointing(next);
290
+ postPointer(next);
291
+ }}
292
+ {...stylex.props(styles.iconBtn, pointing && styles.iconBtnActive)}
293
+ >
294
+ <PointerIcon />
295
+ </button>
296
+ </Tooltip>
297
+ ) : null}
225
298
  <ModelPicker />
226
299
  {busy ? (
227
300
  <Tooltip label="Stop generating">
@@ -257,13 +330,15 @@ function ContextRow({
257
330
  icon,
258
331
  type,
259
332
  slug,
333
+ label,
260
334
  meta,
261
335
  removeLabel,
262
336
  onRemove,
263
337
  }: {
264
338
  icon: React.ReactNode;
265
- type: string;
266
- slug: string;
339
+ type?: string;
340
+ slug?: string;
341
+ label?: string;
267
342
  meta?: string;
268
343
  removeLabel: string;
269
344
  onRemove: () => void;
@@ -272,8 +347,14 @@ function ContextRow({
272
347
  <div {...stylex.props(styles.row)}>
273
348
  <span {...stylex.props(styles.rowIcon)}>{icon}</span>
274
349
  <span {...stylex.props(styles.rowText)}>
275
- <span {...stylex.props(styles.rowType)}>{type}/</span>
276
- <span {...stylex.props(styles.rowSlug)}>{slug}</span>
350
+ {label ? (
351
+ <span {...stylex.props(styles.rowSlug)}>{label}</span>
352
+ ) : (
353
+ <>
354
+ <span {...stylex.props(styles.rowType)}>{type}/</span>
355
+ <span {...stylex.props(styles.rowSlug)}>{slug}</span>
356
+ </>
357
+ )}
277
358
  </span>
278
359
  {meta ? <span {...stylex.props(styles.rowMeta)}>{meta}</span> : null}
279
360
  <button
@@ -364,6 +445,11 @@ const styles = stylex.create({
364
445
  ":hover": { backgroundColor: colors.bgHover, color: colors.text },
365
446
  },
366
447
  },
448
+ chips: {
449
+ display: "flex",
450
+ flexWrap: "wrap",
451
+ gap: space.sm,
452
+ },
367
453
  box: {
368
454
  position: "relative",
369
455
  zIndex: 2,
@@ -0,0 +1,31 @@
1
+ // packages/studio/src/chat/DocLink.tsx
2
+ // A document name that opens in the Studio router. The on-site widget has no
3
+ // router, so the same label renders as text and the chat can still show it.
4
+ import type { CSSProperties, ReactNode } from "react";
5
+ import { Link, useInRouterContext } from "react-router";
6
+
7
+ export function DocLink({
8
+ to,
9
+ children,
10
+ className,
11
+ style,
12
+ }: {
13
+ to: string;
14
+ children: ReactNode;
15
+ className?: string;
16
+ style?: CSSProperties;
17
+ }) {
18
+ const routed = useInRouterContext();
19
+ if (!routed) {
20
+ return (
21
+ <span className={className} style={style}>
22
+ {children}
23
+ </span>
24
+ );
25
+ }
26
+ return (
27
+ <Link to={to} className={className} style={style}>
28
+ {children}
29
+ </Link>
30
+ );
31
+ }
@@ -4,11 +4,11 @@
4
4
  // into the editor. Content models are user-defined, so rows lean on the
5
5
  // server-derived `title` and fall back to the slug.
6
6
  import { useState } from "react";
7
- import { Link } from "react-router";
8
7
  import * as stylex from "@stylexjs/stylex";
9
8
  import { colors, radius, space, type } from "../styles/tokens.stylex";
10
9
  import { contentDocPath } from "../workspace-path";
11
10
  import { useWorkspaceNav } from "../chrome/WorkspaceNavContext";
11
+ import { DocLink } from "./DocLink";
12
12
  import { ChevronDownIcon, FileIcon } from "./icons";
13
13
 
14
14
  const hover = "@media (hover: hover)";
@@ -130,7 +130,7 @@ function DocRow({
130
130
  const secondary = doc.path ?? `${doc.type}/${doc.slug}`;
131
131
 
132
132
  return (
133
- <Link to={to} {...stylex.props(styles.row)}>
133
+ <DocLink to={to} {...stylex.props(styles.row)}>
134
134
  <span {...stylex.props(styles.line)}>
135
135
  <span {...stylex.props(styles.icon)}>
136
136
  <FileIcon size={13} />
@@ -153,7 +153,7 @@ function DocRow({
153
153
  </span>
154
154
  </span>
155
155
  {doc.excerpt ? <span {...stylex.props(styles.excerpt)}>{doc.excerpt}</span> : null}
156
- </Link>
156
+ </DocLink>
157
157
  );
158
158
  }
159
159
 
@@ -0,0 +1,139 @@
1
+ // packages/studio/src/chat/PointerChip.tsx
2
+ import { useEffect } from "react";
3
+ import * as stylex from "@stylexjs/stylex";
4
+ import { colors, space, type } from "../styles/tokens.stylex";
5
+ import { findPointedElement, highlightPointedElement } from "../site-pointer";
6
+ import type { ChatPointer } from "./chat-context";
7
+ import { CloseIcon, PointerIcon } from "./icons";
8
+
9
+ const hover = "@media (hover: hover)";
10
+
11
+ export function pointerKey(pointer: ChatPointer): string {
12
+ return `${pointer.locale}/${pointer.type}/${pointer.slug}/${pointer.blockId ?? ""}/${pointer.quote}`;
13
+ }
14
+
15
+ function pointerLabel(pointer: ChatPointer): string {
16
+ const quote = pointer.quote.replace(/\s+/g, " ").trim();
17
+ const short = quote.length > 42 ? `${quote.slice(0, 41)}…` : quote;
18
+ if (pointer.blockType && short) return `${pointer.blockType} · “${short}”`;
19
+ if (pointer.blockType) return pointer.blockType;
20
+ return short ? `“${short}”` : "element";
21
+ }
22
+
23
+ function pointerChipLabel(pointer: ChatPointer): string {
24
+ const quote = pointer.quote.replace(/\s+/g, " ").trim();
25
+ const raw = quote || pointer.blockType || `${pointer.type}/${pointer.slug}`;
26
+ return raw.length > 32 ? `${raw.slice(0, 31)}…` : raw;
27
+ }
28
+
29
+ function canHighlightHere(): boolean {
30
+ return typeof window !== "undefined" && window.parent === window;
31
+ }
32
+
33
+ export function PointerChip({
34
+ pointer,
35
+ onRemove,
36
+ }: {
37
+ pointer: ChatPointer;
38
+ onRemove?: () => void;
39
+ }) {
40
+ const label = pointerChipLabel(pointer);
41
+ const detail = pointerLabel(pointer);
42
+
43
+ useEffect(() => {
44
+ return () => {
45
+ if (canHighlightHere()) highlightPointedElement(document, null);
46
+ };
47
+ }, []);
48
+
49
+ function show() {
50
+ if (!canHighlightHere()) return;
51
+ highlightPointedElement(document, findPointedElement(document, pointer));
52
+ }
53
+
54
+ function hide() {
55
+ if (!canHighlightHere()) return;
56
+ highlightPointedElement(document, null);
57
+ }
58
+
59
+ return (
60
+ <span
61
+ {...stylex.props(styles.chip, !onRemove && styles.chipStatic)}
62
+ title={`${detail} · ${pointer.type}/${pointer.slug}`}
63
+ onMouseEnter={show}
64
+ onMouseLeave={hide}
65
+ >
66
+ <span {...stylex.props(styles.chipIcon)}>
67
+ <PointerIcon size={13} />
68
+ </span>
69
+ <span {...stylex.props(styles.chipLabel)}>{label}</span>
70
+ {onRemove ? (
71
+ <button
72
+ type="button"
73
+ aria-label={`Remove ${detail}`}
74
+ onClick={onRemove}
75
+ {...stylex.props(styles.chipRemove)}
76
+ >
77
+ <CloseIcon size={11} />
78
+ </button>
79
+ ) : null}
80
+ </span>
81
+ );
82
+ }
83
+
84
+ const styles = stylex.create({
85
+ chip: {
86
+ display: "inline-flex",
87
+ alignItems: "center",
88
+ gap: space.xs,
89
+ maxWidth: "100%",
90
+ height: 26,
91
+ paddingLeft: space.sm,
92
+ paddingRight: 2,
93
+ borderWidth: 1,
94
+ borderStyle: "solid",
95
+ borderColor: colors.border,
96
+ borderRadius: 8,
97
+ backgroundColor: colors.bgSurface,
98
+ color: colors.text,
99
+ fontSize: type.sm,
100
+ },
101
+ chipStatic: {
102
+ paddingRight: space.sm,
103
+ },
104
+ chipIcon: {
105
+ display: "inline-flex",
106
+ flexShrink: 0,
107
+ color: colors.textMuted,
108
+ },
109
+ chipLabel: {
110
+ minWidth: 0,
111
+ overflow: "hidden",
112
+ textOverflow: "ellipsis",
113
+ whiteSpace: "nowrap",
114
+ fontWeight: 500,
115
+ },
116
+ chipRemove: {
117
+ display: "inline-flex",
118
+ alignItems: "center",
119
+ justifyContent: "center",
120
+ flexShrink: 0,
121
+ width: 22,
122
+ height: 22,
123
+ padding: 0,
124
+ borderWidth: 0,
125
+ borderRadius: 6,
126
+ backgroundColor: "transparent",
127
+ color: colors.textMuted,
128
+ cursor: "pointer",
129
+ [hover]: {
130
+ ":hover": { backgroundColor: colors.bgHover, color: colors.text },
131
+ },
132
+ ":focus-visible": {
133
+ outlineWidth: 2,
134
+ outlineStyle: "solid",
135
+ outlineColor: colors.focus,
136
+ outlineOffset: 1,
137
+ },
138
+ },
139
+ });