authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 16:02:49-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 16:06:17-08:00
logecb4a1efa43ae762b4314895505371033170faae
tree051e52c5aa5d2609fde67578050a53cd9f4bd462
parent1b0093a4830a4d95c4684c4b8d6d6b7f6cd6856a

Add VS Code open support and stabilize chat autoscroll

- add VS Code to editor contracts and picker UI - use `code --goto` for line/column targets in open resolution - refine chat scroll intent handling to avoid unwanted stick-to-bottom jumps

4 files changed, 113 insertions(+), 12 deletions(-)

apps/server/src/open.test.ts+22-4
...@@ -5,20 +5,29 @@ import { Effect } from "effect";...@@ -5,20 +5,29 @@ import { Effect } from "effect";
5import { assertSuccess } from "@effect/vitest/utils";5import { assertSuccess } from "@effect/vitest/utils";
66
7describe("resolveEditorLaunch", () => {7describe("resolveEditorLaunch", () => {
8 it.effect("returns cursor command for cursor editor", () =>8 it.effect("returns commands for command-based editors", () =>
9 Effect.gen(function* () {9 Effect.gen(function* () {
10 const launch = yield* resolveEditorLaunch(10 const cursorLaunch = yield* resolveEditorLaunch(
11 { cwd: "/tmp/workspace", editor: "cursor" },11 { cwd: "/tmp/workspace", editor: "cursor" },
12 "darwin",12 "darwin",
13 );13 );
14 assert.deepEqual(launch, {14 assert.deepEqual(cursorLaunch, {
15 command: "cursor",15 command: "cursor",
16 args: ["/tmp/workspace"],16 args: ["/tmp/workspace"],
17 });17 });
18
19 const vscodeLaunch = yield* resolveEditorLaunch(
20 { cwd: "/tmp/workspace", editor: "vscode" },
21 "darwin",
22 );
23 assert.deepEqual(vscodeLaunch, {
24 command: "code",
25 args: ["/tmp/workspace"],
26 });
18 }),27 }),
19 );28 );
2029
21 it.effect("uses --goto when cursor target includes line/column suffixes", () =>30 it.effect("uses --goto when editor supports line/column suffixes", () =>
22 Effect.gen(function* () {31 Effect.gen(function* () {
23 const lineOnly = yield* resolveEditorLaunch(32 const lineOnly = yield* resolveEditorLaunch(
24 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" },33 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" },
...@@ -37,6 +46,15 @@ describe("resolveEditorLaunch", () => {...@@ -37,6 +46,15 @@ describe("resolveEditorLaunch", () => {
37 command: "cursor",46 command: "cursor",
38 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],47 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
39 });48 });
49
50 const vscodeLineAndColumn = yield* resolveEditorLaunch(
51 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" },
52 "darwin",
53 );
54 assert.deepEqual(vscodeLineAndColumn, {
55 command: "code",
56 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
57 });
40 }),58 }),
41 );59 );
4260
apps/server/src/open.ts+1-1
...@@ -33,7 +33,7 @@ interface EditorLaunch {...@@ -33,7 +33,7 @@ interface EditorLaunch {
33const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;33const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
3434
35function shouldUseGotoFlag(editorId: EditorId, target: string): boolean {35function shouldUseGotoFlag(editorId: EditorId, target: string): boolean {
36 return editorId === "cursor" && LINE_COLUMN_SUFFIX_PATTERN.test(target);36 return (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target);
37}37}
3838
39/**39/**
apps/web/src/components/ChatView.tsx+89-7
...@@ -517,6 +517,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -517,6 +517,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
517 const messagesScrollRef = useRef<HTMLDivElement>(null);517 const messagesScrollRef = useRef<HTMLDivElement>(null);
518 const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);518 const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
519 const shouldAutoScrollRef = useRef(true);519 const shouldAutoScrollRef = useRef(true);
520 const lastKnownScrollTopRef = useRef(0);
521 const isPointerScrollActiveRef = useRef(false);
522 const lastTouchClientYRef = useRef<number | null>(null);
523 const pendingUserScrollUpIntentRef = useRef(false);
520 const pendingAutoScrollFrameRef = useRef<number | null>(null);524 const pendingAutoScrollFrameRef = useRef<number | null>(null);
521 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);525 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
522 const composerCommandInputRef = useRef<HTMLInputElement>(null);526 const composerCommandInputRef = useRef<HTMLInputElement>(null);
...@@ -1324,8 +1328,15 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1324,8 +1328,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
1324 const scrollContainer = messagesScrollRef.current;1328 const scrollContainer = messagesScrollRef.current;
1325 if (!scrollContainer) return;1329 if (!scrollContainer) return;
1326 scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });1330 scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
1331 lastKnownScrollTopRef.current = scrollContainer.scrollTop;
1327 shouldAutoScrollRef.current = true;1332 shouldAutoScrollRef.current = true;
1328 }, []);1333 }, []);
1334 const cancelPendingStickToBottom = useCallback(() => {
1335 const pendingFrame = pendingAutoScrollFrameRef.current;
1336 if (pendingFrame === null) return;
1337 pendingAutoScrollFrameRef.current = null;
1338 window.cancelAnimationFrame(pendingFrame);
1339 }, []);
1329 const scheduleStickToBottom = useCallback(() => {1340 const scheduleStickToBottom = useCallback(() => {
1330 if (pendingAutoScrollFrameRef.current !== null) return;1341 if (pendingAutoScrollFrameRef.current !== null) return;
1331 pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {1342 pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
...@@ -1333,19 +1344,77 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1333,19 +1344,77 @@ export default function ChatView({ threadId }: ChatViewProps) {
1333 scrollMessagesToBottom();1344 scrollMessagesToBottom();
1334 });1345 });
1335 }, [scrollMessagesToBottom]);1346 }, [scrollMessagesToBottom]);
1347 const forceStickToBottom = useCallback(() => {
1348 cancelPendingStickToBottom();
1349 scrollMessagesToBottom();
1350 scheduleStickToBottom();
1351 }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]);
1336 const onMessagesScroll = useCallback(() => {1352 const onMessagesScroll = useCallback(() => {
1337 const scrollContainer = messagesScrollRef.current;1353 const scrollContainer = messagesScrollRef.current;
1338 if (!scrollContainer) return;1354 if (!scrollContainer) return;
1339 shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer);1355 const currentScrollTop = scrollContainer.scrollTop;
1356 const isNearBottom = isScrollContainerNearBottom(scrollContainer);
1357
1358 if (!shouldAutoScrollRef.current && isNearBottom) {
1359 shouldAutoScrollRef.current = true;
1360 pendingUserScrollUpIntentRef.current = false;
1361 } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) {
1362 const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
1363 if (scrolledUp) {
1364 shouldAutoScrollRef.current = false;
1365 }
1366 pendingUserScrollUpIntentRef.current = false;
1367 } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) {
1368 const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
1369 if (scrolledUp) {
1370 shouldAutoScrollRef.current = false;
1371 }
1372 } else if (shouldAutoScrollRef.current && !isNearBottom) {
1373 // Catch-all for keyboard/assistive scroll interactions.
1374 const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
1375 if (scrolledUp) {
1376 shouldAutoScrollRef.current = false;
1377 }
1378 }
1379
1380 lastKnownScrollTopRef.current = currentScrollTop;
1381 }, []);
1382 const onMessagesWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
1383 if (event.deltaY < 0) {
1384 pendingUserScrollUpIntentRef.current = true;
1385 }
1386 }, []);
1387 const onMessagesPointerDown = useCallback((_event: React.PointerEvent<HTMLDivElement>) => {
1388 isPointerScrollActiveRef.current = true;
1389 }, []);
1390 const onMessagesPointerUp = useCallback((_event: React.PointerEvent<HTMLDivElement>) => {
1391 isPointerScrollActiveRef.current = false;
1392 }, []);
1393 const onMessagesPointerCancel = useCallback((_event: React.PointerEvent<HTMLDivElement>) => {
1394 isPointerScrollActiveRef.current = false;
1395 }, []);
1396 const onMessagesTouchStart = useCallback((event: React.TouchEvent<HTMLDivElement>) => {
1397 const touch = event.touches[0];
1398 if (!touch) return;
1399 lastTouchClientYRef.current = touch.clientY;
1400 }, []);
1401 const onMessagesTouchMove = useCallback((event: React.TouchEvent<HTMLDivElement>) => {
1402 const touch = event.touches[0];
1403 if (!touch) return;
1404 const previousTouchY = lastTouchClientYRef.current;
1405 if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
1406 pendingUserScrollUpIntentRef.current = true;
1407 }
1408 lastTouchClientYRef.current = touch.clientY;
1409 }, []);
1410 const onMessagesTouchEnd = useCallback((_event: React.TouchEvent<HTMLDivElement>) => {
1411 lastTouchClientYRef.current = null;
1340 }, []);1412 }, []);
1341 useEffect(() => {1413 useEffect(() => {
1342 return () => {1414 return () => {
1343 const frame = pendingAutoScrollFrameRef.current;1415 cancelPendingStickToBottom();
1344 if (frame !== null) {
1345 window.cancelAnimationFrame(frame);
1346 }
1347 };1416 };
1348 }, []);1417 }, [cancelPendingStickToBottom]);
1349 useLayoutEffect(() => {1418 useLayoutEffect(() => {
1350 if (!activeThread?.id) return;1419 if (!activeThread?.id) return;
1351 shouldAutoScrollRef.current = true;1420 shouldAutoScrollRef.current = true;
...@@ -1922,7 +1991,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1922,7 +1991,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1922 ]);1991 ]);
1923 // Sending a message should always bring the latest user turn into view.1992 // Sending a message should always bring the latest user turn into view.
1924 shouldAutoScrollRef.current = true;1993 shouldAutoScrollRef.current = true;
1925 scheduleStickToBottom();1994 forceStickToBottom();
19261995
1927 setThreadError(threadIdForSend, null);1996 setThreadError(threadIdForSend, null);
1928 promptRef.current = "";1997 promptRef.current = "";
...@@ -2442,6 +2511,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2442,6 +2511,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
2442 ref={setMessagesScrollContainerRef}2511 ref={setMessagesScrollContainerRef}
2443 className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 py-3 sm:px-5 sm:py-4"2512 className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 py-3 sm:px-5 sm:py-4"
2444 onScroll={onMessagesScroll}2513 onScroll={onMessagesScroll}
2514 onWheel={onMessagesWheel}
2515 onPointerDown={onMessagesPointerDown}
2516 onPointerUp={onMessagesPointerUp}
2517 onPointerCancel={onMessagesPointerCancel}
2518 onTouchStart={onMessagesTouchStart}
2519 onTouchMove={onMessagesTouchMove}
2520 onTouchEnd={onMessagesTouchEnd}
2521 onTouchCancel={onMessagesTouchEnd}
2445 >2522 >
2446 <MessagesTimeline2523 <MessagesTimeline
2447 key={activeThread.id}2524 key={activeThread.id}
...@@ -3602,6 +3679,11 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -3602,6 +3679,11 @@ const OpenInPicker = memo(function OpenInPicker({
3602 Icon: CursorIcon,3679 Icon: CursorIcon,
3603 value: "cursor",3680 value: "cursor",
3604 },3681 },
3682 {
3683 label: "VS Code",
3684 Icon: FileIcon,
3685 value: "vscode",
3686 },
3605 {3687 {
3606 label: isMacPlatform(navigator.platform)3688 label: isMacPlatform(navigator.platform)
3607 ? "Finder"3689 ? "Finder"
packages/contracts/src/editor.ts+1
...@@ -3,6 +3,7 @@ import { TrimmedNonEmptyString } from "./baseSchemas";...@@ -3,6 +3,7 @@ import { TrimmedNonEmptyString } from "./baseSchemas";
33
4export const EDITORS = [4export const EDITORS = [
5 { id: "cursor", label: "Cursor", command: "cursor" },5 { id: "cursor", label: "Cursor", command: "cursor" },
6 { id: "vscode", label: "VS Code", command: "code" },
6 { id: "file-manager", label: "File Manager", command: null },7 { id: "file-manager", label: "File Manager", command: null },
7] as const;8] as const;
89