fix(B01): preserve question routing and nested disclosure state
This commit is contained in:
1 parent
21ca0e0fc3
commit
51531dcae8
15 files changed
+341
-55
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { isQuestionToolName } from '@shared/tool-identity'
|
||||
import type { ChatMessage, MessagePart } from '@shared/types'
|
||||
import { projectQuestionAnswers } from '../workbuddy/conversation/question-projection'
|
||||
import SourceAgentAvatar from './SourceAgentAvatar'
|
||||
@@ -91,7 +92,7 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
|
||||
if (questionProjection.positionedAnswers.has(part.toolId) || activeQuestionIds.has(part.toolId)) return { role: 'inline-question' }
|
||||
const name = tools.find(tool => tool.id === part.toolId)?.name.toLowerCase() ?? ''
|
||||
if (['show_widget', 'visualize:show_widget', 'visualizer:show_widget'].includes(name)) return { role: 'show-widget' }
|
||||
if (['ask_followup_question', 'ask_user_question', 'askfollowupquestion', 'askuserquestion'].includes(name)) return { role: 'inline-question' }
|
||||
if (isQuestionToolName(name)) return { role: 'inline-question' }
|
||||
return undefined
|
||||
}
|
||||
const renderTool = (id: string): React.ReactNode => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useId, useRef } from 'react'
|
||||
import { SourceToolHeader } from './SourceToolHeader'
|
||||
import { useDisclosureState } from './disclosure-state'
|
||||
import './tool-result-source.css'
|
||||
import type { ConversationToolCall, QuestionAnswerItem, QuestionLabels } from './types'
|
||||
import { getToolCallPhase } from './ToolCall'
|
||||
import { defaultQuestionLabels } from './QuestionFloating'
|
||||
@@ -47,7 +50,8 @@ export function AskUserQuestion({ tool, contentSettled, labels: labelOverrides,
|
||||
const phase = getToolCallPhase(tool.status)
|
||||
const metadata = tool.metaData || tool.metadata
|
||||
const wasUnsettled = useRef(contentSettled)
|
||||
const [expanded, setExpanded] = useState(() => contentSettled === false)
|
||||
const [expanded, setExpanded] = useDisclosureState('question', () => contentSettled === false)
|
||||
const contentId = `question-${useId()}-content`
|
||||
|
||||
useEffect(() => {
|
||||
if (contentSettled && wasUnsettled.current === false) setExpanded(false)
|
||||
@@ -95,21 +99,21 @@ export function AskUserQuestion({ tool, contentSettled, labels: labelOverrides,
|
||||
|
||||
return (
|
||||
<section className={['ask-user-question', 'ask-user-question--completed', expanded && 'ask-user-question--expanded', className].filter(Boolean).join(' ')}>
|
||||
<button
|
||||
type="button"
|
||||
<SourceToolHeader
|
||||
className="ask-user-question__header"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded(value => !value)}
|
||||
>
|
||||
<span className="ask-user-question__icon" aria-hidden="true">?</span>
|
||||
<span className="ask-user-question__title">{labels.title}</span>
|
||||
<span className="ask-user-question__arrow" aria-hidden="true">{expanded ? '-' : '+'}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="ask-user-question__content">
|
||||
statusText={labels.title}
|
||||
collapsible
|
||||
expanded={expanded}
|
||||
controlsId={contentId}
|
||||
onToggle={() => setExpanded(value => !value)}
|
||||
/>
|
||||
<div id={contentId}
|
||||
className={`cr-tool-exp__content-shell${expanded ? ' cr-tool-exp__content-shell--expanded' : ''}`}
|
||||
aria-hidden={!expanded} inert={!expanded}>
|
||||
<div className="cr-tool-exp__content ask-user-question__content">
|
||||
<QuestionAnswerTranscript questionAnswer={questionAnswer} labels={labels} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment, 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 { SourceFoldChevronIcon } from '../message-actions/SourceActionIcons'
|
||||
import {
|
||||
buildCompletedFoldModel,
|
||||
@@ -25,12 +26,12 @@ function renderPart(
|
||||
summaryGroup?: CompletedFoldSummaryGroup
|
||||
): ReactNode {
|
||||
const classification = unit.classification ?? classifyCompletedFoldPart(unit.part, unit.index)
|
||||
return <Fragment key={unit.key}>{render(unit.part, unit.index, {
|
||||
return <DisclosureStateScope key={unit.key} id={partDisclosureId(unit.part, unit.index)}>{render(unit.part, unit.index, {
|
||||
kind: summaryGroup ? 'summary-member' : 'part',
|
||||
classification,
|
||||
presentationGroupId: classification.presentationGroupId,
|
||||
summaryGroup
|
||||
})}</Fragment>
|
||||
})}</DisclosureStateScope>
|
||||
}
|
||||
|
||||
function renderPresentationUnit(
|
||||
@@ -41,9 +42,9 @@ function renderPresentationUnit(
|
||||
if (unit.kind === 'part') return renderPart(unit.unit, render)
|
||||
const children = unit.group.units.map(member => renderPart(member, render, unit.group))
|
||||
return (
|
||||
<Fragment key={unit.group.key}>
|
||||
<DisclosureStateScope key={unit.group.key} id={`summary:${partDisclosureId(unit.group.units[0].part, unit.group.units[0].index)}`}>
|
||||
{renderGroup ? renderGroup(unit.group, children) : <>{children}</>}
|
||||
</Fragment>
|
||||
</DisclosureStateScope>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,7 +67,7 @@ function CollapseSection({
|
||||
controlledExpanded?: boolean
|
||||
onExpandedChange?: (expanded: boolean) => void
|
||||
}) {
|
||||
const [localExpanded, setLocalExpanded] = useState(defaultExpanded)
|
||||
const [localExpanded, setLocalExpanded] = useDisclosureState(`collapse:${id}`, defaultExpanded)
|
||||
const expanded = controlledExpanded ?? localExpanded
|
||||
const contentId = `${id}-content`
|
||||
const toggle = () => {
|
||||
@@ -145,7 +146,16 @@ export function LiveEventDisclosure({
|
||||
)
|
||||
}
|
||||
|
||||
export function CompletedFold({
|
||||
function partDisclosureId(part: CompletedFoldPart, index: number): string {
|
||||
const id = 'id' in part && typeof part.id === 'string' ? part.id : part.type === 'tool' && typeof part.toolId === 'string' ? part.toolId : index
|
||||
return JSON.stringify([part.type, id])
|
||||
}
|
||||
|
||||
export function CompletedFold(props: CompletedFoldProps) {
|
||||
return <DisclosureStateProvider key={props.foldId}><CompletedFoldContent {...props} /></DisclosureStateProvider>
|
||||
}
|
||||
|
||||
function CompletedFoldContent({
|
||||
parts,
|
||||
turnFinished,
|
||||
turnDurationMs,
|
||||
@@ -173,11 +183,11 @@ export function CompletedFold({
|
||||
if (!model) return <>{parts.map((part, index) => {
|
||||
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
|
||||
if (!classification.hasRootBlock) return null
|
||||
return <Fragment key={`${part.type}:${index}`}>{render(part, index, {
|
||||
return <DisclosureStateScope key={`${part.type}:${index}`} id={partDisclosureId(part, index)}>{render(part, index, {
|
||||
kind: 'part',
|
||||
classification,
|
||||
presentationGroupId: classification.presentationGroupId
|
||||
})}</Fragment>
|
||||
})}</DisclosureStateScope>
|
||||
})}</>
|
||||
|
||||
// The compact model groups hidden process around visible anchors. Once opened,
|
||||
@@ -198,7 +208,7 @@ export function CompletedFold({
|
||||
) : parts.map((part, index) => {
|
||||
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
|
||||
if (!classification.hasRootBlock) return null
|
||||
return <Fragment key={`${part.type}:${index}`}>{render(part, index, { kind: 'part', classification })}</Fragment>
|
||||
return <DisclosureStateScope key={`${part.type}:${index}`} id={partDisclosureId(part, index)}>{render(part, index, { kind: 'part', classification })}</DisclosureStateScope>
|
||||
})
|
||||
})()}
|
||||
</CollapseSection>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import { useDisclosureState } from './disclosure-state'
|
||||
import { useSourceStickyAutoScroll } from './source-tool-effects'
|
||||
import { SourceFoldChevronIcon } from '../message-actions/SourceActionIcons'
|
||||
import { FailedIcon } from './RecoveredBlockIcons'
|
||||
@@ -20,6 +21,7 @@ export interface SourceToolHeaderProps {
|
||||
collapsible?: boolean
|
||||
expanded?: boolean
|
||||
onToggle?: () => void
|
||||
controlsId?: string
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
@@ -41,6 +43,7 @@ export function SourceToolHeader({
|
||||
collapsible = false,
|
||||
expanded = false,
|
||||
onToggle,
|
||||
controlsId,
|
||||
actions,
|
||||
className
|
||||
}: SourceToolHeaderProps) {
|
||||
@@ -60,6 +63,7 @@ export function SourceToolHeader({
|
||||
role={canToggle && !onPrimaryClick ? 'button' : undefined}
|
||||
tabIndex={canToggle ? 0 : undefined}
|
||||
aria-expanded={canToggle ? expanded : undefined}
|
||||
aria-controls={canToggle ? controlsId : undefined}
|
||||
onMouseDown={canToggle ? preventMultiClickSelection : undefined}
|
||||
onClick={canToggle ? onToggle : undefined}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -130,7 +134,7 @@ export function SourceToolExpandable({
|
||||
className,
|
||||
...headerProps
|
||||
}: SourceToolExpandableProps) {
|
||||
const [expanded, setExpanded] = useState(() => defaultExpanded || expand === true)
|
||||
const [expanded, setExpanded] = useDisclosureState('tool-expandable', () => defaultExpanded || expand === true)
|
||||
const priorExpand = useRef<boolean | undefined>(undefined)
|
||||
const expandable = canExpand && children != null
|
||||
const open = expandable && expanded
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useDisclosureState } from './disclosure-state'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import './task-source.css'
|
||||
import type { ConversationToolCall } from './types'
|
||||
@@ -27,7 +28,7 @@ export interface ThinkingBlockProps {
|
||||
}
|
||||
|
||||
export function ThinkingBlock({ text, isFinished = false, isLastContent = false, title = t('message.loading.thinking'), className, renderMarkdown }: ThinkingBlockProps) {
|
||||
const [expanded, setExpanded] = useState(isLastContent)
|
||||
const [expanded, setExpanded] = useDisclosureState('thinking', isLastContent)
|
||||
if (!text) return null
|
||||
|
||||
return (
|
||||
@@ -136,7 +137,7 @@ export function PlanCreateBlock({
|
||||
maxDisplayTasks = 5,
|
||||
className
|
||||
}: PlanCreateBlockProps) {
|
||||
const [collapsed, setCollapsed] = useState(initiallyCollapsed)
|
||||
const [collapsed, setCollapsed] = useDisclosureState('plan-collapsed', initiallyCollapsed)
|
||||
const finished = status === 'success' || status === 'error' || status === 'cancelled'
|
||||
const succeeded = status === 'success'
|
||||
const tasks = useMemo(() => (Array.isArray(data.todolist) ? data.todolist : []), [data.todolist])
|
||||
@@ -320,7 +321,7 @@ const taskSourceAttributes = {
|
||||
}
|
||||
|
||||
export function TaskBlock({ data, status, onApprove, onReject, renderMarkdown, toolName = 'task', className }: TaskBlockProps) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [expanded, setExpanded] = useDisclosureState('task-expanded', true)
|
||||
const [decisionByIndex, setDecisionByIndex] = useState<Record<number, 'approved' | 'rejected' | 'responding' | 'error'>>({})
|
||||
const [errorByIndex, setErrorByIndex] = useState<Record<number, string>>({})
|
||||
const isExecuting = ['idle', 'pending', 'parsing', 'stream_executing', 'full_executing', 'executing', 'running'].includes(status)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useDisclosureState } from './disclosure-state'
|
||||
import { Children, useId, useMemo, useState, type ReactNode } from 'react'
|
||||
import { SourceFoldChevronIcon } from '../message-actions/SourceActionIcons'
|
||||
import { SourceToolExpandable } from './SourceToolHeader'
|
||||
@@ -172,7 +173,7 @@ export function ToolGroup({
|
||||
className
|
||||
}: ToolGroupProps) {
|
||||
const contentId = useId()
|
||||
const [localExpanded, setLocalExpanded] = useState(defaultExpanded)
|
||||
const [localExpanded, setLocalExpanded] = useDisclosureState('tool-group', defaultExpanded)
|
||||
const [visiblePages, setVisiblePages] = useState(1)
|
||||
const expanded = controlledExpanded ?? localExpanded
|
||||
const items = useMemo(() => Children.toArray(children), [children])
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useDisclosureState } from './disclosure-state'
|
||||
import { isQuestionToolName } from '@shared/tool-identity'
|
||||
import './tool-result-source.css'
|
||||
import { useEffect, useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { AskUserQuestion } from './AskUserQuestion'
|
||||
@@ -132,10 +134,11 @@ const exactAliases: Record<CapturedToolKind, string[]> = {
|
||||
'exit-plan': ['exit_plan_mode', 'exitplanmode'],
|
||||
task: ['task', 'Task'],
|
||||
'task-output': ['TaskOutput', 'task_output', 'task-output'],
|
||||
question: ['ask_followup_question', 'askfollowupquestion', 'ask-followup-question', 'ask_user_question', 'askuserquestion']
|
||||
question: [] // Question aliases are shared with message classification.
|
||||
}
|
||||
|
||||
export function getCapturedToolKind(toolName: string): CapturedToolKind | undefined {
|
||||
if (isQuestionToolName(toolName)) return 'question'
|
||||
for (const [kind, aliases] of Object.entries(exactAliases) as [CapturedToolKind, string[]][]) {
|
||||
if (aliases.includes(toolName)) return kind
|
||||
}
|
||||
@@ -1081,8 +1084,7 @@ function mcpDisplayCount(value: unknown): number | undefined {
|
||||
|
||||
function McpDisplayView({ tool }: { tool: ConversationToolCall }) {
|
||||
const data = convertMcpDisplay(tool)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
useEffect(() => setExpanded(false), [tool.id])
|
||||
const [expanded, setExpanded] = useDisclosureState(`mcp-display:${tool.id}`, false)
|
||||
const canExpand = data.detailText !== undefined
|
||||
const toggle = () => setExpanded(value => !value)
|
||||
return <div className={`cr-captured-tool cr-tool-display-list${canExpand ? ' cr-tool-display-list--clickable' : ''}`} data-tool-call-id={tool.id} data-tool-name={tool.name} data-tool-status={tool.status} role="list">
|
||||
|
||||
@@ -106,8 +106,7 @@
|
||||
.cr-tool-call__header,
|
||||
.cr-tool-call__toggle,
|
||||
.cr-meta-fold__head,
|
||||
.conversation-high-credit-approval__header,
|
||||
.ask-user-question__header {
|
||||
.conversation-high-credit-approval__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -119,8 +118,7 @@
|
||||
}
|
||||
|
||||
.cr-tool-call__toggle,
|
||||
.cr-meta-fold__head,
|
||||
.ask-user-question__header {
|
||||
.cr-meta-fold__head {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
@@ -145,14 +143,12 @@
|
||||
.cr-tool-call__icon,
|
||||
.cr-tool-call__title,
|
||||
.cr-meta-fold__summary,
|
||||
.conversation-high-credit-approval__title,
|
||||
.ask-user-question__title {
|
||||
.conversation-high-credit-approval__title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cr-tool-call__title,
|
||||
.conversation-high-credit-approval__title,
|
||||
.ask-user-question__title {
|
||||
.conversation-high-credit-approval__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -175,8 +171,7 @@
|
||||
.cr-tool-call__status--skipped { color: var(--conversation-muted); }
|
||||
|
||||
.cr-tool-call__arrow,
|
||||
.cr-meta-fold__arrow,
|
||||
.ask-user-question__arrow {
|
||||
.cr-meta-fold__arrow {
|
||||
color: var(--conversation-muted);
|
||||
flex: 0 0 auto;
|
||||
font-size: 14px;
|
||||
@@ -190,8 +185,7 @@
|
||||
}
|
||||
|
||||
.cr-tool-call__body,
|
||||
.cr-meta-fold__body,
|
||||
.ask-user-question__content {
|
||||
.cr-meta-fold__body {
|
||||
border-top: 1px solid var(--conversation-border);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
@@ -278,10 +272,6 @@
|
||||
.ask-user-question--failed,
|
||||
.ask-user-question--skipped { align-items: center; color: var(--conversation-muted); display: flex; font-size: 12px; gap: 8px; }
|
||||
.ask-user-question--failed { color: var(--conversation-error); }
|
||||
.ask-user-question__header { gap: 8px; min-height: 28px; padding: 0; }
|
||||
.ask-user-question__icon { align-items: center; border: 1px solid var(--conversation-border); border-radius: 50%; color: var(--conversation-muted); display: inline-flex; flex: 0 0 18px; font-size: 11px; height: 18px; justify-content: center; }
|
||||
.ask-user-question__title { flex: 1; }
|
||||
.ask-user-question__content { padding: 9px 11px; }
|
||||
.question-answer-transcript { display: grid; gap: 10px; }
|
||||
.question-answer-transcript__item { display: grid; gap: 4px; }
|
||||
.question-answer-transcript__question { font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
@@ -339,9 +329,10 @@
|
||||
.cb-plan-task--clickable:hover { background: color-mix(in srgb, var(--conversation-text) 4%, transparent); }
|
||||
.cb-plan-task--clickable:focus-visible { outline: 2px solid var(--conversation-accent); outline-offset: 2px; }
|
||||
|
||||
/* 10/21 alignment plan: answered QuestionView typography, not a tool summary. */
|
||||
.ask-user-question--completed { background: var(--cb-content-background, var(--cb-gray-color)); border-radius: var(--yc-radius-2xl); padding: var(--yc-space-3) var(--yc-space-4); }
|
||||
.ask-user-question__content { max-height: 22.5rem; overflow-y: auto; padding: var(--yc-space-3) 0 0; }
|
||||
/* Answered QuestionView: ToolExpandable header sits outside the recovered gray content shell. */
|
||||
.ask-user-question--completed { background: transparent; border: 0; border-radius: 0; padding: 2px 0; }
|
||||
.ask-user-question--completed .ask-user-question__content { border: 0; }
|
||||
.ask-user-question__header .cr-tool-head__main:focus-visible { outline: 2px solid var(--conversation-accent); outline-offset: 2px; }
|
||||
.question-answer-transcript { gap: var(--yc-space-4); }
|
||||
.question-answer-transcript__question { color: var(--cb-text-tertiary, var(--cb-text-secondary)); font-size: var(--yc-font-size-5); font-weight: 400; line-height: 1.75; }
|
||||
.question-answer-transcript__answer { color: var(--cb-text-primary); font-size: var(--yc-font-size-5); font-weight: 400; line-height: 1.75; }
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState, useSyncExternalStore, type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
||||
|
||||
interface DisclosureStore {
|
||||
values: Map<string, boolean>
|
||||
listeners: Set<() => void>
|
||||
}
|
||||
const StoreContext = createContext<DisclosureStore | null>(null)
|
||||
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() }))
|
||||
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
|
||||
}
|
||||
|
||||
export function DisclosureStateScope({ id, children }: { id: string; children: ReactNode }) {
|
||||
// Scope is an absolute part/group identity: moving a part into a summary cannot change it.
|
||||
return <ScopeContext.Provider value={id}>{children}</ScopeContext.Provider>
|
||||
}
|
||||
|
||||
export function useDisclosureState(slot: string, initial: boolean | (() => boolean)): [boolean, Dispatch<SetStateAction<boolean>>] {
|
||||
const store = useContext(StoreContext)
|
||||
const scope = useContext(ScopeContext)
|
||||
const key = JSON.stringify([scope, slot])
|
||||
const [fallback, setFallback] = useState(initial)
|
||||
const initialValue = useRef(fallback)
|
||||
const subscribe = useCallback((listener: () => void) => {
|
||||
store?.listeners.add(listener)
|
||||
return () => { store?.listeners.delete(listener) }
|
||||
}, [store])
|
||||
const snapshot = useCallback(() => store?.values.get(key) ?? initialValue.current, [store, key])
|
||||
const retained = useSyncExternalStore(subscribe, snapshot, snapshot)
|
||||
const setValue = useCallback<Dispatch<SetStateAction<boolean>>>((action) => {
|
||||
if (!store) { setFallback(action); return }
|
||||
const previous = store.values.get(key) ?? initialValue.current
|
||||
const next = typeof action === 'function' ? action(previous) : action
|
||||
store.values.set(key, next)
|
||||
if (previous !== next) store.listeners.forEach(listener => listener())
|
||||
}, [store, key])
|
||||
return [store ? retained : fallback, setValue]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Display-only identity lookup. Never rewrite the recorded name or invocation. */
|
||||
const questionAliases = new Set([
|
||||
'askuserquestion', 'ask_user_question',
|
||||
'askfollowupquestion', 'ask_followup_question', 'ask-followup-question'
|
||||
])
|
||||
|
||||
export function isQuestionToolName(name: string): boolean {
|
||||
return questionAliases.has(name.toLowerCase())
|
||||
}
|
||||
Reference in new issue
Block a user