feat(conversation): follow active reasoning while preserving manual reading

This commit is contained in:
Codex committed 2026-10-21 17:21:32 +09:00
1 parent 35b9a62ed4
commit 4a72008b2e
12 files changed
+530 -40

No files matched your search

+4 -5
View File
@@ -3,9 +3,9 @@ import assert from 'node:assert/strict'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom') const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom')
const dom = new JSDOM('<html><body><main id="root"></main></body></html>', { pretendToBeVisual: true, url: 'http://localhost/' }) const dom = new JSDOM('<html><body><main id="root"></main></body></html>', { pretendToBeVisual: true, url: 'http://localhost/' })
for (const key of ['window', 'document', 'Node', 'Element', 'HTMLElement', 'MouseEvent', 'KeyboardEvent', 'Event']) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true }) for (const key of ['window', 'document', 'Node', 'Element', 'HTMLElement', 'MouseEvent', 'KeyboardEvent', 'Event', 'CustomEvent']) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true })
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true }) Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true, requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window) })
const { act } = require('react') as typeof import('react') const { act } = require('react') as typeof import('react')
const { createRoot } = require('react-dom/client') as typeof import('react-dom/client') const { createRoot } = require('react-dom/client') as typeof import('react-dom/client')
const { CompletedFold } = require('../src/renderer/src/workbuddy/conversation/CompletedFold') as typeof import('../src/renderer/src/workbuddy/conversation/CompletedFold') const { CompletedFold } = require('../src/renderer/src/workbuddy/conversation/CompletedFold') as typeof import('../src/renderer/src/workbuddy/conversation/CompletedFold')
@@ -34,7 +34,7 @@ async function main() {
await act(async () => root.render(<Fixture />)) await act(async () => root.render(<Fixture />))
assert.equal(expanded(outer), 'false') assert.equal(expanded(outer), 'false')
await keyboard(outer, 'Enter') await keyboard(outer, 'Enter')
for (const selector of [group, thinking, ordinary, question]) { assert.equal(expanded(selector), 'false'); await click(selector); assert.equal(expanded(selector), 'true') } for (const selector of [group, thinking, ordinary, question]) { assert.equal(expanded(selector), 'false'); await click(selector); assert.equal(expanded(selector), 'true', `${selector} opens on click`) }
assert.equal(expanded(second), 'false', 'local summaries are independent') assert.equal(expanded(second), 'false', 'local summaries are independent')
for (const selector of ['.cb-task-result-item-title', '.cb-plan-header-left']) { assert.equal(expanded(selector), 'true'); await click(selector); assert.equal(expanded(selector), 'false') } for (const selector of ['.cb-task-result-item-title', '.cb-plan-header-left']) { assert.equal(expanded(selector), 'true'); await click(selector); assert.equal(expanded(selector), 'false') }
await keyboard(outer, ' ') await keyboard(outer, ' ')
@@ -59,8 +59,7 @@ async function main() {
await act(async () => root.render(<Fixture id="live-message" finished={false} />)) await act(async () => root.render(<Fixture id="live-message" finished={false} />))
for (const selector of [group, thinking, ordinary, question]) await click(selector) for (const selector of [group, thinking, ordinary, question]) await click(selector)
await act(async () => root.render(<Fixture id="live-message" finished />)) await act(async () => root.render(<Fixture id="live-message" finished />))
assert.equal(expanded(outer), 'false', 'completion starts folded after live events') assert.equal(expanded(outer), 'true', 'completion preserves manually opened reasoning reader')
await click(outer)
for (const selector of [group, thinking, ordinary, question]) assert.equal(expanded(selector), 'true', `${selector} survives live-to-completed reparenting`) for (const selector of [group, thinking, ordinary, question]) assert.equal(expanded(selector), 'true', `${selector} survives live-to-completed reparenting`)
await act(async () => root.unmount()); dom.window.close() await act(async () => root.unmount()); dom.window.close()
console.log('completed-fold-nested: real question/tool/thinking/local-summary remount, event, keyboard and identity contracts passed') console.log('completed-fold-nested: real question/tool/thinking/local-summary remount, event, keyboard and identity contracts passed')
+3 -1
View File
@@ -355,10 +355,12 @@ const collapsedThinking = renderToStaticMarkup(<ThinkingBlock text="Hidden reaso
// The source collapse keeps its content mounted for CSS transitions; it must be inaccessible while closed. // The source collapse keeps its content mounted for CSS transitions; it must be inaccessible while closed.
assert.match(collapsedThinking, /cr-collapse--collapsed/) assert.match(collapsedThinking, /cr-collapse--collapsed/)
assert.match(collapsedThinking, /class="cr-collapse__content" aria-hidden="true" inert=""/) assert.match(collapsedThinking, /class="cr-collapse__content" aria-hidden="true" inert=""/)
const expandedThinking = renderToStaticMarkup(<ThinkingBlock text="Visible reasoning" isFinished isLastContent renderMarkdown={text => <strong>{text}</strong>} />) const expandedThinking = renderToStaticMarkup(<ThinkingBlock text="Visible reasoning" isLastContent renderMarkdown={text => <strong>{text}</strong>} />)
assert.match(expandedThinking, /Visible reasoning/) assert.match(expandedThinking, /Visible reasoning/)
assert.match(expandedThinking, /<strong>Visible reasoning<\/strong>/) assert.match(expandedThinking, /<strong>Visible reasoning<\/strong>/)
assert.doesNotMatch(expandedThinking, /cr-collapse--collapsed/) assert.doesNotMatch(expandedThinking, /cr-collapse--collapsed/)
const historicalLatestThinking = renderToStaticMarkup(<ThinkingBlock text="History" isFinished isLastContent />)
assert.match(historicalLatestThinking, /cr-collapse--collapsed/, 'finished history stays initially closed even when last')
const planTasks = Array.from({ length: 8 }, (_, index) => ({ const planTasks = Array.from({ length: 8 }, (_, index) => ({
id: `task-${index}`, id: `task-${index}`,
+237
View File
@@ -0,0 +1,237 @@
/** Actual React DOM; synthetic geometry/RAF/ResizeObserver. No native layout, model, or network claim. */
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { readFileSync } from 'node:fs'
const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom')
const dom = new JSDOM('<!doctype html><html><body><main id="root"></main></body></html>', { pretendToBeVisual: true, url: 'http://localhost/' })
for (const key of ['window', 'document', 'Node', 'Element', 'HTMLElement', 'MouseEvent', 'KeyboardEvent', 'Event', 'CustomEvent', 'WheelEvent', 'MutationObserver', 'getComputedStyle']) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true })
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
let nextFrame = 0
const frames = new Map<number, FrameRequestCallback>()
const raf = (cb: FrameRequestCallback) => { frames.set(++nextFrame, cb); return nextFrame }
const caf = (id: number) => frames.delete(id)
Object.assign(globalThis, { requestAnimationFrame: raf, cancelAnimationFrame: caf })
Object.assign(dom.window, { requestAnimationFrame: raf, cancelAnimationFrame: caf })
const observers = new Set<ResizeObserverFixture>()
class ResizeObserverFixture {
targets = new Set<Element>()
constructor(public callback: ResizeObserverCallback) { observers.add(this) }
observe(target: Element) { this.targets.add(target) }
unobserve(target: Element) { this.targets.delete(target) }
disconnect() { this.targets.clear(); observers.delete(this) }
}
Object.assign(globalThis, { ResizeObserver: ResizeObserverFixture })
Object.assign(dom.window, { ResizeObserver: ResizeObserverFixture })
let height = 900
const scrollPositions = new WeakMap<HTMLElement, number>()
// jsdom has no layout engine: expose deterministic geometry to real component code.
Object.defineProperties(dom.window.HTMLElement.prototype, {
scrollTop: { configurable: true, get() { return scrollPositions.get(this) ?? 0 }, set(value: number) { scrollPositions.set(this, this.classList.contains('cr-reasoning__scroll') ? Math.min(Math.max(0, value), Math.max(0, height - 200)) : value) } },
scrollHeight: { configurable: true, get() { return this.classList.contains('cr-reasoning__scroll') ? height : 0 } },
clientHeight: { configurable: true, get() { return this.classList.contains('cr-reasoning__scroll') ? 200 : 0 } },
offsetWidth: { configurable: true, get() { return 320 } },
clientWidth: { configurable: true, get() { return 300 } }
})
dom.window.HTMLElement.prototype.getBoundingClientRect = function () { return { x: 0, y: 0, left: 0, top: 0, right: 320, bottom: 200, width: 320, height: 200, toJSON() {} } }
const { act } = require('react') as typeof import('react')
const { createRoot } = require('react-dom/client') as typeof import('react-dom/client')
const { ThinkingBlock } = require('../src/renderer/src/workbuddy/conversation/SpecializedBlocks') as typeof import('../src/renderer/src/workbuddy/conversation/SpecializedBlocks')
const { DisclosureStateProvider, DisclosureStateScope } = require('../src/renderer/src/workbuddy/conversation/disclosure-state') as typeof import('../src/renderer/src/workbuddy/conversation/disclosure-state')
const MessageBubble = require('../src/renderer/src/components/MessageBubble').default as typeof import('../src/renderer/src/components/MessageBubble').default
const { installReasoningReadingAnchor } = require('../src/renderer/src/workbuddy/conversation/reasoning-reading-anchor') as typeof import('../src/renderer/src/workbuddy/conversation/reasoning-reading-anchor')
const root = createRoot(document.getElementById('root')!)
let checks = 0
const check = (value: unknown, message: string) => { assert.ok(value, message); checks++ }
const get = <T extends Element = HTMLElement>(selector: string) => { const el = document.querySelector<T>(selector); assert.ok(el, selector); return el }
const section = (id = 'a') => get(`[data-test-part="${id}"] .cr-reasoning`)
const scroll = (id = 'a') => get<HTMLElement>(`[data-test-part="${id}"] .cr-reasoning__scroll`)
const expanded = (id = 'a') => section(id).querySelector('button')!.getAttribute('aria-expanded') === 'true'
const following = (id = 'a') => section(id).getAttribute('data-follow-state') === 'following'
const clickHeader = async (id = 'a') => act(async () => section(id).querySelector('button')!.dispatchEvent(new MouseEvent('click', { bubbles: true })))
const flush = async () => act(async () => { const pending = [...frames.values()]; frames.clear(); pending.forEach(cb => cb(0)) })
const resize = async () => { await act(async () => { for (const observer of [...observers]) observer.callback([...observer.targets].map(target => ({ target })) as ResizeObserverEntry[], observer as unknown as ResizeObserver) }); await flush() }
type Part = { id: string; text: string; active?: boolean; finished?: boolean }
let parts: Part[] = [{ id: 'a', text: 'Reasoning A', active: true }]
let identity = 'session-a:message-a', mounted = true
function Fixture() {
return <DisclosureStateProvider key={identity}>{mounted && parts.map(part => <DisclosureStateScope key={part.id} id={part.id}><div data-test-part={part.id}><ThinkingBlock text={part.text} isLastContent={part.active} isFinished={part.finished} /></div></DisclosureStateScope>)}</DisclosureStateProvider>
}
const render = async () => { await act(async () => root.render(<Fixture />)) }
const resume = async (id = 'a') => { const button = [...section(id).querySelectorAll('button')].find(el => el.textContent?.includes('查看最新')); assert.ok(button, 'explicit resume action'); await act(async () => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))); await flush() }
async function testOuterAnchor() {
const pane = document.createElement('div'), otherPane = document.createElement('div')
pane.style.overflowAnchor = 'auto'
pane.innerHTML = '<div class="chat-thread"><div data-message-id="message-a"><section class="cr-reasoning" data-reasoning-id="part-a"><div class="cr-reasoning__scroll">read</div></section></div></div>'
otherPane.innerHTML = '<div data-message-id="message-a"><section class="cr-reasoning" data-reasoning-id="part-a">other</section></div>'
document.body.append(pane, otherPane)
let flowTop = 600, visible = true, pauses = 0, releases = 0, otherPauses = 0
const rect = (top: number) => ({ x: 0, y: top, left: 0, top, right: 320, bottom: top + 200, width: 320, height: 200, toJSON() {} })
pane.getBoundingClientRect = () => ({ ...rect(10), width: visible ? 320 : 0, height: visible ? 200 : 0 })
pane.getClientRects = () => (visible ? [rect(10)] : []) as unknown as DOMRectList
const prepareNode = (node: HTMLElement) => {
node.getBoundingClientRect = () => ({ ...rect(flowTop - pane.scrollTop + 10), width: visible ? 320 : 0, height: visible ? 200 : 0 })
node.getClientRects = () => (visible ? [node.getBoundingClientRect()] : []) as unknown as DOMRectList
return node
}
let node = prepareNode(pane.querySelector<HTMLElement>('[data-reasoning-id]')!)
const anchor = installReasoningReadingAnchor(pane, () => { pauses++ }, () => { releases++ })
const other = installReasoningReadingAnchor(otherPane, () => { otherPauses++ }, () => {})
const capture = () => node.dispatchEvent(new CustomEvent('ycode:reasoning-reading', { bubbles: true }))
pane.scrollTop = 400; capture()
check(pauses === 1 && otherPauses === 0 && pane.style.overflowAnchor === 'auto', 'outer capture is pane-local and preserves browser anchoring style')
flowTop += 75; await resize()
check(pane.scrollTop === 475, 'earlier-content growth adjusts outer scroll by anchor delta, not bottom')
node.remove()
node = prepareNode(document.createElement('section')); node.className = 'cr-reasoning'; node.dataset.reasoningId = 'part-a'
pane.querySelector('[data-message-id]')!.append(node)
flowTop += 40; await resize()
check(pane.scrollTop === 515, 'reparented node reacquired by message and part identity')
node.remove(); await resize(); check(pane.scrollTop === 515, 'missing anchor is safe no-op')
const wrong = document.createElement('div'); wrong.dataset.messageId = 'message-b'; wrong.append(node); pane.append(wrong)
flowTop += 25; await resize(); check(pane.scrollTop === 515, 'same part ID in wrong message cannot capture anchor')
pane.querySelector('[data-message-id="message-a"]')!.append(node); wrong.remove()
node.setAttribute('aria-hidden', 'true'); await resize(); check(pane.scrollTop === 515, 'hidden anchor is no-op')
node.removeAttribute('aria-hidden'); visible = false; await resize(); check(pane.scrollTop === 515, 'display-none pane without layout rectangles is no-op')
visible = true; await resize(); check(pane.scrollTop === 540, 'visible restored pane compensates prior offset once')
flowTop += 20; pane.scrollTop += 20; await resize()
check(pane.scrollTop === 560, 'already native-compensated anchor receives no duplicate correction')
for (const event of [new WheelEvent('wheel', { bubbles: true }), new Event('touchstart', { bubbles: true }), new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true }), new MouseEvent('pointerdown', { bubbles: true, button: 0 })]) {
capture(); flowTop += 30
// Queue observer frame without running it; outside intention must cancel it.
for (const observer of [...observers]) observer.callback([], observer as unknown as ResizeObserver)
const before = pane.scrollTop
pane.dispatchEvent(event); await flush()
check(pane.scrollTop === before && pane.style.overflowAnchor === 'auto', `${event.type} releases outer anchor and cancels pending correction`)
}
capture(); const beforeKey = releases
node.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
pane.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }))
check(releases === beforeKey, 'inner reading and non-scroll keys do not release anchor')
anchor.reset(); flowTop += 100; const beforeReset = pane.scrollTop; await resize()
check(pane.scrollTop === beforeReset && pane.style.overflowAnchor === 'auto', 'session/reset clears anchor and restores overflow style')
capture(); const beforeDispose = pauses
const staleObservers = [...observers]
anchor.dispose(); other.dispose(); capture()
check(pauses === beforeDispose && observers.size === 0 && pane.style.overflowAnchor === 'auto', 'dispose removes listener, observers, queued frames and overflow override')
staleObservers.forEach(observer => observer.callback([], observer as unknown as ResizeObserver))
check(frames.size === 0, 'late resize callback after dispose cannot enqueue work')
pane.remove(); otherPane.remove()
}
async function main() {
await render(); await flush()
check(expanded() && following(), 'active A initially opens and follows')
check(scroll().scrollTop >= height - 200, 'active A goes to synthetic bottom')
await act(async () => scroll().dispatchEvent(new Event('scroll', { bubbles: true })))
parts = [{ ...parts[0], active: false }, { id: 'b', text: 'Reasoning B', active: true }]
await render(); await flush()
check(!expanded('a') && expanded('b'), 'later reasoning auto-closes untouched A and opens B')
await clickHeader('b'); parts[1].text += ' new tokens'; await render(); await flush()
check(!expanded('b'), 'manual-closed B remains closed on tokens')
await clickHeader('a'); check(expanded('a'), 'old reasoning can be opened manually')
await flush(); check(scroll('a').scrollTop === 0, 'first old manual opening starts at top, not saved automatic bottom')
parts.push({ id: 'c', text: 'Reasoning C', active: true }); parts[1].active = false
await render(); await flush(); check(expanded('a'), 'manual-open old A remains open after later reasoning')
identity = 'session-a:inputs'; parts = [{ id: 'a', text: 'Input reasoning', active: true }]; await render(); await flush()
const intents = [
() => new WheelEvent('wheel', { deltaY: -20, bubbles: true }),
() => new Event('touchstart', { bubbles: true }),
() => new KeyboardEvent('keydown', { key: 'PageUp', bubbles: true }),
() => new MouseEvent('pointerdown', { clientX: 315, bubbles: true })
]
for (const makeEvent of intents) {
await act(async () => scroll().dispatchEvent(makeEvent()))
check(!following(), `${makeEvent().type} pauses follow`)
await act(async () => { scroll().scrollTop = height - 200; scroll().dispatchEvent(new Event('scroll', { bubbles: true })) })
check(!following(), 'reaching bottom does not silently resume')
await resume(); check(following(), 'explicit latest resumes')
}
height += 200; await resize(); check(scroll().scrollTop >= height - 200, 'async content resize follows bottom')
parts[0].text += ' queued token'; await render()
await act(async () => { scroll().dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -10 })); scroll().scrollTop = 123; scroll().dispatchEvent(new Event('scroll', { bubbles: true })) })
await flush(); check(!following() && scroll().scrollTop === 123, 'manual intent wins over queued RAF')
height += 300; await resize(); check(scroll().scrollTop === 123, 'paused resize retains reading position')
mounted = false; await render(); mounted = true; await render(); await flush()
check(!following() && scroll().scrollTop === 123, 'same-part remount restores paused reading position')
await resume()
await act(async () => section().querySelector('.cr-reasoning__text')!.dispatchEvent(new MouseEvent('pointerdown', { button: 0, bubbles: true })))
check(!following(), 'desktop text selection intention pauses follow')
identity = 'session-a:empty'; parts = [{ id: 'a', text: '', active: true }]; await render()
parts[0].text = 'First tokens'; await render(); await flush()
const beforeResize = scroll().scrollTop; height += 150; await resize()
check(scroll().scrollTop > beforeResize, 'empty-to-text mount attaches async resize observer')
const finalPosition = scroll().scrollTop
parts[0].finished = true; await render(); await flush()
await act(async () => scroll().dispatchEvent(new WheelEvent('wheel', { bubbles: true })))
mounted = false; await render(); mounted = true; await render(); await flush()
check(scroll().scrollTop === finalPosition, 'finish before native scroll event retains synchronously saved clamped follow position')
identity = 'session-a:history'; parts = [{ id: 'a', text: 'Finished history', finished: true }]; await render(); await flush()
check(!expanded(), 'finished history starts closed')
await clickHeader(); await flush()
check(expanded() && !following() && scroll().scrollTop === 0, 'first manual history opening starts at old top without follow')
identity = 'session-b:history'; parts = [{ id: 'a', text: 'Other session', finished: true }]; await render(); await flush()
check(!expanded(), 'session identity isolates manual open choice')
identity = 'session-b:message-two'; parts = [{ id: 'a', text: 'Other message', active: true }]; await render(); await flush()
check(expanded() && following(), 'message identity isolates paused follow state')
// Exercise real MessageBubble grouping/reparenting, not only the leaf fixture.
let message: import('../src/shared/types').ChatMessage = { id: 'actual-message', role: 'assistant', content: '', createdAt: 1, status: 'streaming', parts: [{ type: 'reasoning', id: 'actual-a', text: 'Actual reasoning A' }] }
let actualSession = 'actual-session-a'
const renderMessage = async () => { await act(async () => root.render(<MessageBubble message={message} sessionId={actualSession} />)); await flush() }
for (const next of [{ type: 'tool', toolId: 'auto-tool' }, { type: 'text', text: 'Answer' }] as import('../src/shared/types').MessagePart[]) {
message = { ...message, id: `auto-${next.type}`, parts: [{ type: 'reasoning', id: 'actual-a', text: 'Automatic A' }] }
await renderMessage()
message = { ...message, parts: [...message.parts!, next] }; await renderMessage()
check(get('.cr-reasoning > button').getAttribute('aria-expanded') === 'false', `new ${next.type} auto-closes untouched A`)
}
message = { ...message, id: 'actual-message', parts: [{ type: 'reasoning', id: 'actual-a', text: 'Actual reasoning A' }] }
await renderMessage()
const actual = () => get<HTMLElement>('.cr-reasoning__scroll')
let readingEvents = 0
const receive = () => { readingEvents++ }
document.getElementById('root')!.addEventListener('ycode:reasoning-reading', receive)
await act(async () => actual().dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -20 })))
check(readingEvents === 1, 'manual reading bubbles scoped protection event to conversation ancestor')
for (const next of [
{ type: 'tool', toolId: 'actual-t' },
{ type: 'text', text: 'Later answer' },
{ type: 'reasoning', id: 'actual-b', text: 'Actual reasoning B' }
] as import('../src/shared/types').MessagePart[]) {
message = { ...message, parts: [...message.parts!, next], tools: [{ id: 'actual-t', name: 'Read', args: { file_path: 'fixture' }, argsRaw: '{}', status: 'ok' }] }
await renderMessage()
const first = get('.cr-reasoning')
check(first.querySelector('button')!.getAttribute('aria-expanded') === 'true', `${next.type} does not fold manually read old reasoning`)
let ancestor = first.parentElement
while (ancestor && ancestor.id !== 'root') {
if (ancestor.classList.contains('cr-collapse')) check(ancestor.querySelector(':scope > .cr-collapse__header')?.getAttribute('aria-expanded') === 'true', `${next.type} protects containing disclosure`)
ancestor = ancestor.parentElement
}
}
message = { ...message, status: 'done' }; await renderMessage()
const outer = get('.cr-collapse--completed > .cr-collapse__header')
check(outer.getAttribute('aria-expanded') === 'true', 'completion does not conceal manual reasoning reader')
await act(async () => { actual().scrollTop = 171; actual().dispatchEvent(new Event('scroll', { bubbles: true })) })
await act(async () => outer.dispatchEvent(new MouseEvent('click', { bubbles: true })))
check(get('.cr-collapse--completed > .cr-collapse__header').getAttribute('aria-expanded') === 'false', 'explicit ancestor close overrides read protection')
await act(async () => { actual().scrollTop = 0; actual().dispatchEvent(new Event('scroll', { bubbles: true })) })
await flush()
await act(async () => get('.cr-collapse--completed > .cr-collapse__header').dispatchEvent(new MouseEvent('click', { bubbles: true })))
await flush()
check(actual().scrollTop === 171, 'hidden ancestor synthetic zero-scroll does not erase saved reading anchor on reopening')
actualSession = 'actual-session-b'; await renderMessage()
check(get('.cr-reasoning > button').getAttribute('aria-expanded') === 'false' && get('.cr-reasoning').getAttribute('data-follow-state') === 'paused', 'actual MessageBubble session switch with identical message/part IDs resets manual state')
document.getElementById('root')!.removeEventListener('ycode:reasoning-reading', receive)
// Integration contract only: ChatPane itself is not mounted in this isolated DOM fixture.
const paneSource = readFileSync('src/renderer/src/components/ChatPane.tsx', 'utf8')
check(/installReasoningReadingAnchor\(/.test(paneSource) && /\.dispose\(\)/.test(paneSource), 'ChatPane installs and disposes outer helper (source integration contract)')
check(/reasoningReading\.current = true; followBottom\.current = false/.test(paneSource), 'outer reading event disables outer follow (source contract)')
const styles = readFileSync('src/renderer/src/workbuddy/conversation/source-styles.css', 'utf8')
check(/\.cr-reasoning\[data-follow-state="paused"\] \.cr-reasoning__scroll\s*\{\s*overflow-anchor:\s*auto/.test(styles), 'paused inner scroll retains native anchoring (CSS contract)')
await act(async () => root.unmount()); check(observers.size === 0, 'resize observers disconnect on cleanup')
await testOuterAnchor()
dom.window.close()
console.log(JSON.stringify({ result: 'PASS', suite: 'reasoning-follow-dom', assertions: checks, evidence: 'Actual ReactDOM with synthetic geometry/RAF/resize; not native scroll or pixel evidence' }))
}
main().then(() => process.exit(0)).catch(error => { console.error(error); dom.window.close(); process.exit(1) })
+3 -3
View File
@@ -42,7 +42,7 @@ for (const file of ['scripts/build-runtime-regressions.mjs', '.tmp/claude-approv
// Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model. // Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model.
if (domModule) { if (domModule) {
for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression']) { for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression', 'reasoning-follow-dom-regression']) {
const outfile = `.tmp/alignment-tests/${file}.cjs` const outfile = `.tmp/alignment-tests/${file}.cjs`
await build({ entryPoints: [`scripts/${file}.tsx`], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' }) await build({ entryPoints: [`scripts/${file}.tsx`], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' })
const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 }) const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 })
@@ -56,5 +56,5 @@ if (domModule) {
console.log(JSON.stringify({ result: 'PASS', phase: 'alignment-offline', suites: tests.length + 3 })) console.log(JSON.stringify({ result: 'PASS', phase: 'alignment-offline', suites: tests.length + 3 }))
console.log(JSON.stringify(domModule console.log(JSON.stringify(domModule
? { result: 'PASS', phase: 'alignment-dom', suites: 7 } ? { result: 'PASS', phase: 'alignment-dom', suites: 8 }
: { result: 'SKIP', phase: 'alignment-dom', suites: 7, reason: 'YCODE_TEST_DOM_MODULE is not set; DOM coverage was not run. Use --require-dom to forbid this skip.' })) : { result: 'SKIP', phase: 'alignment-dom', suites: 8, reason: 'YCODE_TEST_DOM_MODULE is not set; DOM coverage was not run. Use --require-dom to forbid this skip.' }))
@@ -0,0 +1,13 @@
/** Offline React DOM only. Geometry is synthetic; this is not native scroll/pixel evidence. */
import { build } from 'esbuild'
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
const domModule = process.env.YCODE_TEST_DOM_MODULE
if (!domModule) throw new Error('Set YCODE_TEST_DOM_MODULE to an installed jsdom module path. DOM coverage cannot be skipped.')
if (typeof createRequire(process.cwd() + '/package.json')(domModule).JSDOM !== 'function') throw new Error('DOM module must export JSDOM')
const outfile = '.tmp/alignment-tests/reasoning-follow-dom-regression.cjs'
await build({ entryPoints: ['scripts/reasoning-follow-dom-regression.tsx'], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' })
const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 })
if (result.error) throw result.error
process.exit(result.status ?? 1)
+20 -1
View File
@@ -1,3 +1,4 @@
import { installReasoningReadingAnchor } from '../workbuddy/conversation/reasoning-reading-anchor'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import MessageBubble, { planMarkdown } from './MessageBubble' import MessageBubble, { planMarkdown } from './MessageBubble'
import { useChatStore } from '../stores/chat' import { useChatStore } from '../stores/chat'
@@ -230,6 +231,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const composerStackRef = useRef<HTMLDivElement>(null) const composerStackRef = useRef<HTMLDivElement>(null)
const followBottom = useRef(true) const followBottom = useRef(true)
const reasoningReading = useRef(false)
const resetReasoningAnchor = useRef<(() => void) | null>(null)
/** 把表格引用挂到当前草稿(原版 message.quote.add:引用与正文分开保存,不移写正文)。 */ /** 把表格引用挂到当前草稿(原版 message.quote.add:引用与正文分开保存,不移写正文)。 */
const quoteTable = useCallback((quote: MessageQuote): void => { const quoteTable = useCallback((quote: MessageQuote): void => {
setDrafts((all) => { setDrafts((all) => {
@@ -429,6 +432,17 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
disabled: queueEditRestoring disabled: queueEditRestoring
}) })
// Listen only inside this conversation: inner reading never changes another pane.
useEffect(() => {
const el = scrollRef.current
if (!el) return
const anchor = installReasoningReadingAnchor(el,
() => { reasoningReading.current = true; followBottom.current = false },
() => { reasoningReading.current = false })
resetReasoningAnchor.current = anchor.reset
return () => { anchor.dispose(); resetReasoningAnchor.current = null }
}, [currentId, isHome])
// The selected model label comes from the local registry. // The selected model label comes from the local registry.
const modelLabel = effectiveModel?.name ?? '配置自定义模型' const modelLabel = effectiveModel?.name ?? '配置自定义模型'
useEffect(() => { useEffect(() => {
@@ -456,6 +470,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
queueEditDecision.current = null queueEditDecision.current = null
setQueueEditTarget(null) setQueueEditTarget(null)
resolveQueueEdit?.(false) resolveQueueEdit?.(false)
resetReasoningAnchor.current?.()
reasoningReading.current = false
followBottom.current = true followBottom.current = true
const el = scrollRef.current const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight if (el) el.scrollTop = el.scrollHeight
@@ -472,6 +488,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
if (loading || loadError) throw new Error('会话尚未就绪,请重试或等待加载完成') if (loading || loadError) throw new Error('会话尚未就绪,请重试或等待加载完成')
if (!effectiveModel) throw new Error('请先配置自定义模型') if (!effectiveModel) throw new Error('请先配置自定义模型')
if (composition.input.blockedByInteraction) throw new Error('请先处理当前交互') if (composition.input.blockedByInteraction) throw new Error('请先处理当前交互')
resetReasoningAnchor.current?.()
reasoningReading.current = false
followBottom.current = true followBottom.current = true
const submitKey = draftKey const submitKey = draftKey
const submittedSnapshot = cloneDraft(draftsRef.current[submitKey] ?? EMPTY_DRAFT) const submittedSnapshot = cloneDraft(draftsRef.current[submitKey] ?? EMPTY_DRAFT)
@@ -748,6 +766,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
<> <>
<div className="conversation-timeline"><div className="cr-message-list-viewport"> <div className="conversation-timeline"><div className="cr-message-list-viewport">
<div className="chat-scroll cr-message-list" ref={scrollRef} onScroll={(event) => { <div className="chat-scroll cr-message-list" ref={scrollRef} onScroll={(event) => {
if (event.target !== event.currentTarget || reasoningReading.current) return
const el = event.currentTarget const el = event.currentTarget
followBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80 followBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80
}}> }}>
@@ -756,7 +775,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
{loadError && <div className="yc-session-load-error" role="alert"><p>会话加载失败:{loadError}</p><button type="button" onClick={() => currentId && void useChatStore.getState().selectSession(currentId)}>重试</button></div>} {loadError && <div className="yc-session-load-error" role="alert"><p>会话加载失败:{loadError}</p><button type="button" onClick={() => currentId && void useChatStore.getState().selectSession(currentId)}>重试</button></div>}
{!loading && !loadError && messages.length === 0 && <p className="yc-session-empty">此会话暂无消息</p>} {!loading && !loadError && messages.length === 0 && <p className="yc-session-empty">此会话暂无消息</p>}
{messages.map((message) => ( {messages.map((message) => (
<MessageBubble key={message.id} message={message} sessionId={currentId} onQuote={quoteTable} darkMode={darkMode} <MessageBubble key={JSON.stringify([currentId, message.id])} message={message} sessionId={currentId} onQuote={quoteTable} darkMode={darkMode}
quotedSourceIds={quotedSourceIds} quotedSourceIds={quotedSourceIds}
artifactCount={!running && !sending && message.id === lastAssistantId ? artifactCount : 0} artifactCount={!running && !sending && message.id === lastAssistantId ? artifactCount : 0}
onShowArtifacts={() => window.dispatchEvent(new CustomEvent('ycode:open-artifacts', { detail: { sessionId: currentId } }))} onShowArtifacts={() => window.dispatchEvent(new CustomEvent('ycode:open-artifacts', { detail: { sessionId: currentId } }))}
@@ -5,7 +5,7 @@ import SourceAgentAvatar from './SourceAgentAvatar'
import SourceArtifactLink from './SourceArtifactLink' import SourceArtifactLink from './SourceArtifactLink'
import { AssistantMessageDetails } from './AssistantMessageDetails' import { AssistantMessageDetails } from './AssistantMessageDetails'
import { CompletedFold, LiveEventDisclosure } from '../workbuddy/conversation/CompletedFold' import { CompletedFold, LiveEventDisclosure } from '../workbuddy/conversation/CompletedFold'
import type { CompletedFoldToolCallClassification } from '../workbuddy/conversation/completed-fold' import { completedFoldPartIdentity, type CompletedFoldToolCallClassification } from '../workbuddy/conversation/completed-fold'
import { resolveSourceToolIcon } from '../workbuddy/conversation/SourceToolIcons' import { resolveSourceToolIcon } from '../workbuddy/conversation/SourceToolIcons'
import { SourceToolHeader } from '../workbuddy/conversation/SourceToolHeader' import { SourceToolHeader } from '../workbuddy/conversation/SourceToolHeader'
import { findToolApproval, isActiveToolApproval } from '../workbuddy/conversation/tool-approval' import { findToolApproval, isActiveToolApproval } from '../workbuddy/conversation/tool-approval'
@@ -125,7 +125,7 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
} }
return ( return (
<div className="msg--assistant cb-assistant-message cr-frame cr-frame--left"><div className="cr-frame__content"><div className="cr-agent"> <div data-message-id={message.id} className="msg--assistant cb-assistant-message cr-frame cr-frame--left"><div className="cr-frame__content"><div className="cr-agent">
{/* 助手头部:24px 圆形标识 + 名称,间距 10px,行高 24.5px */} {/* 助手头部:24px 圆形标识 + 名称,间距 10px,行高 24.5px */}
<div className="msg__header cr-agent__header"> <div className="msg__header cr-agent__header">
<div className="cr-agent__logo" aria-hidden>{assistantAvatar ?? (message.assistantDisplay?.avatarUrl ? <img src={message.assistantDisplay.avatarUrl} alt="" style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }} /> : <SourceAgentAvatar />)}</div> <div className="cr-agent__logo" aria-hidden>{assistantAvatar ?? (message.assistantDisplay?.avatarUrl ? <img src={message.assistantDisplay.avatarUrl} alt="" style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }} /> : <SourceAgentAvatar />)}</div>
@@ -134,7 +134,7 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
<div className="cr-agent__body-row"><div className="cr-agent__body"> <div className="cr-agent__body-row"><div className="cr-agent__body">
{streaming && typeof message.turnStartedAt === 'number' && Number.isFinite(message.turnStartedAt) && <StreamingTurnDuration startedAt={message.turnStartedAt} />} {streaming && typeof message.turnStartedAt === 'number' && Number.isFinite(message.turnStartedAt) && <StreamingTurnDuration startedAt={message.turnStartedAt} />}
<div className="cr-agent__content"> <div className="cr-agent__content">
<CompletedFold parts={parts} foldId={message.id} <CompletedFold key={JSON.stringify([sessionId, message.id])} parts={parts} foldId={message.id}
turnFinished={message.status === 'done' || message.status === 'cancelled' || message.status === 'error'} turnFinished={message.status === 'done' || message.status === 'cancelled' || message.status === 'error'}
turnCancelled={message.status === 'cancelled'} turnDurationMs={message.turnDurationMs} turnCancelled={message.status === 'cancelled'} turnDurationMs={message.turnDurationMs}
classifyToolCall={classifyTool} classifyToolCall={classifyTool}
@@ -168,7 +168,8 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
isExecuting={actionSummary?.status === 'running'} primaryLoading={actionSummary?.status === 'running'} isExecuting={actionSummary?.status === 'running'} primaryLoading={actionSummary?.status === 'running'}
primaryContent={actionText} /> primaryContent={actionText} />
</div> </div>
return <LiveEventDisclosure id={`events-${encodeURIComponent(message.id)}-${group.startIndex}`} return <LiveEventDisclosure id={`events-${encodeURIComponent(message.id)}-${encodeURIComponent(group.key)}`}
protectedPartIds={group.units.map(unit => completedFoldPartIdentity(unit.part, unit.index))}
header={activityHeader} expanded={expandedGroups[group.key] ?? false} header={activityHeader} expanded={expandedGroups[group.key] ?? false}
onExpandedChange={expanded => setExpandedGroups(previous => ({ ...previous, [group.key]: expanded }))}> onExpandedChange={expanded => setExpandedGroups(previous => ({ ...previous, [group.key]: expanded }))}>
{children} {children}
@@ -1,5 +1,5 @@
import { useId, useState, type KeyboardEvent, type ReactNode } from 'react' import { useId, useState, type KeyboardEvent, type ReactNode } from 'react'
import { DisclosureStateProvider, DisclosureStateScope, useDisclosureState } from './disclosure-state' import { DisclosureStateProvider, DisclosureStateScope, useDisclosureState, useReasoningReadProtection } from './disclosure-state'
import { SourceFoldChevronIcon } from '../message-actions/SourceActionIcons' import { SourceFoldChevronIcon } from '../message-actions/SourceActionIcons'
import { import {
buildCompletedFoldModel, buildCompletedFoldModel,
@@ -57,23 +57,27 @@ function CollapseSection({
children, children,
defaultExpanded = false, defaultExpanded = false,
controlledExpanded, controlledExpanded,
onExpandedChange onExpandedChange,
protectedPartIds = []
}: { }: {
id: string id: string
variant: 'completed' | 'process' variant: 'completed' | 'process'
title: ReactNode title: ReactNode
headerLabel?: string headerLabel?: string
children: ReactNode children: ReactNode
protectedPartIds?: readonly string[]
defaultExpanded?: boolean defaultExpanded?: boolean
controlledExpanded?: boolean controlledExpanded?: boolean
onExpandedChange?: (expanded: boolean) => void onExpandedChange?: (expanded: boolean) => void
}) { }) {
const [localExpanded, setLocalExpanded] = useDisclosureState(`collapse:${id}`, defaultExpanded) const [localExpanded, setLocalExpanded] = useDisclosureState(`collapse:${id}`, defaultExpanded)
const expanded = controlledExpanded ?? localExpanded const [manualChoice, setManualChoice] = useDisclosureState(`collapse-manual:${id}`, false)
const protectedRead = useReasoningReadProtection(protectedPartIds)
const expanded = controlledExpanded ?? (manualChoice ? localExpanded : localExpanded || protectedRead)
const contentId = `${id}-content` const contentId = `${id}-content`
const toggle = () => { const toggle = () => {
const next = !expanded const next = !expanded
if (controlledExpanded === undefined) setLocalExpanded(next) if (controlledExpanded === undefined) { setManualChoice(true); setLocalExpanded(next) }
onExpandedChange?.(next) onExpandedChange?.(next)
} }
const toggleFromKey = (event: KeyboardEvent<HTMLElement>) => { const toggleFromKey = (event: KeyboardEvent<HTMLElement>) => {
@@ -115,17 +119,23 @@ export function LiveEventDisclosure({
id, id,
header, header,
children, children,
expanded, expanded: requestedExpanded,
onExpandedChange onExpandedChange,
protectedPartIds = []
}: { }: {
id: string id: string
header: ReactNode header: ReactNode
children: ReactNode children: ReactNode
expanded: boolean expanded: boolean
protectedPartIds?: readonly string[]
onExpandedChange: (expanded: boolean) => void onExpandedChange: (expanded: boolean) => void
}): React.JSX.Element { }): React.JSX.Element {
const contentId = `${id}-content` const contentId = `${id}-content`
const toggle = () => onExpandedChange(!expanded) const [manualChoice, setManualChoice] = useDisclosureState(`live-manual:${id}`, false)
const [manualExpanded, setManualExpanded] = useDisclosureState(`live-expanded:${id}`, false)
const protectedRead = useReasoningReadProtection(protectedPartIds)
const expanded = manualChoice ? manualExpanded : requestedExpanded || protectedRead
const toggle = () => { setManualChoice(true); setManualExpanded(!expanded); onExpandedChange(!expanded) }
const toggleFromKey = (event: KeyboardEvent<HTMLElement>) => { const toggleFromKey = (event: KeyboardEvent<HTMLElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault() event.preventDefault()
@@ -165,7 +175,10 @@ function CompletedFoldContent({
renderUnit: render, renderUnit: render,
renderGroup renderGroup
}: CompletedFoldProps) { }: CompletedFoldProps) {
const [completedExpanded, setCompletedExpanded] = useState(expandFinishedEvents) const [completedChoice, setCompletedChoice] = useState<boolean | undefined>(undefined)
const protectedRead = useReasoningReadProtection(parts.map(completedFoldPartIdentity))
const completedExpanded = completedChoice ?? (expandFinishedEvents || protectedRead)
const setCompletedExpanded = setCompletedChoice
const reactId = useId() const reactId = useId()
const foldKey = encodeURIComponent(foldId ?? reactId) const foldKey = encodeURIComponent(foldId ?? reactId)
@@ -211,7 +224,7 @@ function CompletedFoldContent({
</CollapseSection> </CollapseSection>
} }
const renderSegments = (segments: readonly CompletedFoldSegment[]) => segments.map((segment, segmentIndex) => { const renderSegments = (segments: readonly CompletedFoldSegment[]) => segments.map(segment => {
if (segment.kind === 'unit') return renderPresentationUnit(segment.unit, render, renderGroup) if (segment.kind === 'unit') return renderPresentationUnit(segment.unit, render, renderGroup)
if (segment.kind === 'completed') { if (segment.kind === 'completed') {
return ( return (
@@ -229,9 +242,10 @@ function CompletedFoldContent({
} }
return ( return (
<CollapseSection <CollapseSection
key={`process:${foldKey}:${segment.group.after.key}:${segmentIndex}`} key={`process:${foldKey}:${segment.group.after.key}`}
id={`process-${foldKey}-${segment.group.after.index}-${segmentIndex}`} id={`process-${foldKey}-${encodeURIComponent(segment.group.after.key)}`}
variant="process" variant="process"
protectedPartIds={segment.group.units.map(unit => completedFoldPartIdentity(unit.part, unit.index))}
title={segment.group.title} title={segment.group.title}
defaultExpanded={expandFinishedEvents && segment.group.units.some(unit => unit.part.type === 'tool' || unit.part.type === 'reasoning')} defaultExpanded={expandFinishedEvents && segment.group.units.some(unit => unit.part.type === 'tool' || unit.part.type === 'reasoning')}
> >
@@ -1,5 +1,5 @@
import { useDisclosureState } from './disclosure-state' import { useDisclosureState, useReasoningState } from './disclosure-state'
import { useMemo, useState, type ReactNode } from 'react' import { useMemo, useState, useRef, useLayoutEffect, type ReactNode } from 'react'
import './task-source.css' import './task-source.css'
import type { ConversationToolCall } from './types' import type { ConversationToolCall } from './types'
import { conversationLabel as t } from './labels' import { conversationLabel as t } from './labels'
@@ -28,24 +28,114 @@ export interface ThinkingBlockProps {
} }
export function ThinkingBlock({ text, isFinished = false, isLastContent = false, title = t('message.loading.thinking'), className, renderMarkdown }: ThinkingBlockProps) { export function ThinkingBlock({ text, isFinished = false, isLastContent = false, title = t('message.loading.thinking'), className, renderMarkdown }: ThinkingBlockProps) {
const [expanded, setExpanded] = useDisclosureState('thinking', isLastContent) const active = isLastContent && !isFinished
const [state, update, reasoningId] = useReasoningState(active)
const expanded = state.mode === 'manual-open' || (state.mode === 'auto' && state.autoOpen && isLastContent)
const scrollRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const scheduleRef = useRef<(() => void) | null>(null)
const stateRef = useRef(state)
stateRef.current = state
const isVisible = (el: HTMLElement) => el.clientHeight > 0 && !el.closest('[aria-hidden="true"], [inert]')
const readingIntent = () => scrollRef.current?.dispatchEvent(new CustomEvent('ycode:reasoning-reading', { bubbles: true }))
const pause = () => {
// Set the ref before React commits: an already queued observer frame must not win.
stateRef.current = { ...stateRef.current, mode: 'manual-open', follow: 'paused' }
update({ mode: 'manual-open', follow: 'paused' })
readingIntent()
}
useLayoutEffect(() => {
const viewport = scrollRef.current
const content = contentRef.current
if (!viewport || !content || !expanded) return
const writePosition = (top: number) => {
viewport.scrollTop = top
const scrollTop = viewport.scrollTop // Native clamping is authoritative.
stateRef.current = { ...stateRef.current, scrollTop }
update({ scrollTop })
}
let frame = 0
let disposed = false
let wasVisible = isVisible(viewport)
const follow = () => {
frame = 0
if (disposed) return
if (!isVisible(viewport)) { wasVisible = false; return }
if (stateRef.current.follow === 'following' && active) writePosition(viewport.scrollHeight)
else if (!wasVisible) writePosition(stateRef.current.scrollTop)
wasVisible = true
}
const schedule = () => { if (!disposed && !frame) frame = requestAnimationFrame(follow) }
scheduleRef.current = schedule
if (isVisible(viewport)) writePosition(stateRef.current.follow === 'following' && active
? viewport.scrollHeight : stateRef.current.scrollTop)
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(schedule)
observer?.observe(content)
observer?.observe(viewport)
// Parent disclosures keep children mounted. Reopening must restore the saved
// reading anchor even when the inner content's own dimensions never changed.
const visibilityObserver = typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(() => {
if (!isVisible(viewport)) wasVisible = false
schedule()
})
let ancestor: HTMLElement | null = viewport.parentElement
while (ancestor) {
if (ancestor.classList.contains('cr-collapse__content')) visibilityObserver?.observe(ancestor, { attributes: true, attributeFilter: ['aria-hidden', 'inert'] })
ancestor = ancestor.parentElement
}
schedule()
return () => {
disposed = true
scheduleRef.current = null
observer?.disconnect()
visibilityObserver?.disconnect()
if (frame) cancelAnimationFrame(frame)
}
}, [expanded, active, Boolean(text), update])
useLayoutEffect(() => { scheduleRef.current?.() }, [text])
if (!text) return null if (!text) return null
return ( return (
<section className={['cr-reasoning', 'cr-collapse', !expanded && 'cr-collapse--collapsed', isFinished ? 'cr-collapse--completed' : 'cr-collapse--streaming', !isFinished && 'cr-reasoning--streaming', className].filter(Boolean).join(' ')}> <section data-reasoning-id={reasoningId} data-follow-state={state.follow} data-disclosure-mode={state.mode} className={['cr-reasoning', 'cr-collapse', !expanded && 'cr-collapse--collapsed', isFinished ? 'cr-collapse--completed' : 'cr-collapse--streaming', !isFinished && 'cr-reasoning--streaming', className].filter(Boolean).join(' ')}>
<button <button type="button" className="cr-collapse__header" aria-expanded={expanded}
type="button" onClick={() => {
className="cr-collapse__header" readingIntent()
aria-expanded={expanded} stateRef.current = { ...stateRef.current, follow: 'paused' }
onClick={() => setExpanded(value => !value)} update({ mode: expanded ? 'manual-closed' : 'manual-open', follow: 'paused',
> ...(!expanded && state.mode === 'auto' && !active ? { scrollTop: 0 } : {}) })
}}>
<span className="cr-collapse__lead-icon"><ThinkingIcon /></span> <span className="cr-collapse__lead-icon"><ThinkingIcon /></span>
<span className="cr-collapse__title">{title}</span> <span className="cr-collapse__title">{title}</span>
<span className="cr-collapse__icon" aria-hidden="true"> <span className="cr-collapse__icon" aria-hidden="true"><SourceFoldChevronIcon /></span>
<SourceFoldChevronIcon />
</span>
</button> </button>
<div className="cr-collapse__content" aria-hidden={!expanded} inert={!expanded}><div className="cr-collapse__content-inner">{renderMarkdown ? renderMarkdown(text) : text}</div></div> <div className="cr-collapse__content" aria-hidden={!expanded} inert={!expanded}>
<div ref={scrollRef} className="cr-collapse__content-inner cr-reasoning__scroll" tabIndex={expanded ? 0 : -1}
role="region" aria-label={title} onWheel={pause} onTouchStart={pause}
onPointerDown={event => {
// Includes native scrollbar drags and selecting/copying markdown text.
if (event.button === 0 || event.pointerType === 'touch') pause()
}}
onKeyDown={event => { if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) pause() }}
onScroll={event => {
if (expanded && isVisible(event.currentTarget)) {
const scrollTop = event.currentTarget.scrollTop
stateRef.current = { ...stateRef.current, scrollTop }
update({ scrollTop })
}
}}>
<div ref={contentRef} className="cr-reasoning__text">{renderMarkdown ? renderMarkdown(text) : text}</div>
</div>
{expanded && state.follow === 'paused' && <button type="button" className="cr-reasoning__latest" onClick={() => {
stateRef.current = { ...stateRef.current, follow: 'following' }
update({ follow: 'following' })
const el = scrollRef.current
if (el) {
el.scrollTop = el.scrollHeight
stateRef.current = { ...stateRef.current, scrollTop: el.scrollTop }
update({ scrollTop: el.scrollTop })
}
}}>查看最新</button>}
</div>
</section> </section>
) )
} }
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useRef, useState, useSyncExtern
interface DisclosureStore { interface DisclosureStore {
values: Map<string, boolean> values: Map<string, boolean>
reasoning: Map<string, ReasoningState>
listeners: Set<() => void> listeners: Set<() => void>
} }
const StoreContext = createContext<DisclosureStore | null>(null) const StoreContext = createContext<DisclosureStore | null>(null)
@@ -9,7 +10,7 @@ const ScopeContext = createContext('')
/** Lives for one message/fold, not globally. Presentation reparenting must not erase user choices. */ /** Lives for one message/fold, not globally. Presentation reparenting must not erase user choices. */
export function DisclosureStateProvider({ children }: { children: ReactNode }) { export function DisclosureStateProvider({ children }: { children: ReactNode }) {
const [store] = useState<DisclosureStore>(() => ({ values: new Map(), listeners: new Set() })) const [store] = useState<DisclosureStore>(() => ({ values: new Map(), reasoning: new Map(), listeners: new Set() }))
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider> return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
} }
@@ -39,3 +40,48 @@ export function useDisclosureState(slot: string, initial: boolean | (() => boole
}, [store, key]) }, [store, key])
return [store ? retained : fallback, setValue] return [store ? retained : fallback, setValue]
} }
export interface ReasoningState {
mode: 'auto' | 'manual-open' | 'manual-closed'
autoOpen: boolean
follow: 'following' | 'paused'
scrollTop: number
}
/** Reasoning has independent disclosure and scroll intent; existing tool slots stay boolean. */
export function useReasoningState(active: boolean) {
const store = useContext(StoreContext)
const scope = useContext(ScopeContext)
const [initial] = useState<ReasoningState>(() => ({ mode: 'auto', autoOpen: active,
follow: active ? 'following' : 'paused', scrollTop: 0 }))
const fallback = useRef(initial)
const [, refresh] = useState(0)
if (store && !store.reasoning.has(scope)) store.reasoning.set(scope, initial)
const subscribe = useCallback((listener: () => void) => {
store?.listeners.add(listener)
return () => { store?.listeners.delete(listener) }
}, [store])
const snapshot = useCallback(() => store?.reasoning.get(scope) ?? fallback.current, [store, scope])
const state = useSyncExternalStore(subscribe, snapshot, snapshot)
const update = useCallback((patch: Partial<ReasoningState>) => {
const previous = store?.reasoning.get(scope) ?? fallback.current
const next = { ...previous, ...patch }
if (Object.keys(patch).every(key => previous[key as keyof ReasoningState] === next[key as keyof ReasoningState])) return
if (store) {
store.reasoning.set(scope, next)
store.listeners.forEach(listener => listener())
} else { fallback.current = next; refresh(value => value + 1) }
}, [store, scope])
return [state, update, scope] as const
}
/** Only protect the exact members of an ancestor, never all historic disclosures. */
export function useReasoningReadProtection(partIds: readonly string[]): boolean {
const store = useContext(StoreContext)
const subscribe = useCallback((listener: () => void) => {
store?.listeners.add(listener)
return () => { store?.listeners.delete(listener) }
}, [store])
const snapshot = () => partIds.some(id => store?.reasoning.get(id)?.mode === 'manual-open')
return useSyncExternalStore(subscribe, snapshot, snapshot)
}
@@ -0,0 +1,61 @@
/** A pane-local DOM anchor; no module-global readers or message state. */
export function installReasoningReadingAnchor(
viewport: HTMLElement,
onPause: () => void,
onRelease: () => void
): { reset: () => void; dispose: () => void } {
let anchor: { messageId: string; partId: string; offset: number } | null = null
let frame = 0
let disposed = false
const laidOut = (node: HTMLElement) => {
const rect = node.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
const reset = () => {
anchor = null
if (frame) cancelAnimationFrame(frame)
frame = 0
onRelease()
}
const compensate = () => {
frame = 0
if (disposed || !anchor) return
const node = Array.from(viewport.querySelectorAll<HTMLElement>('[data-reasoning-id]')).find(item =>
item.dataset.reasoningId === anchor!.partId && item.closest<HTMLElement>('[data-message-id]')?.dataset.messageId === anchor!.messageId)
if (!node || !laidOut(viewport) || !laidOut(node) || node.closest('[aria-hidden="true"], [inert]')) return
// Native scroll anchoring may already have preserved the position. Correct
// only the residual delta, leaving nested paused readers eligible for it.
const delta = node.getBoundingClientRect().top - viewport.getBoundingClientRect().top - anchor.offset
if (Math.abs(delta) > 0.5) viewport.scrollTop += delta
}
const schedule = () => { if (!disposed && anchor && !frame) frame = requestAnimationFrame(compensate) }
const pause = (event: Event) => {
const node = event.target instanceof Element ? event.target.closest<HTMLElement>('[data-reasoning-id]') : null
const message = node?.closest<HTMLElement>('[data-message-id]')
if (node && message && viewport.contains(node) && laidOut(viewport) && laidOut(node)) {
anchor = { messageId: message.dataset.messageId!, partId: node.dataset.reasoningId!,
offset: node.getBoundingClientRect().top - viewport.getBoundingClientRect().top }
}
onPause()
}
const outsideIntent = (event: Event) => {
if (event.target instanceof Element && event.target.closest('.cr-reasoning')) return
if (event instanceof KeyboardEvent && !['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) return
reset()
}
viewport.addEventListener('ycode:reasoning-reading', pause)
const intentEvents = ['wheel', 'pointerdown', 'touchstart', 'keydown']
intentEvents.forEach(name => viewport.addEventListener(name, outsideIntent, { passive: true }))
const resize = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(schedule)
resize?.observe(viewport.querySelector('.chat-thread') ?? viewport)
const mutation = typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(schedule)
mutation?.observe(viewport, { childList: true, subtree: true, characterData: true })
return { reset, dispose: () => {
disposed = true
reset()
resize?.disconnect()
mutation?.disconnect()
viewport.removeEventListener('ycode:reasoning-reading', pause)
intentEvents.forEach(name => viewport.removeEventListener(name, outsideIntent))
} }
}
@@ -5,3 +5,11 @@
* Runtime/ASAR mismatches remain recorded in the source manifest. * Runtime/ASAR mismatches remain recorded in the source manifest.
*/ */
.chat-pane.cr-theme{--cb-font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;--cb-font-family-mono:"Menlo", "Monaco", "Courier New", monospace;--cb-spacing-xs:4px;--cb-spacing-sm:8px;--cb-spacing-md:12px;--cb-spacing-lg:16px;--cb-spacing-xl:20px;--cb-spacing-xxl:24px;--cb-border-radius-sm:4px;--cb-border-radius-md:6px;--cb-border-radius-lg:8px;--cb-border-radius-xl:16px;--cb-border-radius-2xl:24px;--cb-transition-fast:.15s ease;--cb-transition-base:.2s ease;--cb-transition-slow:.3s ease;--cb-z-index-dropdown:100;--cb-z-index-modal:1000;--cb-z-index-modal-confirm:100001;--cb-z-index-tooltip:1100;--cb-vscode-editor-background:var(--vscode-editor-background,#fff);--cb-vscode-sideBar-background:var(--vscode-sideBar-background,#f3f3f3);--cb-vscode-input-background:var(--vscode-input-background,#fff);--cb-vscode-dropdown-background:var(--vscode-dropdown-background,#fff);--cb-vscode-foreground:var(--vscode-foreground,#333);--cb-vscode-editor-foreground:var(--vscode-editor-foreground,#4c4f69);--cb-vscode-titleBar-activeBackground:var(--vscode-titleBar-activeBackground,#ddd);--cb-vscode-titleBar-activeForeground:var(--vscode-titleBar-activeForeground,#333);--cb-vscode-titleBar-inactiveBackground:var(--vscode-titleBar-inactiveBackground,#ddd);--cb-vscode-titleBar-inactiveForeground:var(--vscode-titleBar-inactiveForeground,#666);--cb-titlebar-control-hover-background:#0000000f;--cb-vscode-descriptionForeground:var(--vscode-descriptionForeground,#717171);--cb-vscode-widget-border:var(--vscode-widget-border,#e1e1e1);--cb-vscode-panel-border:var(--vscode-panel-border,#e1e1e1);--cb-vscode-list-hoverBackground:var(--vscode-list-hoverBackground,#f0f0f0);--cb-vscode-toolbar-hoverBackground:var(--vscode-toolbar-hoverBackground,#f0f0f0);--cb-vscode-button-background:var(--vscode-button-background,#007acc);--cb-vscode-button-foreground:var(--vscode-button-foreground,#fff);--cb-vscode-button-hoverBackground:var(--vscode-button-hoverBackground,#0062a3);--cb-vscode-scrollbarSlider-background:var(--vscode-scrollbarSlider-background,#c1c1c1);--cb-vscode-scrollbarSlider-hoverBackground:var(--vscode-scrollbarSlider-hoverBackground,#a8a8a8);--cb-vscode-textLink-foreground:var(--vscode-textLink-foreground,#006ab1);--cb-panel-bg-primary:var(--vscode-editor-background,#fff);--cb-text-primary:#333;--cb-text-secondary:#666;--cb-text-disabled:#0000004d;--cb-text-link:#006ab1;--cb-stroke-secondary:#d8dee4;--cb-markdown-hr-border-color:#ebebeb;--cb-bg-primary:#0000000d;--cb-bg-secondary:#f5f5f5;--cb-team-member-card-background:#f5f5f5;--cb-team-member-icon-background:#f5f5f5;--cb-button-dark-background:#1a1a1a;--cb-button-dark-foreground:#fff;--cb-button-dark-hover-background:#333;--cb-text-error-active:#b42c3f;--cb-button-danger-secondary-fg:#b42c3f;--cb-text-link-hover:#0e58a0;--cb-text-solid:#000;--cb-bg-primary-hover:#ebebeb;--cb-bg-primary-active:#f2f2f2;--cb-border-secondary:#ebebeb;--cb-shadow-popover:0 6px 24px 0 #0000000a, 0 4px 6px 0 #0000000a;--cb-border-drag-indicator:#1470b4;--cb-tooltip-bg:#000000b3}.chat-pane.cr-theme[data-cr-theme=dark]{--cb-vscode-editor-background:var(--vscode-editor-background,#1e1e1e);--cb-vscode-sideBar-background:var(--vscode-sideBar-background,#252526);--cb-vscode-input-background:var(--vscode-input-background,#3c3c3c);--cb-vscode-dropdown-background:var(--vscode-dropdown-background,#3c3c3c);--cb-vscode-foreground:var(--vscode-foreground,#ccc);--cb-vscode-editor-foreground:var(--vscode-editor-foreground,#ccc);--cb-vscode-titleBar-activeBackground:var(--vscode-titleBar-activeBackground,#3c3c3c);--cb-vscode-titleBar-activeForeground:var(--vscode-titleBar-activeForeground,#ccc);--cb-vscode-titleBar-inactiveBackground:var(--vscode-titleBar-inactiveBackground,#3c3c3c);--cb-vscode-titleBar-inactiveForeground:var(--vscode-titleBar-inactiveForeground,#999);--cb-titlebar-control-hover-background:#ffffff14;--cb-vscode-descriptionForeground:var(--vscode-descriptionForeground,#9d9d9d);--cb-vscode-widget-border:var(--vscode-widget-border,#414141);--cb-vscode-panel-border:var(--vscode-panel-border,#3c3c3c);--cb-vscode-list-hoverBackground:var(--vscode-list-hoverBackground,#2a2d2e);--cb-vscode-toolbar-hoverBackground:var(--vscode-toolbar-hoverBackground,#353535);--cb-vscode-button-background:var(--vscode-button-background,#0e639c);--cb-vscode-button-foreground:var(--vscode-button-foreground,#fff);--cb-vscode-button-hoverBackground:var(--vscode-button-hoverBackground,#17b);--cb-vscode-button-secondaryBackground:var(--vscode-button-secondaryBackground,#3a3d41);--cb-vscode-button-secondaryForeground:var(--vscode-button-secondaryForeground,#fff);--cb-vscode-button-secondaryHoverBackground:var(--vscode-button-secondaryHoverBackground,#45494e);--cb-vscode-scrollbarSlider-background:var(--vscode-scrollbarSlider-background,#797979);--cb-vscode-scrollbarSlider-hoverBackground:var(--vscode-scrollbarSlider-hoverBackground,#6Line truncated .chat-pane.cr-theme{--cb-font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;--cb-font-family-mono:"Menlo", "Monaco", "Courier New", monospace;--cb-spacing-xs:4px;--cb-spacing-sm:8px;--cb-spacing-md:12px;--cb-spacing-lg:16px;--cb-spacing-xl:20px;--cb-spacing-xxl:24px;--cb-border-radius-sm:4px;--cb-border-radius-md:6px;--cb-border-radius-lg:8px;--cb-border-radius-xl:16px;--cb-border-radius-2xl:24px;--cb-transition-fast:.15s ease;--cb-transition-base:.2s ease;--cb-transition-slow:.3s ease;--cb-z-index-dropdown:100;--cb-z-index-modal:1000;--cb-z-index-modal-confirm:100001;--cb-z-index-tooltip:1100;--cb-vscode-editor-background:var(--vscode-editor-background,#fff);--cb-vscode-sideBar-background:var(--vscode-sideBar-background,#f3f3f3);--cb-vscode-input-background:var(--vscode-input-background,#fff);--cb-vscode-dropdown-background:var(--vscode-dropdown-background,#fff);--cb-vscode-foreground:var(--vscode-foreground,#333);--cb-vscode-editor-foreground:var(--vscode-editor-foreground,#4c4f69);--cb-vscode-titleBar-activeBackground:var(--vscode-titleBar-activeBackground,#ddd);--cb-vscode-titleBar-activeForeground:var(--vscode-titleBar-activeForeground,#333);--cb-vscode-titleBar-inactiveBackground:var(--vscode-titleBar-inactiveBackground,#ddd);--cb-vscode-titleBar-inactiveForeground:var(--vscode-titleBar-inactiveForeground,#666);--cb-titlebar-control-hover-background:#0000000f;--cb-vscode-descriptionForeground:var(--vscode-descriptionForeground,#717171);--cb-vscode-widget-border:var(--vscode-widget-border,#e1e1e1);--cb-vscode-panel-border:var(--vscode-panel-border,#e1e1e1);--cb-vscode-list-hoverBackground:var(--vscode-list-hoverBackground,#f0f0f0);--cb-vscode-toolbar-hoverBackground:var(--vscode-toolbar-hoverBackground,#f0f0f0);--cb-vscode-button-background:var(--vscode-button-background,#007acc);--cb-vscode-button-foreground:var(--vscode-button-foreground,#fff);--cb-vscode-button-hoverBackground:var(--vscode-button-hoverBackground,#0062a3);--cb-vscode-scrollbarSlider-background:var(--vscode-scrollbarSlider-background,#c1c1c1);--cb-vscode-scrollbarSlider-hoverBackground:var(--vscode-scrollbarSlider-hoverBackground,#a8a8a8);--cb-vscode-textLink-foreground:var(--vscode-textLink-foreground,#006ab1);--cb-panel-bg-primary:var(--vscode-editor-background,#fff);--cb-text-primary:#333;--cb-text-secondary:#666;--cb-text-disabled:#0000004d;--cb-text-link:#006ab1;--cb-stroke-secondary:#d8dee4;--cb-markdown-hr-border-color:#ebebeb;--cb-bg-primary:#0000000d;--cb-bg-secondary:#f5f5f5;--cb-team-member-card-background:#f5f5f5;--cb-team-member-icon-background:#f5f5f5;--cb-button-dark-background:#1a1a1a;--cb-button-dark-foreground:#fff;--cb-button-dark-hover-background:#333;--cb-text-error-active:#b42c3f;--cb-button-danger-secondary-fg:#b42c3f;--cb-text-link-hover:#0e58a0;--cb-text-solid:#000;--cb-bg-primary-hover:#ebebeb;--cb-bg-primary-active:#f2f2f2;--cb-border-secondary:#ebebeb;--cb-shadow-popover:0 6px 24px 0 #0000000a, 0 4px 6px 0 #0000000a;--cb-border-drag-indicator:#1470b4;--cb-tooltip-bg:#000000b3}.chat-pane.cr-theme[data-cr-theme=dark]{--cb-vscode-editor-background:var(--vscode-editor-background,#1e1e1e);--cb-vscode-sideBar-background:var(--vscode-sideBar-background,#252526);--cb-vscode-input-background:var(--vscode-input-background,#3c3c3c);--cb-vscode-dropdown-background:var(--vscode-dropdown-background,#3c3c3c);--cb-vscode-foreground:var(--vscode-foreground,#ccc);--cb-vscode-editor-foreground:var(--vscode-editor-foreground,#ccc);--cb-vscode-titleBar-activeBackground:var(--vscode-titleBar-activeBackground,#3c3c3c);--cb-vscode-titleBar-activeForeground:var(--vscode-titleBar-activeForeground,#ccc);--cb-vscode-titleBar-inactiveBackground:var(--vscode-titleBar-inactiveBackground,#3c3c3c);--cb-vscode-titleBar-inactiveForeground:var(--vscode-titleBar-inactiveForeground,#999);--cb-titlebar-control-hover-background:#ffffff14;--cb-vscode-descriptionForeground:var(--vscode-descriptionForeground,#9d9d9d);--cb-vscode-widget-border:var(--vscode-widget-border,#414141);--cb-vscode-panel-border:var(--vscode-panel-border,#3c3c3c);--cb-vscode-list-hoverBackground:var(--vscode-list-hoverBackground,#2a2d2e);--cb-vscode-toolbar-hoverBackground:var(--vscode-toolbar-hoverBackground,#353535);--cb-vscode-button-background:var(--vscode-button-background,#0e639c);--cb-vscode-button-foreground:var(--vscode-button-foreground,#fff);--cb-vscode-button-hoverBackground:var(--vscode-button-hoverBackground,#17b);--cb-vscode-button-secondaryBackground:var(--vscode-button-secondaryBackground,#3a3d41);--cb-vscode-button-secondaryForeground:var(--vscode-button-secondaryForeground,#fff);--cb-vscode-button-secondaryHoverBackground:var(--vscode-button-secondaryHoverBackground,#45494e);--cb-vscode-scrollbarSlider-background:var(--vscode-scrollbarSlider-background,#797979);--cb-vscode-scrollbarSlider-hoverBackground:var(--vscode-scrollbarSlider-hoverBackground,#6Line truncated
/* Reasoning owns its small viewport; gestures must not chain into the transcript. */
.cr-reasoning__scroll { max-height: 200px; overflow-y: auto; overscroll-behavior: contain; overflow-anchor: none; }
.cr-reasoning__text { display: flow-root; }
.cr-reasoning__latest { display: block; margin: 4px 0 4px auto; padding: 2px 8px; border: 0; border-radius: 4px; background: transparent; color: var(--conversation-text-secondary, inherit); font: inherit; cursor: pointer; }
.cr-reasoning__latest:focus-visible, .cr-reasoning__scroll:focus-visible { outline: 2px solid var(--conversation-text-secondary, currentColor); outline-offset: 2px; }
.cr-reasoning[data-follow-state="paused"] .cr-reasoning__scroll { overflow-anchor: auto; }