From c6b303fdd19bcf557ebace43086bb2f202b2026a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 4 Aug 2026 09:42:47 -0700 Subject: [PATCH] [eric] chat: collapsing a disclosure keeps the clicked row anchored, the page never jumps --- .../tool-bubbles/CompactMcpBubble.tsx | 4 ++-- .../tool-bubbles/CreateAgentBubble.tsx | 4 ++-- .../tool-bubbles/DefaultToolBubble.tsx | 4 ++-- .../tool-bubbles/ToolGroupBubble.tsx | 6 ++--- .../AgentChat/tool-bubbles/toolRowMotion.ts | 22 +++++++++++++++++++ 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx index fc7e447c..d2343cd9 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/CompactMcpBubble.tsx @@ -4,7 +4,7 @@ import Typography from '@mui/material/Typography'; import Collapse from '@mui/material/Collapse'; import IconButton from '@mui/material/IconButton'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { COLLAPSE_MS, COLLAPSE_EASE, chevronSx, shimmerTextSx } from './toolRowMotion'; +import { COLLAPSE_MS, COLLAPSE_EASE, chevronSx, shimmerTextSx, keepRowAnchored } from './toolRowMotion'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import BlockIcon from '@mui/icons-material/Block'; import SearchIcon from '@mui/icons-material/Search'; @@ -79,7 +79,7 @@ export const CompactMcpBubble: React.FC = ({ return ( { keepRowAnchored(e.currentTarget as HTMLElement); toggle(); } : undefined} sx={{ cursor: canToggleDetails ? 'pointer' : 'default', borderBottom: showBody && canToggleDetails ? `1px solid ${c.border.subtle}` : 'none', diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/CreateAgentBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/CreateAgentBubble.tsx index e52f1728..1e6fca59 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/CreateAgentBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/CreateAgentBubble.tsx @@ -11,7 +11,7 @@ import { AgentMessage } from '@/shared/state/agentsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { ElapsedTimer, formatElapsed } from '../parsing/toolBubbleChrome'; import { AgentResponseBody } from './AgentResponseBody'; -import { chevronSx, shimmerTextSx, railEnterSx, pressSx } from './toolRowMotion'; +import { chevronSx, shimmerTextSx, railEnterSx, pressSx, keepRowAnchored } from './toolRowMotion'; interface CreateAgentBubbleProps { call: AgentMessage; @@ -47,7 +47,7 @@ export const CreateAgentBubble: React.FC = ({ {/* Flat row like every other tool disclosure: no capsule, accent + shimmer only while live. */} { keepRowAnchored(e.currentTarget as HTMLElement); toggle(); }} sx={{ display: 'flex', alignItems: 'center', diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx index 0d726df5..017c5784 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/DefaultToolBubble.tsx @@ -24,7 +24,7 @@ import { domainFromUrl } from './SourceFavicons'; import { DomainIcon } from './DomainIcon'; import VendoredToolUi from '@toolui/VendoredToolUi'; import WidgetCopyChip from '../tool-ui/WidgetCopyChip'; -import { COLLAPSE_MS, COLLAPSE_EASE, chevronSx, shimmerTextSx, railEnterSx } from './toolRowMotion'; +import { COLLAPSE_MS, COLLAPSE_EASE, chevronSx, shimmerTextSx, railEnterSx, keepRowAnchored } from './toolRowMotion'; interface DefaultToolBubbleProps { call: AgentMessage; @@ -87,7 +87,7 @@ export const DefaultToolBubble: React.FC = ({ { keepRowAnchored(e.currentTarget as HTMLElement); toggle(); } : undefined} sx={{ // Rows are FLAT in every state (Claude/ChatGPT transition language): no capsule at rest, // no box on expand; the output hangs off the indent rail below. diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx index f5baa1a8..6fdaa1cf 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolGroupBubble.tsx @@ -8,7 +8,7 @@ import TerminalIcon from '@mui/icons-material/Terminal'; import CheckIcon from '@mui/icons-material/Check'; import CircularProgress from '@mui/material/CircularProgress'; import { summarizeToolGroup } from './summarizeToolGroup'; -import { COLLAPSE_MS, COLLAPSE_EASE, chevronSx, shimmerTextSx, railEnterSx, pressSx } from './toolRowMotion'; +import { COLLAPSE_MS, COLLAPSE_EASE, chevronSx, shimmerTextSx, railEnterSx, pressSx, keepRowAnchored } from './toolRowMotion'; import { AgentMessage, ToolGroupMeta } from '@/shared/state/agentsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useMountReveal } from './useMountReveal'; @@ -150,7 +150,7 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = box around the group, the detail hangs off a thin indent rail under the same quiet row. */} {!expanded ? ( { userToggledRef.current = true; setExpanded(true); }} + onClick={(e: React.MouseEvent) => { keepRowAnchored(e.currentTarget as HTMLElement); userToggledRef.current = true; setExpanded(true); }} sx={{ display: 'inline-flex', alignItems: 'center', @@ -186,7 +186,7 @@ const ToolGroupBubble: React.FC = React.memo(({ group, isSessionRunning = ) : ( { userToggledRef.current = true; setExpanded(false); }} + onClick={(e: React.MouseEvent) => { keepRowAnchored(e.currentTarget as HTMLElement); userToggledRef.current = true; setExpanded(false); }} sx={{ display: 'flex', alignItems: 'center', diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/toolRowMotion.ts b/frontend/src/app/pages/AgentChat/tool-bubbles/toolRowMotion.ts index 909be423..ac056851 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/toolRowMotion.ts +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/toolRowMotion.ts @@ -49,3 +49,25 @@ export const pressSx: Record = { transition: 'transform 100ms ease', '&:active': { transform: 'scale(0.98)' }, }; + +// assistant-ui's scroll lock, anchored: while a disclosure animates, the clicked row keeps its exact +// viewport Y (the scroller compensates per frame), so collapsing a tall block never jumps the page. +export function keepRowAnchored(rowEl: HTMLElement | null): void { + if (!rowEl) return; + let scroller: HTMLElement | null = rowEl.parentElement; + while (scroller) { + const style = getComputedStyle(scroller); + if (/(auto|scroll)/.test(style.overflowY) && scroller.scrollHeight > scroller.clientHeight) break; + scroller = scroller.parentElement; + } + if (!scroller) return; + const anchorY = rowEl.getBoundingClientRect().top; + let raf = 0; + const step = (): void => { + const dy = rowEl.getBoundingClientRect().top - anchorY; + if (dy !== 0) scroller!.scrollTop += dy; + raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + window.setTimeout(() => cancelAnimationFrame(raf), COLLAPSE_MS + 80); +}