@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.
- package/dist/chunks/{UiKitApp-BDsrXN12.js → UiKitApp-_EDqWID3.js} +2 -2
- package/dist/chunks/{WorkspaceRoutes-BTZ1Ah4F.js → WorkspaceRoutes-B2PrFgpz.js} +28979 -27418
- package/dist/chunks/{dashboard-FT7dm5wi.js → dashboard-BnlnMnnr.js} +55 -54
- package/dist/chunks/{index-DHJvh5Uy.js → index-D_BHF_8q.js} +1 -1
- package/dist/dashboard.js +2 -2
- package/dist/index.js +2 -2
- package/dist/site-widget.css +4 -0
- package/dist/site-widget.js +8304 -8029
- package/dist/studio.css +186 -12
- package/package.json +2 -2
- package/src/WorkspaceRoutes.tsx +5 -2
- package/src/api.ts +63 -7
- package/src/chat/ChangeCard.tsx +8 -2
- package/src/chat/ChatMessage.tsx +171 -102
- package/src/chat/ChatPanel.tsx +54 -34
- package/src/chat/ChatProvider.tsx +116 -20
- package/src/chat/Composer.tsx +48 -7
- package/src/chat/PointerChip.tsx +9 -5
- package/src/chat/ThreadHistory.tsx +3 -3
- package/src/chat/chat-context.ts +67 -3
- package/src/chat/chat-helpers.ts +12 -0
- package/src/chat/chat-state.ts +11 -0
- package/src/chrome/AppShell.tsx +16 -5
- package/src/chrome/CommandPalette.tsx +12 -0
- package/src/chrome/ContentExplorerSidebar.tsx +23 -3
- package/src/chrome/UserMenu.tsx +34 -3
- package/src/chrome/WorkspaceStatusBar.tsx +209 -27
- package/src/chrome/content-api-url.ts +0 -78
- package/src/chrome/content-tree-visibility.ts +13 -3
- package/src/chrome/explorer-icons.tsx +5 -0
- package/src/code-session-entry.ts +9 -0
- package/src/components/WorkspaceSwitcher.tsx +2 -2
- package/src/element-origin.ts +149 -0
- package/src/host-pointer-bootstrap.ts +11 -0
- package/src/host-pointer.ts +147 -0
- package/src/install-preview-pointer.ts +19 -3
- package/src/pages/CodeSessionFiles.tsx +221 -0
- package/src/pages/CodeSessionPage.tsx +1188 -0
- package/src/pages/DocEditorPage.tsx +33 -15
- package/src/pages/PreviewSize.tsx +181 -0
- package/src/pages/SettingsGeneralPage.tsx +67 -2
- package/src/pages/WorkspaceHomePage.tsx +313 -0
- package/src/pages/WorkspaceLayout.tsx +10 -1
- package/src/pages/code-session-boot.ts +43 -0
- package/src/pages/code-session-context.tsx +51 -0
- package/src/pages/code-session-files.ts +42 -0
- package/src/pages/code-session-frame.ts +52 -0
- package/src/pages/code-session-routes.ts +42 -0
- package/src/pages/home-rows.ts +140 -0
- package/src/pages/preview-size.ts +33 -0
- package/src/pages/session-preview.ts +109 -0
- package/src/pages/use-session-preview.ts +70 -0
- package/src/replica/store.ts +11 -0
- package/src/replica/types.ts +6 -0
- package/src/site-pointer.ts +24 -5
- package/src/studio-config.ts +1 -1
- package/src/ui/ListRow.tsx +12 -3
- package/src/ui/Page.tsx +12 -1
- package/src/workspace-path.ts +35 -3
- package/src/chrome/ContentApiPopover.tsx +0 -215
- 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
|
-
|
|
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(
|
|
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(
|
|
245
|
-
|
|
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(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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 = () =>
|
|
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
|
-
|
|
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: () =>
|
|
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
|
|
package/src/chat/Composer.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/src/chat/PointerChip.tsx
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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>
|
package/src/chat/chat-context.ts
CHANGED
|
@@ -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(
|
|
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 {
|
|
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[] {
|
package/src/chat/chat-helpers.ts
CHANGED
|
@@ -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" ||
|
package/src/chat/chat-state.ts
CHANGED
|
@@ -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
|
+
}
|