@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
@@ -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);
@@ -151,10 +163,24 @@ export function useChatSession(): ChatSessionValue {
151
163
  return ctx;
152
164
  }
153
165
 
166
+ /** False outside a chat provider. True while a turn is submitted or streaming. */
167
+ export function useChatBusy(): boolean {
168
+ const ctx = useContext(ChatSessionContext);
169
+ return ctx?.status === "streaming" || ctx?.status === "submitted";
170
+ }
171
+
154
172
  // ---------------------------------------------------------------------------
155
173
 
156
174
  const modelStorageKey = (ws: string) => `backmatter-chat-model:${ws}`;
157
175
 
176
+ function newestThread(threads: readonly { id: string; updatedAt: string }[]): string | null {
177
+ let best: { id: string; updatedAt: string } | null = null;
178
+ for (const thread of threads) {
179
+ if (!best || thread.updatedAt > best.updatedAt) best = thread;
180
+ }
181
+ return best?.id ?? null;
182
+ }
183
+
158
184
  /** Apply tool statuses locally so the top bar moves before the next git poll. */
159
185
  function publishTreePatches(workspaceId: string, patches: TreeStatusPatch[]) {
160
186
  let published = false;
@@ -183,18 +209,40 @@ export function ChatProvider({
183
209
  workspaceId,
184
210
  startOpen = false,
185
211
  pointer = false,
212
+ codeSessionId = null,
213
+ route = null,
214
+ viewed = null,
215
+ onRouteDismiss,
216
+ onCheckoutWrite,
217
+ onTurnSettled,
186
218
  children,
187
219
  }: {
188
220
  workspaceId: string;
189
221
  startOpen?: boolean;
190
222
  /** Dev bar chat can point at elements on the host page. */
191
223
  pointer?: boolean;
224
+ codeSessionId?: string | null;
225
+ route?: string | null;
226
+ /** Document for `route`, resolved by the caller. The agent still receives the URL. */
227
+ viewed?: DocRef | null;
228
+ onRouteDismiss?: (route: string) => void;
229
+ /** A file tool finished in the checkout. The preview does not hot-reload on its own. */
230
+ onCheckoutWrite?: () => void;
231
+ /** The reply finished, so a code session can read the edit set the turn just saved. */
232
+ onTurnSettled?: () => void;
192
233
  children: React.ReactNode;
193
234
  }) {
194
235
  const [panel, dispatch] = useReducer(
195
236
  panelReducer,
196
237
  startOpen ? { ...initialPanelState, mode: "open" as const } : initialPanelState,
197
238
  );
239
+ const dispatchPanel = useCallback(
240
+ (action: PanelAction) => {
241
+ if (!allowsPanelAction(action, Boolean(codeSessionId))) return;
242
+ dispatch(action);
243
+ },
244
+ [codeSessionId],
245
+ );
198
246
  const [aiAvailable, setAiAvailable] = useState<boolean | null>(null);
199
247
  const [models, setModels] = useState<AiModelInfo[]>([]);
200
248
  const [tier, setTier] = useState<AiTier>("ask");
@@ -206,8 +254,10 @@ export function ChatProvider({
206
254
  const [pointers, setPointers] = useState<ChatPointer[]>([]);
207
255
  const [previewPointer, setPreviewPointer] = useState(false);
208
256
  const [pointerHighlight, setPointerHighlight] = useState<"local" | "channel">("local");
209
- const [includeFocus, setIncludeFocus] = useState(true);
257
+ const [includeFocus, setIncludeFocus] = useState(!codeSessionId);
210
258
  const [threadsVersion, setThreadsVersion] = useState(0);
259
+ // Bumped when the person picks a chat, so a late session list cannot replace it.
260
+ const restore = useRef(0);
211
261
  const { focus } = useEditorFocus();
212
262
 
213
263
  // Models + availability.
@@ -234,15 +284,30 @@ export function ChatProvider({
234
284
  };
235
285
  }, [workspaceId]);
236
286
 
237
- // Reset per-workspace session state.
287
+ // Reset per-workspace session state. A code session reopens its newest chat.
238
288
  useEffect(() => {
239
289
  setThreadId(null);
240
290
  setFreshThread(false);
241
291
  setPendingText(null);
242
292
  setMentions([]);
243
293
  setPointers([]);
244
- setIncludeFocus(true);
245
- }, [workspaceId]);
294
+ setIncludeFocus(!codeSessionId);
295
+ if (!codeSessionId) return;
296
+ const token = ++restore.current;
297
+ fetchThreads(workspaceId, codeSessionId)
298
+ .then((res) => {
299
+ if (restore.current !== token) return;
300
+ const latest = newestThread(res.threads);
301
+ if (!latest) return;
302
+ setThreadId(latest);
303
+ })
304
+ .catch(() => {
305
+ // A missed list leaves the blank chat. Sending still starts one.
306
+ });
307
+ return () => {
308
+ restore.current += 1;
309
+ };
310
+ }, [workspaceId, codeSessionId]);
246
311
 
247
312
  const setModel = useCallback(
248
313
  (id: string) => {
@@ -252,14 +317,20 @@ export function ChatProvider({
252
317
  [workspaceId],
253
318
  );
254
319
 
255
- const openThread = useCallback((id: string) => {
256
- setThreadId(id);
257
- setFreshThread(false);
258
- setPendingText(null);
259
- dispatch({ type: "showChat" });
260
- }, []);
320
+ const openThread = useCallback(
321
+ (thread: { id: string; codeSessionId?: string | null }) => {
322
+ if ((thread.codeSessionId ?? null) !== (codeSessionId ?? null)) return;
323
+ restore.current += 1;
324
+ setThreadId(thread.id);
325
+ setFreshThread(false);
326
+ setPendingText(null);
327
+ dispatch({ type: "showChat" });
328
+ },
329
+ [codeSessionId],
330
+ );
261
331
 
262
332
  const newThread = useCallback(() => {
333
+ restore.current += 1;
263
334
  setThreadId(null);
264
335
  setFreshThread(false);
265
336
  setPendingText(null);
@@ -275,8 +346,8 @@ export function ChatProvider({
275
346
  setMentions((prev) => removeMentionFrom(prev, ref));
276
347
  }, []);
277
348
  const addPointer = useCallback((next: ChatPointer) => {
278
- setPointers((prev) => addPointerTo(prev, next));
279
- }, []);
349
+ setPointers((prev) => addPointerTo(prev, next, codeSessionId ? "replace" : "append"));
350
+ }, [codeSessionId]);
280
351
  const removePointer = useCallback((next: ChatPointer) => {
281
352
  setPointers((prev) => removePointerFrom(prev, next));
282
353
  }, []);
@@ -289,28 +360,40 @@ export function ChatProvider({
289
360
  selection: null,
290
361
  mentions: [],
291
362
  pointers: [],
363
+ route: null,
292
364
  }));
293
- contextRef.current = () => buildChatContext({ focus, includeFocus, mentions, pointers });
365
+ contextRef.current = () =>
366
+ codeSessionId
367
+ ? codeSessionChatContext({ pointers, route })
368
+ : buildChatContext({ focus, includeFocus, mentions, pointers });
294
369
  const modelRef = useRef<string | null>(model);
295
370
  modelRef.current = model;
296
371
 
297
372
  // Creating a thread lazily on first send.
298
373
  const startThread = useCallback(
299
374
  async (text: string) => {
300
- const res = await createThread(workspaceId);
375
+ restore.current += 1;
376
+ const res = await createThread(
377
+ workspaceId,
378
+ codeSessionId ? { codeSessionId } : undefined,
379
+ );
301
380
  setPendingText(text);
302
381
  setFreshThread(true);
303
382
  setThreadId(res.thread.id);
304
383
  bumpThreads();
305
384
  },
306
- [workspaceId, bumpThreads],
385
+ [workspaceId, bumpThreads, codeSessionId],
307
386
  );
308
387
 
388
+ const dismissRoute = useCallback(() => {
389
+ if (route && onRouteDismiss) onRouteDismiss(route);
390
+ }, [route, onRouteDismiss]);
391
+
309
392
  const ui = useMemo<ChatUIValue>(
310
393
  () => ({
311
394
  workspaceId,
312
395
  panel,
313
- dispatch,
396
+ dispatch: dispatchPanel,
314
397
  aiAvailable,
315
398
  models,
316
399
  tier,
@@ -335,10 +418,15 @@ export function ChatProvider({
335
418
  focus,
336
419
  threadsVersion,
337
420
  bumpThreads,
421
+ codeSessionId,
422
+ route,
423
+ viewed,
424
+ dismissRoute,
338
425
  }),
339
426
  [
340
427
  workspaceId,
341
428
  panel,
429
+ dispatchPanel,
342
430
  aiAvailable,
343
431
  models,
344
432
  tier,
@@ -361,6 +449,10 @@ export function ChatProvider({
361
449
  focus,
362
450
  threadsVersion,
363
451
  bumpThreads,
452
+ codeSessionId,
453
+ route,
454
+ viewed,
455
+ dismissRoute,
364
456
  ],
365
457
  );
366
458
 
@@ -380,13 +472,15 @@ export function ChatProvider({
380
472
  getContext={() => contextRef.current()}
381
473
  getModel={() => modelRef.current}
382
474
  onThreadTouched={bumpThreads}
475
+ onCheckoutWrite={onCheckoutWrite}
476
+ onTurnSettled={onTurnSettled}
383
477
  >
384
478
  {scoped}
385
479
  </ChatSession>
386
480
  ) : (
387
481
  <EmptySession onFirstSend={startThread}>{scoped}</EmptySession>
388
482
  ),
389
- [threadId, workspaceId, freshThread, pendingText, bumpThreads, startThread],
483
+ [threadId, workspaceId, freshThread, pendingText, bumpThreads, startThread, onCheckoutWrite, onTurnSettled],
390
484
  );
391
485
 
392
486
  return (
@@ -461,6 +555,8 @@ type SessionProps = {
461
555
  getContext: () => ChatRequestContext;
462
556
  getModel: () => string | null;
463
557
  onThreadTouched: () => void;
558
+ onCheckoutWrite?: () => void;
559
+ onTurnSettled?: () => void;
464
560
  children: React.ReactNode;
465
561
  };
466
562
 
@@ -539,6 +635,8 @@ function LiveSession({
539
635
  getContext,
540
636
  getModel,
541
637
  onThreadTouched,
638
+ onCheckoutWrite,
639
+ onTurnSettled,
542
640
  children,
543
641
  }: SessionProps & {
544
642
  thread: ChatThread | null;
@@ -574,7 +672,10 @@ function LiveSession({
574
672
  id: threadId,
575
673
  messages: initialMessages,
576
674
  transport,
577
- onFinish: () => onThreadTouched(),
675
+ onFinish: () => {
676
+ onThreadTouched();
677
+ onTurnSettled?.();
678
+ },
578
679
  });
579
680
 
580
681
  const { messages, status, sendMessage } = chat;
@@ -632,6 +733,7 @@ function LiveSession({
632
733
  if (streaming && write && ref) editingKey = docKey(ref);
633
734
  if (part.state === "output-available" && !seen.has(part.toolCallId)) {
634
735
  seen.add(part.toolCallId);
736
+ if (checkoutWriteReloads(name, part.output)) onCheckoutWrite?.();
635
737
  const patches = treeStatusPatches(part.output);
636
738
  if (patches.length > 0) publishTreePatches(workspaceId, patches);
637
739
  const found = write ? changesInToolOutput(part.output) : [];
@@ -650,7 +752,7 @@ function LiveSession({
650
752
  }
651
753
  }
652
754
  setAiEditingDocKey(streaming ? editingKey : null);
653
- }, [messages, status, setAiEditingDocKey, workspaceId]);
755
+ }, [messages, status, setAiEditingDocKey, workspaceId, onCheckoutWrite]);
654
756
 
655
757
  useEffect(() => () => setAiEditingDocKey(null), [setAiEditingDocKey]);
656
758
 
@@ -1,8 +1,9 @@
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";
6
+ import { useCodeSessionContext } from "../pages/code-session-context";
6
7
  import { Tooltip } from "../ui/Tooltip";
7
8
  import { useChatSession, useChatUI } from "./ChatProvider";
8
9
  import { MentionPopover, type MentionKey } from "./MentionPopover";
@@ -26,6 +27,27 @@ function isMentionKey(key: string): key is MentionKey {
26
27
 
27
28
  export { POINTER_ARM_EVENT } from "./pointer-arm";
28
29
 
30
+ /** The page open in the code session preview. Same row as the editor's open document. */
31
+ export function RouteChip({
32
+ route,
33
+ doc,
34
+ onDismiss,
35
+ }: {
36
+ route: string;
37
+ doc?: { type: string; slug: string } | null;
38
+ onDismiss: () => void;
39
+ }) {
40
+ return (
41
+ <ContextRow
42
+ icon={<FileIcon size={13} />}
43
+ {...(doc ? { type: doc.type, slug: doc.slug } : { label: route })}
44
+ meta="in preview"
45
+ removeLabel="Remove the page from context"
46
+ onRemove={onDismiss}
47
+ />
48
+ );
49
+ }
50
+
29
51
  function postPointer(on: boolean) {
30
52
  if (typeof window === "undefined") return;
31
53
  window.dispatchEvent(new CustomEvent(POINTER_ARM_EVENT, { detail: { on } }));
@@ -43,15 +65,25 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
43
65
  const ref = useRef<HTMLTextAreaElement>(null);
44
66
  const boxRef = useRef<HTMLDivElement>(null);
45
67
  const mentionKeys = useRef<((key: MentionKey) => void) | null>(null);
46
- const busy = session.status === "streaming" || session.status === "submitted";
68
+ const updating = useCodeSessionContext()?.updating === true;
69
+ const busy = session.status === "streaming" || session.status === "submitted" || updating;
47
70
  const canSend = text.trim().length > 0 && !busy && ui.aiAvailable === true;
48
71
 
49
- // Auto-grow.
50
- useEffect(() => {
72
+ // Auto-grow. Measuring must not leave a 1px swing: that resizes the thread
73
+ // on every key, and the thread then tries to scroll to the bottom.
74
+ useLayoutEffect(() => {
51
75
  const el = ref.current;
52
76
  if (!el) return;
77
+ const previous = el.offsetHeight;
78
+ el.style.overflowY = "hidden";
53
79
  el.style.height = "0px";
54
- el.style.height = `${Math.min(el.scrollHeight, 180)}px`;
80
+ const next = Math.min(el.scrollHeight, 180);
81
+ if (previous > 0 && Math.abs(next - previous) <= 1) {
82
+ el.style.height = `${previous}px`;
83
+ return;
84
+ }
85
+ el.style.height = `${next}px`;
86
+ if (next >= 180) el.style.overflowY = "auto";
55
87
  }, [text]);
56
88
 
57
89
  useEffect(() => {
@@ -138,10 +170,12 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
138
170
  setMention(null);
139
171
  }
140
172
 
141
- const focusChip = ui.focus && ui.includeFocus ? ui.focus.docRef : null;
173
+ const sessionChat = Boolean(ui.codeSessionId);
174
+ const focusChip = !sessionChat && ui.focus && ui.includeFocus ? ui.focus.docRef : null;
175
+ const pageChip = sessionChat && ui.route ? ui.route : null;
142
176
  const selectionActive =
143
177
  ui.focus?.selection && ui.focus.selection.end > ui.focus.selection.start;
144
- const hasContext = Boolean(focusChip || ui.mentions.length > 0);
178
+ const hasContext = Boolean(focusChip || pageChip || ui.mentions.length > 0);
145
179
 
146
180
  return (
147
181
  <div {...stylex.props(styles.wrap)}>
@@ -168,6 +202,13 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
168
202
  onRemove={() => ui.setIncludeFocus(false)}
169
203
  />
170
204
  ) : null}
205
+ {pageChip ? (
206
+ <RouteChip
207
+ route={pageChip}
208
+ doc={ui.viewed}
209
+ onDismiss={() => ui.dismissRoute()}
210
+ />
211
+ ) : null}
171
212
  {ui.mentions.map((m) => (
172
213
  <ContextRow
173
214
  key={docKey(m)}
@@ -232,6 +273,7 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
232
273
  {...stylex.props(styles.textarea)}
233
274
  />
234
275
  <div {...stylex.props(styles.footer)}>
276
+ {!sessionChat ? (
235
277
  <Tooltip label="Mention a document (@)">
236
278
  <button
237
279
  type="button"
@@ -257,6 +299,7 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
257
299
  <AtIcon />
258
300
  </button>
259
301
  </Tooltip>
302
+ ) : null}
260
303
  <span {...stylex.props(styles.spacer)} />
261
304
  {ui.pointer ? (
262
305
  <Tooltip label={pointing ? "Stop pointing" : "Point at an element on the page"}>
@@ -276,7 +319,7 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
276
319
  </button>
277
320
  </Tooltip>
278
321
  ) : null}
279
- {ui.focus ? (
322
+ {!sessionChat && ui.focus ? (
280
323
  <Tooltip
281
324
  label={
282
325
  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
+ }