diff --git a/scripts/alignment-conversation-regression.tsx b/scripts/alignment-conversation-regression.tsx index a2730f4..27cc361 100644 --- a/scripts/alignment-conversation-regression.tsx +++ b/scripts/alignment-conversation-regression.tsx @@ -44,3 +44,32 @@ const expandedGrouped=renderToStaticMarkup(Number(m[1])),[0,1,2,3,4,5]) assert.deepEqual([...expandedGrouped.matchAll(/data-local-group="(\d+)"/g)].map(m=>Number(m[1])),[1,4], 'opening completion must preserve independently controlled local summaries') + +// A01: exercise the real native name through MessageBubble, not just its projection. +const nativeQuestion = {...question, status: 'done' as const, tools: question.tools!.map(tool => ({...tool, output: 'RAW SDK ANSWER'}))} +for (const name of ['AskUserQuestion', 'askuserquestion', 'ask_user_question', 'AskFollowupQuestion', 'ask-followup-question']) { + const fixture = {...nativeQuestion, tools: nativeQuestion.tools.map(tool => ({...tool, name}))} + const before = JSON.stringify(fixture) + const markup = renderToStaticMarkup() + assert.match(markup, /ask-user-question--completed/, `${name} routes to dedicated question card`) + assert.doesNotMatch(markup, /RAW SDK ANSWER/, `${name} suppresses raw SDK answer`) + assert.equal(JSON.stringify(fixture), before, 'rendering does not rewrite recorded names or metadata') +} +const ordinary = renderToStaticMarkup() +assert.doesNotMatch(ordinary, /ask-user-question--completed/) +assert.match(ordinary, /RAW SDK ANSWER/, 'ordinary tools retain their raw output') +console.log('A01 native MessageBubble identity aliases, immutable inputs and ordinary raw output passed') +const importedNative = {...nativeQuestion, interactions: [], tools: nativeQuestion.tools.map(tool => ({...tool, + args: {questions: [{id: 'q1', question: 'Imported native question'}]}, output: 'q1 -> imported answer', metadata: {importedHistory: true} +}))} +const importedMarkup = renderToStaticMarkup() +assert.match(importedMarkup, /ask-user-question--completed/) +assert.equal(importedNative.tools[0].name, 'AskUserQuestion', 'historical display adaptation does not rewrite stored name') +const duplicateMarkup = renderToStaticMarkup() +assert.equal((duplicateMarkup.match(/ask-user-question--completed/g) || []).length, 1, 'linked answers have one dedicated card') +const legacyNative = {...nativeQuestion, parts: undefined, tools: nativeQuestion.tools.map(tool => ({...tool, metadata: {questionAnswer: {questions: [{id: 'legacy', question: 'Unknown-position question', answers: ['legacy answer']}]}}})), interactions: []} +const legacyNativeMarkup = renderToStaticMarkup() +assert.match(legacyNativeMarkup, /历史问答(原始位置不可用)/) +assert.equal((legacyNativeMarkup.match(/Unknown-position question/g) || []).length, 1) +assert.doesNotMatch(legacyNativeMarkup, /RAW SDK ANSWER/) +console.log('A01 imported native card, linked dedup and unknown-position compatibility passed') diff --git a/scripts/completed-fold-nested-regression.tsx b/scripts/completed-fold-nested-regression.tsx new file mode 100644 index 0000000..ae085cd --- /dev/null +++ b/scripts/completed-fold-nested-regression.tsx @@ -0,0 +1,68 @@ +/** Real nested React DOM components; deliberately no Electron, SDK, network, or layout claim. */ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom') +const dom = new JSDOM('
', { 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 }) +Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true }) +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) +const { act } = require('react') as typeof import('react') +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 { ToolGroup } = require('../src/renderer/src/workbuddy/conversation/ToolCall') as typeof import('../src/renderer/src/workbuddy/conversation/ToolCall') +const { ToolCallRenderer } = require('../src/renderer/src/workbuddy/conversation/ToolCallRenderer') as typeof import('../src/renderer/src/workbuddy/conversation/ToolCallRenderer') +const { ThinkingBlock, TaskBlock, PlanCreateBlock } = require('../src/renderer/src/workbuddy/conversation/SpecializedBlocks') as typeof import('../src/renderer/src/workbuddy/conversation/SpecializedBlocks') +const root = createRoot(document.getElementById('root')!) +const tool = (id: string) => ({ id, name: id === 'q' ? 'AskUserQuestion' : 'UnknownOrdinaryTool', status: 'executed' as const, args: { value: id }, result: 'RAW TOOL RESULT', metaData: id === 'q' ? { questionAnswer: { questions: [{ question: 'Choose?', answers: ['Yes'] }] } } : undefined }) +const initialParts = [{ type: 'reasoning', id: 'r', text: 'Thought' }, { type: 'tool', toolId: 't' }, { type: 'tool', toolId: 'q' }, { type: 'text', text: 'Answer' }, { type: 'reasoning', id: 'r2', text: 'Later thought' }, { type: 'tool', toolId: 't2' }, { type: 'tool', toolId: 'task' }, { type: 'tool', toolId: 'plan' }] +function Fixture({ parts = initialParts, id = 'message-one', finished = true }: { parts?: typeof initialParts; id?: string; finished?: boolean }) { + return part.toolId === 'q' ? { role: 'inline-question' } : undefined} + renderGroup={(group, children) => {children}} + renderUnit={part => part.type === 'tool' && part.toolId === 'task' ? : part.type === 'tool' && part.toolId === 'plan' ? : part.type === 'reasoning' ? : part.type === 'tool' ? :

