@backmatter/studio 0.0.15 → 0.0.17

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 (80) hide show
  1. package/dist/chunks/{UiKitApp-BDsrXN12.js → UiKitApp-D7vllRQE.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-BTZ1Ah4F.js → WorkspaceRoutes-DH1LD-2Q.js} +30013 -27965
  3. package/dist/chunks/{dashboard-FT7dm5wi.js → dashboard-Dsaeud2x.js} +55 -54
  4. package/dist/chunks/{define-studio-BYtYDGLX.js → define-studio-DlYjgrwr.js} +12 -16
  5. package/dist/chunks/{index-DHJvh5Uy.js → index-CGMqw2NH.js} +1 -1
  6. package/dist/dashboard.js +2 -2
  7. package/dist/define.js +1 -1
  8. package/dist/index.js +4 -4
  9. package/dist/site-widget.css +49 -1
  10. package/dist/site-widget.js +8341 -8058
  11. package/dist/studio.css +279 -13
  12. package/package.json +2 -2
  13. package/src/WorkspaceRoutes.tsx +5 -2
  14. package/src/api.ts +82 -7
  15. package/src/chat/ChangeCard.tsx +8 -2
  16. package/src/chat/ChatMessage.tsx +171 -102
  17. package/src/chat/ChatPanel.tsx +54 -34
  18. package/src/chat/ChatProvider.tsx +122 -20
  19. package/src/chat/Composer.tsx +51 -8
  20. package/src/chat/PointerChip.tsx +9 -5
  21. package/src/chat/ThreadHistory.tsx +3 -3
  22. package/src/chat/chat-context.ts +67 -3
  23. package/src/chat/chat-helpers.ts +12 -0
  24. package/src/chat/chat-state.ts +11 -0
  25. package/src/chrome/AppShell.tsx +16 -5
  26. package/src/chrome/CommandPalette.tsx +12 -0
  27. package/src/chrome/ContentExplorerSidebar.tsx +23 -3
  28. package/src/chrome/Toast.tsx +115 -0
  29. package/src/chrome/UserMenu.tsx +34 -3
  30. package/src/chrome/WorkspaceStatusBar.tsx +221 -30
  31. package/src/chrome/commit-error.ts +13 -0
  32. package/src/chrome/content-api-url.ts +0 -78
  33. package/src/chrome/content-tree-visibility.ts +13 -3
  34. package/src/chrome/explorer-icons.tsx +5 -0
  35. package/src/chrome/pull-failure.ts +61 -0
  36. package/src/code-session-entry.ts +9 -0
  37. package/src/components/WorkspaceSwitcher.tsx +2 -2
  38. package/src/element-origin.ts +149 -0
  39. package/src/fields/FieldControl.tsx +748 -0
  40. package/src/fields/choose-field-input.ts +64 -0
  41. package/src/fields/load-relation-documents.ts +35 -0
  42. package/src/fields/omit-field.ts +12 -0
  43. package/src/fields/relation-targets.ts +44 -0
  44. package/src/fields/resolve-object-fields.ts +21 -0
  45. package/src/host-pointer-bootstrap.ts +11 -0
  46. package/src/host-pointer.ts +147 -0
  47. package/src/install-preview-pointer.ts +19 -3
  48. package/src/pages/BlockSettingsDialog.tsx +19 -115
  49. package/src/pages/BlocksList.tsx +43 -35
  50. package/src/pages/BodyProse.tsx +16 -1
  51. package/src/pages/CodeSessionFiles.tsx +221 -0
  52. package/src/pages/CodeSessionPage.tsx +1361 -0
  53. package/src/pages/DocEditorPage.tsx +120 -107
  54. package/src/pages/PreviewSize.tsx +181 -0
  55. package/src/pages/RenderedBody.tsx +17 -2
  56. package/src/pages/SettingsGeneralPage.tsx +67 -2
  57. package/src/pages/WorkspaceHomePage.tsx +313 -0
  58. package/src/pages/WorkspaceLayout.tsx +10 -1
  59. package/src/pages/branch-notice.ts +16 -0
  60. package/src/pages/code-session-boot.ts +43 -0
  61. package/src/pages/code-session-context.tsx +53 -0
  62. package/src/pages/code-session-files.ts +42 -0
  63. package/src/pages/code-session-frame.ts +52 -0
  64. package/src/pages/code-session-routes.ts +42 -0
  65. package/src/pages/home-rows.ts +140 -0
  66. package/src/pages/preview-size.ts +33 -0
  67. package/src/pages/session-preview.ts +109 -0
  68. package/src/pages/use-session-preview.ts +70 -0
  69. package/src/replica/store.ts +11 -0
  70. package/src/replica/types.ts +6 -0
  71. package/src/shell-resets.css +15 -0
  72. package/src/site-pointer.ts +24 -5
  73. package/src/studio-config.ts +1 -1
  74. package/src/ui/Input.tsx +178 -20
  75. package/src/ui/ListRow.tsx +12 -3
  76. package/src/ui/Page.tsx +12 -1
  77. package/src/ui/popup.ts +11 -2
  78. package/src/workspace-path.ts +35 -3
  79. package/src/chrome/ContentApiPopover.tsx +0 -215
  80. package/src/pages/WorkspaceHomeRedirect.tsx +0 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backmatter/studio",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
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": {
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@ai-sdk/react": "^4.0.100",
59
- "@backmatter/core": "^0.0.28",
59
+ "@backmatter/core": "^0.0.34",
60
60
  "@base-ui/react": "^1.6.0",
