@backmatter/studio 0.0.3 → 0.0.5

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.3",
3
+ "version": "0.0.5",
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",
@@ -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);
@@ -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 + ui.pointers.length,
58
+ (ui.focus && ui.includeFocus ? 1 : 0) + ui.mentions.length,
59
59
  );
60
60
  const contextReserve = contextRows * CONTEXT_ROW_HEIGHT + CONTEXT_BAR_PADDING + 12;
61
61
 
@@ -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,
@@ -572,9 +572,13 @@ function LiveSession({
572
572
 
573
573
  const send = useCallback(
574
574
  async (text: string) => {
575
+ const context = getContext();
575
576
  await sendMessage(
576
- { text },
577
- { body: { model: getModel(), context: getContext() } },
577
+ {
578
+ text,
579
+ metadata: context.pointers.length ? { pointers: context.pointers } : undefined,
580
+ },
581
+ { body: { model: getModel(), context } },
578
582
  );
579
583
  },
580
584
  [sendMessage, getModel, getContext],
@@ -8,8 +8,8 @@ import { useChatSession, useChatUI } from "./ChatProvider";
8
8
  import { MentionPopover, type MentionKey } from "./MentionPopover";
9
9
  import { ModelPicker } from "./ModelPicker";
10
10
  import { AtIcon, CloseIcon, FileIcon, FocusIcon, PointerIcon, SendIcon, StopIcon } from "./icons";
11
- import type { ChatPointer } from "./chat-context";
12
11
  import { mentionQueryAt } from "./chat-helpers";
12
+ import { PointerChip, pointerKey } from "./PointerChip";
13
13
 
14
14
  const hover = "@media (hover: hover)";
15
15
 
@@ -23,21 +23,14 @@ function isMentionKey(key: string): key is MentionKey {
23
23
  return MENTION_KEYS.has(key);
24
24
  }
25
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
- }
26
+ export const POINTER_ARM_EVENT = "backmatter:pointer-arm";
37
27
 
38
28
  function postPointer(on: boolean) {
39
- if (typeof window === "undefined" || window.parent === window) return;
40
- window.parent.postMessage({ type: "backmatter:pointer", on }, location.origin);
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
+ }
41
34
  }
42
35
 
43
36
  export function Composer({ autoFocus }: { autoFocus: boolean }) {
@@ -92,14 +85,24 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
92
85
 
93
86
  useEffect(() => {
94
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
+ }
95
94
  function onMessage(event: MessageEvent) {
96
95
  if (event.source !== window.parent || event.origin !== location.origin) return;
97
96
  const data = event.data as { type?: string; on?: boolean };
98
97
  if (data?.type !== "backmatter:pointer" || data.on !== false) return;
99
- setPointing(false);
98
+ disarm();
100
99
  }
100
+ window.addEventListener(POINTER_ARM_EVENT, onArm);
101
101
  window.addEventListener("message", onMessage);
102
- return () => window.removeEventListener("message", onMessage);
102
+ return () => {
103
+ window.removeEventListener(POINTER_ARM_EVENT, onArm);
104
+ window.removeEventListener("message", onMessage);
105
+ };
103
106
  }, [ui.pointer]);
104
107
 
105
108
  const submit = useCallback(() => {
@@ -137,7 +140,7 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
137
140
  const focusChip = ui.focus && ui.includeFocus ? ui.focus.docRef : null;
138
141
  const selectionActive =
139
142
  ui.focus?.selection && ui.focus.selection.end > ui.focus.selection.start;
140
- const hasContext = Boolean(focusChip || ui.mentions.length > 0 || ui.pointers.length > 0);
143
+ const hasContext = Boolean(focusChip || ui.mentions.length > 0);
141
144
 
142
145
  return (
143
146
  <div {...stylex.props(styles.wrap)}>
@@ -174,19 +177,20 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
174
177
  onRemove={() => ui.removeMention(m)}
175
178
  />
176
179
  ))}
180
+ </div>
181
+ ) : null}
182
+ <div ref={boxRef} {...stylex.props(styles.box)}>
183
+ {ui.pointers.length > 0 ? (
184
+ <div {...stylex.props(styles.chips)} aria-label="Pointed elements">
177
185
  {ui.pointers.map((pointer) => (
178
- <ContextRow
186
+ <PointerChip
179
187
  key={pointerKey(pointer)}
180
- icon={<PointerIcon size={13} />}
181
- label={pointerLabel(pointer)}
182
- meta={`${pointer.type}/${pointer.slug}`}
183
- removeLabel={`Remove ${pointerLabel(pointer)}`}
188
+ pointer={pointer}
184
189
  onRemove={() => ui.removePointer(pointer)}
185
190
  />
186
191
  ))}
187
192
  </div>
188
- ) : null}
189
- <div ref={boxRef} {...stylex.props(styles.box)}>
193
+ ) : null}
190
194
  <textarea
191
195
  ref={ref}
192
196
  value={text}
@@ -441,6 +445,11 @@ const styles = stylex.create({
441
445
  ":hover": { backgroundColor: colors.bgHover, color: colors.text },
442
446
  },
443
447
  },
448
+ chips: {
449
+ display: "flex",
450
+ flexWrap: "wrap",
451
+ gap: space.sm,
452
+ },
444
453
  box: {
445
454
  position: "relative",
446
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
+ });
@@ -36,6 +36,20 @@ function samePointer(a: ChatPointer, b: ChatPointer): boolean {
36
36
  );
37
37
  }
38
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
+
39
53
  export function addPointer(list: ChatPointer[], next: ChatPointer): ChatPointer[] {
40
54
  if (list.some((item) => samePointer(item, next))) return list;
41
55
  const added = [...list, next];
@@ -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)}>
@@ -1,7 +1,10 @@
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 drop the editor without navigating.
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
 
@@ -17,9 +20,9 @@ export function fetchNotifyingSessionExpiry(
17
20
  ): typeof fetch {
18
21
  return async (input, init) => {
19
22
  const res = await base(input, init);
20
- if (res.status === 401 && parent && origin) {
21
- parent.postMessage({ type: SESSION_EXPIRED_MESSAGE }, origin);
22
- }
23
+ if (res.status !== 401) return res;
24
+ if (parent && origin) parent.postMessage({ type: SESSION_EXPIRED_MESSAGE }, origin);
25
+ else reportUnauthorized(401);
23
26
  return res;
24
27
  };
25
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
  }