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

+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 MessageBubble, { planMarkdown } from './MessageBubble'
import { useChatStore } from '../stores/chat'
@@ -230,6 +231,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const scrollRef = useRef<HTMLDivElement>(null)
const composerStackRef = useRef<HTMLDivElement>(null)
const followBottom = useRef(true)
const reasoningReading = useRef(false)
const resetReasoningAnchor = useRef<(() => void) | null>(null)
/** 把表格引用挂到当前草稿(原版 message.quote.add:引用与正文分开保存,不移写正文)。 */
const quoteTable = useCallback((quote: MessageQuote): void => {
setDrafts((all) => {
@@ -429,6 +432,17 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
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.
const modelLabel = effectiveModel?.name ?? '配置自定义模型'
useEffect(() => {
@@ -456,6 +470,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
queueEditDecision.current = null
setQueueEditTarget(null)
resolveQueueEdit?.(false)
resetReasoningAnchor.current?.()
reasoningReading.current = false
followBottom.current = true
const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight
@@ -472,6 +488,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
if (loading || loadError) throw new Error('会话尚未就绪,请重试或等待加载完成')
if (!effectiveModel) throw new Error('请先配置自定义模型')
if (composition.input.blockedByInteraction) throw new Error('请先处理当前交互')
resetReasoningAnchor.current?.()
reasoningReading.current = false
followBottom.current = true
const submitKey = draftKey
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="chat-scroll cr-message-list" ref={scrollRef} onScroll={(event) => {
if (event.target !== event.currentTarget || reasoningReading.current) return
const el = event.currentTarget
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>}
{!loading && !loadError && messages.length === 0 && <p className="yc-session-empty">此会话暂无消息</p>}
{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}
artifactCount={!running && !sending && message.id === lastAssistantId ? artifactCount : 0}
onShowArtifacts={() => window.dispatchEvent(new CustomEvent('ycode:open-artifacts', { detail: { sessionId: currentId } }))}
@@ -5,7 +5,7 @@ import SourceAgentAvatar from './SourceAgentAvatar'
import SourceArtifactLink from './SourceArtifactLink'
import { AssistantMessageDetails } from './AssistantMessageDetails'
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 { SourceToolHeader } from '../workbuddy/conversation/SourceToolHeader'
import { findToolApproval, isActiveToolApproval } from '../workbuddy/conversation/tool-approval'
@@ -125,7 +125,7 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
}
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 */}
<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>
@@ -134,7 +134,7 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
<div className="cr-agent__body-row"><div className="cr-agent__body">
{streaming && typeof message.turnStartedAt === 'number' && Number.isFinite(message.turnStartedAt) && <StreamingTurnDuration startedAt={message.turnStartedAt} />}
<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'}
turnCancelled={message.status === 'cancelled'} turnDurationMs={message.turnDurationMs}
classifyToolCall={classifyTool}
@@ -168,7 +168,8 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
isExecuting={actionSummary?.status === 'running'} primaryLoading={actionSummary?.status === 'running'}
primaryContent={actionText} />
</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}
onExpandedChange={expanded => setExpandedGroups(previous => ({ ...previous, [group.key]: expanded }))}>
{children}
@@ -1,5 +1,5 @@
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 {
buildCompletedFoldModel,
@@ -57,23 +57,27 @@ function CollapseSection({
children,
defaultExpanded = false,
controlledExpanded,
onExpandedChange
onExpandedChange,
protectedPartIds = []
}: {
id: string
variant: 'completed' | 'process'
title: ReactNode
headerLabel?: string
children: ReactNode
protectedPartIds?: readonly string[]
defaultExpanded?: boolean
controlledExpanded?: boolean
onExpandedChange?: (expanded: boolean) => void
}) {
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 toggle = () => {
const next = !expanded
if (controlledExpanded === undefined) setLocalExpanded(next)
if (controlledExpanded === undefined) { setManualChoice(true); setLocalExpanded(next) }
onExpandedChange?.(next)
}
const toggleFromKey = (event: KeyboardEvent<HTMLElement>) => {
@@ -115,17 +119,23 @@ export function LiveEventDisclosure({
id,
header,
children,
expanded,
onExpandedChange
expanded: requestedExpanded,
onExpandedChange,
protectedPartIds = []
}: {
id: string
header: ReactNode
children: ReactNode
expanded: boolean
protectedPartIds?: readonly string[]
onExpandedChange: (expanded: boolean) => void
}): React.JSX.Element {
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>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
@@ -165,7 +175,10 @@ function CompletedFoldContent({
renderUnit: render,
renderGroup
}: 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 foldKey = encodeURIComponent(foldId ?? reactId)
@@ -211,7 +224,7 @@ function CompletedFoldContent({
</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 === 'completed') {
return (
@@ -229,9 +242,10 @@ function CompletedFoldContent({
}
return (
<CollapseSection
key={`process:${foldKey}:${segment.group.after.key}:${segmentIndex}`}
id={`process-${foldKey}-${segment.group.after.index}-${segmentIndex}`}
key={`process:${foldKey}:${segment.group.after.key}`}
id={`process-${foldKey}-${encodeURIComponent(segment.group.after.key)}`}
variant="process"
protectedPartIds={segment.group.units.map(unit => completedFoldPartIdentity(unit.part, unit.index))}
title={segment.group.title}
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 { useMemo, useState, type ReactNode } from 'react'
import { useDisclosureState, useReasoningState } from './disclosure-state'
import { useMemo, useState, useRef, useLayoutEffect, type ReactNode } from 'react'
import './task-source.css'
import type { ConversationToolCall } from './types'
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) {
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
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(' ')}>
<button
type="button"
className="cr-collapse__header"
aria-expanded={expanded}
onClick={() => setExpanded(value => !value)}
>
<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 type="button" className="cr-collapse__header" aria-expanded={expanded}
onClick={() => {
readingIntent()
stateRef.current = { ...stateRef.current, follow: 'paused' }
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__title">{title}</span>
<span className="cr-collapse__icon" aria-hidden="true">
<SourceFoldChevronIcon />
</span>
<span className="cr-collapse__icon" aria-hidden="true"><SourceFoldChevronIcon /></span>
</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>
)
}
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useRef, useState, useSyncExtern
interface DisclosureStore {
values: Map<string, boolean>
reasoning: Map<string, ReasoningState>
listeners: Set<() => void>
}
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. */
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>
}
@@ -39,3 +40,48 @@ export function useDisclosureState(slot: string, initial: boolean | (() => boole
}, [store, key])
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.
*/
.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; }