{open && body}
{open && loadState === 'ready' && !trace.previewComplete && (
@@ -121,8 +124,9 @@ function TimelineThought({ label, thought, chatId }) {
)
}
-function SingleActivity({ entry, chatId, live }) {
+function SingleActivity({ entry, chatId, live, surfaceKey }) {
const { item, idx } = entry
+ const blockKey = assistantBlockKey(item, idx)
if (item.type === 'thinking') {
return (
)
}
@@ -143,15 +148,21 @@ function SingleActivity({ entry, chatId, live }) {
t={item}
chatId={chatId}
compact
+ disclosureKey={`${surfaceKey}:tool:${blockKey}`}
/>
{hasHelpers &&
}
>
)
}
-function GroupedActivityStretch({ entries, chatId, live = false }) {
- const [userOpen, setUserOpen] = useState(false)
+function GroupedActivityStretch({ entries, chatId, live = false, surfaceKey }) {
+ const stretchKey = assistantBlockKey(entries[0]?.item, entries[0]?.idx)
+ const [userOpen, setUserOpen] = useDisclosureState(
+ chatId,
+ `${surfaceKey}:activity:${stretchKey}`,
+ )
const headerRef = useRef(null)
+ const timelineRef = useRef(null)
const timelineId = useId()
const lastItem = entries[entries.length - 1]?.item
@@ -267,7 +278,7 @@ function GroupedActivityStretch({ entries, chatId, live = false }) {
// no forced-open state for a tap to fight, so the user can peek into a
// live run and close it again.
onToggle={() => {
- preserveTogglePosition(headerRef.current)
+ preserveTogglePosition(headerRef.current, timelineRef.current)
setUserOpen(o => !o)
}}
// A delegating turn's helper rollup ("2 running · 1 done"); the header
@@ -284,7 +295,7 @@ function GroupedActivityStretch({ entries, chatId, live = false }) {
subagent={tool.subagent}
/>
))}
-
+
{open && entries.map(({ item, idx }) => {
if (item.type === 'thinking') {
const key = assistantBlockKey(item, idx)
@@ -294,13 +305,19 @@ function GroupedActivityStretch({ entries, chatId, live = false }) {
label={thoughtDurationLabel(item.duration_ms)}
thought={item}
chatId={chatId}
+ disclosureKey={`${surfaceKey}:thought:${key}`}
/>
)
}
// chatId + the block's tool_use_id let ToolBlock lazily fetch a
// truncated large output on expand (GET /tool-output/{tool_use_id}).
return (
-
+
)
})}
@@ -308,9 +325,23 @@ function GroupedActivityStretch({ entries, chatId, live = false }) {
)
}
-export default function ActivityStretch({ entries, chatId, live = false }) {
+export default function ActivityStretch({ entries, chatId, live = false, surfaceKey }) {
if (entries.length === 1) {
- return
+ return (
+
+ )
}
- return
+ return (
+
+ )
}
diff --git a/frontend/src/components/ChatView/ChatView.jsx b/frontend/src/components/ChatView/ChatView.jsx
index e099b2418..a0bc539c0 100644
--- a/frontend/src/components/ChatView/ChatView.jsx
+++ b/frontend/src/components/ChatView/ChatView.jsx
@@ -3778,6 +3778,7 @@ export default function ChatView({
-
+
)
}
@@ -333,6 +339,7 @@ export default memo(MsgContentInner, (prev, next) => {
return (
prev.msg === next.msg
&& prev.chatId === next.chatId
+ && prev.messageKey === next.messageKey
&& prev.onQuestionAnswer === next.onQuestionAnswer
&& prev.onResume === next.onResume
&& prev.onInternalNav === next.onInternalNav
diff --git a/frontend/src/components/ChatView/StreamingMessage.jsx b/frontend/src/components/ChatView/StreamingMessage.jsx
index 394f869c9..2324d92f6 100644
--- a/frontend/src/components/ChatView/StreamingMessage.jsx
+++ b/frontend/src/components/ChatView/StreamingMessage.jsx
@@ -33,6 +33,7 @@ export default function StreamingMessage({
{
- preserveTogglePosition(headerRef.current)
+ preserveTogglePosition(headerRef.current, detailRef.current)
setOpen(o => !o)
}}
aria-expanded={open}
@@ -355,6 +357,7 @@ export default function ToolBlock({ t, chatId, compact = false }) {
)}
{hasDetail && (
'thinking is an activity type, not an empty icon column')
assert.match(activityStretch, /function TimelineThought/,
'a mixed thought owns its disclosure state')
- assert.match(activityStretch, /const \[open, setOpen\] = useState\(false\)/,
- 'nested thinking starts collapsed')
+ assert.match(activityStretch, /const \[open, setOpen\] = useDisclosureState\(chatId, disclosureKey\)/,
+ 'nested thinking restores its per-chat disclosure state')
assert.match(activityStretch, /className="chat__activity-think-toggle"/)
assert.doesNotMatch(activityStretch, /chat__activity-think-chevron/,
'nested reasoning uses its icon and row affordance without a chevron')
@@ -72,7 +72,7 @@ test('every thinking entry remains the same collapsed nested disclosure', () =>
'deferred thought state changes should be announced')
assert.match(activityStretch, /className="chat__lazy-retry" onClick=\{trace\.retry\}/,
'a failed thought should retry without a close/reopen ritual')
- assert.match(activityStretch, /preserveTogglePosition\(headerRef\.current\)\s*setOpen\(o => !o\)/,
+ assert.match(activityStretch, /preserveTogglePosition\(headerRef\.current, bodyRef\.current\)\s*setOpen\(o => !o\)/,
'opening a long trace preserves the reader anchor')
assert.doesNotMatch(activityStretch, /if \(thinkingOnly\) \{/,
'a thinking-only entry must not swap component type when the first tool arrives')
@@ -82,10 +82,10 @@ test('every thinking entry remains the same collapsed nested disclosure', () =>
test('a single activity discloses directly without a redundant parent row', () => {
assert.match(activityStretch, /if \(entries\.length === 1\)/)
- assert.match(activityStretch, /return
{
diff --git a/frontend/src/components/ChatView/__tests__/activityNoAutoOpen.test.js b/frontend/src/components/ChatView/__tests__/activityNoAutoOpen.test.js
index e576b429c..fe2f29eda 100644
--- a/frontend/src/components/ChatView/__tests__/activityNoAutoOpen.test.js
+++ b/frontend/src/components/ChatView/__tests__/activityNoAutoOpen.test.js
@@ -17,9 +17,9 @@ const src = readFileSync(new URL('../ActivityStretch.jsx', import.meta.url), 'ut
// removed, and that history must not trip the code-level guard below.
const body = src.slice(src.indexOf('function GroupedActivityStretch'))
-test('the stretch starts collapsed and open is exactly userOpen', () => {
- assert.match(body, /const \[userOpen, setUserOpen\] = useState\(false\)/,
- 'userOpen initializes to false (collapsed by default)')
+test('the stretch restores saved user state and open is exactly userOpen', () => {
+ assert.match(body, /const \[userOpen, setUserOpen\] = useDisclosureState\(/,
+ 'userOpen restores only the user-authored per-chat state')
assert.match(body, /\n\s*const open = userOpen\n/,
'open derives from userOpen alone — no force-open expression')
// No `open = running || userOpen` / `userOpen || live` style force-open.
@@ -34,7 +34,7 @@ test('the only open-state write is the user toggle, guarded by preserveTogglePos
'setUserOpen is called from exactly one place')
// preserveTogglePosition runs BEFORE the state mutation on every toggle path —
// the scroll anchor is captured before the height changes.
- assert.match(src, /preserveTogglePosition\(headerRef\.current\)\s*setUserOpen\(o => !o\)/,
+ assert.match(src, /preserveTogglePosition\(headerRef\.current, timelineRef\.current\)\s*setUserOpen\(o => !o\)/,
'the toggle preserves the anchor before flipping open state')
})
diff --git a/frontend/src/components/ChatView/__tests__/disclosureState.test.js b/frontend/src/components/ChatView/__tests__/disclosureState.test.js
new file mode 100644
index 000000000..6038f400d
--- /dev/null
+++ b/frontend/src/components/ChatView/__tests__/disclosureState.test.js
@@ -0,0 +1,40 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+ _resetDisclosureStateForTests,
+ disclosureIsOpen,
+ persistDisclosureOpen,
+} from '../disclosureState.js'
+
+function memorySessionStorage() {
+ const values = new Map()
+ return {
+ getItem: key => values.get(key) ?? null,
+ setItem: (key, value) => values.set(key, String(value)),
+ }
+}
+
+test('disclosure screen state restores per chat and stable surface key', () => {
+ const original = globalThis.sessionStorage
+ globalThis.sessionStorage = memorySessionStorage()
+ _resetDisclosureStateForTests()
+ try {
+ persistDisclosureOpen('chat-a', 'message-1:activity:think-1', true)
+ assert.equal(disclosureIsOpen('chat-a', 'message-1:activity:think-1'), true)
+ assert.equal(disclosureIsOpen('chat-a', 'message-2:activity:think-1'), false)
+ assert.equal(disclosureIsOpen('chat-b', 'message-1:activity:think-1'), false)
+
+ // Simulate a ChatView remount by dropping the module's memory cache. The
+ // browser-session copy remains the restoration authority.
+ _resetDisclosureStateForTests()
+ assert.equal(disclosureIsOpen('chat-a', 'message-1:activity:think-1'), true)
+
+ persistDisclosureOpen('chat-a', 'message-1:activity:think-1', false)
+ _resetDisclosureStateForTests()
+ assert.equal(disclosureIsOpen('chat-a', 'message-1:activity:think-1'), false)
+ } finally {
+ _resetDisclosureStateForTests()
+ globalThis.sessionStorage = original
+ }
+})
diff --git a/frontend/src/components/ChatView/__tests__/preserveTogglePosition.test.js b/frontend/src/components/ChatView/__tests__/preserveTogglePosition.test.js
index 5bec926bd..ab1ba3ea3 100644
--- a/frontend/src/components/ChatView/__tests__/preserveTogglePosition.test.js
+++ b/frontend/src/components/ChatView/__tests__/preserveTogglePosition.test.js
@@ -26,19 +26,22 @@ test('toggle position is corrected from the DOM mutation before rAF', () => {
}
try {
- const parentElement = {}
+ const body = {}
const scroller = { scrollTop: 40 }
let top = 120
const anchor = {
- parentElement,
+ parentElement: {},
+ nextElementSibling: body,
closest: () => scroller,
getBoundingClientRect: () => ({ top }),
}
preserveTogglePosition(anchor)
- assert.equal(observedNode, parentElement)
- assert.deepEqual(observedOptions, { childList: true },
- 'live descendant churn must not win the disclosure mutation race')
+ assert.equal(observedNode, body)
+ assert.deepEqual(observedOptions, {
+ attributes: true,
+ attributeFilter: ['hidden'],
+ }, 'the body visibility commit must be the exact correction boundary')
top = 155
mutationCallback()
@@ -86,6 +89,41 @@ test('rAF remains a fallback when MutationObserver is unavailable', () => {
}
})
+test('FOLLOW_BOTTOM leaves toggle movement entirely to the scroll controller', () => {
+ const originalMutationObserver = globalThis.MutationObserver
+ const originalRaf = globalThis.requestAnimationFrame
+ let observed = false
+ let scheduled = false
+ globalThis.MutationObserver = class {
+ constructor() {}
+ observe() { observed = true }
+ disconnect() {}
+ }
+ globalThis.requestAnimationFrame = () => {
+ scheduled = true
+ return 1
+ }
+
+ try {
+ const scroller = {
+ dataset: { scrollMode: 'FOLLOW_BOTTOM' },
+ scrollTop: 30,
+ querySelector: () => ({ style: {}, offsetHeight: 10 }),
+ }
+ const anchor = {
+ closest: () => scroller,
+ getBoundingClientRect: () => ({ top: 80 }),
+ }
+ preserveTogglePosition(anchor, {})
+ assert.equal(observed, false)
+ assert.equal(scheduled, false)
+ assert.equal(scroller.scrollTop, 30)
+ } finally {
+ globalThis.MutationObserver = originalMutationObserver
+ globalThis.requestAnimationFrame = originalRaf
+ }
+})
+
test('closing a disclosure primes the bottom spacer before React removes its body', () => {
const originalMutationObserver = globalThis.MutationObserver
const originalRaf = globalThis.requestAnimationFrame
diff --git a/frontend/src/components/ChatView/__tests__/useScrollMode.test.js b/frontend/src/components/ChatView/__tests__/useScrollMode.test.js
index 48db45b21..1ad024320 100644
--- a/frontend/src/components/ChatView/__tests__/useScrollMode.test.js
+++ b/frontend/src/components/ChatView/__tests__/useScrollMode.test.js
@@ -15,6 +15,7 @@ import {
layoutMayOwnScroll,
mountMediaSettled,
modeForChatExit,
+ modeForDisclosureToggle,
modeForForegroundReturn,
modeForQuestionSubmission,
modeForQueuedSubmission,
@@ -211,6 +212,26 @@ test('disclosure activation is recognized as an anchor-latching reading action',
'a non-interactive status row must not stop live follow')
})
+test('disclosure toggles follow only in FOLLOW_BOTTOM and otherwise hold the reader anchor', () => {
+ const row = {
+ dataset: { key: 'assistant-1' },
+ offsetTop: 420,
+ offsetHeight: 300,
+ }
+ const scrollEl = {
+ scrollTop: 500,
+ querySelectorAll: () => [row],
+ }
+ const follow = { kind: 'FOLLOW_BOTTOM' }
+ assert.equal(modeForDisclosureToggle(scrollEl, follow), follow,
+ 'autoscroll remains the sole authority while following the tail')
+ assert.deepEqual(
+ modeForDisclosureToggle(scrollEl, { kind: 'PIN_USER_MSG', cid: 'c1' }),
+ { kind: 'ANCHOR_AT', key: 'assistant-1', offset: -80 },
+ 'outside autoscroll the visible reading position is frozen before resize',
+ )
+})
+
test('only provably clamped wheel input gets a next-frame no-scroll release', () => {
const middle = {
scrollTop: 500,
diff --git a/frontend/src/components/ChatView/disclosureState.js b/frontend/src/components/ChatView/disclosureState.js
new file mode 100644
index 000000000..fe4dd8b35
--- /dev/null
+++ b/frontend/src/components/ChatView/disclosureState.js
@@ -0,0 +1,68 @@
+import { useRef, useState } from 'react'
+
+
+// Disclosure state is screen state, not transcript data. Keep it for the
+// browser session so leaving a chat and returning restores the exact activity,
+// thought, and tool rows the reader opened without writing presentation state
+// into the durable conversation.
+const STORAGE_PREFIX = 'chat-disclosures:'
+const MAX_OPEN_DISCLOSURES = 200
+const cache = new Map()
+
+function storageKey(chatId) {
+ return `${STORAGE_PREFIX}${chatId}`
+}
+
+function readOpenKeys(chatId) {
+ const id = String(chatId || '')
+ if (!id) return new Set()
+ if (cache.has(id)) return cache.get(id)
+ let keys = []
+ try {
+ const parsed = JSON.parse(sessionStorage.getItem(storageKey(id)) || '[]')
+ if (Array.isArray(parsed)) keys = parsed.filter(key => typeof key === 'string')
+ } catch {}
+ const openKeys = new Set(keys.slice(-MAX_OPEN_DISCLOSURES))
+ cache.set(id, openKeys)
+ return openKeys
+}
+
+export function persistDisclosureOpen(chatId, disclosureKey, open) {
+ const id = String(chatId || '')
+ const key = String(disclosureKey || '')
+ if (!id || !key) return
+ const openKeys = readOpenKeys(id)
+ // Refresh insertion order when opening so the bounded set retains the most
+ // recently used disclosures in an unusually long tool-heavy chat.
+ openKeys.delete(key)
+ if (open) openKeys.add(key)
+ while (openKeys.size > MAX_OPEN_DISCLOSURES) {
+ openKeys.delete(openKeys.values().next().value)
+ }
+ try {
+ sessionStorage.setItem(storageKey(id), JSON.stringify([...openKeys]))
+ } catch {}
+}
+
+export function disclosureIsOpen(chatId, disclosureKey) {
+ return readOpenKeys(chatId).has(String(disclosureKey || ''))
+}
+
+export function useDisclosureState(chatId, disclosureKey) {
+ const [open, setOpenState] = useState(
+ () => disclosureIsOpen(chatId, disclosureKey),
+ )
+ const openRef = useRef(open)
+ openRef.current = open
+ const setOpen = (next) => {
+ const value = typeof next === 'function' ? !!next(openRef.current) : !!next
+ openRef.current = value
+ persistDisclosureOpen(chatId, disclosureKey, value)
+ setOpenState(value)
+ }
+ return [open, setOpen]
+}
+
+export function _resetDisclosureStateForTests() {
+ cache.clear()
+}
diff --git a/frontend/src/components/ChatView/preserveTogglePosition.js b/frontend/src/components/ChatView/preserveTogglePosition.js
index c497dacf1..dcab150ef 100644
--- a/frontend/src/components/ChatView/preserveTogglePosition.js
+++ b/frontend/src/components/ChatView/preserveTogglePosition.js
@@ -15,11 +15,18 @@ function unwindProvisionalSpacer(spacer) {
provisionalSpacer.delete(spacer)
}
-export function preserveTogglePosition(anchorEl) {
+export function preserveTogglePosition(anchorEl, bodyEl = anchorEl?.nextElementSibling) {
if (!anchorEl || typeof requestAnimationFrame !== 'function') return
const scroller = anchorEl.closest?.('.chat__scroll')
if (!scroller) return
+ // FOLLOW_BOTTOM is already a complete, idempotent layout policy: the scroll
+ // controller follows the new real-content tail after every ResizeObserver
+ // pass. A second header-local correction would race that authority and make
+ // identical toggles sometimes hold and sometimes move. Outside follow mode,
+ // preserve the reader's exact header position below.
+ if (scroller.dataset?.scrollMode === 'FOLLOW_BOTTOM') return
+
// Closing a disclosure at the physical tail removes height before the chat's
// ResizeObserver can grow its dynamic bottom reservation. The browser clamps
// scrollTop in that gap, paints the header lower for one frame, then the
@@ -31,11 +38,10 @@ export function preserveTogglePosition(anchorEl) {
if (spacer) unwindProvisionalSpacer(spacer)
if (anchorEl.getAttribute?.('aria-expanded') === 'true') {
- const body = anchorEl.nextElementSibling
- if (spacer && body) {
- const rectHeight = body.getBoundingClientRect?.().height || 0
+ if (spacer && bodyEl) {
+ const rectHeight = bodyEl.getBoundingClientRect?.().height || 0
const styles = typeof getComputedStyle === 'function'
- ? getComputedStyle(body)
+ ? getComputedStyle(bodyEl)
: null
const marginTop = Number.parseFloat(styles?.marginTop) || 0
const marginBottom = Number.parseFloat(styles?.marginBottom) || 0
@@ -67,16 +73,17 @@ export function preserveTogglePosition(anchorEl) {
if (Math.abs(delta) > 0.5) scroller.scrollTop += delta
}
- if (typeof MutationObserver === 'function' && anchorEl.parentElement) {
+ if (typeof MutationObserver === 'function' && bodyEl) {
observer = new MutationObserver(settle)
- // The disclosure body is always a DIRECT sibling of the header. Observe
- // only that wrapper's child list: an open live activity stretch keeps
- // changing text, tool status, and timeline children below this level. A
- // subtree observer can therefore settle on unrelated stream churn before
- // React inserts/removes the disclosure body, making the same tap hold on
- // one attempt and drift on the next. Direct-child observation names the
- // actual transition and leaves live descendants out of the race.
- observer.observe(anchorEl.parentElement, { childList: true })
+ // Every disclosure keeps its body node mounted and flips `hidden` while
+ // inserting/removing its rendered children. Watching the header's parent
+ // child list never saw that transition, leaving the rAF fallback to race a
+ // ResizeObserver. The body's own hidden attribute is the exact commit
+ // boundary, independent of live descendant churn.
+ observer.observe(bodyEl, {
+ attributes: true,
+ attributeFilter: ['hidden'],
+ })
}
// Safety fallback for an unusual disclosure that changes layout without a
diff --git a/frontend/src/components/ChatView/useScrollMode.js b/frontend/src/components/ChatView/useScrollMode.js
index 48f3d9f70..4b6bd69b0 100644
--- a/frontend/src/components/ChatView/useScrollMode.js
+++ b/frontend/src/components/ChatView/useScrollMode.js
@@ -560,7 +560,7 @@ export function readerInputActivatesDisclosure(
pointerButton = 0,
) {
const disclosure = target?.closest?.(
- 'button.chat__activity-header, button.chat__tool-header, button.chat__marker-header',
+ 'button.chat__activity-header, button.chat__activity-think-toggle, button.chat__tool-header, button.chat__marker-header',
)
if (!disclosure) return false
return (type === 'pointerdown' && pointerButton === 0)
@@ -617,6 +617,17 @@ export function modeForChatExit(scrollEl) {
}
+/** A disclosure toggle obeys the existing reading mode instead of inventing a
+ * second scroll policy. FOLLOW_BOTTOM stays live and follows the resized tail;
+ * every non-follow mode freezes the exact visible message anchor before the
+ * disclosure changes height. Repeating the same toggle therefore has the same
+ * result until the reader explicitly changes scroll mode. */
+export function modeForDisclosureToggle(scrollEl, currentMode) {
+ if (currentMode?.kind === 'FOLLOW_BOTTOM') return currentMode
+ return anchorModeFromScroll(scrollEl) || currentMode
+}
+
+
/** Submitting an in-message question answer resumes output inside the same
* assistant row and may replace the card's controls immediately. It is not a
* request to follow the live tail. Freeze the exact visible row/offset before
@@ -891,12 +902,14 @@ export default function useScrollMode({
const previousMode = modeRef.current
if (nextMode === previousMode) return previousMode
modeRef.current = nextMode
+ const scrollEl = scrollRef.current
+ if (scrollEl) scrollEl.dataset.scrollMode = nextMode.kind
recordTrace('transitions', event, {
from: previousMode,
to: nextMode,
})
return nextMode
- }, [recordTrace])
+ }, [recordTrace, scrollRef])
// The sole automatic scrollTop funnel inside the controller. `applyMode`
// remains exported as a pure executor for unit tests, but live code routes
@@ -1529,15 +1542,14 @@ export default function useScrollMode({
scrollEl,
})
if (activatesDisclosure) {
- // A disclosure tap says "hold what I am reading", not "keep following
- // the conversation tail". Latch that intent BEFORE React changes the
- // body height. The normal gesture gate below then defers ResizeObserver
- // writes until pointerup, at which point it replays this anchor rather
- // than the stale FOLLOW_BOTTOM that caused the non-repeatable jump.
- const anchor = anchorModeFromScroll(scrollEl)
- if (anchor) {
+ // A disclosure tap obeys the mode the reader already chose. FOLLOW_BOTTOM
+ // remains the sole tail authority; every other mode latches the visible
+ // anchor BEFORE React changes body height. The gesture gate below defers
+ // ResizeObserver writes until pointerup, then replays that same policy.
+ const nextMode = modeForDisclosureToggle(scrollEl, modeRef.current)
+ if (nextMode && nextMode !== modeRef.current) {
readerLocationExplicitRef.current = true
- transitionMode(anchor, 'reader:disclosure-toggle')
+ transitionMode(nextMode, 'reader:disclosure-toggle')
persistMode()
}
}
From 72bd7a7894620bc00bd9a13d7e860d5332883099 Mon Sep 17 00:00:00 2001
From: Hamza Merzic
Date: Tue, 21 Jul 2026 23:48:17 +0000
Subject: [PATCH 4/5] Harden mobile activity and question geometry
---
ARCHITECTURE.md | 7 +-
frontend/src/components/ChatView/ChatView.css | 6 +-
.../src/components/ChatView/MsgContent.jsx | 4 +-
.../__tests__/accessibilityHardening.test.js | 4 +
.../__tests__/toolBlockPresentation.test.js | 3 +
.../ChatView/__tests__/useScrollMode.test.js | 53 ++++++++++
.../src/components/ChatView/useScrollMode.js | 54 ++++++++---
tests/chat-redesign.spec.mjs | 96 +++++++++++++++++++
tests/second-send-pin.spec.mjs | 61 ++++++++++--
9 files changed, 264 insertions(+), 24 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 6c7313a82..dafa616d2 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -596,8 +596,11 @@ and attaches their rule ids to new diagnostic chats. The Playwright lock-in spec
card enters its pending state or output resumes, the controller snapshots the
currently visible message and its exact viewport offset as `ANCHOR_AT`. Resumed
output grows without dragging the reader, even when the chat had been following
- the tail before Submit. A failed answer keeps that settled reading anchor for the
- retryable card rather than manufacturing follow intent again.
+ the tail before Submit. If a mobile viewport grows before that output arrives,
+ the dynamic spacer temporarily reserves enough room to keep the anchor target
+ reachable; the reservation disappears as real content replaces it. A failed
+ answer keeps that settled reading anchor for the retryable card rather than
+ manufacturing follow intent again.
The source handoff
preserves the question, its answer, and every pre/post-answer thinking, tool, and
text block in event order, without hiding, duplicating, or reordering them. Only a
diff --git a/frontend/src/components/ChatView/ChatView.css b/frontend/src/components/ChatView/ChatView.css
index 9bcd53915..707fe62cb 100644
--- a/frontend/src/components/ChatView/ChatView.css
+++ b/frontend/src/components/ChatView/ChatView.css
@@ -771,8 +771,12 @@
}
.chat__tool--compact .chat__tool-detail {
+ margin-block-start: var(--activity-row-gap, 4px);
margin-inline-start: 20px;
- padding-inline: 0;
+ padding: 8px 10px 10px;
+ border: 1px solid var(--border-light);
+ border-radius: 10px;
+ background: var(--surface);
}
/* The header is a real