From 23537aaf28ce9f743495c12c3750e8e43556c496 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 18:39:29 +0900 Subject: [PATCH] fix: reject invalid history parents on Windows and give questions exclusive composer --- scripts/input-composition-regression.ts | 11 ++- scripts/protocol-bridge-regression.ts | 61 ++++++++++++- scripts/question-composer-dom-regression.tsx | 89 +++++++++++++++++++ scripts/run-alignment-regression.mjs | 6 +- src/main/model-bridge/history.ts | 37 ++++++-- src/renderer/src/components/ChatPane.tsx | 4 +- .../workbuddy/composer/ComposerControls.tsx | 4 +- .../workbuddy/composer/active-interaction.ts | 18 ++++ .../workbuddy/composer/input-composition.ts | 6 +- 9 files changed, 218 insertions(+), 18 deletions(-) create mode 100644 scripts/question-composer-dom-regression.tsx create mode 100644 src/renderer/src/workbuddy/composer/active-interaction.ts diff --git a/scripts/input-composition-regression.ts b/scripts/input-composition-regression.ts index 1c52517..4333258 100644 --- a/scripts/input-composition-regression.ts +++ b/scripts/input-composition-regression.ts @@ -26,7 +26,7 @@ for (const kind of ['question', 'approval', 'plan', 'mcp'] as const) { running: true }) assert.equal(result.interaction?.kind, kind) - assert.equal(result.input.visible, kind !== 'approval', 'only actual approval replaces normal input') + assert.equal(result.input.visible, kind !== 'approval' && kind !== 'question', 'active question and approval replace normal input') assert.equal(result.input.blockedByInteraction, true) assert.equal(result.input.destination, 'blocked') assert.equal(result.input.preservesDraft, true, 'takeover never clears the draft') @@ -75,3 +75,12 @@ const secondLayout = composeComposer({ queueCount: 1, queueRevision: 2 }).layout assert.notEqual(firstLayout, secondLayout, 'layout key changes when queue height may change') console.log('input-composition-regression: precedence, takeover, queue preservation, task placement, toolbar and escape rules passed') + +for (const kind of ['question', 'approval'] as const) { + for (const status of ['pending', 'responding', 'error', 'answered', 'rejected', 'cancelled'] as const) { + const active = ['pending', 'responding', 'error'].includes(status) + const state = composeComposer({ interaction: { id: kind, kind, status } }) + assert.equal(state.input.visible, !active, `${kind}/${status} input exclusivity`) + assert.equal(state.input.mounted, true, 'draft editor always remains mounted') + } +} diff --git a/scripts/protocol-bridge-regression.ts b/scripts/protocol-bridge-regression.ts index da16492..62398c6 100644 --- a/scripts/protocol-bridge-regression.ts +++ b/scripts/protocol-bridge-regression.ts @@ -1,5 +1,7 @@ import assert from 'node:assert/strict' -import { mkdtemp, readFile, stat, writeFile, rm } from 'node:fs/promises' +import fs, { mkdir, mkdtemp, readFile, realpath, stat, symlink, writeFile, rm } from 'node:fs/promises' +import { syncBuiltinESMExports } from 'node:module' +import { mock } from 'node:test' import { join } from 'node:path' import { tmpdir } from 'node:os' import { createConversionContext } from '../src/main/model-bridge/history' @@ -158,7 +160,7 @@ try { } finally { await Promise.all([lease.close(), lease.close()]) } await assert.rejects(fetch(lease.baseUrl)); checks++ } - const dir = await mkdtemp(join(tmpdir(), 'ycode-bridge-')) + const dir = await mkdtemp(join(await realpath(tmpdir()), 'ycode-bridge-')) try { for (const reasoningMode of ['hidden', 'reasoning']) { const file = join(dir, reasoningMode + '.json') @@ -207,6 +209,61 @@ try { await writeFile(join(dir, 'blocked'), 'not a directory') const blocked = await createConversionContext(join(dir, 'blocked', 'history.json')).catch(() => null) check(blocked === null, 'Invalid history path fails closed') + await assert.rejects(createConversionContext(join(dir, 'blocked', 'missing', 'history.json')), /history/); checks++ + await assert.rejects(createConversionContext(dir), /history/); checks++ + const missingFile = join(dir, 'new', 'nested', 'history.json') + const fresh = await createConversionContext(missingFile) + check(fresh.responsesHistory.size === 0, 'Genuinely missing parents allow a new history') + await fresh.persist!() + check((await createConversionContext(missingFile)).responsesHistory.size === 0, 'New nested history persists and reloads') + + // Simulate Windows' leaf ENOENT below a regular-file parent on every platform. + const blockedFile = join(dir, 'blocked', 'history.json'), originalLstat = fs.lstat + mock.method(fs, 'lstat', async (...args: Parameters) => { + if (String(args[0]) === blockedFile) throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + return originalLstat(...args) + }) + syncBuiltinESMExports() + try { await assert.rejects(createConversionContext(blockedFile), /history/); checks++ } + finally { mock.restoreAll(); syncBuiltinESMExports() } + + // Missing during read is not a new conversation; permission errors are never absence. + for (const [method, code, target] of [['readFile', 'ENOENT', snapshotFile], ['lstat', 'EACCES', snapshotFile], ['lstat', 'EACCES', dir]] as const) { + const original = fs[method] + mock.method(fs, method, async (...args: any[]) => { + if (String(args[0]) === target) throw Object.assign(new Error('unavailable'), { code }) + return (original as Function)(...args) + }) + syncBuiltinESMExports() + try { await assert.rejects(createConversionContext(snapshotFile), /history/); checks++ } + finally { mock.restoreAll(); syncBuiltinESMExports() } + } + + const linkedDir = join(dir, 'linked'), targetDir = join(dir, 'target') + await mkdir(targetDir) + await symlink(targetDir, linkedDir, process.platform === 'win32' ? 'junction' : 'dir') + const linked = await createConversionContext(join(linkedDir, 'history.json')) + await linked.persist!() + check((await createConversionContext(join(targetDir, 'history.json'))).responsesHistory.size === 0, 'Trusted parent directory links remain supported') + const changedFile = join(dir, 'changed', 'history.json'), changed = await createConversionContext(changedFile) + await writeFile(join(dir, 'changed'), 'not a directory') + await assert.rejects(changed.persist!(), /history/); checks++ + const danglingDir = join(dir, 'dangling') + await symlink(join(dir, 'absent-target'), danglingDir, process.platform === 'win32' ? 'junction' : 'dir') + await assert.rejects(createConversionContext(join(danglingDir, 'history.json')), /history/); checks++ + if (process.platform !== 'win32') { + const linkedFile = join(dir, 'linked.json') + await symlink(snapshotFile, linkedFile) + await assert.rejects(createConversionContext(linkedFile), /history/); checks++ + } + + const concurrentFile = join(dir, 'concurrent.json') + const [left, right] = await Promise.all([createConversionContext(concurrentFile), createConversionContext(concurrentFile)]) + left.responsesHistory.set(`${'a'.repeat(64)}:${'b'.repeat(64)}`, []) + right.responsesHistory.set(`${'c'.repeat(64)}:${'d'.repeat(64)}`, []) + await Promise.all([left.persist!(), right.persist!()]) + check((await createConversionContext(concurrentFile)).responsesHistory.size === 2, 'Concurrent atomic history saves preserve both snapshots') + check(!(await fs.readdir(dir)).some(name => name.endsWith('.tmp')), 'Atomic history saves leave no temporary files') } finally { await rm(dir, { recursive: true, force: true }) } } finally { upstream.closeAllConnections(); await new Promise(resolve => upstream.close(() => resolve())) } console.log(`Protocol bridge offline regression: ${checks} checks passed (no SDK/CLI or external API used)`) diff --git a/scripts/question-composer-dom-regression.tsx b/scripts/question-composer-dom-regression.tsx new file mode 100644 index 0000000..ec840e8 --- /dev/null +++ b/scripts/question-composer-dom-regression.tsx @@ -0,0 +1,89 @@ +/** jsdom behavior only; no layout, browser pixels, SDK process or model calls. */ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { readFileSync } from 'node:fs' +const externalRequire=createRequire(process.cwd()+'/package.json') +const {JSDOM}=externalRequire(process.env.YCODE_TEST_DOM_MODULE||'jsdom') +const dom=new JSDOM('
',{url:'http://localhost/',pretendToBeVisual:true}) +for(const key of ['window','document','Node','Element','HTMLElement','HTMLInputElement','HTMLTextAreaElement','MouseEvent','KeyboardEvent','Document','Text','DOMParser','MutationObserver','Range','ShadowRoot','File','FileReader','Event','getComputedStyle']) Object.defineProperty(globalThis,key,{value:dom.window[key],configurable:true}) +Object.defineProperty(globalThis,'navigator',{value:dom.window.navigator,configurable:true}) +Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true,requestAnimationFrame:dom.window.requestAnimationFrame.bind(dom.window),cancelAnimationFrame:dom.window.cancelAnimationFrame.bind(dom.window)}) +const composerStyle=document.createElement('style') +composerStyle.textContent=['composer.css','composer-source.css'].map(file=>readFileSync(`src/renderer/src/workbuddy/composer/${file}`,'utf8')).join('\n') +document.head.append(composerStyle) +const React=require('react') as typeof import('react') +const {act}=React +const {createRoot}=require('react-dom/client') as typeof import('react-dom/client') +const {ComposerControls}=require('../src/renderer/src/workbuddy/composer/ComposerControls') as typeof import('../src/renderer/src/workbuddy/composer/ComposerControls') +const {composeComposer}=require('../src/renderer/src/workbuddy/composer/input-composition') as typeof import('../src/renderer/src/workbuddy/composer/input-composition') +const {QuestionFloating}=require('../src/renderer/src/workbuddy/conversation/QuestionFloating') as typeof import('../src/renderer/src/workbuddy/conversation/QuestionFloating') +const {findActiveComposerInteraction}=require('../src/renderer/src/workbuddy/composer/active-interaction') as typeof import('../src/renderer/src/workbuddy/composer/active-interaction') +const root=createRoot(document.getElementById('root')!) +let checks=0 +const check=(value:unknown,message:string)=>{assert.ok(value,message);checks++} +const click=(selector:string)=>document.querySelector(selector)!.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true})) +const question: import('../src/shared/conversation').ChatInteraction = {id:'question',kind:'question',runId:'run-a',toolCallId:'ask-a',status:'pending',title:'Release?',questions:[{id:'q',question:'Release?',options:['Fix first','Release now']}]} +let session='session-a', running:{runId:string;sessionId:string}|null={runId:'run-a',sessionId:'session-a'}, unavailable=false +let status=question.status, sends=0,stops=0,answers=0,skips=0,changes=0 +let resolve!:()=>void,reject!:(error:Error)=>void +const draft='Existing unsent draft' +const attachments=[{id:'attachment',name:'draft.txt',mimeType:'text/plain',size:1}] +const messages=()=>[{id:'assistant',role:'assistant' as const,content:'',createdAt:1,status:'streaming' as const,interactions:[{...question,status}]}] +const render=()=>act(async()=>{ + const active=findActiveComposerInteraction(messages(),session,running,unavailable) + const composition=composeComposer({interaction:active,queueCount:2,running:!!running}) + const surface=active?.kind==='question'?{answers++;return new Promise((yes,no)=>{resolve=yes;reject=no})}} onSkip={async()=>{skips++;status='cancelled'}}/>:undefined + root.render({changes++}} onSend={()=>{sends++}} onStop={()=>{stops++}} + attachments={attachments} resources={['/fixture/keep.ts']} approvalKey={active?.id} + approvalSlot={!composition.input.visible?surface:undefined} floatingSlot={composition.input.visible?surface:undefined} + queueSlot={composition.queue.visible?
queued
:undefined}/>) +}) +async function main(){ + status='answered';await render() + const stack=document.querySelector('.conversation-input-area__input-stack')! + const editor=stack.querySelector('[contenteditable]')! + await act(async()=>editor.focus()) + status='pending';await render() + check(stack===document.querySelector('.conversation-input-area__input-stack'),'question preserves mounted draft stack') + check(stack.hidden&&stack.hasAttribute('inert')&&stack.getAttribute('aria-hidden')==='true','pending question hides/inerts/accessibility-hides ordinary composer') + check(getComputedStyle(stack).display==='none','normal composer removed from layout') + check(!stack.contains(document.activeElement),'focus leaves hidden editor') + check(document.querySelectorAll('[role="dialog"]').length===1&&!!document.querySelector('.question-floating__option'),'only question surface shown') + check(!document.querySelector('.wb-composer__toolbar, [data-queue], button[aria-label="停止"],button[aria-label="发送"]'),'ordinary controls and queue absent; no extra stop') + const changesBefore=changes + await act(async()=>{editor.focus();editor.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true}))}) + check(!stack.contains(document.activeElement)&&sends===0&&changes===changesBefore,'hidden editor cannot focus, edit or send') + await act(async()=>click('.question-floating__option')) + await act(async()=>click('.question-floating__advance')) + check(answers===1,'question submits through its own callback') + status='responding';await render() + check(stack.hidden&&document.querySelector('[role="dialog"]')?.getAttribute('aria-busy')==='true','responding question retains exclusivity') + await act(async()=>reject(Error('retry fixture'))) + status='error';await render() + check(stack.hidden&&document.querySelector('[role="alert"]')?.textContent?.includes('retry fixture'),'failed answer remains retryable without exposing composer') + await act(async()=>click('.question-floating__advance'));await act(async()=>resolve()) + status='answered';await render() + check(!stack.hidden&&!stack.hasAttribute('inert'),'answered restores composer') + check(stack.querySelector('[contenteditable]')===editor&&stack.textContent?.includes(draft)&&stack.textContent.includes('draft.txt')&&stack.textContent.includes('keep.ts'),'same editor draft attachments resources survive') + check(!!document.querySelector('[data-queue]')&&!!document.querySelector('.wb-composer__toolbar'),'queue toolbar restored') + status='pending';await render();await act(async()=>click('.question-floating__dismiss'));await render() + check(skips===1&&!stack.hidden,'dismiss/skip restores normal input after terminal state') + status='pending';running=null;await render() + check(!stack.hidden&&!document.querySelector('[role="dialog"]'),'historical pending question without live run cannot take over') + running={runId:'other-run',sessionId:session};await render() + check(!stack.hidden,'old-run question does not take over current run') + running={runId:'run-a',sessionId:'other-session'};await render() + check(!stack.hidden,'other-session question does not take over') + running={runId:'run-a',sessionId:session};unavailable=true;await render() + check(!stack.hidden,'hidden/loading/error pane has no actionable question') + unavailable=false;status='cancelled';await render() + check(!stack.hidden,'cancelled history question leaves normal composer') + check(sends===0&&stops===0,'question transitions never invoke normal send or stop') + // Approval behavior remains unchanged, including selection without a live run. + const approval={...question,kind:'approval' as const,requestId:'approval',options:[]} + check(findActiveComposerInteraction([{...messages()[0],interactions:[approval]}],session,null)?.kind==='approval','existing approval selection unchanged') + await act(async()=>root.unmount());dom.window.close() + console.log(`Question composer DOM: ${checks} checks passed (not pixel/SDK verification)`);process.exit(0) +} +main().catch(error=>{console.error(error);process.exit(1)}) diff --git a/scripts/run-alignment-regression.mjs b/scripts/run-alignment-regression.mjs index 4f86b7b..c372106 100644 --- a/scripts/run-alignment-regression.mjs +++ b/scripts/run-alignment-regression.mjs @@ -42,7 +42,7 @@ for (const file of ['scripts/build-runtime-regressions.mjs', '.tmp/claude-approv // Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model. if (domModule) { - for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression', 'reasoning-follow-dom-regression']) { + for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'question-composer-dom-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression', 'reasoning-follow-dom-regression']) { const outfile = `.tmp/alignment-tests/${file}.cjs` 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 }) @@ -56,5 +56,5 @@ if (domModule) { console.log(JSON.stringify({ result: 'PASS', phase: 'alignment-offline', suites: tests.length + 3 })) console.log(JSON.stringify(domModule - ? { result: 'PASS', phase: 'alignment-dom', suites: 8 } - : { result: 'SKIP', phase: 'alignment-dom', suites: 8, reason: 'YCODE_TEST_DOM_MODULE is not set; DOM coverage was not run. Use --require-dom to forbid this skip.' })) + ? { result: 'PASS', phase: 'alignment-dom', suites: 9 } + : { result: 'SKIP', phase: 'alignment-dom', suites: 9, reason: 'YCODE_TEST_DOM_MODULE is not set; DOM coverage was not run. Use --require-dom to forbid this skip.' })) diff --git a/src/main/model-bridge/history.ts b/src/main/model-bridge/history.ts index aff377d..3b55d5f 100644 --- a/src/main/model-bridge/history.ts +++ b/src/main/model-bridge/history.ts @@ -1,5 +1,5 @@ -import { lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' -import { dirname } from 'node:path' +import { lstat, mkdir, readFile, rename, stat as followStat, unlink, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { BridgeError, record, type ConversionContext, type Json } from './protocol' @@ -12,9 +12,36 @@ function validItem(x: unknown): boolean { if (x.type === 'function_call') return typeof x.call_id === 'string' && typeof x.name === 'string' && typeof x.arguments === 'string' return x.type === 'message' && x.role === 'assistant' && Array.isArray(x.content) && x.content.every((s: unknown) => record(s) && s.type === 'output_text' && typeof s.text === 'string') } +async function validateParents(file: string): Promise { + const parents: string[] = [] + for (let parent = dirname(resolve(file)); ; parent = dirname(parent)) { + parents.push(parent) + if (dirname(parent) === parent) break + } + // Check from the root: Windows may report ENOENT (not ENOTDIR) below a file. + // Only a missing component below verified directories is a new history path. + for (const parent of parents.reverse()) { + let stat + try { stat = await lstat(parent) } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + // The main process owns this path; OS/user-data directory links are valid. + // Follow those links only after lstat so dangling links are not "missing". + if (stat.isSymbolicLink()) stat = await followStat(parent) + if (!stat.isDirectory()) throw failure() + } +} async function load(file: string): Promise> { try { - const stat = await lstat(file) + await validateParents(file) + let stat + try { stat = await lstat(file) } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map() + throw error + } if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES) throw failure() const value = JSON.parse(await readFile(file, 'utf8')) if (!record(value) || value.version !== 1 || !Array.isArray(value.entries) || value.entries.length > 1024) throw failure() @@ -27,8 +54,7 @@ async function load(file: string): Promise> { entries.set(entry[0], items) } return entries - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map() + } catch { throw failure() } } @@ -52,6 +78,7 @@ export async function createConversionContext(historyFile?: string): Promise MAX_BYTES) throw failure() await mkdir(dirname(historyFile), { recursive: true, mode: 0o700 }) + await validateParents(historyFile) temp = `${historyFile}.${randomBytes(8).toString('hex')}.tmp` await writeFile(temp, data, { encoding: 'utf8', mode: 0o600, flag: 'wx' }) await rename(temp, historyFile); temp = undefined diff --git a/src/renderer/src/components/ChatPane.tsx b/src/renderer/src/components/ChatPane.tsx index fdcdd87..f54e302 100644 --- a/src/renderer/src/components/ChatPane.tsx +++ b/src/renderer/src/components/ChatPane.tsx @@ -20,6 +20,7 @@ import type { ResourceEntry } from '@shared/resources' import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls' import { ComposerBranchLabel, ContextUsageRing } from '../workbuddy/composer/context-info/ComposerContextInfo' import { composeComposer } from '../workbuddy/composer/input-composition' +import { findActiveComposerInteraction } from '../workbuddy/composer/active-interaction' import { PromptQueuePanel, usePromptQueue } from '../workbuddy/composer/PromptQueuePanel' import { useCapabilityCatalog } from '../workbuddy/composer/useCapabilityCatalog' import { withCapabilitySelection } from '../workbuddy/composer/capability-selection' @@ -415,8 +416,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, const availableReferenceFiles = referenceFiles.owner === referenceScope ? referenceFiles.items : [] const addMenuError = (reason: unknown): void => useChatStore.setState({ error: reason instanceof Error ? reason.message : String(reason) }) - const activeInteraction = !hidden && !loading && !loadError && currentId ? messages.flatMap((message) => message.interactions ?? []).findLast((item) => - (item.kind === 'question' || item.kind === 'approval') && ['pending', 'responding', 'error'].includes(item.status)) : undefined + const activeInteraction = findActiveComposerInteraction(messages, currentId, running, hidden || loading || !!loadError) const respondToActive: typeof respond = (id, response) => { const state = useChatStore.getState() if (state.currentSessionId !== currentId || state.loading || state.loadError || hidden) return Promise.reject(new Error('审批所属会话已切换')) diff --git a/src/renderer/src/workbuddy/composer/ComposerControls.tsx b/src/renderer/src/workbuddy/composer/ComposerControls.tsx index c3b03ac..ab5c7c3 100644 --- a/src/renderer/src/workbuddy/composer/ComposerControls.tsx +++ b/src/renderer/src/workbuddy/composer/ComposerControls.tsx @@ -67,7 +67,7 @@ export interface ComposerControlsProps { permissionSlot?: ReactNode modeSlot?: ReactNode expertSlot?: ReactNode - /** An active tool approval replaces, rather than floats above, normal editing. */ + /** An active question or tool approval replaces, rather than floats above, normal editing. */ approvalSlot?: ReactNode approvalKey?: string floatingSlot?: ReactNode @@ -146,7 +146,7 @@ export function readReferenceUris(transfer: Pick): stri return transfer.getData('text/uri-list').split(/\r\n?|\n/).filter((value) => value.trim() && !value.startsWith('#')) } -/** Keep each approval subtree scoped to its request identity. */ +/** Keep each takeover subtree scoped to its request identity. */ function ApprovalComposerSurface({ children }: { children: ReactNode }): React.JSX.Element { return
{children} diff --git a/src/renderer/src/workbuddy/composer/active-interaction.ts b/src/renderer/src/workbuddy/composer/active-interaction.ts new file mode 100644 index 0000000..82fee51 --- /dev/null +++ b/src/renderer/src/workbuddy/composer/active-interaction.ts @@ -0,0 +1,18 @@ +import type { ChatMessage } from '../../../../shared/types' +import type { ChatInteraction } from '../../../../shared/conversation' + +/** Only a live question in the selected run may take over its composer. + * Historical question cards remain read-only; retain existing approval selection. + */ +export function findActiveComposerInteraction( + messages: readonly ChatMessage[], + sessionId: string | null, + running: { runId: string; sessionId?: string } | null, + unavailable = false +): ChatInteraction | undefined { + if (unavailable || !sessionId) return undefined + return messages.flatMap(message => message.interactions ?? []).findLast(item => + ['pending', 'responding', 'error'].includes(item.status) && + (item.kind === 'approval' || (item.kind === 'question' && + running?.sessionId === sessionId && running.runId === item.runId))) +} diff --git a/src/renderer/src/workbuddy/composer/input-composition.ts b/src/renderer/src/workbuddy/composer/input-composition.ts index 5a91ba0..4500e96 100644 --- a/src/renderer/src/workbuddy/composer/input-composition.ts +++ b/src/renderer/src/workbuddy/composer/input-composition.ts @@ -66,7 +66,7 @@ export interface ComposerQueueComposition { export interface ComposerInputComposition { /** The editor stays mounted so draft, attachments and capability selections survive takeover. */ mounted: true - /** Only real tool approval replaces the normal input surface. */ + /** Active questions and tool approvals replace the normal input surface. */ visible: boolean disabled: boolean blockedByInteraction: boolean @@ -137,7 +137,7 @@ function panelId(value: ComposerInteractionSnapshot | ComposerNoticeSnapshot | C * data. * 4. Tasks move into the queue header when the queue is visible, otherwise * they stay in the unified stack above the editor. - * 5. Running state keeps its stop action semantics; approval takeover hides the normal toolbar and renders only the approval card. + * 5. Running state keeps its stop action semantics; question/approval takeover hides the normal toolbar and renders only the interaction card. */ export function composeComposer(input: ComposerCompositionInput = {}): ComposerComposition { const interaction = isActiveComposerInteraction(input.interaction) ? input.interaction : null @@ -192,7 +192,7 @@ export function composeComposer(input: ComposerCompositionInput = {}): ComposerC queue, input: { mounted: true, - visible: interaction?.kind !== 'approval', + visible: interaction?.kind !== 'approval' && interaction?.kind !== 'question', disabled, blockedByInteraction: interactionTakeover, destination: interactionTakeover ? 'blocked' : running ? 'queue' : 'send',