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";
55import { assertSuccess } from "@effect/vitest/utils";
66
77describe("resolveEditorLaunch", () => {
8 it.effect("returns cursor command for cursor editor", () =>
8 it.effect("returns commands for command-based editors", () =>
99 Effect.gen(function* () {
10 const launch = yield* resolveEditorLaunch(
10 const cursorLaunch = yield* resolveEditorLaunch(
1111 { cwd: "/tmp/workspace", editor: "cursor" },
1212 "darwin",
1313 );
14 assert.deepEqual(launch, {
14 assert.deepEqual(cursorLaunch, {
1515 command: "cursor",
1616 args: ["/tmp/workspace"],
1717 });
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 });
1827 }),
1928 );
2029
21 it.effect("uses --goto when cursor target includes line/column suffixes", () =>
30 it.effect("uses --goto when editor supports line/column suffixes", () =>
2231 Effect.gen(function* () {
2332 const lineOnly = yield* resolveEditorLaunch(
2433 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" },
......@@ -37,6 +46,15 @@ describe("resolveEditorLaunch", () => {
3746 command: "cursor",
3847 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
3948 });
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 });
4058 }),
4159 );
4260
apps/server/src/open.ts+1-1
......@@ -33,7 +33,7 @@ interface EditorLaunch {
3333const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
3434
3535function 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);
3737}
3838
3939/**
apps/web/src/components/ChatView.tsx+89-7
......@@ -517,6 +517,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
517517 const messagesScrollRef = useRef<HTMLDivElement>(null);
518518 const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
519519 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);
520524 const pendingAutoScrollFrameRef = useRef<number | null>(null);
521525 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
522526 const composerCommandInputRef = useRef<HTMLInputElement>(null);
......@@ -1324,8 +1328,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
13241328 const scrollContainer = messagesScrollRef.current;
13251329 if (!scrollContainer) return;
13261330 scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
1331 lastKnownScrollTopRef.current = scrollContainer.scrollTop;
13271332 shouldAutoScrollRef.current = true;
13281333 }, []);
1334 const cancelPendingStickToBottom = useCallback(() => {
1335 const pendingFrame = pendingAutoScrollFrameRef.current;
1336 if (pendingFrame === null) return;
1337 pendingAutoScrollFrameRef.current = null;
1338 window.cancelAnimationFrame(pendingFrame);
1339 }, []);
13291340 const scheduleStickToBottom = useCallback(() => {
13301341 if (pendingAutoScrollFrameRef.current !== null) return;
13311342 pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
......@@ -1333,19 +1344,77 @@ export default function ChatView({ threadId }: ChatViewProps) {
13331344 scrollMessagesToBottom();
13341345 });
13351346 }, [scrollMessagesToBottom]);
1347 const forceStickToBottom = useCallback(() => {
1348 cancelPendingStickToBottom();
1349 scrollMessagesToBottom();
1350 scheduleStickToBottom();
1351 }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]);
13361352 const onMessagesScroll = useCallback(() => {
13371353 const scrollContainer = messagesScrollRef.current;
13381354 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;
13401412 }, []);
13411413 useEffect(() => {
13421414 return () => {
1343 const frame = pendingAutoScrollFrameRef.current;
1344 if (frame !== null) {
1345 window.cancelAnimationFrame(frame);
1346 }
1415 cancelPendingStickToBottom();
13471416 };
1348 }, []);
1417 }, [cancelPendingStickToBottom]);
13491418 useLayoutEffect(() => {
13501419 if (!activeThread?.id) return;
13511420 shouldAutoScrollRef.current = true;
......@@ -1922,7 +1991,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19221991 ]);
19231992 // Sending a message should always bring the latest user turn into view.
19241993 shouldAutoScrollRef.current = true;
1925 scheduleStickToBottom();
1994 forceStickToBottom();
19261995
19271996 setThreadError(threadIdForSend, null);
19281997 promptRef.current = "";
......@@ -2442,6 +2511,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
24422511 ref={setMessagesScrollContainerRef}
24432512 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"
24442513 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}
24452522 >
24462523 <MessagesTimeline
24472524 key={activeThread.id}
......@@ -3602,6 +3679,11 @@ const OpenInPicker = memo(function OpenInPicker({
36023679 Icon: CursorIcon,
36033680 value: "cursor",
36043681 },
3682 {
3683 label: "VS Code",
3684 Icon: FileIcon,
3685 value: "vscode",
3686 },
36053687 {
36063688 label: isMacPlatform(navigator.platform)
36073689 ? "Finder"
packages/contracts/src/editor.ts+1
......@@ -3,6 +3,7 @@ import { TrimmedNonEmptyString } from "./baseSchemas";
33
44export const EDITORS = [
55 { id: "cursor", label: "Cursor", command: "cursor" },
6 { id: "vscode", label: "VS Code", command: "code" },
67 { id: "file-manager", label: "File Manager", command: null },
78] as const;
89