61
61
  "@central-icons-react/round-outlined-radius-2-stroke-1.5": "1.2.15",
62
62
  "@dnd-kit/abstract": "^0.5.0",
@@ -3,7 +3,7 @@
3
3
  // a site Studio mounts the same tree under its base path.
4
4
  import { Route } from "react-router";
5
5
  import { WorkspaceLayout } from "./pages/WorkspaceLayout";
6
- import { WorkspaceHomeRedirect } from "./pages/WorkspaceHomeRedirect";
6
+ import { WorkspaceHomePage } from "./pages/WorkspaceHomePage";
7
7
  import { TypeDocsPage } from "./pages/TypeDocsPage";
8
8
  import { SettingsGeneralPage } from "./pages/SettingsGeneralPage";
9
9
  import { SettingsUsagePage } from "./pages/SettingsUsagePage";
@@ -15,6 +15,7 @@ import { SettingsUsersPage } from "./pages/SettingsUsersPage";
15
15
  import { GithubConnectPage } from "./pages/GithubConnectPage";
16
16
  import { DocEditorPage } from "./pages/DocEditorPage";
17
17
  import { MediaGalleryPage } from "./pages/MediaGalleryPage";
18
+ import CodeSessionPage from "./pages/CodeSessionPage";
18
19
 
19
20
  /**
20
21
  * Returns `<Route>` elements. Spread the result inside a `<Routes>`; react-router
@@ -23,8 +24,10 @@ import { MediaGalleryPage } from "./pages/MediaGalleryPage";
23
24
  export function workspaceRoutes() {
24
25
  return (
25
26
  <Route path="/:workspaceSlug" element={<WorkspaceLayout />}>
26
- <Route index element={<WorkspaceHomeRedirect />} />
27
+ <Route index element={<WorkspaceHomePage />} />
28
+ <Route path="content" element={<TypeDocsPage />} />
27
29
  <Route path="content/:type" element={<TypeDocsPage />} />
30
+ <Route path="code" element={<CodeSessionPage />} />
28
31
  <Route element={<SettingsLayout />}>
29
32
  <Route path="settings" element={<SettingsIndexRedirect />} />
30
33
  <Route path="settings/general" element={<SettingsGeneralPage />} />
package/src/api.ts CHANGED
@@ -23,6 +23,7 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
23
23
  const body = (await res.json().catch(() => ({}))) as {
24
24
  error?: string;
25
25
  details?: string[];
26
+ paths?: string[];
26
27
  };
27
28
  if (res.status === 401 || res.status === 426) reportUnauthorized(res.status, body.error);
28
29
  const code = body.error ?? `request failed: ${res.status}`;
@@ -33,7 +34,7 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
33
34
  const err = new Error(`${code}${detailSuffix}`) as ApiError;
34
35
  err.status = res.status;
35
36
  err.code = body.error;
36
- err.details = body.details;
37
+ err.details = body.details ?? body.paths;
37
38
  throw err;
38
39
  }
39
40
  return res.json() as Promise<T>;
@@ -355,6 +356,11 @@ export type SchemaField = {
355
356
  fields?: Record<string, SchemaField>;
356
357
  /** Studio input name from `field.text({ input })`; see `defineStudio`. */
