From 1718f308062852ab228c21be26e79f7bbf05a3ef Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 31 Aug 2026 12:04:32 -0700 Subject: [PATCH] [eric] chat: fullscreen side gutters scroll the transcript instead of eating the wheel (cherry picked from commit fe1ebaa5 on eric/feature-requests) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PNA9xhjw5SkfuB2tJ5KwCj --- .../src/app/pages/AgentChat/AgentChat.tsx | 23 +++++++++++++++++- .../app/pages/AgentChat/gutterWheel.test.ts | 24 +++++++++++++++++++ .../src/app/pages/AgentChat/gutterWheel.ts | 13 ++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/pages/AgentChat/gutterWheel.test.ts create mode 100644 frontend/src/app/pages/AgentChat/gutterWheel.ts diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 3c4f85a8..170b0030 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -84,6 +84,7 @@ import { shallowEqual } from 'react-redux'; import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext'; import { parseMcpToolName, getMcpInputSummary } from '@/shared/mcpToolMeta'; import { isNarration } from './parsing/isNarration'; +import { shouldForwardGutterWheel } from './gutterWheel'; import { openMarketplace } from '@/app/pages/Directory/openMarketplace'; const CONTEXT_WINDOWS: Record = { @@ -831,6 +832,23 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }; }, []); + // Fullscreen centers the column inside wide empty gutters, and the gutter is where the cursor naturally rests; a vertical wheel there can only mean "scroll the thread". Only the BARE gutter forwards (target === currentTarget), so nothing inside the column can be double-scrolled, honoring the one-owner rule. + const onGutterWheel = useCallback((e: React.WheelEvent) => { + if (!shouldForwardGutterWheel({ + ctrlKey: e.ctrlKey, metaKey: e.metaKey, deltaX: e.deltaX, deltaY: e.deltaY, + targetIsGutter: e.target === e.currentTarget, + })) return; + const el = scrollContainerRef.current; + if (!el) return; + if (e.deltaY < 0) { + // Mirror the container's own wheel: reading up from the gutter is real follow-away intent. + userScrollIntentUntilRef.current = performance.now() + USER_SCROLL_INTENT_MS; + pinAbortRef.current = true; + } + el.scrollTop += e.deltaY; + e.stopPropagation(); + }, []); + const scrollToBottomRafRef = useRef(null); const scrollToBottom = useCallback(() => { const el = scrollContainerRef.current; @@ -1638,7 +1656,10 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const statusStyle = STATUS_STYLES[session.status] || { color: c.text.tertiary, bg: c.bg.secondary }; return ( - + {/* Marks transcript AND composer as one chat, so Cmd+A from the composer can still find the conversation (ENG-231). */} diff --git a/frontend/src/app/pages/AgentChat/gutterWheel.test.ts b/frontend/src/app/pages/AgentChat/gutterWheel.test.ts new file mode 100644 index 00000000..5d74edf4 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/gutterWheel.test.ts @@ -0,0 +1,24 @@ +// Run: npm test (frontend/scripts/run-tests.mjs) +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { shouldForwardGutterWheel } from './gutterWheel.ts'; + +const base = { ctrlKey: false, metaKey: false, deltaX: 0, deltaY: 40, targetIsGutter: true }; + +test('a plain vertical wheel on the bare gutter forwards', () => { + assert.equal(shouldForwardGutterWheel(base), true); + assert.equal(shouldForwardGutterWheel({ ...base, deltaY: -40 }), true); +}); + +test('anything inside the column keeps its own scroll (no double-scroll)', () => { + assert.equal(shouldForwardGutterWheel({ ...base, targetIsGutter: false }), false); +}); + +test('pinch-zoom stays free on every surface', () => { + assert.equal(shouldForwardGutterWheel({ ...base, ctrlKey: true }), false); + assert.equal(shouldForwardGutterWheel({ ...base, metaKey: true }), false); +}); + +test('horizontal-dominant gestures pass through to the canvas', () => { + assert.equal(shouldForwardGutterWheel({ ...base, deltaX: 80, deltaY: 10 }), false); +}); diff --git a/frontend/src/app/pages/AgentChat/gutterWheel.ts b/frontend/src/app/pages/AgentChat/gutterWheel.ts new file mode 100644 index 00000000..65d44d44 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/gutterWheel.ts @@ -0,0 +1,13 @@ +// Haik: in fullscreen, scrolling only works over the center text column; the wide side gutters eat +// the wheel. A wheel on the BARE gutter forwards to the transcript; everything else keeps its owner. +export function shouldForwardGutterWheel(e: { + ctrlKey: boolean; + metaKey: boolean; + deltaX: number; + deltaY: number; + targetIsGutter: boolean; +}): boolean { + if (e.ctrlKey || e.metaKey) return false; + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return false; + return e.targetIsGutter; +}