feat(conversation): follow active reasoning while preserving manual reading
This commit is contained in:
1 parent
35b9a62ed4
commit
4a72008b2e
12 files changed
+530
-40
No files matched your search
@@ -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')
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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) })
|
||||||
@@ -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)
|
||||||
@@ -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; }
|
||||||
Reference in new issue
Block a user