Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |

Expand Down
70 changes: 53 additions & 17 deletions frontend/src/components/ChatView/ActivityStretch.jsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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}
Expand All @@ -96,7 +99,7 @@ function TimelineThought({ label, thought, chatId }) {
</span>
<span className="chat__activity-think-label">{label}</span>
</button>
<div id={bodyId} className="chat__reasoning-body" hidden={!open}>
<div ref={bodyRef} id={bodyId} className="chat__reasoning-body" hidden={!open}>
{open && body}
{open && loadState === 'ready' && !trace.previewComplete && (
<div className="chat__lazy-status chat__reasoning-preview-status">
Expand All @@ -121,15 +124,17 @@ 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 (
<TimelineThought
key={assistantBlockKey(item, idx)}
label={live ? 'Thinking' : thoughtDurationLabel(item.duration_ms)}
thought={item}
chatId={chatId}
disclosureKey={`${surfaceKey}:thought:${blockKey}`}
/>
)
}
Expand All @@ -138,15 +143,26 @@ function SingleActivity({ entry, chatId, live }) {
&& Object.keys(item.subagent).length > 0
return (
<>
<ToolBlock key={assistantBlockKey(item, idx)} t={item} chatId={chatId} />
<ToolBlock
key={assistantBlockKey(item, idx)}
t={item}
chatId={chatId}
compact
disclosureKey={`${surfaceKey}:tool:${blockKey}`}
/>
{hasHelpers && <SubagentChips subagent={item.subagent} />}
</>
)
}

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
Expand Down Expand Up @@ -262,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
Expand All @@ -279,7 +295,7 @@ function GroupedActivityStretch({ entries, chatId, live = false }) {
subagent={tool.subagent}
/>
))}
<div id={timelineId} className="chat__activity-timeline" hidden={!open}>
<div ref={timelineRef} id={timelineId} className="chat__activity-timeline" hidden={!open}>
{open && entries.map(({ item, idx }) => {
if (item.type === 'thinking') {
const key = assistantBlockKey(item, idx)
Expand All @@ -289,23 +305,43 @@ 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 (
<ToolBlock key={assistantBlockKey(item, idx)} t={item} chatId={chatId} />
<ToolBlock
key={assistantBlockKey(item, idx)}
t={item}
chatId={chatId}
disclosureKey={`${surfaceKey}:tool:${assistantBlockKey(item, idx)}`}
/>
)
})}
</div>
</div>
)
}

export default function ActivityStretch({ entries, chatId, live = false }) {
export default function ActivityStretch({ entries, chatId, live = false, surfaceKey }) {
if (entries.length === 1) {
return <SingleActivity entry={entries[0]} chatId={chatId} live={live} />
return (
<SingleActivity
entry={entries[0]}
chatId={chatId}
live={live}
surfaceKey={surfaceKey}
/>
)
}
return <GroupedActivityStretch entries={entries} chatId={chatId} live={live} />
return (
<GroupedActivityStretch
entries={entries}
chatId={chatId}
live={live}
surfaceKey={surfaceKey}
/>
)
}
36 changes: 27 additions & 9 deletions frontend/src/components/ChatView/ChatView.css
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,31 @@
border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border-light)); /* CONTRACT: 1px line mixing accent with hairline */
}

/* A lone activity is already one disclosure row; putting a card boundary
around it adds hierarchy without information and makes a single command
look heavier than the surrounding answer. Multi-step children keep their
timeline rail, while the one-step surface stays the same quiet line used by
the collapsed activity summary. Its expanded detail remains available. */
.chat__tool--compact.chat__tool--running,
.chat__tool--compact.chat__tool--done {
background: none;
border: 0;
border-radius: 0;
}

.chat__tool--compact .chat__tool-header {
padding-inline: 0;
}

.chat__tool--compact .chat__tool-detail {
margin-block-start: var(--activity-row-gap, 4px);
margin-inline-start: 20px;
padding: 8px 10px 10px;
border: 1px solid var(--border-light);
border-radius: 10px;
background: var(--surface);
}

/* The header is a real <button> when there's detail to disclose (keyboard
operable) and a static div otherwise; the button resets below keep both
looking identical to the old clickable div. */
Expand Down Expand Up @@ -994,23 +1019,16 @@
flex-wrap: wrap;
width: 100%;
max-width: 100%;
gap: 5px 8px;
gap: 5px;
padding: 2px 8px;
}

.chat__sources-label {
padding-top: 7px;
color: var(--muted); /* CONTRACT: lower emphasis section label */
font-size: 12.5px;
font-weight: 600;
}

