fix: reject invalid history parents on Windows and give questions exclusive composer

This commit is contained in:
Codex committed 2026-10-21 18:39:29 +09:00
1 parent a4e77cacbe
commit 23537aaf28
9 files changed
+218 -18

No files matched your search

+10 -1
View File
@@ -26,7 +26,7 @@ for (const kind of ['question', 'approval', 'plan', 'mcp'] as const) {
running: true running: true
}) })
assert.equal(result.interaction?.kind, kind) 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.blockedByInteraction, true)
assert.equal(result.input.destination, 'blocked') assert.equal(result.input.destination, 'blocked')
assert.equal(result.input.preservesDraft, true, 'takeover never clears the draft') 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') 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') 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')
}
}
+59 -2
View File
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict' 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 { join } from 'node:path'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { createConversionContext } from '../src/main/model-bridge/history' import { createConversionContext } from '../src/main/model-bridge/history'
@@ -158,7 +160,7 @@ try {
} finally { await Promise.all([lease.close(), lease.close()]) } } finally { await Promise.all([lease.close(), lease.close()]) }
await assert.rejects(fetch(lease.baseUrl)); checks++ 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 { try {
for (const reasoningMode of ['hidden', 'reasoning']) { for (const reasoningMode of ['hidden', 'reasoning']) {
const file = join(dir, reasoningMode + '.json') const file = join(dir, reasoningMode + '.json')
@@ -207,6 +209,61 @@ try {
await writeFile(join(dir, 'blocked'), 'not a directory') await writeFile(join(dir, 'blocked'), 'not a directory')
const blocked = await createConversionContext(join(dir, 'blocked', 'history.json')).catch(() => null) const blocked = await createConversionContext(join(dir, 'blocked', 'history.json')).catch(() => null)
check(blocked === null, 'Invalid history path fails closed') 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<typeof fs.lstat>) => {
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 { await rm(dir, { recursive: true, force: true }) }
} finally { upstream.closeAllConnections(); await new Promise<void>(resolve => upstream.close(() => resolve())) } } finally { upstream.closeAllConnections(); await new Promise<void>(resolve => upstream.close(() => resolve())) }
console.log(`Protocol bridge offline regression: ${checks} checks passed (no SDK/CLI or external API used)`) console.log(`Protocol bridge offline regression: ${checks} checks passed (no SDK/CLI or external API used)`)
@@ -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('<html><body><main id="root"></main></body></html>',{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'?<QuestionFloating config={{questions:active.questions,toolCallId:active.toolCallId}} responding={status==='responding'}
onSelect={()=>{answers++;return new Promise<void>((yes,no)=>{resolve=yes;reject=no})}} onSkip={async()=>{skips++;status='cancelled'}}/>:undefined
root.render(<ComposerControls key={session} sessionId={session} value={draft} onChange={()=>{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?<div data-queue="true">queued</div>:undefined}/>)
})
async function main(){
status='answered';await render()
const stack=document.querySelector<HTMLElement>('.conversation-input-area__input-stack')!
const editor=stack.querySelector<HTMLElement>('[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)})
+3 -3
View File
@@ -42,7 +42,7 @@ for (const file of ['scripts/build-runtime-regressions.mjs', '.tmp/claude-approv
// Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model. // Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model.
if (domModule) { if (domModule) {
for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression', '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` const outfile = `.tmp/alignment-tests/${file}.cjs`
await build({ entryPoints: [`scripts/${file}.tsx`], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' }) await build({ entryPoints: [`scripts/${file}.tsx`], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' })
const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 }) const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 })
@@ -56,5 +56,5 @@ if (domModule) {
console.log(JSON.stringify({ result: 'PASS', phase: 'alignment-offline', suites: tests.length + 3 })) console.log(JSON.stringify({ result: 'PASS', phase: 'alignment-offline', suites: tests.length + 3 }))
console.log(JSON.stringify(domModule console.log(JSON.stringify(domModule
? { result: 'PASS', phase: 'alignment-dom', suites: 8 } ? { result: 'PASS', phase: 'alignment-dom', suites: 9 }
: { 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: '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.' }))
+32 -5
View File
@@ -1,5 +1,5 @@
import { lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' import { lstat, mkdir, readFile, rename, stat as followStat, unlink, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path' import { dirname, resolve } from 'node:path'
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
import { BridgeError, record, type ConversionContext, type Json } from './protocol' 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' 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') 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<void> {
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<Map<string, Json[] | null>> { async function load(file: string): Promise<Map<string, Json[] | null>> {
try { 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() if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES) throw failure()
const value = JSON.parse(await readFile(file, 'utf8')) const value = JSON.parse(await readFile(file, 'utf8'))
if (!record(value) || value.version !== 1 || !Array.isArray(value.entries) || value.entries.length > 1024) throw failure() 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<Map<string, Json[] | null>> {
entries.set(entry[0], items) entries.set(entry[0], items)
} }
return entries return entries
} catch (error) { } catch {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map()
throw failure() throw failure()
} }
} }
@@ -52,6 +78,7 @@ export async function createConversionContext(historyFile?: string): Promise<Con
const data = JSON.stringify({ version: 1, entries: [...merged] }) const data = JSON.stringify({ version: 1, entries: [...merged] })
if (Buffer.byteLength(data) > MAX_BYTES) throw failure() if (Buffer.byteLength(data) > MAX_BYTES) throw failure()
await mkdir(dirname(historyFile), { recursive: true, mode: 0o700 }) await mkdir(dirname(historyFile), { recursive: true, mode: 0o700 })
await validateParents(historyFile)
temp = `${historyFile}.${randomBytes(8).toString('hex')}.tmp` temp = `${historyFile}.${randomBytes(8).toString('hex')}.tmp`
await writeFile(temp, data, { encoding: 'utf8', mode: 0o600, flag: 'wx' }) await writeFile(temp, data, { encoding: 'utf8', mode: 0o600, flag: 'wx' })
await rename(temp, historyFile); temp = undefined await rename(temp, historyFile); temp = undefined
+2 -2
View File
@@ -20,6 +20,7 @@ import type { ResourceEntry } from '@shared/resources'
import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls' import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls'
import { ComposerBranchLabel, ContextUsageRing } from '../workbuddy/composer/context-info/ComposerContextInfo' import { ComposerBranchLabel, ContextUsageRing } from '../workbuddy/composer/context-info/ComposerContextInfo'
import { composeComposer } from '../workbuddy/composer/input-composition' import { composeComposer } from '../workbuddy/composer/input-composition'
import { findActiveComposerInteraction } from '../workbuddy/composer/active-interaction'
import { PromptQueuePanel, usePromptQueue } from '../workbuddy/composer/PromptQueuePanel' import { PromptQueuePanel, usePromptQueue } from '../workbuddy/composer/PromptQueuePanel'
import { useCapabilityCatalog } from '../workbuddy/composer/useCapabilityCatalog' import { useCapabilityCatalog } from '../workbuddy/composer/useCapabilityCatalog'
import { withCapabilitySelection } from '../workbuddy/composer/capability-selection' 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 availableReferenceFiles = referenceFiles.owner === referenceScope ? referenceFiles.items : []
const addMenuError = (reason: unknown): void => useChatStore.setState({ error: reason instanceof Error ? reason.message : String(reason) }) 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) => const activeInteraction = findActiveComposerInteraction(messages, currentId, running, hidden || loading || !!loadError)
(item.kind === 'question' || item.kind === 'approval') && ['pending', 'responding', 'error'].includes(item.status)) : undefined
const respondToActive: typeof respond = (id, response) => { const respondToActive: typeof respond = (id, response) => {
const state = useChatStore.getState() const state = useChatStore.getState()
if (state.currentSessionId !== currentId || state.loading || state.loadError || hidden) return Promise.reject(new Error('审批所属会话已切换')) if (state.currentSessionId !== currentId || state.loading || state.loadError || hidden) return Promise.reject(new Error('审批所属会话已切换'))
@@ -67,7 +67,7 @@ export interface ComposerControlsProps {
permissionSlot?: ReactNode permissionSlot?: ReactNode
modeSlot?: ReactNode modeSlot?: ReactNode
expertSlot?: 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 approvalSlot?: ReactNode
approvalKey?: string approvalKey?: string
floatingSlot?: ReactNode floatingSlot?: ReactNode
@@ -146,7 +146,7 @@ export function readReferenceUris(transfer: Pick<DataTransfer, 'getData'>): stri
return transfer.getData('text/uri-list').split(/\r\n?|\n/).filter((value) => value.trim() && !value.startsWith('#')) 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 { function ApprovalComposerSurface({ children }: { children: ReactNode }): React.JSX.Element {
return <div className="conversation-input-area__approval" data-composer-approval="true"> return <div className="conversation-input-area__approval" data-composer-approval="true">
{children} {children}
@@ -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)))
}
@@ -66,7 +66,7 @@ export interface ComposerQueueComposition {
export interface ComposerInputComposition { export interface ComposerInputComposition {
/** The editor stays mounted so draft, attachments and capability selections survive takeover. */ /** The editor stays mounted so draft, attachments and capability selections survive takeover. */
mounted: true mounted: true
/** Only real tool approval replaces the normal input surface. */ /** Active questions and tool approvals replace the normal input surface. */
visible: boolean visible: boolean
disabled: boolean disabled: boolean
blockedByInteraction: boolean blockedByInteraction: boolean
@@ -137,7 +137,7 @@ function panelId(value: ComposerInteractionSnapshot | ComposerNoticeSnapshot | C
* data. * data.
* 4. Tasks move into the queue header when the queue is visible, otherwise * 4. Tasks move into the queue header when the queue is visible, otherwise
* they stay in the unified stack above the editor. * 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 { export function composeComposer(input: ComposerCompositionInput = {}): ComposerComposition {
const interaction = isActiveComposerInteraction(input.interaction) ? input.interaction : null const interaction = isActiveComposerInteraction(input.interaction) ? input.interaction : null
@@ -192,7 +192,7 @@ export function composeComposer(input: ComposerCompositionInput = {}): ComposerC
queue, queue,
input: { input: {
mounted: true, mounted: true,
visible: interaction?.kind !== 'approval', visible: interaction?.kind !== 'approval' && interaction?.kind !== 'question',
disabled, disabled,
blockedByInteraction: interactionTakeover, blockedByInteraction: interactionTakeover,
destination: interactionTakeover ? 'blocked' : running ? 'queue' : 'send', destination: interactionTakeover ? 'blocked' : running ? 'queue' : 'send',