From ecb4a1efa43ae762b4314895505371033170faae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Mar 2026 16:02:49 -0800 Subject: [PATCH] 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 --- apps/server/src/open.test.ts | 26 ++++++-- apps/server/src/open.ts | 2 +- apps/web/src/components/ChatView.tsx | 96 ++++++++++++++++++++++++++-- packages/contracts/src/editor.ts | 1 + 4 files changed, 113 insertions(+), 12 deletions(-) diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 2ab65b90ea82eb628a0244e9e4d3edb75a9bdeae..8adb3e1a68fb8848d525a70a5581d73dcc23a1f7 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -5,20 +5,29 @@ import { Effect } from "effect"; import { assertSuccess } from "@effect/vitest/utils"; describe("resolveEditorLaunch", () => { - it.effect("returns cursor command for cursor editor", () => + it.effect("returns commands for command-based editors", () => Effect.gen(function* () { - const launch = yield* resolveEditorLaunch( + const cursorLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "cursor" }, "darwin", ); - assert.deepEqual(launch, { + assert.deepEqual(cursorLaunch, { command: "cursor", args: ["/tmp/workspace"], }); + + const vscodeLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "vscode" }, + "darwin", + ); + assert.deepEqual(vscodeLaunch, { + command: "code", + args: ["/tmp/workspace"], + }); }), ); - it.effect("uses --goto when cursor target includes line/column suffixes", () => + it.effect("uses --goto when editor supports line/column suffixes", () => Effect.gen(function* () { const lineOnly = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, @@ -37,6 +46,15 @@ describe("resolveEditorLaunch", () => { command: "cursor", args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], }); + + const vscodeLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" }, + "darwin", + ); + assert.deepEqual(vscodeLineAndColumn, { + command: "code", + args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], + }); }), ); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 97c822827d600475bc865b67a3b087cba8b7109a..dcd4e736d3bbfd5681badb94ec606d26e7b5b0d2 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -33,7 +33,7 @@ interface EditorLaunch { const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; function shouldUseGotoFlag(editorId: EditorId, target: string): boolean { - return editorId === "cursor" && LINE_COLUMN_SUFFIX_PATTERN.test(target); + return (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target); } /** diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8591ca22f1cff52a9292cb91b759431d72fe4e54..42accea48010ac22b85c2323c4f0a672d9e459b7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -517,6 +517,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const messagesScrollRef = useRef(null); const [messagesScrollElement, setMessagesScrollElement] = useState(null); const shouldAutoScrollRef = useRef(true); + const lastKnownScrollTopRef = useRef(0); + const isPointerScrollActiveRef = useRef(false); + const lastTouchClientYRef = useRef(null); + const pendingUserScrollUpIntentRef = useRef(false); const pendingAutoScrollFrameRef = useRef(null); const composerEditorRef = useRef(null); const composerCommandInputRef = useRef(null); @@ -1324,8 +1328,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); + lastKnownScrollTopRef.current = scrollContainer.scrollTop; shouldAutoScrollRef.current = true; }, []); + const cancelPendingStickToBottom = useCallback(() => { + const pendingFrame = pendingAutoScrollFrameRef.current; + if (pendingFrame === null) return; + pendingAutoScrollFrameRef.current = null; + window.cancelAnimationFrame(pendingFrame); + }, []); const scheduleStickToBottom = useCallback(() => { if (pendingAutoScrollFrameRef.current !== null) return; pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { @@ -1333,19 +1344,77 @@ export default function ChatView({ threadId }: ChatViewProps) { scrollMessagesToBottom(); }); }, [scrollMessagesToBottom]); + const forceStickToBottom = useCallback(() => { + cancelPendingStickToBottom(); + scrollMessagesToBottom(); + scheduleStickToBottom(); + }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); const onMessagesScroll = useCallback(() => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; - shouldAutoScrollRef.current = isScrollContainerNearBottom(scrollContainer); + const currentScrollTop = scrollContainer.scrollTop; + const isNearBottom = isScrollContainerNearBottom(scrollContainer); + + if (!shouldAutoScrollRef.current && isNearBottom) { + shouldAutoScrollRef.current = true; + pendingUserScrollUpIntentRef.current = false; + } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { + const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; + if (scrolledUp) { + shouldAutoScrollRef.current = false; + } + pendingUserScrollUpIntentRef.current = false; + } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { + const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; + if (scrolledUp) { + shouldAutoScrollRef.current = false; + } + } else if (shouldAutoScrollRef.current && !isNearBottom) { + // Catch-all for keyboard/assistive scroll interactions. + const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; + if (scrolledUp) { + shouldAutoScrollRef.current = false; + } + } + + lastKnownScrollTopRef.current = currentScrollTop; + }, []); + const onMessagesWheel = useCallback((event: React.WheelEvent) => { + if (event.deltaY < 0) { + pendingUserScrollUpIntentRef.current = true; + } + }, []); + const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = true; + }, []); + const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = false; + }, []); + const onMessagesPointerCancel = useCallback((_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = false; + }, []); + const onMessagesTouchStart = useCallback((event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + lastTouchClientYRef.current = touch.clientY; + }, []); + const onMessagesTouchMove = useCallback((event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + const previousTouchY = lastTouchClientYRef.current; + if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { + pendingUserScrollUpIntentRef.current = true; + } + lastTouchClientYRef.current = touch.clientY; + }, []); + const onMessagesTouchEnd = useCallback((_event: React.TouchEvent) => { + lastTouchClientYRef.current = null; }, []); useEffect(() => { return () => { - const frame = pendingAutoScrollFrameRef.current; - if (frame !== null) { - window.cancelAnimationFrame(frame); - } + cancelPendingStickToBottom(); }; - }, []); + }, [cancelPendingStickToBottom]); useLayoutEffect(() => { if (!activeThread?.id) return; shouldAutoScrollRef.current = true; @@ -1922,7 +1991,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); // Sending a message should always bring the latest user turn into view. shouldAutoScrollRef.current = true; - scheduleStickToBottom(); + forceStickToBottom(); setThreadError(threadIdForSend, null); promptRef.current = ""; @@ -2442,6 +2511,14 @@ export default function ChatView({ threadId }: ChatViewProps) { ref={setMessagesScrollContainerRef} 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" onScroll={onMessagesScroll} + onWheel={onMessagesWheel} + onPointerDown={onMessagesPointerDown} + onPointerUp={onMessagesPointerUp} + onPointerCancel={onMessagesPointerCancel} + onTouchStart={onMessagesTouchStart} + onTouchMove={onMessagesTouchMove} + onTouchEnd={onMessagesTouchEnd} + onTouchCancel={onMessagesTouchEnd} >