@backmatter/studio 0.0.14 → 0.0.16

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 (67) hide show
  1. package/dist/chunks/{UiKitApp-fYeVEf4-.js → UiKitApp-_EDqWID3.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-Dt7JvEWT.js → WorkspaceRoutes-B2PrFgpz.js} +29019 -27476
  3. package/dist/chunks/{dashboard-BuY2aGkO.js → dashboard-BnlnMnnr.js} +55 -54
  4. package/dist/chunks/{index-NWDqEZYW.js → index-D_BHF_8q.js} +1 -1
  5. package/dist/dashboard.js +2 -2
  6. package/dist/index.js +51 -52
  7. package/dist/site-widget.css +4 -0
  8. package/dist/site-widget.js +9419 -9156
  9. package/dist/studio.css +186 -12
  10. package/package.json +2 -2
  11. package/src/WorkspaceRoutes.tsx +5 -2
  12. package/src/api.ts +64 -11
  13. package/src/chat/ChangeCard.tsx +9 -15
  14. package/src/chat/ChatMessage.tsx +172 -103
  15. package/src/chat/ChatPanel.tsx +54 -34
  16. package/src/chat/ChatProvider.tsx +120 -24
  17. package/src/chat/Composer.tsx +48 -7
  18. package/src/chat/DocListCard.tsx +2 -16
  19. package/src/chat/MentionPopover.tsx +8 -9
  20. package/src/chat/PointerChip.tsx +9 -5
  21. package/src/chat/ThreadHistory.tsx +3 -3
  22. package/src/chat/chat-context.ts +74 -10
  23. package/src/chat/chat-helpers.ts +15 -8
  24. package/src/chat/chat-state.ts +11 -0
  25. package/src/chrome/AppShell.tsx +16 -5
  26. package/src/chrome/CommandPalette.tsx +14 -10
  27. package/src/chrome/ContentExplorerSidebar.tsx +23 -3
  28. package/src/chrome/UserMenu.tsx +34 -3
  29. package/src/chrome/WorkspaceStatusBar.tsx +215 -50
  30. package/src/chrome/content-api-url.ts +0 -78
  31. package/src/chrome/content-tree-visibility.ts +13 -3
  32. package/src/chrome/explorer-icons.tsx +5 -0
  33. package/src/code-session-entry.ts +9 -0
  34. package/src/components/WorkspaceSwitcher.tsx +2 -2
  35. package/src/element-origin.ts +149 -0
  36. package/src/host-pointer-bootstrap.ts +11 -0
  37. package/src/host-pointer.ts +147 -0
  38. package/src/install-preview-pointer.ts +19 -3
  39. package/src/pages/CodeSessionFiles.tsx +221 -0
  40. package/src/pages/CodeSessionPage.tsx +1188 -0
  41. package/src/pages/DocEditorPage.tsx +33 -15
  42. package/src/pages/EmbedChatPage.tsx +3 -3
  43. package/src/pages/EmbedPublishPage.tsx +2 -3
  44. package/src/pages/PreviewSize.tsx +181 -0
  45. package/src/pages/SettingsGeneralPage.tsx +67 -2
  46. package/src/pages/WorkspaceHomePage.tsx +313 -0
  47. package/src/pages/WorkspaceLayout.tsx +10 -1
  48. package/src/pages/code-session-boot.ts +43 -0
  49. package/src/pages/code-session-context.tsx +51 -0
  50. package/src/pages/code-session-files.ts +42 -0
  51. package/src/pages/code-session-frame.ts +52 -0
  52. package/src/pages/code-session-routes.ts +42 -0
  53. package/src/pages/home-rows.ts +140 -0
  54. package/src/pages/preview-size.ts +33 -0
  55. package/src/pages/session-preview.ts +109 -0
  56. package/src/pages/type-docs.ts +3 -6
  57. package/src/pages/use-session-preview.ts +70 -0
  58. package/src/replica/store.ts +13 -10
  59. package/src/replica/types.ts +7 -2
  60. package/src/site-pointer.ts +24 -5
  61. package/src/site-widget.tsx +2 -3
  62. package/src/studio-config.ts +1 -1
  63. package/src/ui/ListRow.tsx +12 -3
  64. package/src/ui/Page.tsx +12 -1
  65. package/src/workspace-path.ts +35 -3
  66. package/src/chrome/ContentApiPopover.tsx +0 -215
  67. package/src/pages/WorkspaceHomeRedirect.tsx +0 -17
@@ -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";
@@ -34,7 +34,7 @@ type ToolPart = Extract<UIMessage["parts"][number], { toolCallId: string }>;
34
34
  type ListDocsOutput = { docs: DocCardItem[]; total?: number };
35
35
  type SearchDocsOutput = {
36
36
  matches: Array<{
37
- docRef: { locale: string; type: string; slug: string };
37
+ docRef: { type: string; slug: string };
38
38
  source?: string;
39
39
  excerpt?: string;
40
40
  title?: string | null;
@@ -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",