357
358
  input?: string;
359
+ options?: string[];
360
+ min?: number;
361
+ max?: number;
362
+ step?: number;
363
+ where?: Record<string, string | number | boolean>;
358
364
  };
359
365
 
360
366
  export type BlockSnapshot = {
@@ -371,6 +377,8 @@ export type SchemaPayload = {
371
377
  singleton?: boolean;
372
378
  indexSlug?: string;
373
379
  format?: "mdx" | "page";
380
+ external?: boolean;
381
+ template?: string;
374
382
  fields: Record<string, SchemaField>;
375
383
  }>;
376
384
  blocks?: BlockSnapshot[];
@@ -400,10 +408,21 @@ export type GitChange = {
400
408
  type: string;
401
409
  slug: string;
402
410
  status: ChangeStatus;
411
+ /** Working-tree edit time. Present on git status; absent when derived from the document list. */
412
+ updatedAt?: string;
403
413
  };
404
414
 
405
415
  export type GitHead = { sha: string; updatedAt: string };
406
416
 
417
+ /** A commit on the branch, newest first. `cms` means Studio wrote it. */
418
+ export type RecentCommit = {
419
+ sha: string;
420
+ message: string;
421
+ author: string;
422
+ committedAt: string;
423
+ cms: boolean;
424
+ };
425
+
407
426
  /** `git status` for the current user on their checked-out branch. */
408
427
  export type GitState = {
409
428
  defaultBranch: string;
@@ -413,6 +432,10 @@ export type GitState = {
413
432
  githubError?: "installation_missing" | "github_list_failed" | null;
414
433
  head: GitHead | null;
415
434
  changes: GitChange[];
435
+ /** Last git status list, including deletions and edit times. */
436
+ fetchedChanges?: GitChange[];
437
+ /** Latest commits on the branch, once git status has been fetched. */
438
+ recentCommits?: RecentCommit[];
416
439
  };
417
440
 
418
441
  export function countConflicts(state: Pick<GitState, "changes"> | null | undefined) {
@@ -466,6 +489,46 @@ export function commitWorkspace(workspaceId: string, message?: string) {
466
489
  });
467
490
  }
468
491
 
492
+ export type CodeSessionEditList = {
493
+ files: Array<{ path: string; deleted: boolean }>;
494
+ skipped: Array<{ path: string; reason: string }>;
495
+ foldPending: boolean;
496
+ };
497
+
498
+ export function fetchCodeSessionEdits(workspaceId: string, sessionId: string) {
499
+ return api<CodeSessionEditList>(
500
+ `/workspaces/${workspaceId}/code-sessions/${sessionId}/edits`,
501
+ );
502
+ }
503
+
504
+ export function updateCodeSessionBranch(workspaceId: string, sessionId: string) {
505
+ return api<{ ok: true; baseSha: string }>(
506
+ `/workspaces/${workspaceId}/code-sessions/${sessionId}/update`,
507
+ { method: "POST" },
508
+ );
509
+ }
510
+
511
+ export function clearCodeSessionEdits(workspaceId: string, sessionId: string) {
512
+ return api<{ ok: true; baseSha: string }>(
513
+ `/workspaces/${workspaceId}/code-sessions/${sessionId}/clear-edits`,
514
+ { method: "POST" },
515
+ );
516
+ }
517
+
518
+ export function commitCodeSessionEdits(
519
+ workspaceId: string,
520
+ sessionId: string,
521
+ message?: string,
522
+ ) {
523
+ return api<{ ok: true; commitSha: string; foldErrors: string[] }>(
524
+ `/workspaces/${workspaceId}/code-sessions/${sessionId}/commit`,
525
+ {
526
+ method: "POST",
527
+ body: JSON.stringify(message?.trim() ? { message: message.trim() } : {}),
528
+ },
529
+ );
530
+ }
531
+
469
532
  /** Default commit message the server uses when none is given (mirrored for the placeholder). */
470
533
  export function defaultCommitMessage(changes: GitChange[]) {
471
534
  if (changes.length === 1) {
@@ -701,6 +764,8 @@ export type ChatThread = {
701
764
  workspaceId: string;
702
765
  userId: string;
703
766
  branch: string;
767
+ /** Null for an editor thread; a code session id for a session thread. */
768
+ codeSessionId?: string | null;
704
769
  title: string;
705
770
  createdAt: string;
706
771
  updatedAt: string;
@@ -728,10 +793,11 @@ export type ChatChangeSummary = {
728
793
  workspaceId: string;
729
794
  userId: string;
730
795
  branch: string;
731
- resourceKind: "content";
796
+ resourceKind: "content" | "code";
797
+ path: string | null;
732
798
  type: string;
733
799
  slug: string;
734
- op: "write" | "create";
800
+ op: "write" | "create" | "delete";
735
801
  beforeSource: ContentSource | null;
736
802
  diff: ChatChangeDiff | null;
737
803
  status: ChatChangeStatus;
@@ -740,14 +806,23 @@ export type ChatChangeSummary = {
740
806
  summary?: string;
741
807
  };
742
808
 
743
- export function fetchThreads(workspaceId: string) {
744
- return api<{ threads: ChatThread[] }>(`/workspaces/${workspaceId}/ai/threads`);
809
+ export function fetchThreads(workspaceId: string, codeSessionId?: string) {
810
+ const query = codeSessionId
811
+ ? `?codeSessionId=${encodeURIComponent(codeSessionId)}`
812
+ : "";
813
+ return api<{ threads: ChatThread[] }>(`/workspaces/${workspaceId}/ai/threads${query}`);
745
814
  }
746
815
 
747
- export function createThread(workspaceId: string, title?: string) {
816
+ export function createThread(
817
+ workspaceId: string,
818
+ opts?: { title?: string; codeSessionId?: string },
819
+ ) {
820
+ const body: { title?: string; codeSessionId?: string } = {};
821
+ if (opts?.title) body.title = opts.title;
822
+ if (opts?.codeSessionId) body.codeSessionId = opts.codeSessionId;
748
823
  return api<{ thread: ChatThread }>(`/workspaces/${workspaceId}/ai/threads`, {
749
824
  method: "POST",
750
- body: JSON.stringify(title ? { title } : {}),
825
+ body: JSON.stringify(body),
751
826
  });
752
827
  }
753
828
 
@@ -28,8 +28,14 @@ export function ChangeCard({
28
28
  }) {
29
29
  const [expanded, setExpanded] = useState(false);
30
30
  const nav = useWorkspaceNav();
31
- const label = `${change.type}/${change.slug}`;
32
- const to = nav ? contentDocPath(nav.workspaceSlug, change.type, change.slug) : "#";
31
+ const label =
32
+ change.resourceKind === "code"
33
+ ? (change.path ?? "")
34
+ : `${change.type}/${change.slug}`;
35
+ const to =
36
+ change.resourceKind === "code" || !nav
37
+ ? "#"
38
+ : contentDocPath(nav.workspaceSlug, change.type, change.slug);
33
39
  const status = change.status;
34
40
 
35
41
  return (
@@ -1,5 +1,5 @@
1
1
  // packages/studio/src/chat/ChatMessage.tsx
2
- import { useState, type ComponentPropsWithoutRef } from "react";
2
+ import { useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
3
3
  import Markdown from "react-markdown";
4
4
  import * as stylex from "@stylexjs/stylex";
5
5
  import { getToolName, isToolUIPart, type UIMessage } from "ai";
@@ -22,7 +22,7 @@ import {
22
22
  changesInToolOutput,
23
23
  type WriteToolOutput,
24
24
  } from "./chat-helpers";
25
- import { CheckIcon, EyeIcon, FileIcon, SearchIcon, UndoIcon } from "./icons";
25
+ import { CheckIcon, ChevronDownIcon, EyeIcon, FileIcon, SearchIcon, UndoIcon } from "./icons";
26
26
  import { pointersFromMetadata } from "./chat-context";
27
27
  import { PointerChip, pointerKey } from "./PointerChip";
28
28
  import { Reveal } from "./Reveal";
@@ -79,6 +79,51 @@ function ToolIcon({ name }: { name: string }) {
79
79
  return <FileIcon size={12} />;
80
80
  }
81
81
 
82
+ type TraceRow = {
83
+ key: string;
84
+ name: string;
85
+ label: string;
86
+ failed: boolean;
87
+ error: string | null;
88
+ };
89
+
90
+ function ToolTrace({ rows }: { rows: TraceRow[] }) {
91
+ const [open, setOpen] = useState(false);
92
+ if (rows.length === 0) return null;
93
+ const list = rows.map((row) => (
94
+ <div key={row.key} {...stylex.props(styles.toolRow, row.failed && styles.toolFailed)}>
95
+ <ToolIcon name={row.name} />
96
+ <span>{row.label}</span>
97
+ {row.error ? <span {...stylex.props(styles.toolError)}>{row.error}</span> : null}
98
+ </div>
99
+ ));
100
+
101
+ const failed = rows.filter((row) => row.failed).length;
102
+ const summary = rows.length === 1 ? "1 step" : `${rows.length} steps`;
103
+ return (
104
+ <div {...stylex.props(styles.toolBlock)}>
105
+ <button
106
+ type="button"
107
+ aria-expanded={open}
108
+ aria-label={open ? `Hide ${summary}` : `Show ${summary}`}
109
+ onClick={() => setOpen((value) => !value)}
110
+ {...stylex.props(styles.traceToggle)}
111
+ >
112
+ <span {...stylex.props(styles.traceChevron, open && styles.traceChevronOpen)}>
113
+ <ChevronDownIcon />
114
+ </span>
115
+ <span>{summary}</span>
116
+ {failed > 0 ? (
117
+ <span {...stylex.props(styles.toolFailed)}>
118
+ {failed === 1 ? "1 failed" : `${failed} failed`}
119
+ </span>
120
+ ) : null}
121
+ </button>
122
+ {open ? list : null}
123
+ </div>
124
+ );
125
+ }
126
+
82
127
  const md = {
83
128
  p: (p: ComponentPropsWithoutRef<"p">) => <p {...p} {...stylex.props(mdStyles.p)} />,
84
129
  ul: (p: ComponentPropsWithoutRef<"ul">) => <ul {...p} {...stylex.props(mdStyles.list)} />,
@@ -213,108 +258,107 @@ export function ChatMessage({
213
258
  );
214
259
  }
215
260
 
216
- return (
217
- <div {...stylex.props(styles.assistant)}>
218
- {parts.map((part, i) => {
219
- if (part.type === "text") {
220
- const text = streaming ? trimPartialPlaceholder(part.text) : part.text;
221
- if (!text.trim()) return null;
222
- const segments = splitDocsPlaceholders(text);
223
- let n = 0;
224
- return segments.map((seg, j) => {
225
- if (seg.kind === "docs") {
226
- const target = placeholderTarget.get(`${i}:${n++}`);
227
- if (target === undefined) return null;
228
- return (
229
- <Reveal key={`${i}-docs-${j}`} active={streaming} origin="start">
230
- {renderDocCard(target, `${i}-docs-${j}`)}
231
- </Reveal>
232
- );
233
- }
234
- return (
235
- <Reveal key={`${i}-${j}`} active={streaming} origin="start">
236
- <div {...stylex.props(styles.markdown)}>
237
- <Markdown components={md}>{seg.text}</Markdown>
238
- </div>
239
- </Reveal>
240
- );
241
- });
242
- }
243
- if (part.type === "reasoning") return null;
244
- if (isToolUIPart(part)) {
245
- const tp = part as ToolPart;
246
- if (tp.state !== "output-available" && tp.state !== "output-error") return null;
247
- const name = getToolName(tp);
248
- const label = toolLabel(
249
- name,
250
- tp.input,
251
- tp.state === "output-available" ? tp.output : undefined,
252
- tp.state,
253
- );
254
- const write = isWriteTool(name);
255
- const commit = isCommitTool(name);
256
- const out = tp.state === "output-available" ? (tp.output as WriteToolOutput) : null;
257
- const failed =
258
- tp.state === "output-error" ||
259
- (out !== null && (write || commit) && !out.ok);
260
- const written = write && out && out.ok ? changesInToolOutput(out) : [];
261
- // Doc lists placed inline via [[docs]] are not repeated here. While
262
- // streaming, the placeholder usually arrives a moment after the tool
263
- // result, so hold the fallback back until the stream has ended;
264
- // otherwise the card would jump from here to its inline position.
265
- const docList =
266
- tp.state === "output-available" && !inlined.has(i) && !streaming
267
- ? docListFromTool(name, tp.output)
268
- : null;
269
- const cards = written.map((raw) => {
270
- const change = {
271
- ...(session.changes[raw.id] ?? raw),
272
- summary: session.changes[raw.id]?.summary ?? raw.summary,
273
- };
274
- const viewStatus = presentedChangeStatus(change, stack);
275
- return { change, viewStatus };
276
- });
277
- return (
278
- <Reveal key={tp.toolCallId} active={streaming} origin="start">
279
- <div {...stylex.props(styles.toolBlock)}>
280
- <div {...stylex.props(styles.toolRow, failed && styles.toolFailed)}>
281
- <ToolIcon name={name} />
282
- <span>{label}</span>
283
- {failed ? (
284
- <span {...stylex.props(styles.toolError)}>
285
- {tp.state === "output-error"
286
- ? tp.errorText
287
- : (out as { message?: string } | null)?.message}
288
- </span>
289
- ) : null}
290
- </div>
291
- {docList ? (
292
- <DocListCard
293
- items={docList.items}
294
- total={docList.total}
295
- workspaceId={workspaceId}
296
- emptyLabel={name === "searchDocs" ? "No matches" : "No documents"}
297
- />
298
- ) : null}
299
- {cards.map(({ change, viewStatus }) =>
300
- viewStatus ? (
301
- <ChangeCard
302
- key={change.id}
303
- change={{ ...change, status: viewStatus }}
304
- workspaceId={workspaceId}
305
- busy={busyId === change.id}
306
- canAct={canAct && !streaming}
307
- onKeep={() => void act(change.id, () => session.keep(change.id))}
308
- onUndo={() => void act(change.id, () => session.undo(change.id))}
309
- />
310
- ) : null,
311
- )}
312
- </div>
313
- </Reveal>
261
+ const flow: ReactNode[] = [];
262
+ const traceRows: TraceRow[] = [];
263
+ let traceSlotted = false;
264
+ for (let i = 0; i < parts.length; i++) {
265
+ const part = parts[i];
266
+ if (part.type === "text") {
267
+ const text = streaming ? trimPartialPlaceholder(part.text) : part.text;
268
+ if (!text.trim()) continue;
269
+ const segments = splitDocsPlaceholders(text);
270
+ let n = 0;
271
+ for (let j = 0; j < segments.length; j++) {
272
+ const seg = segments[j];
273
+ if (seg.kind === "docs") {
274
+ const target = placeholderTarget.get(`${i}:${n++}`);
275
+ if (target === undefined) continue;
276
+ flow.push(
277
+ <Reveal key={`${i}-docs-${j}`} active={streaming} origin="start">
278
+ {renderDocCard(target, `${i}-docs-${j}`)}
279
+ </Reveal>,
314
280
  );
281
+ continue;
315
282
  }
316
- return null;
317
- })}
283
+ flow.push(
284
+ <Reveal key={`${i}-${j}`} active={streaming} origin="start">
285
+ <div {...stylex.props(styles.markdown)}>
286
+ <Markdown components={md}>{seg.text}</Markdown>
287
+ </div>
288
+ </Reveal>,
289
+ );
290
+ }
291
+ continue;
292
+ }
293
+ if (part.type === "reasoning" || !isToolUIPart(part)) continue;
294
+ const tp = part as ToolPart;
295
+ if (tp.state !== "output-available" && tp.state !== "output-error") continue;
296
+ const name = getToolName(tp);
297
+ const label = toolLabel(
298
+ name,
299
+ tp.input,
300
+ tp.state === "output-available" ? tp.output : undefined,
301
+ tp.state,
302
+ );
303
+ const write = isWriteTool(name);
304
+ const commit = isCommitTool(name);
305
+ const out = tp.state === "output-available" ? (tp.output as WriteToolOutput) : null;
306
+ const failed =
307
+ tp.state === "output-error" || (out !== null && (write || commit) && !out.ok);
308
+ const error = failed
309
+ ? (tp.state === "output-error"
310
+ ? tp.errorText
311
+ : (out as { message?: string } | null)?.message) ?? null
312
+ : null;
313
+ traceRows.push({ key: tp.toolCallId, name, label, failed, error });
314
+ if (!traceSlotted) {
315
+ traceSlotted = true;
316
+ flow.push(<ToolTrace key="trace" rows={traceRows} />);
317
+ }
318
+ const written = write && out && out.ok ? changesInToolOutput(out) : [];
319
+ // Doc lists placed inline via [[docs]] are not repeated here. While
320
+ // streaming, the placeholder usually arrives a moment after the tool
321
+ // result, so hold the fallback back until the stream has ended;
322
+ // otherwise the card would jump from here to its inline position.
323
+ const docList =
324
+ tp.state === "output-available" && !inlined.has(i) && !streaming
325
+ ? docListFromTool(name, tp.output)
326
+ : null;
327
+ if (docList) {
328
+ flow.push(
329
+ <DocListCard
330
+ key={`${tp.toolCallId}-docs`}
331
+ items={docList.items}
332
+ total={docList.total}
333
+ workspaceId={workspaceId}
334
+ emptyLabel={name === "searchDocs" ? "No matches" : "No documents"}
335
+ />,
336
+ );
337
+ }
338
+ for (const raw of written) {
339
+ const change = {
340
+ ...(session.changes[raw.id] ?? raw),
341
+ summary: session.changes[raw.id]?.summary ?? raw.summary,
342
+ };
343
+ const viewStatus = presentedChangeStatus(change, stack);
344
+ if (!viewStatus) continue;
345
+ flow.push(
346
+ <ChangeCard
347
+ key={change.id}
348
+ change={{ ...change, status: viewStatus }}
349
+ workspaceId={workspaceId}
350
+ busy={busyId === change.id}
351
+ canAct={canAct && !streaming}
352
+ onKeep={() => void act(change.id, () => session.keep(change.id))}
353
+ onUndo={() => void act(change.id, () => session.undo(change.id))}
354
+ />,
355
+ );
356
+ }
357
+ }
358
+
359
+ return (
360
+ <div {...stylex.props(styles.assistant)}>
361
+ {flow}
318
362
  {applied.length > 1 && canAct && !streaming ? (
319
363
  <div {...stylex.props(styles.bulk)}>
320
364
  <span {...stylex.props(styles.bulkLabel)}>
@@ -389,6 +433,31 @@ const styles = stylex.create({
389
433
  flexDirection: "column",
390
434
  gap: space.sm,
391
435
  },
436
+ traceToggle: {
437
+ display: "inline-flex",
438
+ alignItems: "center",
439
+ gap: space.sm,
440
+ padding: 0,
441
+ borderWidth: 0,
442
+ backgroundColor: "transparent",
443
+ font: "inherit",
444
+ fontSize: type.sm,
445
+ color: colors.textMuted,
446
+ cursor: "pointer",
447
+ textAlign: "left",
448
+ },
449
+ traceChevron: {
450
+ display: "inline-flex",
451
+ transform: "rotate(-90deg)",
452
+ transitionProperty: "transform",
453
+ transitionDuration: "120ms",
454
+ "@media (prefers-reduced-motion: reduce)": {
455
+ transitionDuration: "0ms",
456
+ },
457
+ },
458
+ traceChevronOpen: {
459
+ transform: "rotate(0deg)",
460
+ },
392
461
  toolRow: {
393
462
  display: "flex",
394
463
  alignItems: "center",
@@ -32,6 +32,7 @@ export function ChatPanel({ fill = false, site = false }: { fill?: boolean; site
32
32
  const session = useChatSession();
33
33
  const visible = isPanelVisible(ui.panel.mode);
34
34
  const fullscreen = ui.panel.mode === "fullscreen";
35
+ const docked = Boolean(ui.codeSessionId);
35
36
  const threadId = session.thread?.id ?? null;
36
37
  const knownIds = useRef<Set<string> | null>(null);
37
38
  const seenThread = useRef<string | null | undefined>(undefined);
@@ -50,7 +51,9 @@ export function ChatPanel({ fill = false, site = false }: { fill?: boolean; site
50
51
  resetKey: threadId,
51
52
  pinKey: anchorId,
52
53
  slotRef,
53
- deps: [session.messages, session.status, viewportTick],
54
+ // The composer growing changes this viewport. That is not a new message,
55
+ // and following it scrolls the thread on every line the person types.
56
+ deps: [session.messages, session.status],
54
57
  });
55
58
 
56
59
  if (seenThread.current !== threadId) {
@@ -75,7 +78,7 @@ export function ChatPanel({ fill = false, site = false }: { fill?: boolean; site
75
78
 
76
79
  const contextRows = Math.max(
77
80
  1,
78
- (ui.focus && ui.includeFocus ? 1 : 0) + ui.mentions.length,
81
+ (ui.focus && ui.includeFocus ? 1 : 0) + (ui.route ? 1 : 0) + ui.mentions.length,
79
82
  );
80
83
  const contextReserve = contextRows * CONTEXT_ROW_HEIGHT + CONTEXT_BAR_PADDING + 12;
81
84
 
@@ -153,7 +156,11 @@ export function ChatPanel({ fill = false, site = false }: { fill?: boolean; site
153
156
  aria-label="AI chat"
154
157
  data-backmatter-panel=""
155
158
  {...stylex.props(
156
- fill ? styles.panelFill : styles.panel,
159
+ fill
160
+ ? ui.codeSessionId
161
+ ? styles.panelColumnFill
162
+ : styles.panelFill
163
+ : styles.panel,
157
164
  site && styles.panelSite,
158
165
  !fill && fullscreen && styles.panelFull,
159
166
  )}
@@ -193,37 +200,41 @@ export function ChatPanel({ fill = false, site = false }: { fill?: boolean; site
193
200
  Back to chat
194
201
  </button>
195
202
  )}
196
- <span {...stylex.props(styles.divider)} />
197
- <Tooltip label="Minimize (Esc)">
198
- <button
199
- type="button"
200
- aria-label="Minimize"
201
- onClick={() => ui.dispatch({ type: "minimize" })}
202
- {...stylex.props(styles.iconBtn)}
203
- >
204
- <MinimizeIcon />
205
- </button>
206
- </Tooltip>
207
- <Tooltip label={fullscreen ? "Exit fullscreen" : "Fullscreen"}>
208
- <button
209
- type="button"
210
- aria-label={fullscreen ? "Exit fullscreen" : "Fullscreen"}
211
- onClick={() => ui.dispatch({ type: "toggleFullscreen" })}
212
- {...stylex.props(styles.iconBtn)}
213
- >
214
- {fullscreen ? <CollapseIcon /> : <ExpandIcon />}
215
- </button>
216
- </Tooltip>
217
- <Tooltip label="Close">
218
- <button
219
- type="button"
220
- aria-label="Close"
221
- onClick={() => ui.dispatch({ type: "close" })}
222
- {...stylex.props(styles.iconBtn)}
223
- >
224
- <CloseIcon />
225
- </button>
226
- </Tooltip>
203
+ {docked ? null : (
204
+ <>
205
+ <span {...stylex.props(styles.divider)} />
206
+ <Tooltip label="Minimize (Esc)">
207
+ <button
208
+ type="button"
209
+ aria-label="Minimize"
210
+ onClick={() => ui.dispatch({ type: "minimize" })}
211
+ {...stylex.props(styles.iconBtn)}
212
+ >
213
+ <MinimizeIcon />
214
+ </button>
215
+ </Tooltip>
216
+ <Tooltip label={fullscreen ? "Exit fullscreen" : "Fullscreen"}>
217
+ <button
218
+ type="button"
219
+ aria-label={fullscreen ? "Exit fullscreen" : "Fullscreen"}
220
+ onClick={() => ui.dispatch({ type: "toggleFullscreen" })}
221
+ {...stylex.props(styles.iconBtn)}
222
+ >
223
+ {fullscreen ? <CollapseIcon /> : <ExpandIcon />}
224
+ </button>
225
+ </Tooltip>
226
+ <Tooltip label="Close">
227
+ <button
228
+ type="button"
229
+ aria-label="Close"
230
+ onClick={() => ui.dispatch({ type: "close" })}
231
+ {...stylex.props(styles.iconBtn)}
232
+ >
233
+ <CloseIcon />
234
+ </button>
235
+ </Tooltip>
236
+ </>
237
+ )}
227
238
  </div>
228
239
  </header>
229
240
 
@@ -353,6 +364,15 @@ const styles = stylex.create({
353
364
  overflow: "hidden",
354
365
  backgroundColor: colors.bgPanel,
355
366
  },
367
+ panelColumnFill: {
368
+ position: "relative",
369
+ flex: 1,
370
+ minHeight: 0,
371
+ display: "flex",
372
+ flexDirection: "column",
373
+ overflow: "hidden",
374
+ backgroundColor: colors.bgPanel,
375
+ },
356
376
  header: {
357
377
  display: "flex",
358
378
  alignItems: "center",