@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/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 +294 -0
- package/dist/studio.css +46 -6
- package/package.json +6 -1
- package/src/chat/ChangeCard.tsx +11 -6
- package/src/chat/ChatMessage.tsx +27 -2
- package/src/chat/ChatPanel.tsx +13 -3
- package/src/chat/ChatProvider.tsx +6 -2
- package/src/chat/Composer.tsx +33 -24
- 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 +14 -0
- package/src/chat/icons.tsx +10 -0
- package/src/chat/session-expiry.ts +8 -5
- package/src/chrome/OpenSiteButton.tsx +33 -25
- package/src/pages/EmbedPublishPage.tsx +279 -84
- package/src/pages/SettingsGeneralPage.tsx +2 -0
- package/src/pages/host-env.ts +6 -1
- package/src/site-pointer.ts +271 -0
- package/src/site-widget.tsx +395 -0
- 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.
|
|
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",
|
package/src/chat/ChangeCard.tsx
CHANGED
|
@@ -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
|
-
|
|
60
|
-
<
|
|
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
|
-
</
|
|
63
|
-
|
|
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}
|
package/src/chat/ChatMessage.tsx
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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,
|
package/src/chat/ChatPanel.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
{
|
|
577
|
-
|
|
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],
|
package/src/chat/Composer.tsx
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
40
|
-
window.
|
|
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
|
-
|
|
98
|
+
disarm();
|
|
100
99
|
}
|
|
100
|
+
window.addEventListener(POINTER_ARM_EVENT, onArm);
|
|
101
101
|
window.addEventListener("message", onMessage);
|
|
102
|
-
return () =>
|
|
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
|
|
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
|
-
<
|
|
186
|
+
<PointerChip
|
|
179
187
|
key={pointerKey(pointer)}
|
|
180
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/chat/DocListCard.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
</
|
|
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
|
+
});
|
package/src/chat/chat-context.ts
CHANGED
|
@@ -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];
|
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)}>
|
|
@@ -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.
|
|
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
|
|
|
@@ -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
|
|
21
|
-
|
|
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
|
|
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
|
}
|