diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 152684d0..dafa616d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -491,7 +491,7 @@ The removals not yet done at HEAD are noted in the last bullet: ## Chat scroll + steer contract -**Owner-authoritative contract — v1.5 (2026-07-15).** This section is the +**Owner-authoritative contract — v1.6 (2026-07-22).** This section is the canonical source of truth for how a chat scrolls and steers. When implementation, comments, and this contract disagree, the implementation/comments are the bug: fix behavior to match this contract. If a real case is unspecified or the desired @@ -592,6 +592,15 @@ and attaches their rule ids to new diagnostic chats. The Playwright lock-in spec separate answers. The answer response declares this ownership independently as `answer_turn: "same" | "new"`: an in-process question answer (`answer_delivered`) resumes that same row and turn, so answering must not retire its source bridge. + Submitting an in-message answer is also a deliberate reading action: before the + 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. 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 @@ -623,7 +632,7 @@ path means routing it through the same entries rather than inventing another rul | Viewport/keyboard changes | `PIN_USER_MSG` | same `PIN_USER_MSG` | Reapply pin after resize; never infer intent from keyboard-open geometry | | Viewport/keyboard changes | follow or anchor hold | same follow if still at tail, otherwise hold anchor | Never creates follow | | Chat exits/backgrounds/returns | any | `ANCHOR_AT` | Restore exact saved anchor | -| In-process question is answered | any | same mode and active assistant row | None | +| In-process question is answered | any | `ANCHOR_AT` on current visible row; same active assistant row | Hold exact visible anchor through card reflow and resumed output | | Live assistant row settles to the durable transcript | any | same mode and row identity | None (except R3's exact spacer handoff) | | Offscreen question or paused-turn nudge tapped | any hold | `ANCHOR_AT` at physical tail | User-requested one-shot move; clears the overlaid composer | diff --git a/frontend/src/components/ChatView/ActivityStretch.jsx b/frontend/src/components/ChatView/ActivityStretch.jsx index 96c09673..58b18178 100644 --- a/frontend/src/components/ChatView/ActivityStretch.jsx +++ b/frontend/src/components/ChatView/ActivityStretch.jsx @@ -1,4 +1,4 @@ -import { useId, useMemo, useRef, useState } from 'react' +import { useId, useMemo, useRef } from 'react' import { StandardMarkdown } from './markdown/BlockRenderer.jsx' import ToolBlock from './ToolBlock.jsx' import { @@ -16,6 +16,7 @@ import { preserveTogglePosition } from './preserveTogglePosition.js' import ActivityLineHeader, { ActivityTypeIcon } from './ActivityLineHeader.jsx' import SubagentChips from './SubagentChips.jsx' import { useThinkingTrace } from './useThinkingTrace.js' +import { useDisclosureState } from './disclosureState.js' // One collapsible activity line standing in for a MULTI-STEP contiguous stretch // of thinking and tool blocks, so a build turn's pre-prose burst reads as one @@ -32,8 +33,9 @@ import { useThinkingTrace } from './useThinkingTrace.js' // the transcript. A thought keeps this same child component as tools arrive, // avoiding an automatic height/state change in the middle of a live run. // -// COLLAPSED BY DEFAULT, ALWAYS — the line never auto-opens; the user's tap is -// the only thing that opens or closes it, mid-run included. An earlier version +// COLLAPSED ON FIRST ENCOUNTER, then restored from this chat's session screen +// state — the line never auto-opens; the user's tap is the only thing that +// changes its saved open/closed value, mid-run included. An earlier version // of the tool-group card this replaces force-opened while any child was running // (`open = running || userOpen`), on the theory that the live tool should stay // visible mid-stream. That was wrong on two counts: @@ -48,9 +50,10 @@ import { useThinkingTrace } from './useThinkingTrace.js' // shimmer plus the running-first activity summary already say what is // executing. So the sole open/close signal is `userOpen`, and no effect or // prop derives it. -function TimelineThought({ label, thought, chatId }) { - const [open, setOpen] = useState(false) +function TimelineThought({ label, thought, chatId, disclosureKey }) { + const [open, setOpen] = useDisclosureState(chatId, disclosureKey) const headerRef = useRef(null) + const bodyRef = useRef(null) const bodyId = useId() const trace = useThinkingTrace({ open, thought, chatId }) const content = thinkingContentForDisplay(trace.content) @@ -85,7 +88,7 @@ function TimelineThought({ label, thought, chatId }) { type="button" className="chat__activity-think-toggle" onClick={() => { - preserveTogglePosition(headerRef.current) + preserveTogglePosition(headerRef.current, bodyRef.current) setOpen(o => !o) }} aria-expanded={open} @@ -96,7 +99,7 @@ function TimelineThought({ label, thought, chatId }) { {label} -