@backmatter/studio 0.0.15 → 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 (61) hide show
  1. package/dist/chunks/{UiKitApp-BDsrXN12.js → UiKitApp-_EDqWID3.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-BTZ1Ah4F.js → WorkspaceRoutes-B2PrFgpz.js} +28979 -27418
  3. package/dist/chunks/{dashboard-FT7dm5wi.js → dashboard-BnlnMnnr.js} +55 -54
  4. package/dist/chunks/{index-DHJvh5Uy.js → index-D_BHF_8q.js} +1 -1
  5. package/dist/dashboard.js +2 -2
  6. package/dist/index.js +2 -2
  7. package/dist/site-widget.css +4 -0
  8. package/dist/site-widget.js +8304 -8029
  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 +63 -7
  13. package/src/chat/ChangeCard.tsx +8 -2
  14. package/src/chat/ChatMessage.tsx +171 -102
  15. package/src/chat/ChatPanel.tsx +54 -34
  16. package/src/chat/ChatProvider.tsx +116 -20
  17. package/src/chat/Composer.tsx +48 -7
  18. package/src/chat/PointerChip.tsx +9 -5
  19. package/src/chat/ThreadHistory.tsx +3 -3
  20. package/src/chat/chat-context.ts +67 -3
  21. package/src/chat/chat-helpers.ts +12 -0
  22. package/src/chat/chat-state.ts +11 -0
  23. package/src/chrome/AppShell.tsx +16 -5
  24. package/src/chrome/CommandPalette.tsx +12 -0
  25. package/src/chrome/ContentExplorerSidebar.tsx +23 -3
  26. package/src/chrome/UserMenu.tsx +34 -3
  27. package/src/chrome/WorkspaceStatusBar.tsx +209 -27
  28. package/src/chrome/content-api-url.ts +0 -78
  29. package/src/chrome/content-tree-visibility.ts +13 -3
  30. package/src/chrome/explorer-icons.tsx +5 -0
  31. package/src/code-session-entry.ts +9 -0
  32. package/src/components/WorkspaceSwitcher.tsx +2 -2
  33. package/src/element-origin.ts +149 -0
  34. package/src/host-pointer-bootstrap.ts +11 -0
  35. package/src/host-pointer.ts +147 -0
  36. package/src/install-preview-pointer.ts +19 -3
  37. package/src/pages/CodeSessionFiles.tsx +221 -0
  38. package/src/pages/CodeSessionPage.tsx +1188 -0
  39. package/src/pages/DocEditorPage.tsx +33 -15
  40. package/src/pages/PreviewSize.tsx +181 -0
  41. package/src/pages/SettingsGeneralPage.tsx +67 -2
  42. package/src/pages/WorkspaceHomePage.tsx +313 -0
  43. package/src/pages/WorkspaceLayout.tsx +10 -1
  44. package/src/pages/code-session-boot.ts +43 -0
  45. package/src/pages/code-session-context.tsx +51 -0
  46. package/src/pages/code-session-files.ts +42 -0
  47. package/src/pages/code-session-frame.ts +52 -0
  48. package/src/pages/code-session-routes.ts +42 -0
  49. package/src/pages/home-rows.ts +140 -0
  50. package/src/pages/preview-size.ts +33 -0
  51. package/src/pages/session-preview.ts +109 -0
  52. package/src/pages/use-session-preview.ts +70 -0
  53. package/src/replica/store.ts +11 -0
  54. package/src/replica/types.ts +6 -0
  55. package/src/site-pointer.ts +24 -5
  56. package/src/studio-config.ts +1 -1
  57. package/src/ui/ListRow.tsx +12 -3
  58. package/src/ui/Page.tsx +12 -1
  59. package/src/workspace-path.ts +35 -3
  60. package/src/chrome/ContentApiPopover.tsx +0 -215
  61. package/src/pages/WorkspaceHomeRedirect.tsx +0 -17