{String(part.text)}

} /> +} +const get = (selector: string) => { const el = document.querySelector(selector); assert.ok(el, selector); return el } +const expanded = (selector: string) => get(selector).getAttribute('aria-expanded') +const outer = '.cr-collapse--completed > .cr-collapse__header' +const question = '.ask-user-question__header .cr-tool-head__main' +const ordinary = '[data-tool-call-id="t"] .cr-tool-head__main' +const thinking = '.group-r .cr-reasoning > .cr-collapse__header' +const group = '.group-r > .cr-collapse__header' +const second = '.group-r2 > .cr-collapse__header' +async function click(selector: string) { await act(async () => get(selector).dispatchEvent(new MouseEvent('click', { bubbles: true }))) } +async function keyboard(selector: string, key: string) { await act(async () => get(selector).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))) } +async function main() { + await act(async () => root.render()) + assert.equal(expanded(outer), 'false') + 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') } + 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') } + await keyboard(outer, ' ') + const content = document.getElementById(get(outer).getAttribute('aria-controls')!)! + assert.equal(content.getAttribute('aria-hidden'), 'true') + assert.ok(content.hasAttribute('inert'), 'outer hidden descendants cannot receive focus') + await keyboard(outer, 'Enter') + for (const selector of [group, thinking, ordinary, question]) assert.equal(expanded(selector), 'true', `${selector} survives outer remount`) + assert.equal(expanded(second), 'false') + for (const selector of ['.cb-task-result-item-title', '.cb-plan-header-left']) assert.equal(expanded(selector), 'false', 'specialized tool choice retained') + await click(ordinary) + await act(async () => root.render()) + await click(outer); await click(outer) + assert.equal(expanded(ordinary), 'false', 'explicit collapsed choice persists after later event') + for (const selector of [group, thinking, question]) assert.equal(expanded(selector), 'true', `${selector} retained after later event`) + assert.equal(expanded(second), 'false') + assert.equal(document.querySelectorAll('.ask-user-question--completed').length, 1, 'no duplicated hidden content trees') + await act(async () => root.render()) + assert.equal(expanded(outer), 'false', 'message identity resets outer state') + await click(outer) + for (const selector of [group, thinking, ordinary, question]) assert.equal(expanded(selector), 'false', 'message identity isolates nested state') + await act(async () => root.render()) + for (const selector of [group, thinking, ordinary, question]) await click(selector) + await act(async () => root.render()) + assert.equal(expanded(outer), 'false', 'completion starts folded after live events') + await click(outer) + 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() + console.log('completed-fold-nested: real question/tool/thinking/local-summary remount, event, keyboard and identity contracts passed') +} +main().then(() => process.exit(0)).catch(error => { console.error(error); dom.window.close(); process.exit(1) }) diff --git a/scripts/question-answer-shell-regression.tsx b/scripts/question-answer-shell-regression.tsx new file mode 100644 index 0000000..deaab5d --- /dev/null +++ b/scripts/question-answer-shell-regression.tsx @@ -0,0 +1,61 @@ +/** B01 A04: structural/CSS and DOM contracts, not browser geometry or pixel acceptance. */ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import React, { act } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { createRoot } from 'react-dom/client' +import type { AskUserQuestionTool } from '../src/renderer/src/workbuddy/conversation/AskUserQuestion' +import { AskUserQuestion } from '../src/renderer/src/workbuddy/conversation/AskUserQuestion' + +const tool: AskUserQuestionTool = { name: 'AskUserQuestion', status: 'success', metadata: { questionAnswer: { + questions: [{ id: 'q', question: 'Which runtime?', answers: ['Node.js'] }] +} } } +const markup = renderToStaticMarkup() +assert.match(markup, /cr-tool-head/) +assert.match(markup, /cr-tool-exp__content ask-user-question__content/) +assert.doesNotMatch(markup, /ask-user-question__(?:icon|arrow|title)/) +const css = readFileSync('src/renderer/src/workbuddy/conversation/conversation.css', 'utf8') +const recovered = readFileSync('src/renderer/src/workbuddy/conversation/tool-result-source.css', 'utf8') +assert.match(css, /\.ask-user-question--completed \{ background: transparent; border: 0; border-radius: 0; padding: 2px 0; \}/) +assert.match(css, /\.ask-user-question--completed \.ask-user-question__content \{ border: 0; \}/) +// Contract is sourced from the checked-in REA-recovered ToolExpandable, not the old local 360px card. +const contentRule = [...recovered.matchAll(/(?:^|})\.cr-tool-exp__content\{([^}]+)\}/g)].map(match => match[1]).join(';') +for (const declaration of ['background:var(--cr-bg-primary-default,#2a2c31)', 'border-radius:16px', 'max-height:300px', 'padding:12px 16px', 'overflow:auto']) assert.ok(contentRule.includes(declaration), declaration) +for (const kind of ['question', 'answer']) assert.match(css, new RegExp(`\\.question-answer-transcript__${kind} \\{[^}]*font-size: var\\(--yc-font-size-5\\); font-weight: 400; line-height: 1\\.75;`)) +console.log('question-answer-shell: structural and recovered CSS contracts passed') + +async function main() { + if (!process.env.YCODE_TEST_DOM_MODULE) { + console.log('question-answer-shell: DOM checks NOT RUN (set YCODE_TEST_DOM_MODULE to an available jsdom module)') + return + } + const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom') + const dom = new JSDOM('
', { pretendToBeVisual: true }) + for (const key of ['window', 'document', 'Node', 'Element', 'HTMLElement', 'MouseEvent', 'KeyboardEvent']) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true }) + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + const root = createRoot(document.querySelector('#root')!) + await act(async () => root.render()) + const header = document.querySelector('.ask-user-question__header [role="button"]')! + const content = document.getElementById(header.getAttribute('aria-controls')!)! + assert.ok(header) + assert.equal(header.tabIndex, 0) + assert.equal(header.getAttribute('aria-expanded'), 'false') + assert.equal(content.getAttribute('aria-hidden'), 'true') + assert.ok(content.hasAttribute('inert')) + assert.equal(content.contains(header), false, 'title and toggle must be outside gray content') + assert.equal(content.querySelectorAll('.question-answer-transcript__item').length, 1) + for (const key of ['Enter', ' ']) { + await act(async () => { header.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) }) + assert.equal(header.getAttribute('aria-expanded'), key === 'Enter' ? 'true' : 'false') + assert.equal(content.hasAttribute('inert'), key !== 'Enter') + } + await act(async () => header.click()) + assert.equal(header.getAttribute('aria-expanded'), 'true') + assert.equal(content.getAttribute('aria-hidden'), 'false') + assert.match(content.textContent!, /Which runtime\?Node.js/) + await act(async () => root.unmount()) + dom.window.close() + console.log('question-answer-shell: DOM hierarchy, controls linkage, click, Enter, Space and inert contracts passed') +} +main().then(() => process.exit(0)).catch(error => { console.error(error); process.exit(1) }) diff --git a/scripts/question-identity-regression.tsx b/scripts/question-identity-regression.tsx new file mode 100644 index 0000000..304e96f --- /dev/null +++ b/scripts/question-identity-regression.tsx @@ -0,0 +1,64 @@ +/** A01 real component DOM regression. Offline only; does not verify browser geometry or SDK execution. */ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import type { ChatMessage } from '../src/shared/types' +const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom') +const dom = new JSDOM('
', {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}) +Object.defineProperty(globalThis,'navigator',{value:dom.window.navigator,configurable:true}) +Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true}) +const React = require('react') as typeof import('react') +const {act} = React +const {createRoot} = require('react-dom/client') as typeof import('react-dom/client') +const MessageBubble = require('../src/renderer/src/components/MessageBubble').default as typeof import('../src/renderer/src/components/MessageBubble').default +const root = createRoot(document.getElementById('root')!) +const click = (el: Element) => el.dispatchEvent(new MouseEvent('click',{bubbles:true})) +const answer = {questions:[{id:'q1',question:'Choose a unique option',answers:['Unique answer']}]} +const base: ChatMessage = {id:'native-question',role:'assistant',createdAt:1,content:'Final prose',status:'done',parts:[{type:'tool',toolId:'q'},{type:'text',text:'Final prose'}],tools:[{id:'q',name:'AskUserQuestion',args:{questions:[{id:'q1',question:'Choose a unique option'}]},argsRaw:'{}',status:'ok',output:'RAW SDK ANSWER'}],interactions:[{id:'i',runId:'r',kind:'question',toolCallId:'q',title:'Question',questions:[],status:'answered',answer}]} +let checks=0 +const check=(condition:unknown,label:string)=>{assert.ok(condition,label);checks++} +async function main(){ + for (const name of ['AskUserQuestion','askuserquestion','ask_user_question']) { + const fixture={...base,tools:base.tools!.map(tool=>({...tool,name})),interactions:[...base.interactions!,...base.interactions!]} + const before=JSON.stringify(fixture) + await act(async()=>root.render()) + check(!document.body.textContent!.includes('RAW SDK ANSWER'),`${name}: no raw answer`) + await act(async()=>click(document.querySelector('.cr-collapse--completed > .cr-collapse__header')!)) + check(document.querySelectorAll('.ask-user-question--completed').length===1,`${name}: linked duplicate interactions produce one question card`) + await act(async()=>click(document.querySelector('.ask-user-question__header .cr-tool-head__main')!)) + check(document.querySelector('.ask-user-question__header .cr-tool-head__main')?.getAttribute('aria-expanded')==='true',`${name}: answered card actually expanded`) + check(document.querySelectorAll('.question-answer-transcript__question').length===1,`${name}: expanded answer occurs exactly once`) + check(document.body.textContent!.includes('Choose a unique option') && document.body.textContent!.includes('Unique answer'),`${name}: question and answer visible`) + check(JSON.stringify(fixture)===before,`${name}: recorded source unchanged`) + } + // Native SDK identity throughout pending → answered → completed → outer toggle. + const pending: ChatMessage={...base,status:'streaming',parts:[{type:'tool',toolId:'q'}],interactions:[],tools:base.tools!.map(tool=>({...tool,status:'running',output:undefined}))} + await act(async()=>root.render()) + check(!!document.querySelector('.ask-user-question--pending'),'native pending question uses specialized renderer') + await act(async()=>root.render()) + check(!!document.querySelector('.ask-user-question--completed'),'native answered question uses transcript while turn runs') + await act(async()=>root.render()) + const outer=()=>document.querySelector('.cr-collapse--completed > .cr-collapse__header')! + await act(async()=>click(outer())) + const header=()=>document.querySelector('.ask-user-question__header .cr-tool-head__main')! + if(header().getAttribute('aria-expanded')!=='true') await act(async()=>click(header())) + await act(async()=>click(outer()));await act(async()=>click(outer())) + check(header().getAttribute('aria-expanded')==='true','native answered choice survives completed outer collapse/reopen') + check(!document.body.textContent!.includes('RAW SDK ANSWER'),'native lifecycle never leaks raw SDK answer') + const imported={...base,interactions:[],tools:base.tools!.map(tool=>({...tool,output:'q1 -> Unique answer',metadata:{importedHistory:true}}))} + await act(async()=>root.render()) + await act(async()=>click(document.querySelector('.cr-collapse--completed > .cr-collapse__header')!)) + await act(async()=>click(document.querySelector('.ask-user-question__header .cr-tool-head__main')!)) + check(document.body.textContent!.includes('Unique answer'),'historical native name and answer-text adaptation preserved') + check(imported.tools[0].name==='AskUserQuestion','historical stored name unchanged') + for (const interactions of [base.interactions, []]) { + const legacy={...base,parts:undefined,interactions,tools:base.tools!.map(tool=>({...tool,metadata:{questionAnswer:answer}}))} + await act(async()=>root.render()) + check(document.querySelector('.question-history-unpositioned')?.getAttribute('aria-label')==='历史问答(原始位置不可用)','missing recorded position remains explicitly unknown') + check(document.querySelectorAll('.question-answer-transcript__question').length===1,'unknown-position metadata and interaction do not duplicate') + check(!document.body.textContent!.includes('RAW SDK ANSWER'),'unknown-position raw duplicate suppressed') + } + await act(async()=>root.unmount());dom.window.close() + console.log(`question-identity: ${checks} A01 DOM checks passed (not Electron/pixel/SDK acceptance)`) +} +main().then(() => process.exit(0)).catch(error=>{console.error(error);process.exit(1)}) diff --git a/scripts/run-alignment-regression.mjs b/scripts/run-alignment-regression.mjs index de6a64a..ea0af70 100644 --- a/scripts/run-alignment-regression.mjs +++ b/scripts/run-alignment-regression.mjs @@ -3,7 +3,7 @@ import { build } from 'esbuild' import { spawnSync } from 'node:child_process' const tests = [ 'model-selection-alignment-regression.ts', 'model-settings-alignment-regression.ts', 'session-factory-alignment-regression.ts', - 'session-composer-alignment-regression.tsx', 'alignment-conversation-regression.tsx', + 'session-composer-alignment-regression.tsx', 'alignment-conversation-regression.tsx', 'question-answer-shell-regression.tsx', 'runtime-authorization-regression.ts', 'permission-intercept-regression.tsx' ] for (const file of tests) { @@ -24,7 +24,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. if (process.env.YCODE_TEST_DOM_MODULE) { - for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'permission-intercept-dom-regression']) { + for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'permission-intercept-dom-regression']) { 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' }) const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 }) diff --git a/src/renderer/src/components/MessageBubble.tsx b/src/renderer/src/components/MessageBubble.tsx index e14eb98..9470f81 100644 --- a/src/renderer/src/components/MessageBubble.tsx +++ b/src/renderer/src/components/MessageBubble.tsx @@ -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 => { diff --git a/src/renderer/src/workbuddy/conversation/AskUserQuestion.tsx b/src/renderer/src/workbuddy/conversation/AskUserQuestion.tsx index 5aa18cc..c0c3aba 100644 --- a/src/renderer/src/workbuddy/conversation/AskUserQuestion.tsx +++ b/src/renderer/src/workbuddy/conversation/AskUserQuestion.tsx @@ -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 (
- - {expanded && ( -
+ statusText={labels.title} + collapsible + expanded={expanded} + controlsId={contentId} + onToggle={() => setExpanded(value => !value)} + /> +
+
- )} +
) } diff --git a/src/renderer/src/workbuddy/conversation/CompletedFold.tsx b/src/renderer/src/workbuddy/conversation/CompletedFold.tsx index f05c058..50f1252 100644 --- a/src/renderer/src/workbuddy/conversation/CompletedFold.tsx +++ b/src/renderer/src/workbuddy/conversation/CompletedFold.tsx @@ -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 {render(unit.part, unit.index, { + return {render(unit.part, unit.index, { kind: summaryGroup ? 'summary-member' : 'part', classification, presentationGroupId: classification.presentationGroupId, summaryGroup - })} + })} } 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 ( - + {renderGroup ? renderGroup(unit.group, children) : <>{children}} - + ) } @@ -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 +} + +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 {render(part, index, { + return {render(part, index, { kind: 'part', classification, presentationGroupId: classification.presentationGroupId - })} + })} })} // 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 {render(part, index, { kind: 'part', classification })} + return {render(part, index, { kind: 'part', classification })} }) })()} diff --git a/src/renderer/src/workbuddy/conversation/SourceToolHeader.tsx b/src/renderer/src/workbuddy/conversation/SourceToolHeader.tsx index 9fa3266..c4d0fa4 100644 --- a/src/renderer/src/workbuddy/conversation/SourceToolHeader.tsx +++ b/src/renderer/src/workbuddy/conversation/SourceToolHeader.tsx @@ -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(undefined) const expandable = canExpand && children != null const open = expandable && expanded diff --git a/src/renderer/src/workbuddy/conversation/SpecializedBlocks.tsx b/src/renderer/src/workbuddy/conversation/SpecializedBlocks.tsx index b7419f9..7bf8430 100644 --- a/src/renderer/src/workbuddy/conversation/SpecializedBlocks.tsx +++ b/src/renderer/src/workbuddy/conversation/SpecializedBlocks.tsx @@ -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>({}) const [errorByIndex, setErrorByIndex] = useState>({}) const isExecuting = ['idle', 'pending', 'parsing', 'stream_executing', 'full_executing', 'executing', 'running'].includes(status) diff --git a/src/renderer/src/workbuddy/conversation/ToolCall.tsx b/src/renderer/src/workbuddy/conversation/ToolCall.tsx index 7211a5f..903eb25 100644 --- a/src/renderer/src/workbuddy/conversation/ToolCall.tsx +++ b/src/renderer/src/workbuddy/conversation/ToolCall.tsx @@ -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]) diff --git a/src/renderer/src/workbuddy/conversation/ToolCallRenderer.tsx b/src/renderer/src/workbuddy/conversation/ToolCallRenderer.tsx index 859d137..8bbe524 100644 --- a/src/renderer/src/workbuddy/conversation/ToolCallRenderer.tsx +++ b/src/renderer/src/workbuddy/conversation/ToolCallRenderer.tsx @@ -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 = { '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
diff --git a/src/renderer/src/workbuddy/conversation/conversation.css b/src/renderer/src/workbuddy/conversation/conversation.css index b5c0b46..1f13643 100644 --- a/src/renderer/src/workbuddy/conversation/conversation.css +++ b/src/renderer/src/workbuddy/conversation/conversation.css @@ -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; } diff --git a/src/renderer/src/workbuddy/conversation/disclosure-state.tsx b/src/renderer/src/workbuddy/conversation/disclosure-state.tsx new file mode 100644 index 0000000..44b7fb1 --- /dev/null +++ b/src/renderer/src/workbuddy/conversation/disclosure-state.tsx @@ -0,0 +1,41 @@ +import { createContext, useCallback, useContext, useRef, useState, useSyncExternalStore, type Dispatch, type ReactNode, type SetStateAction } from 'react' + +interface DisclosureStore { + values: Map + listeners: Set<() => void> +} +const StoreContext = createContext(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(() => ({ values: new Map(), listeners: new Set() })) + return {children} +} + +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 {children} +} + +export function useDisclosureState(slot: string, initial: boolean | (() => boolean)): [boolean, Dispatch>] { + 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>>((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] +} diff --git a/src/shared/tool-identity.ts b/src/shared/tool-identity.ts new file mode 100644 index 0000000..a9ea9f3 --- /dev/null +++ b/src/shared/tool-identity.ts @@ -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()) +}