.chat__sources-list {
display: flex;
box-sizing: border-box;
min-width: 0;
max-width: 100%;
flex: 1 1 220px;
flex: 1 1 auto;
flex-wrap: wrap;
gap: 5px;
margin: 0;
Expand Down
21 changes: 16 additions & 5 deletions frontend/src/components/ChatView/ChatView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,7 @@ export default function ChatView({
closePreSendGestureWindow,
freezeChatExit,
freezeForegroundReturn,
freezeQuestionSubmission,
freezeQueuedSubmission,
revealConversationTail,
reapplyActiveMode,
Expand All @@ -842,6 +843,7 @@ export default function ChatView({
scrollRef,
spacerRef,
lastUserMsgRef,
syncComposerGeometry: measureComposerHeight,
messages,
messagesRef,
pendingMessagesLength: pendingQueue.pendingMessages.length,
Expand Down Expand Up @@ -2485,6 +2487,12 @@ export default function ChatView({
sendSilentInFlightRef.current = false
return false
}
// A question-card answer resumes the SAME assistant row. Freeze the
// currently visible message and its exact viewport offset synchronously,
// before QuestionCard's pending state commits or the POST can resume live
// output. Staying in FOLLOW_BOTTOM here caused the card-to-stream handoff
// to drag the screen upward after Submit.
if (resolvedAnswers) freezeQuestionSubmission()
// Block a simultaneous composer send synchronously, but do not paint the
// whole chat as a new active turn until the answer POST commits. On a
// parked/durable question that premature parent transition swaps the
Expand All @@ -2495,10 +2503,10 @@ export default function ChatView({
sendingRef.current = true
promotedRef.current = false
// Hidden answer is a continuation, NOT a new visible send. The
// user may be reading somewhere else; don't yank them with a
// PIN. The agent's response builds into the existing assistant
// message; if the user was at FOLLOW_BOTTOM they'll see it
// forming, if ANCHOR_AT they stay where they are.
// user may be reading somewhere else; don't yank them with a PIN.
// freezeQuestionSubmission above already converted the exact visible
// position into ANCHOR_AT, so resumed output grows inside the existing
// assistant row without creating tail-follow intent.
try {
// Mint a cid for symmetry so the persisted hidden row carries a stable
// identity for reload dedup. It is inert here — a hidden answer send
Expand Down Expand Up @@ -2566,6 +2574,8 @@ export default function ChatView({
// synchronous ref even when React state was already false; otherwise a
// failed answer silently blocks every later composer send. A question
// submitted while a live turn is parked keeps that live turn attached.
// Deliberately keep the reader anchor: the failed card remains the retry
// target, and an error-label reflow must not recreate live following.
sendingRef.current = wasSending
setSending(wasSending)
setServerRunningState(wasServerRunning)
Expand All @@ -2586,7 +2596,7 @@ export default function ChatView({
} finally {
sendSilentInFlightRef.current = false
}
}, [streamSend, commitMessages, fetchMessages])
}, [streamSend, commitMessages, fetchMessages, freezeQuestionSubmission])

function handleSubmit(e) {
e.preventDefault()
Expand Down Expand Up @@ -3768,6 +3778,7 @@ export default function ChatView({
<MsgContent
msg={msg}
chatId={chatId}
messageKey={dataKey}
onQuestionAnswer={doSendSilent}
onResume={doSend}
onInternalNav={internalNav}
Expand Down
5 changes: 1 addition & 4 deletions frontend/src/components/ChatView/MessageSources.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useId } from 'react'
import { messageSources, sourceHost, sourceLabel } from './messageSources.js'

function sourceMark(host) {
Expand All @@ -16,13 +15,11 @@ function sourceMark(host) {
// whole citation set.

export default function MessageSources({ blocks }) {
const labelId = useId()
const sources = messageSources(blocks)
if (sources.length === 0) return null

return (
<section className="chat__sources" aria-labelledby={labelId}>
<span id={labelId} className="chat__sources-label">Sources</span>
<section className="chat__sources" aria-label="Source links">
<ul className="chat__sources-list">
{sources.map(source => {
const label = sourceLabel(source)
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/components/ChatView/MsgContent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function blockAnswerable(block, { msg, isLastMsg, liveQuestionId, onQuestionAnsw
function MsgContentInner({
msg,
chatId,
messageKey,
onQuestionAnswer,
// Resume a turn paused by a drain-gated restart (or interrupted by a crash):
// a stable send callback that re-sends a short "continue". Only the tail
Expand Down Expand Up @@ -278,7 +279,12 @@ function MsgContentInner({
key={assistantBlockKey(node.group[0].item, node.group[0].idx)}
className="chat__tools"
>
<ActivityStretch entries={node.group} chatId={chatId} live={live} />
<ActivityStretch
entries={node.group}
chatId={chatId}
live={live}
surfaceKey={messageKey}
/>
</div>
)
}
Expand All @@ -287,7 +293,9 @@ function MsgContentInner({
{/* The turn's web sources, collected from its tool blocks and shown
once after the answer. Renders nothing when the turn did no web
search, so an ordinary reply is unchanged. */}
{msg.role === 'assistant' && <MessageSources blocks={msg.blocks} />}
{msg.role === 'assistant' && !isStreaming && (
<MessageSources blocks={msg.blocks} />
)}
</>
)
}
Expand Down Expand Up @@ -333,6 +341,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
Expand Down
Loading
Loading