@backmatter/studio 0.0.8 → 0.0.10
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/UiKitApp-BxvTwsko.js +2410 -0
- package/dist/chunks/WorkspaceRoutes-BKKEkAq7.js +90126 -0
- package/dist/chunks/{dashboard-bNp6AbKE.js → dashboard-8rjkfEXl.js} +380 -382
- package/dist/chunks/{index-DoDE-hwB.js → index-laWAz5_p.js} +111 -112
- package/dist/dashboard.js +2 -2
- package/dist/index.js +287 -146
- package/dist/site-widget.css +1615 -0
- package/dist/site-widget.js +30903 -628
- package/dist/studio.css +268 -67
- package/package.json +4 -4
- package/src/App.tsx +12 -29
- package/src/api.ts +55 -0
- package/src/chat/ChangeCard.tsx +2 -0
- package/src/chat/ChatMessage.tsx +73 -62
- package/src/chat/ChatPanel.tsx +110 -15
- package/src/chat/DocListCard.tsx +2 -0
- package/src/chat/LiveStatus.tsx +78 -8
- package/src/chat/Reveal.tsx +34 -0
- package/src/chat/Shimmer.tsx +65 -23
- package/src/chat/chat-helpers.ts +35 -0
- package/src/chat/motion.ts +73 -0
- package/src/chat/useStickToBottom.ts +129 -14
- package/src/chrome/ContentExplorerSidebar.tsx +23 -8
- package/src/chrome/content-tree-visibility.ts +55 -0
- package/src/devbar-state.ts +125 -0
- package/src/index.css +3 -131
- package/src/pages/CliAuthorizePage.tsx +61 -28
- package/src/pages/MediaGalleryPage.tsx +74 -11
- package/src/pages/MediaLightbox.tsx +93 -1
- package/src/pages/SettingsUsagePage.tsx +23 -1
- package/src/pages/media-grid.ts +13 -0
- package/src/pages/ui-kit/UiKitApp.tsx +164 -0
- package/src/pages/ui-kit/buttons.tsx +30 -0
- package/src/pages/ui-kit/catalog.tsx +41 -0
- package/src/pages/ui-kit/colors.tsx +95 -0
- package/src/pages/ui-kit/devbar.tsx +160 -0
- package/src/pages/ui-kit/feedback.tsx +39 -0
- package/src/pages/ui-kit/fields.tsx +47 -0
- package/src/pages/ui-kit/lists.tsx +19 -0
- package/src/pages/ui-kit/overlays.tsx +39 -0
- package/src/pages/ui-kit/path.ts +9 -0
- package/src/pages/ui-kit/sections.tsx +32 -0
- package/src/pages/ui-kit/surfaces.tsx +121 -0
- package/src/pages/ui-kit/topic.tsx +112 -0
- package/src/pages/ui-kit/topics.ts +71 -0
- package/src/pages/ui-kit/type.tsx +55 -0
- package/src/preview-frame-radius.ts +51 -0
- package/src/preview-frame.tsx +108 -49
- package/src/shell-resets.css +155 -0
- package/src/site-bar.tsx +1810 -425
- package/src/site-dashboard-href.ts +21 -0
- package/src/site-devbar-overlay-inject.ts +15 -0
- package/src/site-devbar-overlay.css +217 -0
- package/src/site-pointer.ts +1 -1
- package/src/site-publish-menu.tsx +33 -0
- package/src/site-widget-styles.ts +7 -0
- package/src/site-widget.tsx +61 -21
- package/src/studio-prose-ui.css +39 -0
- package/src/theme/ThemeProvider.tsx +4 -1
- package/src/workspace-path.ts +10 -6
- package/dist/chunks/EmbedPublishPage-CtIlaeGO.js +0 -149
- package/dist/chunks/UiKitPage-DcnSQ-Hf.js +0 -173
- package/dist/chunks/WorkspaceRoutes-DVpUOaMV.js +0 -59550
- package/dist/chunks/status-bar-action-C0fsAWvD.js +0 -30618
- package/src/pages/UiKitPage.tsx +0 -331
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backmatter/studio",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
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": {
|
|
@@ -30,10 +30,10 @@
|
|
|
30
30
|
"./styles": "./src/styles.ts",
|
|
31
31
|
"./widget": {
|
|
32
32
|
"types": "./src/site-widget.tsx",
|
|
33
|
-
"source": "./src/site-widget.tsx",
|
|
34
33
|
"default": "./dist/site-widget.js"
|
|
35
34
|
},
|
|
36
35
|
"./studio.css": "./dist/studio.css",
|
|
36
|
+
"./site-widget.css": "./dist/site-widget.css",
|
|
37
37
|
"./prose-ui-node-shim": "./prose-ui-node-shim.ts",
|
|
38
38
|
"./package.json": "./package.json",
|
|
39
39
|
"./src/*": "./src/*"
|
|
@@ -47,12 +47,12 @@
|
|
|
47
47
|
"!src/**/*.test.tsx"
|
|
48
48
|
],
|
|
49
49
|
"scripts": {
|
|
50
|
-
"build": "vite build",
|
|
50
|
+
"build": "vite build && vite build --config vite.widget.config.ts",
|
|
51
51
|
"test": "bun test"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@ai-sdk/react": "^4.0.100",
|
|
55
|
-
"@backmatter/core": "^0.0.
|
|
55
|
+
"@backmatter/core": "^0.0.26",
|
|
56
56
|
"@base-ui/react": "^1.6.0",
|
|
57
57
|
"@central-icons-react/round-outlined-radius-2-stroke-1.5": "1.2.15",
|
|
58
58
|
"@dnd-kit/abstract": "^0.5.0",
|
package/src/App.tsx
CHANGED
|
@@ -26,21 +26,17 @@ import { replicaStore } from "./replica/instance";
|
|
|
26
26
|
import { readLastWorkspace } from "./last-workspace";
|
|
27
27
|
import { EnterPage } from "./pages/EnterPage";
|
|
28
28
|
import { workspaceRoutes } from "./WorkspaceRoutes";
|
|
29
|
-
import { cmsWorkspaceSlug } from "./workspace-path";
|
|
29
|
+
import { cmsWorkspaceSlug, isCliAuthorizePath } from "./workspace-path";
|
|
30
30
|
import { ThemeProvider } from "./theme/ThemeProvider";
|
|
31
31
|
import { Text } from "./ui/Text";
|
|
32
32
|
import { space } from "./styles/tokens.stylex";
|
|
33
|
-
import {
|
|
33
|
+
import { isUiKitPath } from "./pages/ui-kit/path";
|
|
34
34
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
)
|
|
39
|
-
: null;
|
|
35
|
+
const UiKitApp = lazy(() =>
|
|
36
|
+
import("./pages/ui-kit/UiKitApp").then((mod) => ({ default: mod.UiKitApp })),
|
|
37
|
+
);
|
|
40
38
|
|
|
41
|
-
export
|
|
42
|
-
return isStudioDev() && pathname.replace(/\/$/, "") === "/__ui";
|
|
43
|
-
}
|
|
39
|
+
export { isUiKitPath };
|
|
44
40
|
|
|
45
41
|
export function App() {
|
|
46
42
|
return (
|
|
@@ -87,7 +83,7 @@ function AppInner() {
|
|
|
87
83
|
});
|
|
88
84
|
}, []);
|
|
89
85
|
|
|
90
|
-
if (
|
|
86
|
+
if (isUiKitPath(location.pathname)) {
|
|
91
87
|
return (
|
|
92
88
|
<Suspense
|
|
93
89
|
fallback={
|
|
@@ -96,7 +92,7 @@ function AppInner() {
|
|
|
96
92
|
</div>
|
|
97
93
|
}
|
|
98
94
|
>
|
|
99
|
-
<
|
|
95
|
+
<UiKitApp />
|
|
100
96
|
</Suspense>
|
|
101
97
|
);
|
|
102
98
|
}
|
|
@@ -113,6 +109,10 @@ function AppInner() {
|
|
|
113
109
|
return <EnterPage userId={session.user.id} />;
|
|
114
110
|
}
|
|
115
111
|
|
|
112
|
+
if (isCliAuthorizePath(location.pathname)) {
|
|
113
|
+
return <CliAuthorizePage email={session.user.email} />;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
116
|
return (
|
|
117
117
|
<ReplicaProvider user={session.user}>
|
|
118
118
|
<ReplicaGate>
|
|
@@ -138,28 +138,11 @@ function AppInner() {
|
|
|
138
138
|
<Route path="/workspaces/new" element={<NewWorkspacePage />} />
|
|
139
139
|
<Route path="/invite" element={<AcceptInvitePage />} />
|
|
140
140
|
<Route path="/account" element={<UserSettingsPage />} />
|
|
141
|
-
<Route path="/cli/authorize" element={<CliAuthorizePage />} />
|
|
142
141
|
<Route
|
|
143
142
|
path="/workspaces/:workspaceId/*"
|
|
144
143
|
element={<LegacyWorkspaceRedirect />}
|
|
145
144
|
/>
|
|
146
145
|
{workspaceRoutes()}
|
|
147
|
-
{UiKitPage ? (
|
|
148
|
-
<Route
|
|
149
|
-
path="/__ui"
|
|
150
|
-
element={
|
|
151
|
-
<Suspense
|
|
152
|
-
fallback={
|
|
153
|
-
<div {...stylex.props(styles.pad)}>
|
|
154
|
-
<Text tone="muted">Loading kit…</Text>
|
|
155
|
-
</div>
|
|
156
|
-
}
|
|
157
|
-
>
|
|
158
|
-
<UiKitPage />
|
|
159
|
-
</Suspense>
|
|
160
|
-
}
|
|
161
|
-
/>
|
|
162
|
-
) : null}
|
|
163
146
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
164
147
|
</Routes>
|
|
165
148
|
</Shell>
|
package/src/api.ts
CHANGED
|
@@ -186,6 +186,19 @@ export type WorkspaceUsage = {
|
|
|
186
186
|
saves: number;
|
|
187
187
|
mediaTransforms: number;
|
|
188
188
|
};
|
|
189
|
+
images: {
|
|
190
|
+
transformations: number;
|
|
191
|
+
cachedServes: number;
|
|
192
|
+
uncachedServes: number;
|
|
193
|
+
bytes: number;
|
|
194
|
+
slices: {
|
|
195
|
+
bucket: "prod" | "dev";
|
|
196
|
+
transformations: number;
|
|
197
|
+
cachedServes: number;
|
|
198
|
+
uncachedServes: number;
|
|
199
|
+
bytes: number;
|
|
200
|
+
}[];
|
|
201
|
+
};
|
|
189
202
|
ai: { inputTokens: number; outputTokens: number };
|
|
190
203
|
members: number;
|
|
191
204
|
mediaBytes: number;
|
|
@@ -198,6 +211,8 @@ export type CatalogMedia = {
|
|
|
198
211
|
byteSize: number;
|
|
199
212
|
width: number;
|
|
200
213
|
height: number;
|
|
214
|
+
alt: string;
|
|
215
|
+
caption: string;
|
|
201
216
|
url: string;
|
|
202
217
|
thumbUrl: string;
|
|
203
218
|
variants: Record<string, string>;
|
|
@@ -568,6 +583,35 @@ export function fetchDocs(workspaceId: string) {
|
|
|
568
583
|
return api<{ docs: DocListItem[] }>(`/workspaces/${workspaceId}/docs`);
|
|
569
584
|
}
|
|
570
585
|
|
|
586
|
+
export type PageNavItem = {
|
|
587
|
+
key: string;
|
|
588
|
+
label: string;
|
|
589
|
+
href: string;
|
|
590
|
+
current: boolean;
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
/** Routable pages for the on-site bar menu. Paginated; does not return the full doc list. */
|
|
594
|
+
export function fetchDocNav(
|
|
595
|
+
workspaceId: string,
|
|
596
|
+
input: {
|
|
597
|
+
q?: string;
|
|
598
|
+
limit?: number;
|
|
599
|
+
offset?: number;
|
|
600
|
+
current?: { locale: string; type: string; slug: string };
|
|
601
|
+
} = {},
|
|
602
|
+
) {
|
|
603
|
+
const qs = new URLSearchParams({ nav: "1" });
|
|
604
|
+
if (input.q) qs.set("q", input.q.trim());
|
|
605
|
+
if (input.limit !== undefined) qs.set("limit", String(input.limit));
|
|
606
|
+
if (input.offset !== undefined) qs.set("offset", String(input.offset));
|
|
607
|
+
if (input.current) {
|
|
608
|
+
qs.set("locale", input.current.locale);
|
|
609
|
+
qs.set("type", input.current.type);
|
|
610
|
+
qs.set("slug", input.current.slug);
|
|
611
|
+
}
|
|
612
|
+
return api<{ items: PageNavItem[]; total: number }>(`/workspaces/${workspaceId}/docs?${qs}`);
|
|
613
|
+
}
|
|
614
|
+
|
|
571
615
|
export function fetchDoc(
|
|
572
616
|
workspaceId: string,
|
|
573
617
|
locale: string,
|
|
@@ -925,6 +969,17 @@ export function uploadMedia(
|
|
|
925
969
|
});
|
|
926
970
|
}
|
|
927
971
|
|
|
972
|
+
export function describeMedia(
|
|
973
|
+
workspaceId: string,
|
|
974
|
+
mediaId: string,
|
|
975
|
+
text: { alt?: string; caption?: string },
|
|
976
|
+
) {
|
|
977
|
+
return api<{ media: CatalogMedia }>(`/workspaces/${workspaceId}/media/${mediaId}`, {
|
|
978
|
+
method: "PATCH",
|
|
979
|
+
body: JSON.stringify(text),
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
|
|
928
983
|
export function deleteMedia(workspaceId: string, mediaId: string) {
|
|
929
984
|
return api<{ ok: true }>(`/workspaces/${workspaceId}/media/${mediaId}`, { method: "DELETE" });
|
|
930
985
|
}
|
package/src/chat/ChangeCard.tsx
CHANGED
|
@@ -157,6 +157,8 @@ const styles = stylex.create({
|
|
|
157
157
|
borderRadius: radius.md,
|
|
158
158
|
backgroundColor: colors.bgSurface,
|
|
159
159
|
overflow: "hidden",
|
|
160
|
+
// Matches the extra left inset on the message list.
|
|
161
|
+
marginLeft: `calc(-1 * ${space.xs})`,
|
|
160
162
|
},
|
|
161
163
|
cardUndone: {
|
|
162
164
|
opacity: 0.65,
|
package/src/chat/ChatMessage.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { ChangeCard } from "./ChangeCard";
|
|
|
9
9
|
import { DocListCard, type DocCardItem } from "./DocListCard";
|
|
10
10
|
import { useChatSession, useChatUI } from "./ChatProvider";
|
|
11
11
|
import {
|
|
12
|
+
assistantShowsRow,
|
|
12
13
|
bindDocsPlaceholders,
|
|
13
14
|
isCommitTool,
|
|
14
15
|
isWriteTool,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
import { CheckIcon, EyeIcon, FileIcon, SearchIcon, UndoIcon } from "./icons";
|
|
24
25
|
import { pointersFromMetadata } from "./chat-context";
|
|
25
26
|
import { PointerChip, pointerKey } from "./PointerChip";
|
|
27
|
+
import { Reveal } from "./Reveal";
|
|
26
28
|
|
|
27
29
|
const hover = "@media (hover: hover)";
|
|
28
30
|
|
|
@@ -104,17 +106,25 @@ const md = {
|
|
|
104
106
|
export function ChatMessage({
|
|
105
107
|
message,
|
|
106
108
|
isLast,
|
|
109
|
+
turnAnchor = false,
|
|
107
110
|
}: {
|
|
108
111
|
message: UIMessage;
|
|
109
112
|
isLast: boolean;
|
|
113
|
+
/** This send starts the turn the viewport should hold near the top. */
|
|
114
|
+
turnAnchor?: boolean;
|
|
110
115
|
}) {
|
|
111
116
|
const { workspaceId, tier } = useChatUI();
|
|
112
117
|
const session = useChatSession();
|
|
113
118
|
const [busyId, setBusyId] = useState<string | null>(null);
|
|
114
119
|
const canAct = tier !== "ask";
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
const
|
|
120
|
+
// Only content that mounts during this turn animates. History stays put,
|
|
121
|
+
// and Reveal latches the entrance so a fast reply can't cancel it mid-way.
|
|
122
|
+
const streaming =
|
|
123
|
+
isLast && (session.status === "streaming" || session.status === "submitted");
|
|
124
|
+
|
|
125
|
+
// A reply that is only reasoning lives on the status line. Mounting an empty
|
|
126
|
+
// row here inserts a list gap and the thinking line jumps.
|
|
127
|
+
if (message.role === "assistant" && !assistantShowsRow(message.parts)) return null;
|
|
118
128
|
|
|
119
129
|
if (message.role === "user") {
|
|
120
130
|
const text = message.parts
|
|
@@ -123,17 +133,24 @@ export function ChatMessage({
|
|
|
123
133
|
.join("\n");
|
|
124
134
|
const pointers = pointersFromMetadata(message.metadata);
|
|
125
135
|
return (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
136
|
+
// The anchor is outside the entrance transform. Measuring the rising
|
|
137
|
+
// bubble would place the slot too high, and the thread would snap back
|
|
138
|
+
// down once the transform finished.
|
|
139
|
+
<div data-turn-anchor={turnAnchor ? "" : undefined}>
|
|
140
|
+
<Reveal active={streaming} origin="end">
|
|
141
|
+
<div {...stylex.props(styles.userRow)}>
|
|
142
|
+
<div {...stylex.props(styles.userStack)}>
|
|
143
|
+
{pointers.length > 0 ? (
|
|
144
|
+
<div {...stylex.props(styles.userChips)} aria-label="Pointed elements">
|
|
145
|
+
{pointers.map((pointer) => (
|
|
146
|
+
<PointerChip key={pointerKey(pointer)} pointer={pointer} />
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
) : null}
|
|
150
|
+
{text ? <div {...stylex.props(styles.userBubble)}>{text}</div> : null}
|
|
133
151
|
</div>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</Reveal>
|
|
137
154
|
</div>
|
|
138
155
|
);
|
|
139
156
|
}
|
|
@@ -146,7 +163,6 @@ export function ChatMessage({
|
|
|
146
163
|
(c): c is ChatChangeSummary =>
|
|
147
164
|
Boolean(c) && presentedChangeStatus(c, stack) === "applied",
|
|
148
165
|
);
|
|
149
|
-
const streaming = isLast && live;
|
|
150
166
|
|
|
151
167
|
async function act(id: string, fn: () => Promise<void>) {
|
|
152
168
|
setBusyId(id);
|
|
@@ -197,7 +213,7 @@ export function ChatMessage({
|
|
|
197
213
|
}
|
|
198
214
|
|
|
199
215
|
return (
|
|
200
|
-
<div {...stylex.props(styles.assistant
|
|
216
|
+
<div {...stylex.props(styles.assistant)}>
|
|
201
217
|
{parts.map((part, i) => {
|
|
202
218
|
if (part.type === "text") {
|
|
203
219
|
const text = streaming ? trimPartialPlaceholder(part.text) : part.text;
|
|
@@ -207,12 +223,19 @@ export function ChatMessage({
|
|
|
207
223
|
return segments.map((seg, j) => {
|
|
208
224
|
if (seg.kind === "docs") {
|
|
209
225
|
const target = placeholderTarget.get(`${i}:${n++}`);
|
|
210
|
-
|
|
226
|
+
if (target === undefined) return null;
|
|
227
|
+
return (
|
|
228
|
+
<Reveal key={`${i}-docs-${j}`} active={streaming} origin="start">
|
|
229
|
+
{renderDocCard(target, `${i}-docs-${j}`)}
|
|
230
|
+
</Reveal>
|
|
231
|
+
);
|
|
211
232
|
}
|
|
212
233
|
return (
|
|
213
|
-
<
|
|
214
|
-
<
|
|
215
|
-
|
|
234
|
+
<Reveal key={`${i}-${j}`} active={streaming} origin="start">
|
|
235
|
+
<div {...stylex.props(styles.markdown)}>
|
|
236
|
+
<Markdown components={md}>{seg.text}</Markdown>
|
|
237
|
+
</div>
|
|
238
|
+
</Reveal>
|
|
216
239
|
);
|
|
217
240
|
});
|
|
218
241
|
}
|
|
@@ -250,37 +273,39 @@ export function ChatMessage({
|
|
|
250
273
|
: null;
|
|
251
274
|
const viewStatus = change ? presentedChangeStatus(change, stack) : null;
|
|
252
275
|
return (
|
|
253
|
-
<
|
|
254
|
-
<div {...stylex.props(styles.
|
|
255
|
-
<
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
{
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
276
|
+
<Reveal key={tp.toolCallId} active={streaming} origin="start">
|
|
277
|
+
<div {...stylex.props(styles.toolBlock)}>
|
|
278
|
+
<div {...stylex.props(styles.toolRow, failed && styles.toolFailed)}>
|
|
279
|
+
<ToolIcon name={name} />
|
|
280
|
+
<span>{label}</span>
|
|
281
|
+
{failed ? (
|
|
282
|
+
<span {...stylex.props(styles.toolError)}>
|
|
283
|
+
{tp.state === "output-error"
|
|
284
|
+
? tp.errorText
|
|
285
|
+
: (out as { message?: string } | null)?.message}
|
|
286
|
+
</span>
|
|
287
|
+
) : null}
|
|
288
|
+
</div>
|
|
289
|
+
{docList ? (
|
|
290
|
+
<DocListCard
|
|
291
|
+
items={docList.items}
|
|
292
|
+
total={docList.total}
|
|
293
|
+
workspaceId={workspaceId}
|
|
294
|
+
emptyLabel={name === "searchDocs" ? "No matches" : "No documents"}
|
|
295
|
+
/>
|
|
296
|
+
) : null}
|
|
297
|
+
{change && viewStatus ? (
|
|
298
|
+
<ChangeCard
|
|
299
|
+
change={{ ...change, status: viewStatus }}
|
|
300
|
+
workspaceId={workspaceId}
|
|
301
|
+
busy={busyId === change.id}
|
|
302
|
+
canAct={canAct && !streaming}
|
|
303
|
+
onKeep={() => void act(change.id, () => session.keep(change.id))}
|
|
304
|
+
onUndo={() => void act(change.id, () => session.undo(change.id))}
|
|
305
|
+
/>
|
|
263
306
|
) : null}
|
|
264
307
|
</div>
|
|
265
|
-
|
|
266
|
-
<DocListCard
|
|
267
|
-
items={docList.items}
|
|
268
|
-
total={docList.total}
|
|
269
|
-
workspaceId={workspaceId}
|
|
270
|
-
emptyLabel={name === "searchDocs" ? "No matches" : "No documents"}
|
|
271
|
-
/>
|
|
272
|
-
) : null}
|
|
273
|
-
{change && viewStatus ? (
|
|
274
|
-
<ChangeCard
|
|
275
|
-
change={{ ...change, status: viewStatus }}
|
|
276
|
-
workspaceId={workspaceId}
|
|
277
|
-
busy={busyId === change.id}
|
|
278
|
-
canAct={canAct && !streaming}
|
|
279
|
-
onKeep={() => void act(change.id, () => session.keep(change.id))}
|
|
280
|
-
onUndo={() => void act(change.id, () => session.undo(change.id))}
|
|
281
|
-
/>
|
|
282
|
-
) : null}
|
|
283
|
-
</div>
|
|
308
|
+
</Reveal>
|
|
284
309
|
);
|
|
285
310
|
}
|
|
286
311
|
return null;
|
|
@@ -312,21 +337,7 @@ export function ChatMessage({
|
|
|
312
337
|
);
|
|
313
338
|
}
|
|
314
339
|
|
|
315
|
-
const enter = stylex.keyframes({
|
|
316
|
-
from: { opacity: 0, transform: "translateY(10px)" },
|
|
317
|
-
to: { opacity: 1, transform: "translateY(0)" },
|
|
318
|
-
});
|
|
319
|
-
|
|
320
340
|
const styles = stylex.create({
|
|
321
|
-
enter: {
|
|
322
|
-
animationName: {
|
|
323
|
-
default: enter,
|
|
324
|
-
"@media (prefers-reduced-motion: reduce)": "none",
|
|
325
|
-
},
|
|
326
|
-
animationDuration: "220ms",
|
|
327
|
-
animationTimingFunction: "cubic-bezier(0.2, 0.8, 0.2, 1)",
|
|
328
|
-
animationFillMode: "backwards",
|
|
329
|
-
},
|
|
330
341
|
userRow: {
|
|
331
342
|
display: "flex",
|
|
332
343
|
justifyContent: "flex-end",
|
package/src/chat/ChatPanel.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Floating chat window anchored bottom-right above the ChatBar. Supports
|
|
4
4
|
// minimize (collapses into the bar), fullscreen, and a history view.
|
|
5
|
-
import { useEffect } from "react";
|
|
5
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
6
6
|
import * as stylex from "@stylexjs/stylex";
|
|
7
7
|
import { colors, radius, space, type, z } from "../styles/tokens.stylex";
|
|
8
8
|
import { Tooltip } from "../ui/Tooltip";
|
|
@@ -10,9 +10,10 @@ import { ChatMessage } from "./ChatMessage";
|
|
|
10
10
|
import { useChatSession, useChatUI } from "./ChatProvider";
|
|
11
11
|
import { CONTEXT_BAR_PADDING, CONTEXT_ROW_HEIGHT, Composer } from "./Composer";
|
|
12
12
|
import { LiveStatus } from "./LiveStatus";
|
|
13
|
+
import { motion } from "./motion";
|
|
13
14
|
import { Shimmer } from "./Shimmer";
|
|
14
15
|
import { ThreadHistory } from "./ThreadHistory";
|
|
15
|
-
import { useStickToBottom } from "./useStickToBottom";
|
|
16
|
+
import { useStickToBottom, TURN_PEEK, turnSpacer } from "./useStickToBottom";
|
|
16
17
|
import {
|
|
17
18
|
CloseIcon,
|
|
18
19
|
CollapseIcon,
|
|
@@ -31,13 +32,101 @@ export function ChatPanel({ fill = false, site = false }: { fill?: boolean; site
|
|
|
31
32
|
const session = useChatSession();
|
|
32
33
|
const visible = isPanelVisible(ui.panel.mode);
|
|
33
34
|
const fullscreen = ui.panel.mode === "fullscreen";
|
|
34
|
-
|
|
35
|
+
const threadId = session.thread?.id ?? null;
|
|
36
|
+
const knownIds = useRef<Set<string> | null>(null);
|
|
37
|
+
const seenThread = useRef<string | null | undefined>(undefined);
|
|
38
|
+
const [anchorId, setAnchorId] = useState<string | null>(null);
|
|
39
|
+
const [viewportTick, setViewportTick] = useState(0);
|
|
40
|
+
const spacerRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
const slotRef = useRef<number | null>(null);
|
|
42
|
+
// Captured when the sent message first lands. Later replies must not
|
|
43
|
+
// remeasure it — a new number becomes a new scroll target and the thread jumps.
|
|
44
|
+
const heldSlot = useRef<{ id: string; slot: number } | null>(null);
|
|
45
|
+
// Ease to the live edge on new messages and while streaming, unless the
|
|
46
|
+
// reader scrolled away. A sent message becomes the anchor: the tail grows
|
|
47
|
+
// so that edge leaves the message near the top, and the thread rises to it.
|
|
35
48
|
const scroller = useStickToBottom<HTMLDivElement>({
|
|
36
49
|
active: visible && ui.panel.view === "chat" && !session.loading,
|
|
37
|
-
resetKey:
|
|
38
|
-
|
|
50
|
+
resetKey: threadId,
|
|
51
|
+
pinKey: anchorId,
|
|
52
|
+
slotRef,
|
|
53
|
+
deps: [session.messages, session.status, viewportTick],
|
|
39
54
|
});
|
|
40
55
|
|
|
56
|
+
if (seenThread.current !== threadId) {
|
|
57
|
+
seenThread.current = threadId;
|
|
58
|
+
knownIds.current = null;
|
|
59
|
+
if (anchorId !== null) setAnchorId(null);
|
|
60
|
+
}
|
|
61
|
+
if (!session.loading && knownIds.current === null) {
|
|
62
|
+
knownIds.current = new Set(session.messages.map((m) => m.id));
|
|
63
|
+
} else if (!session.loading && knownIds.current) {
|
|
64
|
+
let fresh: string | null = null;
|
|
65
|
+
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
66
|
+
const message = session.messages[i];
|
|
67
|
+
if (message.role === "user" && !knownIds.current.has(message.id)) {
|
|
68
|
+
fresh = message.id;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const message of session.messages) knownIds.current.add(message.id);
|
|
73
|
+
if (fresh && fresh !== anchorId) setAnchorId(fresh);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const contextRows = Math.max(
|
|
77
|
+
1,
|
|
78
|
+
(ui.focus && ui.includeFocus ? 1 : 0) + ui.mentions.length,
|
|
79
|
+
);
|
|
80
|
+
const contextReserve = contextRows * CONTEXT_ROW_HEIGHT + CONTEXT_BAR_PADDING + 12;
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const el = scroller.ref.current;
|
|
84
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
85
|
+
let height = el.clientHeight;
|
|
86
|
+
const observer = new ResizeObserver(() => {
|
|
87
|
+
const next = el.clientHeight;
|
|
88
|
+
if (next === height) return;
|
|
89
|
+
height = next;
|
|
90
|
+
setViewportTick((n) => n + 1);
|
|
91
|
+
});
|
|
92
|
+
observer.observe(el);
|
|
93
|
+
return () => observer.disconnect();
|
|
94
|
+
}, [visible, threadId, ui.panel.view, scroller.ref]);
|
|
95
|
+
|
|
96
|
+
useLayoutEffect(() => {
|
|
97
|
+
const viewport = scroller.ref.current;
|
|
98
|
+
const spacer = spacerRef.current;
|
|
99
|
+
if (!viewport || !spacer) return;
|
|
100
|
+
const anchor = anchorId
|
|
101
|
+
? viewport.querySelector<HTMLElement>("[data-turn-anchor]")
|
|
102
|
+
: null;
|
|
103
|
+
if (!anchor || !anchorId) {
|
|
104
|
+
spacer.style.height = "0px";
|
|
105
|
+
slotRef.current = null;
|
|
106
|
+
heldSlot.current = null;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const viewTop = viewport.getBoundingClientRect().top;
|
|
110
|
+
const anchorOffset = anchor.getBoundingClientRect().top - viewTop + viewport.scrollTop;
|
|
111
|
+
const contentEnd = spacer.getBoundingClientRect().top - viewTop + viewport.scrollTop;
|
|
112
|
+
const paddingBottom = Number.parseFloat(getComputedStyle(viewport).paddingBottom) || 0;
|
|
113
|
+
if (!heldSlot.current || heldSlot.current.id !== anchorId) {
|
|
114
|
+
heldSlot.current = { id: anchorId, slot: Math.max(0, anchorOffset - TURN_PEEK) };
|
|
115
|
+
}
|
|
116
|
+
const slot = heldSlot.current.slot;
|
|
117
|
+
const height = turnSpacer({
|
|
118
|
+
clientHeight: viewport.clientHeight,
|
|
119
|
+
// The frozen slot, expressed as the anchor offset turnSpacer expects.
|
|
120
|
+
anchorOffset: slot + TURN_PEEK,
|
|
121
|
+
contentEnd,
|
|
122
|
+
paddingBottom,
|
|
123
|
+
peek: TURN_PEEK,
|
|
124
|
+
});
|
|
125
|
+
spacer.style.height = `${height}px`;
|
|
126
|
+
// While the tail still holds the turn, streaming must not pick a new target.
|
|
127
|
+
slotRef.current = height > 0 ? slot : null;
|
|
128
|
+
}, [anchorId, session.messages, session.status, viewportTick, contextReserve, scroller.ref]);
|
|
129
|
+
|
|
41
130
|
useEffect(() => {
|
|
42
131
|
if (!visible) return;
|
|
43
132
|
function onKey(e: KeyboardEvent) {
|
|
@@ -51,14 +140,6 @@ export function ChatPanel({ fill = false, site = false }: { fill?: boolean; site
|
|
|
51
140
|
|
|
52
141
|
if (!visible) return null;
|
|
53
142
|
|
|
54
|
-
// Space under the messages for the floating context bar. Always reserve at least
|
|
55
|
-
// one row so toggling the open document doesn't shift the content.
|
|
56
|
-
const contextRows = Math.max(
|
|
57
|
-
1,
|
|
58
|
-
(ui.focus && ui.includeFocus ? 1 : 0) + ui.mentions.length,
|
|
59
|
-
);
|
|
60
|
-
const contextReserve = contextRows * CONTEXT_ROW_HEIGHT + CONTEXT_BAR_PADDING + 12;
|
|
61
|
-
|
|
62
143
|
const title =
|
|
63
144
|
ui.panel.view === "history"
|
|
64
145
|
? "Chat history"
|
|
@@ -162,7 +243,7 @@ export function ChatPanel({ fill = false, site = false }: { fill?: boolean; site
|
|
|
162
243
|
)}
|
|
163
244
|
>
|
|
164
245
|
{session.loading ? (
|
|
165
|
-
<p {...stylex.props(styles.muted)}>
|
|
246
|
+
<p {...stylex.props(styles.muted, motion.rise, motion.originStart)}>
|
|
166
247
|
<Shimmer>Loading chat…</Shimmer>
|
|
167
248
|
</p>
|
|
168
249
|
) : session.messages.length === 0 ? (
|
|
@@ -178,6 +259,7 @@ export function ChatPanel({ fill = false, site = false }: { fill?: boolean; site
|
|
|
178
259
|
key={m.id}
|
|
179
260
|
message={m}
|
|
180
261
|
isLast={i === session.messages.length - 1}
|
|
262
|
+
turnAnchor={m.id === anchorId}
|
|
181
263
|
/>
|
|
182
264
|
))}
|
|
183
265
|
<LiveStatus parts={liveParts} status={session.status} />
|
|
@@ -195,6 +277,7 @@ export function ChatPanel({ fill = false, site = false }: { fill?: boolean; site
|
|
|
195
277
|
</button>
|
|
196
278
|
</div>
|
|
197
279
|
) : null}
|
|
280
|
+
<div ref={spacerRef} aria-hidden="true" {...stylex.props(styles.tail)} />
|
|
198
281
|
</div>
|
|
199
282
|
<div {...stylex.props(fullscreen && styles.composerFull)}>
|
|
200
283
|
<Composer autoFocus={visible} />
|
|
@@ -252,8 +335,11 @@ const styles = stylex.create({
|
|
|
252
335
|
},
|
|
253
336
|
panelSite: {
|
|
254
337
|
right: 16,
|
|
255
|
-
bottom: "calc(
|
|
338
|
+
bottom: "calc(52px + env(safe-area-inset-bottom, 0px))",
|
|
256
339
|
zIndex: 2147483001,
|
|
340
|
+
width: "min(400px, calc(100vw - 32px))",
|
|
341
|
+
height: "min(640px, calc(100vh - 68px - env(safe-area-inset-bottom, 0px)))",
|
|
342
|
+
transformOrigin: "calc(100% - 60px) 100%",
|
|
257
343
|
},
|
|
258
344
|
panelFill: {
|
|
259
345
|
position: "fixed",
|
|
@@ -339,7 +425,12 @@ const styles = stylex.create({
|
|
|
339
425
|
flex: 1,
|
|
340
426
|
minHeight: 0,
|
|
341
427
|
overflowY: "auto",
|
|
428
|
+
// The browser must not correct scroll when the send tail appears, or the
|
|
429
|
+
// rise below starts from the wrong place.
|
|
430
|
+
overflowAnchor: "none",
|
|
342
431
|
padding: space.lg,
|
|
432
|
+
// A little more room on the left. Cards pull back by the same amount.
|
|
433
|
+
paddingLeft: `calc(${space.lg} + ${space.xs})`,
|
|
343
434
|
},
|
|
344
435
|
messagesReserve: (px: number) => ({
|
|
345
436
|
paddingBottom: px,
|
|
@@ -370,6 +461,10 @@ const styles = stylex.create({
|
|
|
370
461
|
lineHeight: 1.5,
|
|
371
462
|
color: colors.textMuted,
|
|
372
463
|
},
|
|
464
|
+
tail: {
|
|
465
|
+
flexShrink: 0,
|
|
466
|
+
width: "100%",
|
|
467
|
+
},
|
|
373
468
|
error: {
|
|
374
469
|
display: "flex",
|
|
375
470
|
flexShrink: 0,
|
package/src/chat/DocListCard.tsx
CHANGED
|
@@ -178,6 +178,8 @@ const styles = stylex.create({
|
|
|
178
178
|
borderRadius: radius.md,
|
|
179
179
|
backgroundColor: colors.bgSurface,
|
|
180
180
|
overflow: "hidden",
|
|
181
|
+
// Matches the extra left inset on the message list.
|
|
182
|
+
marginLeft: `calc(-1 * ${space.xs})`,
|
|
181
183
|
},
|
|
182
184
|
empty: {
|
|
183
185
|
paddingInline: space.md,
|