@backmatter/studio 0.0.7 → 0.0.9

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 (72) hide show
  1. package/dist/chunks/UiKitApp-b8qFsmA-.js +2401 -0
  2. package/dist/chunks/WorkspaceRoutes-BKKEkAq7.js +90126 -0
  3. package/dist/chunks/{dashboard-DCHRI0WD.js → dashboard-BNUcbYU0.js} +380 -382
  4. package/dist/chunks/{index-BS0-Bvbk.js → index-laWAz5_p.js} +111 -112
  5. package/dist/dashboard.js +2 -2
  6. package/dist/index.js +287 -146
  7. package/dist/site-widget.css +1615 -0
  8. package/dist/site-widget.js +30896 -630
  9. package/dist/studio.css +303 -98
  10. package/package.json +4 -4
  11. package/src/App.tsx +12 -29
  12. package/src/api.ts +55 -0
  13. package/src/chat/ChangeCard.tsx +2 -0
  14. package/src/chat/ChatMessage.tsx +73 -62
  15. package/src/chat/ChatPanel.tsx +110 -15
  16. package/src/chat/DocListCard.tsx +2 -0
  17. package/src/chat/LiveStatus.tsx +78 -8
  18. package/src/chat/Reveal.tsx +34 -0
  19. package/src/chat/Shimmer.tsx +65 -23
  20. package/src/chat/chat-helpers.ts +35 -0
  21. package/src/chat/motion.ts +73 -0
  22. package/src/chat/useStickToBottom.ts +129 -14
  23. package/src/chrome/AppShell.tsx +47 -10
  24. package/src/chrome/AppSidebar.tsx +9 -34
  25. package/src/chrome/ContentExplorerSidebar.tsx +149 -96
  26. package/src/chrome/UserMenu.tsx +3 -1
  27. package/src/chrome/content-tree-visibility.ts +55 -0
  28. package/src/chrome/explorer-icons.tsx +2 -4
  29. package/src/devbar-state.ts +125 -0
  30. package/src/index.css +3 -131
  31. package/src/pages/CliAuthorizePage.tsx +61 -28
  32. package/src/pages/MediaGalleryPage.tsx +144 -85
  33. package/src/pages/MediaLightbox.tsx +195 -96
  34. package/src/pages/SelectRepositoriesLink.tsx +28 -4
  35. package/src/pages/SettingsGeneralPage.tsx +25 -28
  36. package/src/pages/SettingsGithubPage.tsx +413 -124
  37. package/src/pages/SettingsUsagePage.tsx +23 -1
  38. package/src/pages/media-grid.ts +14 -1
  39. package/src/pages/ui-kit/UiKitApp.tsx +164 -0
  40. package/src/pages/ui-kit/buttons.tsx +30 -0
  41. package/src/pages/ui-kit/catalog.tsx +41 -0
  42. package/src/pages/ui-kit/colors.tsx +95 -0
  43. package/src/pages/ui-kit/devbar.tsx +160 -0
  44. package/src/pages/ui-kit/feedback.tsx +39 -0
  45. package/src/pages/ui-kit/fields.tsx +47 -0
  46. package/src/pages/ui-kit/lists.tsx +19 -0
  47. package/src/pages/ui-kit/overlays.tsx +39 -0
  48. package/src/pages/ui-kit/path.ts +9 -0
  49. package/src/pages/ui-kit/sections.tsx +32 -0
  50. package/src/pages/ui-kit/surfaces.tsx +121 -0
  51. package/src/pages/ui-kit/topic.tsx +112 -0
  52. package/src/pages/ui-kit/topics.ts +71 -0
  53. package/src/pages/ui-kit/type.tsx +55 -0
  54. package/src/preview-frame-radius.ts +51 -0
  55. package/src/preview-frame.tsx +93 -49
  56. package/src/shell-resets.css +155 -0
  57. package/src/site-bar.tsx +1791 -425
  58. package/src/site-dashboard-href.ts +21 -0
  59. package/src/site-devbar-overlay-inject.ts +15 -0
  60. package/src/site-devbar-overlay.css +217 -0
  61. package/src/site-pointer.ts +1 -1
  62. package/src/site-publish-menu.tsx +33 -0
  63. package/src/site-widget-styles.ts +7 -0
  64. package/src/site-widget.tsx +61 -21
  65. package/src/studio-prose-ui.css +39 -0
  66. package/src/theme/ThemeProvider.tsx +4 -1
  67. package/src/workspace-path.ts +18 -1
  68. package/dist/chunks/EmbedPublishPage-88VsmOhi.js +0 -149
  69. package/dist/chunks/UiKitPage-X4hkFL2d.js +0 -173
  70. package/dist/chunks/WorkspaceRoutes-DXzdWqN3.js +0 -59523
  71. package/dist/chunks/status-bar-action-wj1KZ51m.js +0 -30612
  72. 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.7",
3
+ "version": "0.0.9",
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.24",
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 { isStudioDev } from "./studio-config";
33
+ import { isUiKitPath } from "./pages/ui-kit/path";
34
34
 
35
- const UiKitPage = isStudioDev()
36
- ? lazy(() =>
37
- import("./pages/UiKitPage").then((mod) => ({ default: mod.UiKitPage })),
38
- )
39
- : null;
35
+ const UiKitApp = lazy(() =>
36
+ import("./pages/ui-kit/UiKitApp").then((mod) => ({ default: mod.UiKitApp })),
37
+ );
40
38
 
41
- export function isUiKitPath(pathname: string) {
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 (UiKitPage && isUiKitPath(location.pathname)) {
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
- <UiKitPage />
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
  }
@@ -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,
@@ -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
- const live = session.status === "streaming" || session.status === "submitted";
116
- // Only the message being added animates in; history loads render in place.
117
- const entering = isLast && live;
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
- <div {...stylex.props(styles.userRow, entering && styles.enter)}>
127
- <div {...stylex.props(styles.userStack)}>
128
- {pointers.length > 0 ? (
129
- <div {...stylex.props(styles.userChips)} aria-label="Pointed elements">
130
- {pointers.map((pointer) => (
131
- <PointerChip key={pointerKey(pointer)} pointer={pointer} />
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
- ) : null}
135
- {text ? <div {...stylex.props(styles.userBubble)}>{text}</div> : null}
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, entering && styles.enter)}>
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
- return target === undefined ? null : renderDocCard(target, `${i}-docs-${j}`);
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
- <div key={`${i}-${j}`} {...stylex.props(styles.markdown)}>
214
- <Markdown components={md}>{seg.text}</Markdown>
215
- </div>
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
- <div key={tp.toolCallId} {...stylex.props(styles.toolBlock)}>
254
- <div {...stylex.props(styles.toolRow, failed && styles.toolFailed)}>
255
- <ToolIcon name={name} />
256
- <span>{label}</span>
257
- {failed ? (
258
- <span {...stylex.props(styles.toolError)}>
259
- {tp.state === "output-error"
260
- ? tp.errorText
261
- : (out as { message?: string } | null)?.message}
262
- </span>
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
- {docList ? (
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",
@@ -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
- // Ease to the bottom on new messages and while streaming, unless the user scrolled up.
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: session.thread?.id ?? null,
38
- deps: [session.messages, session.status],
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(48px + env(safe-area-inset-bottom, 0px))",
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,
@@ -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,