[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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PNA9xhjw5SkfuB2tJ5KwCj
This commit is contained in:
ciregenz
2026-09-01 15:37:19 -07:00
parent 81811d3c3e
commit 1718f30806
3 changed files with 59 additions and 1 deletions
+22 -1
View File
@@ -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<string, number> = {
@@ -831,6 +832,23 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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<HTMLDivElement>) => {
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<number | null>(null);
const scrollToBottom = useCallback(() => {
const el = scrollContainerRef.current;
@@ -1638,7 +1656,10 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const statusStyle = STATUS_STYLES[session.status] || { color: c.text.tertiary, bg: c.bg.secondary };
return (
<Box sx={{ display: 'flex', height: '100%', ...(fullscreenWash && { background: fullscreenWash }) }}>
<Box
onWheel={fullscreenChat ? onGutterWheel : undefined}
sx={{ display: 'flex', height: '100%', ...(fullscreenWash && { background: fullscreenWash }) }}
>
<ContextDrawer />
{/* Marks transcript AND composer as one chat, so Cmd+A from the composer can still find the conversation (ENG-231). */}
<Box data-chat-root sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, overflow: 'hidden', ...(fullscreenChat && { maxWidth: FULLSCREEN_READING_MAX_W, width: '100%', mx: 'auto' }) }}>
@@ -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);
});
@@ -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;
}