@@ -24,6 +24,7 @@ import {
24
24
  createThread,
25
25
  fetchAiModels,
26
26
  fetchThread,
27
+ fetchThreads,
27
28
  keepAllChanges,
28
29
  keepChange,
29
30
  notifyHeadChanged,
@@ -48,6 +49,7 @@ import {
48
49
  addMention as addMentionTo,
49
50
  addPointer as addPointerTo,
50
51
  buildChatContext,
52
+ codeSessionChatContext,
51
53
  removeMention as removeMentionFrom,
52
54
  removePointer as removePointerFrom,
53
55
  type ChatPointer,
@@ -55,6 +57,7 @@ import {
55
57
  } from "./chat-context";
56
58
  import {
57
59
  changeIdsInMessage,
60
+ checkoutWriteReloads,
58
61
  isWriteTool,
59
62
  changesInToolOutput,
60
63
  detailForTreePatch,
@@ -62,6 +65,7 @@ import {
62
65
  type TreeStatusPatch,
63
66
  } from "./chat-helpers";
64
67
  import {
68
+ allowsPanelAction,
65
69
  initialPanelState,
66
70
  panelReducer,
67
71
  type PanelAction,
@@ -84,7 +88,8 @@ export type ChatUIValue = {
84
88
  model: string | null;
85
89
  setModel: (id: string) => void;
86
90
  threadId: string | null;
87
- openThread: (threadId: string) => void;
91
+ /** Refuses a thread that belongs to a different surface (editor vs a code session). */
92
+ openThread: (thread: { id: string; codeSessionId?: string | null }) => void;
88
93
  newThread: () => void;
89
94
  mentions: DocRef[];
90
95
  addMention: (ref: DocRef) => void;
@@ -105,6 +110,13 @@ export type ChatUIValue = {
105
110
  /** Bumped whenever a thread is created/renamed/deleted so history can refetch. */
106
111
  threadsVersion: number;
107
112
  bumpThreads: () => void;
113
+ /** Set when this provider is scoped to a code session checkout. */
114
+ codeSessionId: string | null;
115
+ /** Preview route included in chat context, or null when dismissed. */
116
+ route: string | null;
117
+ /** The document that route resolves to, when a model claims it. */
118
+ viewed: DocRef | null;
119
+ dismissRoute: () => void;
108
120
  };
109
121
 
110
122
  const ChatUIContext = createContext<ChatUIValue | null>(null);
@@ -155,6 +167,14 @@ export function useChatSession(): ChatSessionValue {
155
167
 
156
168
  const modelStorageKey = (ws: string) => `backmatter-chat-model:${ws}`;
157
169
 
170
+ function newestThread(threads: readonly { id: string; updatedAt: string }[]): string | null {
171
+ let best: { id: string; updatedAt: string } | null = null;
172
+ for (const thread of threads) {
173
+ if (!best || thread.updatedAt > best.updatedAt) best = thread;
174
+ }
175
+ return best?.id ?? null;
176
+ }
177
+
158
178
  /** Apply tool statuses locally so the top bar moves before the next git poll. */
159
179
  function publishTreePatches(workspaceId: string, patches: TreeStatusPatch[]) {
160
180
  let published = false;
@@ -183,18 +203,40 @@ export function ChatProvider({
183
203
  workspaceId,
184
204
  startOpen = false,
185
205
  pointer = false,
206
+ codeSessionId = null,
207
+ route = null,
208
+ viewed = null,
209
+ onRouteDismiss,
210
+ onCheckoutWrite,
211
+ onTurnSettled,
186
212
  children,
187
213
  }: {
188
214
  workspaceId: string;
189
215
  startOpen?: boolean;
190
216
  /** Dev bar chat can point at elements on the host page. */
191
217
  pointer?: boolean;
218
+ codeSessionId?: string | null;
219
+ route?: string | null;
220
+ /** Document for `route`, resolved by the caller. The agent still receives the URL. */
221
+ viewed?: DocRef | null;
222
+ onRouteDismiss?: (route: string) => void;
223
+ /** A file tool finished in the checkout. The preview does not hot-reload on its own. */
224
+ onCheckoutWrite?: () => void;
225
+ /** The reply finished, so a code session can read the edit set the turn just saved. */
226
+ onTurnSettled?: () => void;
192
227
  children: React.ReactNode;
193
228
  }) {
194
229
  const [panel, dispatch] = useReducer(
195
230
  panelReducer,
196
231
  startOpen ? { ...initialPanelState, mode: "open" as const } : initialPanelState,
197
232
  );
233
+ const dispatchPanel = useCallback(
234
+ (action: PanelAction) => {
235
+ if (!allowsPanelAction(action, Boolean(codeSessionId))) return;
236
+ dispatch(action);
237
+ },
238
+ [codeSessionId],
239
+ );
198
240
  const [aiAvailable, setAiAvailable] = useState<boolean | null>(null);
199
241
  const [models, setModels] = useState<AiModelInfo[]>([]);
200
242
  const [tier, setTier] = useState<AiTier>("ask");
@@ -206,8 +248,10 @@ export function ChatProvider({
206
248
  const [pointers, setPointers] = useState<ChatPointer[]>([]);
207
249
  const [previewPointer, setPreviewPointer] = useState(false);
208
250
  const [pointerHighlight, setPointerHighlight] = useState<"local" | "channel">("local");
209
- const [includeFocus, setIncludeFocus] = useState(true);
251
+ const [includeFocus, setIncludeFocus] = useState(!codeSessionId);
210
252
  const [threadsVersion, setThreadsVersion] = useState(0);
253
+ // Bumped when the person picks a chat, so a late session list cannot replace it.
254
+ const restore = useRef(0);
211
255
  const { focus } = useEditorFocus();
212
256
 
213
257
  // Models + availability.
@@ -234,15 +278,30 @@ export function ChatProvider({
234
278
  };
235
279
  }, [workspaceId]);
236
280
 
237
- // Reset per-workspace session state.
281
+ // Reset per-workspace session state. A code session reopens its newest chat.
238
282
  useEffect(() => {
239
283
  setThreadId(null);
240
284
  setFreshThread(false);
241
285
  setPendingText(null);
242
286
  setMentions([]);
243
287
  setPointers([]);
244
- setIncludeFocus(true);
245
- }, [workspaceId]);
288
+ setIncludeFocus(!codeSessionId);
289
+ if (!codeSessionId) return;
290
+ const token = ++restore.current;
291
+ fetchThreads(workspaceId, codeSessionId)
292
+ .then((res) => {
293
+ if (restore.current !== token) return;
294
+ const latest = newestThread(res.threads);
295
+ if (!latest) return;
296
+ setThreadId(latest);
297
+ })
298
+ .catch(() => {
299
+ // A missed list leaves the blank chat. Sending still starts one.
300
+ });
301
+ return () => {
302
+ restore.current += 1;
303
+ };
304
+ }, [workspaceId, codeSessionId]);
246
305
 
247
306
  const setModel = useCallback(
248
307
  (id: string) => {
@@ -252,14 +311,20 @@ export function ChatProvider({
252
311
  [workspaceId],
253
312
  );
254
313
 
255
- const openThread = useCallback((id: string) => {
256
- setThreadId(id);
257
- setFreshThread(false);
258
- setPendingText(null);
259
- dispatch({ type: "showChat" });
260
- }, []);
314
+ const openThread = useCallback(
315
+ (thread: { id: string; codeSessionId?: string | null }) => {
316
+ if ((thread.codeSessionId ?? null) !== (codeSessionId ?? null)) return;
317
+ restore.current += 1;
318
+ setThreadId(thread.id);
319
+ setFreshThread(false);
320
+ setPendingText(null);
321
+ dispatch({ type: "showChat" });
322
+ },
323
+ [codeSessionId],
324
+ );
261
325
 
262
326
  const newThread = useCallback(() => {
327
+ restore.current += 1;
263
328
  setThreadId(null);
264
329
  setFreshThread(false);
265
330
  setPendingText(null);
@@ -275,8 +340,8 @@ export function ChatProvider({
275
340
  setMentions((prev) => removeMentionFrom(prev, ref));
276
341
  }, []);
277
342
  const addPointer = useCallback((next: ChatPointer) => {
278
- setPointers((prev) => addPointerTo(prev, next));
279
- }, []);
343
+ setPointers((prev) => addPointerTo(prev, next, codeSessionId ? "replace" : "append"));
344
+ }, [codeSessionId]);
280
345
  const removePointer = useCallback((next: ChatPointer) => {
281
346
  setPointers((prev) => removePointerFrom(prev, next));
282
347
  }, []);
@@ -289,28 +354,40 @@ export function ChatProvider({
289
354
  selection: null,
290
355
  mentions: [],
291
356
  pointers: [],
357
+ route: null,
292
358
  }));
293
- contextRef.current = () => buildChatContext({ focus, includeFocus, mentions, pointers });
359
+ contextRef.current = () =>
360
+ codeSessionId
361
+ ? codeSessionChatContext({ pointers, route })
362
+ : buildChatContext({ focus, includeFocus, mentions, pointers });
294
363
  const modelRef = useRef<string | null>(model);
295
364
  modelRef.current = model;
296
365
 
297
366
  // Creating a thread lazily on first send.
298
367
  const startThread = useCallback(
299
368
  async (text: string) => {
300
- const res = await createThread(workspaceId);
369
+ restore.current += 1;
370
+ const res = await createThread(
371
+ workspaceId,
372
+ codeSessionId ? { codeSessionId } : undefined,
373
+ );
301
374
  setPendingText(text);
302
375
  setFreshThread(true);
303
376
  setThreadId(res.thread.id);
304
377
  bumpThreads();
305
378
  },
306
- [workspaceId, bumpThreads],
379
+ [workspaceId, bumpThreads, codeSessionId],
307
380
  );
308
381
 
382
+ const dismissRoute = useCallback(() => {
383
+ if (route && onRouteDismiss) onRouteDismiss(route);
384
+ }, [route, onRouteDismiss]);
385
+
309
386
  const ui = useMemo<ChatUIValue>(
310
387
  () => ({
311
388
  workspaceId,
312
389
  panel,
313
- dispatch,
390
+ dispatch: dispatchPanel,
314
391
  aiAvailable,
315
392
  models,
316
393
  tier,
@@ -335,10 +412,15 @@ export function ChatProvider({
335
412
  focus,
336
413
  threadsVersion,
337
414
  bumpThreads,
415
+ codeSessionId,
416
+ route,
417
+ viewed,
418
+ dismissRoute,
338
419
  }),
339
420
  [
340
421
  workspaceId,
341
422
  panel,
423
+ dispatchPanel,
342
424
  aiAvailable,
343
425
  models,
344
426
  tier,
@@ -361,6 +443,10 @@ export function ChatProvider({
361
443
  focus,
362
444
  threadsVersion,
363
445
  bumpThreads,
446
+ codeSessionId,
447
+ route,
448
+ viewed,
449
+ dismissRoute,
364
450
  ],
365
451
  );
366
452
 
@@ -380,13 +466,15 @@ export function ChatProvider({
380
466
  getContext={() => contextRef.current()}
381
467
  getModel={() => modelRef.current}
382
468
  onThreadTouched={bumpThreads}
469
+ onCheckoutWrite={onCheckoutWrite}
470
+ onTurnSettled={onTurnSettled}
383
471
  >
384
472
  {scoped}
385
473
  </ChatSession>
386
474
  ) : (
387
475
  <EmptySession onFirstSend={startThread}>{scoped}</EmptySession>
388
476
  ),
389
- [threadId, workspaceId, freshThread, pendingText, bumpThreads, startThread],
477
+ [threadId, workspaceId, freshThread, pendingText, bumpThreads, startThread, onCheckoutWrite, onTurnSettled],
390
478
  );
391
479
 
392
480
  return (
@@ -461,6 +549,8 @@ type SessionProps = {
461
549
  getContext: () => ChatRequestContext;
462
550
  getModel: () => string | null;
463
551
  onThreadTouched: () => void;
552
+ onCheckoutWrite?: () => void;
553
+ onTurnSettled?: () => void;
464
554
  children: React.ReactNode;
465
555
  };
466
556
 
@@ -539,6 +629,8 @@ function LiveSession({
539
629
  getContext,
540
630
  getModel,
541
631
  onThreadTouched,
632
+ onCheckoutWrite,
633
+ onTurnSettled,
542
634
  children,
543
635
  }: SessionProps & {
544
636
  thread: ChatThread | null;
@@ -574,7 +666,10 @@ function LiveSession({
574
666
  id: threadId,
575
667
  messages: initialMessages,
576
668
  transport,
577
- onFinish: () => onThreadTouched(),
669
+ onFinish: () => {
670
+ onThreadTouched();
671
+ onTurnSettled?.();
672
+ },
578
673
  });
579
674
 
580
675
  const { messages, status, sendMessage } = chat;
@@ -632,6 +727,7 @@ function LiveSession({
632
727
  if (streaming && write && ref) editingKey = docKey(ref);
633
728
  if (part.state === "output-available" && !seen.has(part.toolCallId)) {
634
729
  seen.add(part.toolCallId);
730
+ if (checkoutWriteReloads(name, part.output)) onCheckoutWrite?.();
635
731
  const patches = treeStatusPatches(part.output);
636
732
  if (patches.length > 0) publishTreePatches(workspaceId, patches);
637
733
  const found = write ? changesInToolOutput(part.output) : [];
@@ -650,7 +746,7 @@ function LiveSession({
650
746
  }
651
747
  }
652
748
  setAiEditingDocKey(streaming ? editingKey : null);
653
- }, [messages, status, setAiEditingDocKey, workspaceId]);
749
+ }, [messages, status, setAiEditingDocKey, workspaceId, onCheckoutWrite]);
654
750
 
655
751
  useEffect(() => () => setAiEditingDocKey(null), [setAiEditingDocKey]);
656
752
 
@@ -1,5 +1,5 @@
1
1
  // packages/studio/src/chat/Composer.tsx
2
- import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
3
3
  import * as stylex from "@stylexjs/stylex";
4
4
  import { docKey, type DocRef } from "../chrome/EditorFocusContext";
5
5
  import { colors, radius, space, type } from "../styles/tokens.stylex";
@@ -26,6 +26,27 @@ function isMentionKey(key: string): key is MentionKey {
26
26
 
27
27
  export { POINTER_ARM_EVENT } from "./pointer-arm";
28
28
 
29
+ /** The page open in the code session preview. Same row as the editor's open document. */
30
+ export function RouteChip({
31
+ route,
32
+ doc,
33
+ onDismiss,
34
+ }: {
35
+ route: string;
36
+ doc?: { type: string; slug: string } | null;
37
+ onDismiss: () => void;
38
+ }) {
39
+ return (
40
+ <ContextRow
41
+ icon={<FileIcon size={13} />}
42
+ {...(doc ? { type: doc.type, slug: doc.slug } : { label: route })}
43
+ meta="in preview"
44
+ removeLabel="Remove the page from context"
45
+ onRemove={onDismiss}
46
+ />
47
+ );
48
+ }
49
+
29
50
  function postPointer(on: boolean) {
30
51
  if (typeof window === "undefined") return;
31
52
  window.dispatchEvent(new CustomEvent(POINTER_ARM_EVENT, { detail: { on } }));
@@ -46,12 +67,21 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
46
67
  const busy = session.status === "streaming" || session.status === "submitted";
47
68
  const canSend = text.trim().length > 0 && !busy && ui.aiAvailable === true;
48
69
 
49
- // Auto-grow.
50
- useEffect(() => {
70
+ // Auto-grow. Measuring must not leave a 1px swing: that resizes the thread
71
+ // on every key, and the thread then tries to scroll to the bottom.
72
+ useLayoutEffect(() => {
51
73
  const el = ref.current;
52
74
  if (!el) return;
75
+ const previous = el.offsetHeight;
76
+ el.style.overflowY = "hidden";
53
77
  el.style.height = "0px";
54
- el.style.height = `${Math.min(el.scrollHeight, 180)}px`;
78
+ const next = Math.min(el.scrollHeight, 180);
79
+ if (previous > 0 && Math.abs(next - previous) <= 1) {
80
+ el.style.height = `${previous}px`;
81
+ return;
82
+ }
83
+ el.style.height = `${next}px`;
84
+ if (next >= 180) el.style.overflowY = "auto";
55
85
  }, [text]);
56
86
 
57
87
  useEffect(() => {
@@ -138,10 +168,12 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
138
168
  setMention(null);
139
169
  }
140
170
 
141
- const focusChip = ui.focus && ui.includeFocus ? ui.focus.docRef : null;
171
+ const sessionChat = Boolean(ui.codeSessionId);
172
+ const focusChip = !sessionChat && ui.focus && ui.includeFocus ? ui.focus.docRef : null;
173
+ const pageChip = sessionChat && ui.route ? ui.route : null;
142
174
  const selectionActive =
143
175
  ui.focus?.selection && ui.focus.selection.end > ui.focus.selection.start;
144
- const hasContext = Boolean(focusChip || ui.mentions.length > 0);
176
+ const hasContext = Boolean(focusChip || pageChip || ui.mentions.length > 0);
145
177
 
146
178
  return (
147
179
  <div {...stylex.props(styles.wrap)}>
@@ -168,6 +200,13 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
168
200
  onRemove={() => ui.setIncludeFocus(false)}
169
201
  />
170
202
  ) : null}
203
+ {pageChip ? (
204
+ <RouteChip
205
+ route={pageChip}
206
+ doc={ui.viewed}
207
+ onDismiss={() => ui.dismissRoute()}
208
+ />
209
+ ) : null}
171
210
  {ui.mentions.map((m) => (
172
211
  <ContextRow
173
212
  key={docKey(m)}
@@ -232,6 +271,7 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
232
271
  {...stylex.props(styles.textarea)}
233
272
  />
234
273
  <div {...stylex.props(styles.footer)}>
274
+ {!sessionChat ? (
235
275
  <Tooltip label="Mention a document (@)">
236
276
  <button
237
277
  type="button"
@@ -257,6 +297,7 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
257
297
  <AtIcon />
258
298
  </button>
259
299
  </Tooltip>
300
+ ) : null}
260
301
  <span {...stylex.props(styles.spacer)} />
261
302
  {ui.pointer ? (
262
303
  <Tooltip label={pointing ? "Stop pointing" : "Point at an element on the page"}>
@@ -276,7 +317,7 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
276
317
  </button>
277
318
  </Tooltip>
278
319
  ) : null}
279
- {ui.focus ? (
320
+ {!sessionChat && ui.focus ? (
280
321
  <Tooltip
281
322
  label={
282
323
  ui.includeFocus
@@ -11,20 +11,24 @@ import { CloseIcon, PointerIcon } from "./icons";
11
11
  const hover = "@media (hover: hover)";
12
12
 
13
13
  export function pointerKey(pointer: ChatPointer): string {
14
- return `${pointer.type}/${pointer.slug}/${pointer.blockId ?? ""}/${pointer.quote}`;
14
+ return `${pointer.type}/${pointer.slug}/${pointer.blockId ?? ""}/${pointer.component ?? ""}/${pointer.file ?? ""}:${pointer.line ?? ""}/${pointer.quote}`;
15
15
  }
16
16
 
17
17
  function pointerLabel(pointer: ChatPointer): string {
18
18
  const quote = pointer.quote.replace(/\s+/g, " ").trim();
19
19
  const short = quote.length > 42 ? `${quote.slice(0, 41)}…` : quote;
20
- if (pointer.blockType && short) return `${pointer.blockType} · “${short}”`;
21
- if (pointer.blockType) return pointer.blockType;
22
- return short ? `“${short}”` : "element";
20
+ const kind = pointer.blockType || pointer.component;
21
+ const file = pointer.file
22
+ ? `${pointer.file}${pointer.line ? `:${pointer.line}` : ""}`
23
+ : "";
24
+ const parts = [kind && short ? `${kind} · “${short}”` : kind || (short ? `“${short}”` : "element")];
25
+ if (file) parts.push(file);
26
+ return parts.join(" · ");
23
27
  }
24
28
 
25
29
  function pointerChipLabel(pointer: ChatPointer): string {
26
30
  const quote = pointer.quote.replace(/\s+/g, " ").trim();
27
- const raw = quote || pointer.blockType || `${pointer.type}/${pointer.slug}`;
31
+ const raw = quote || pointer.component || pointer.blockType || `${pointer.type}/${pointer.slug}`;
28
32
  return raw.length > 32 ? `${raw.slice(0, 31)}…` : raw;
29
33
  }
30
34
 
@@ -21,7 +21,7 @@ export function ThreadHistory() {
21
21
 
22
22
  useEffect(() => {
23
23
  let cancelled = false;
24
- fetchThreads(ui.workspaceId)
24
+ fetchThreads(ui.workspaceId, ui.codeSessionId ?? undefined)
25
25
  .then((res) => {
26
26
  if (!cancelled) setThreads(res.threads);
27
27
  })
@@ -31,7 +31,7 @@ export function ThreadHistory() {
31
31
  return () => {
32
32
  cancelled = true;
33
33
  };
34
- }, [ui.workspaceId, ui.threadsVersion]);
34
+ }, [ui.workspaceId, ui.codeSessionId, ui.threadsVersion]);
35
35
 
36
36
  const filtered = useMemo(() => {
37
37
  const q = query.trim().toLowerCase();
@@ -110,7 +110,7 @@ export function ThreadHistory() {
110
110
  >
111
111
  <button
112
112
  type="button"
113
- onClick={() => ui.openThread(t.id)}
113
+ onClick={() => ui.openThread(t)}
114
114
  {...stylex.props(styles.rowBtn)}
115
115
  >
116
116
  <span {...stylex.props(styles.title)}>{t.title || "New chat"}</span>
@@ -14,6 +14,14 @@ export type ChatPointer = {
14
14
  blockType: string | null;
15
15
  blockIndex: number | null;
16
16
  quote: string;
17
+ /** React component that rendered the element, when the dev server exposes it. */
18
+ component?: string | null;
19
+ /** Checkout-relative source path. */
20
+ file?: string | null;
21
+ line?: number | null;
22
+ column?: number | null;
23
+ /** Component names from the element toward the layout. */
24
+ stack?: string[];
17
25
  };
18
26
 
19
27
  export const POINTER_CAP = 8;
@@ -24,14 +32,23 @@ export type ChatRequestContext = {
24
32
  selection: { start: number; end: number } | null;
25
33
  mentions: DocRef[];
26
34
  pointers: ChatPointer[];
35
+ route: string | null;
27
36
  };
28
37
 
38
+ function parseChatRoute(raw: unknown): string | null {
39
+ if (typeof raw !== "string") return null;
40
+ return raw.startsWith("/") && !raw.startsWith("//") ? raw : null;
41
+ }
42
+
29
43
  function samePointer(a: ChatPointer, b: ChatPointer): boolean {
30
44
  return (
31
45
  a.type === b.type &&
32
46
  a.slug === b.slug &&
33
47
  a.blockId === b.blockId &&
34
- a.quote === b.quote
48
+ a.quote === b.quote &&
49
+ (a.component ?? null) === (b.component ?? null) &&
50
+ (a.file ?? null) === (b.file ?? null) &&
51
+ (a.line ?? null) === (b.line ?? null)
35
52
  );
36
53
  }
37
54
 
@@ -49,7 +66,12 @@ export function pointersFromMetadata(metadata: unknown): ChatPointer[] {
49
66
  return pointers.slice(0, POINTER_CAP);
50
67
  }
51
68
 
52
- export function addPointer(list: ChatPointer[], next: ChatPointer): ChatPointer[] {
69
+ export function addPointer(
70
+ list: ChatPointer[],
71
+ next: ChatPointer,
72
+ mode: "append" | "replace" = "append",
73
+ ): ChatPointer[] {
74
+ if (mode === "replace") return [next];
53
75
  if (list.some((item) => samePointer(item, next))) return list;
54
76
  const added = [...list, next];
55
77
  return added.length > POINTER_CAP ? added.slice(added.length - POINTER_CAP) : added;
@@ -80,14 +102,50 @@ export function chatPointerFromGrab(
80
102
  blockType: value.blockType,
81
103
  blockIndex: value.blockIndex,
82
104
  quote: value.quote.replace(/\s+/g, " ").trim().slice(0, 240),
105
+ ...placeFrom(value),
106
+ };
107
+ }
108
+
109
+ function placeFrom(value: Record<string, unknown>): Pick<ChatPointer, "component" | "file" | "line" | "column" | "stack"> {
110
+ const component = typeof value.component === "string" && value.component ? value.component.slice(0, 80) : null;
111
+ const file =
112
+ typeof value.file === "string" && /^[\w./@+-]+\.(?:tsx|ts|jsx|js|mdx)$/.test(value.file) && !value.file.includes("..")
113
+ ? value.file.slice(0, 240)
114
+ : null;
115
+ const line = typeof value.line === "number" && Number.isInteger(value.line) && value.line > 0 ? value.line : null;
116
+ const column = typeof value.column === "number" && Number.isInteger(value.column) && value.column > 0 ? value.column : null;
117
+ const stack = Array.isArray(value.stack)
118
+ ? value.stack.filter((item): item is string => typeof item === "string" && item.length > 0).slice(0, 4)
119
+ : [];
120
+ return {
121
+ ...(component ? { component } : {}),
122
+ ...(file ? { file } : {}),
123
+ ...(line ? { line } : {}),
124
+ ...(column ? { column } : {}),
125
+ ...(stack.length > 0 ? { stack } : {}),
83
126
  };
84
127
  }
85
128
 
129
+ /** A code session sends the route and the pointed blocks, not the open document. */
130
+ export function codeSessionChatContext(input: {
131
+ pointers: ChatPointer[];
132
+ route?: string | null;
133
+ }): ChatRequestContext {
134
+ return buildChatContext({
135
+ focus: null,
136
+ includeFocus: false,
137
+ mentions: [],
138
+ pointers: input.pointers,
139
+ route: input.route,
140
+ });
141
+ }
142
+
86
143
  export function buildChatContext(input: {
87
144
  focus: EditorFocus | null;
88
145
  includeFocus: boolean;
89
146
  mentions: DocRef[];
90
147
  pointers: ChatPointer[];
148
+ route?: string | null;
91
149
  }): ChatRequestContext {
92
150
  const focusDoc = input.includeFocus && input.focus ? input.focus.docRef : null;
93
151
  const selection =
@@ -105,7 +163,13 @@ export function buildChatContext(input: {
105
163
  if (pointers.some((item) => samePointer(item, pointer))) continue;
106
164
  pointers.push(pointer);
107
165
  }
108
- return { focusDoc, selection, mentions, pointers: pointers.slice(-POINTER_CAP) };
166
+ return {
167
+ focusDoc,
168
+ selection,
169
+ mentions,
170
+ pointers: pointers.slice(-POINTER_CAP),
171
+ route: parseChatRoute(input.route),
172
+ };
109
173
  }
110
174
 
111
175
  export function addMention(list: DocRef[], ref: DocRef): DocRef[] {
@@ -10,6 +10,18 @@ export type WriteToolOutput =
10
10
  | { ok: true; change?: ChatChangeSummary; changes?: ChatChangeSummary[] }
11
11
  | { ok: false; error: string; message: string };
12
12
 
13
+ export function isCheckoutWriteTool(name: string) {
14
+ return name === "writeFile" || name === "editFile" || name === "deleteFile";
15
+ }
16
+
17
+ /** A finished checkout tool changed the host, so the preview should load again. */
18
+ export function checkoutWriteReloads(name: string, output: unknown): boolean {
19
+ const body = output as { ok?: boolean } | undefined;
20
+ if (isCheckoutWriteTool(name)) return !body || body.ok !== false;
21
+ if (name === "runCommand") return body?.ok === true;
22
+ return false;
23
+ }
24
+
13
25
  export function isWriteTool(name: string) {
14
26
  return (
15
27
  name === "writeDoc" ||
@@ -57,3 +57,14 @@ export function panelReducer(state: PanelState, action: PanelAction): PanelState
57
57
  export function isPanelVisible(mode: PanelMode): boolean {
58
58
  return mode === "open" || mode === "fullscreen";
59
59
  }
60
+
61
+ /** A docked column has no window chrome: it cannot close, minimize, or go fullscreen. */
62
+ export function allowsPanelAction(action: PanelAction, docked: boolean): boolean {
63
+ if (!docked) return true;
64
+ return (
65
+ action.type !== "toggle" &&
66
+ action.type !== "close" &&
67
+ action.type !== "minimize" &&
68
+ action.type !== "toggleFullscreen"
69
+ );
70
+ }