diff --git a/scripts/permission-intercept-dom-regression.tsx b/scripts/permission-intercept-dom-regression.tsx index e4c6197..90223e3 100644 --- a/scripts/permission-intercept-dom-regression.tsx +++ b/scripts/permission-intercept-dom-regression.tsx @@ -55,8 +55,7 @@ async function main(){ id='e';status='pending';await render();await act(async()=>click('.conversation-intercept-card__rule-action')) check(document.querySelector('[role="status"]')?.textContent?.includes('未提供命中规则详情'),'missing rule explanation explicit') // Exercise the real composer with the real approval surface, not a skeleton. - let session='session-a', active=true, sends=0, stops=0, changes=0, stopFails=false, request='request-1', stopPending=false - let rejectStop!: (error: Error)=>void + let session='session-a', active=true, sends=0, stops=0, changes=0, stopFails=false, request='request-1' let settled: 'answered'|'rejected'|undefined const draft='preserved draft' const attachments=[{id:'attachment-a',name:'a.txt',mimeType:'text/plain',size:1}] @@ -65,7 +64,7 @@ async function main(){ const renderComposer=()=>act(async()=>{ const composition=composeComposer({interaction:active?{id:session,kind:'approval',status:'pending'}:null,queueCount:2,running:true}) root.render({changes++}} onSend={()=>{sends++}} - attachments={attachments} resources={resources} quotes={quotes} onStop={()=>{stops++;if(stopFails)throw new Error('stop fixture failed');if(stopPending)return new Promise((_yes,no)=>{rejectStop=no})}} + attachments={attachments} resources={resources} quotes={quotes} onStop={async()=>{stops++;if(stopFails)throw new Error('stop fixture failed')}} queueSlot={composition.queue.visible?
two queued messages
:undefined} approvalKey={request} approvalSlot={!composition.input.visible?{declined++}} onDecisionComplete={decision=>{settled=decision}}/>:undefined}/>) }) @@ -75,6 +74,8 @@ async function main(){ check(getComputedStyle(stack).display==='none','composer CSS removes the normal input from layout during approval') check(document.querySelectorAll('[data-composer-approval] [role="dialog"]').length===1,'one bottom approval surface') check(!document.querySelector('.wb-composer__toolbar'),'normal toolbar and send controls removed during approval') + check(!document.querySelector('button[aria-label="停止当前运行"],button[aria-label="停止"],button[aria-label="发送"],.wb-composer__approval-stop'),'approval takeover has no standalone stop or send button even with onStop supplied') + check(document.querySelector('[data-composer-approval]')?.children.length===1,'bottom approval surface contains only the approval card') check(!document.querySelector('[data-queue]'),'queue hidden without deleting queue state') const editor=stack.querySelector('[contenteditable]')! check(editor?.getAttribute('contenteditable')==='false','rich editor disabled during approval') @@ -99,18 +100,21 @@ async function main(){ active=true;await renderComposer();await act(async()=>click('[data-option-id="once"]'));const staleResolve=resolve session='session-b';active=false;settled=undefined;await renderComposer();await act(async()=>staleResolve()) check(!document.querySelector('[data-composer-approval]')&&settled===undefined,'session switch unmounts old request and ignores its late result') - active=true;stopFails=true;await renderComposer();await act(async()=>click('.wb-composer__approval-stop')) - check(document.querySelector('[data-composer-approval] [role="alert"]')?.textContent?.includes('stop fixture failed'),'failed cancellation visible in approval surface') - request='request-2';stopFails=false;await renderComposer() - check(!document.querySelector('[data-composer-approval] [role="alert"]'),'next request clears previous cancellation failure') - await act(async()=>click('.wb-composer__approval-stop')) - check(!document.querySelector('[data-composer-approval] [role="alert"]'),'cancellation retry clears error') - check(stops===2,'stop remains reachable independently of normal send') - stopPending=true;await renderComposer();await act(async()=>click('.wb-composer__approval-stop')) - check(document.querySelector('.wb-composer__approval-stop')?.disabled,'pending stop disabled against duplicate cancellation') - request='request-3';stopPending=false;await renderComposer();await act(async()=>rejectStop(new Error('obsolete stop failure'))) - check(!document.querySelector('[data-composer-approval] [role="alert"]')&&!document.querySelector('.wb-composer__approval-stop')?.disabled,'late previous-request cancellation cannot alter new request') - active=false;await renderComposer();check(!document.querySelector('[data-composer-approval]'),'cancelled interaction restores ordinary input') + // Stopping remains a normal-composer/parent action, never an extra approval control. + stopFails=true;await renderComposer();await act(async()=>click('.wb-composer__queue-stop')) + check(document.querySelector('[role="alert"]')?.textContent?.includes('stop fixture failed'),'normal composer still reports stop failure') + stopFails=false;await act(async()=>click('.wb-composer__queue-stop')) + check(stops===2,'normal composer stop remains reachable and can retry independently of send') + active=true;await renderComposer() + const previousApproval=document.querySelector('[data-composer-approval]') + check(!document.querySelector('[data-composer-approval] [role="alert"]'),'normal stop failure does not leak into approval surface') + check(!document.querySelector('button[aria-label="停止当前运行"],button[aria-label="停止"],.wb-composer__approval-stop'),'approval has no standalone cancellation control') + request='request-2';await renderComposer() + check(document.querySelector('[data-composer-approval]')!==previousApproval,'replacement request remounts keyed approval wrapper') + check(stops===2&&sends===0,'approval takeover and replacement neither stop the run nor send a message') + // Parent cancellation removes the pending interaction without depending on approval stop UI. + active=false;await renderComposer();check(!document.querySelector('[data-composer-approval]'),'parent-cancelled interaction restores ordinary input') + check(!!document.querySelector('.wb-composer__queue-stop')&&!!document.querySelector('[data-queue]'),'normal stop and queue restore after parent cancellation') // An upload already started before takeover must finish into the same draft without stealing focus. let uploaded: typeof attachments=[], stagingResolve!: (value: typeof attachments[number])=>void, uploadActive=false const uploadHost={pick:async()=>[],stage:()=>new Promise(yes=>{stagingResolve=yes})} @@ -129,6 +133,15 @@ async function main(){ check(!document.querySelector('.conversation-input-area__input-stack')!.contains(document.activeElement),'upload completion does not focus hidden editor') uploadActive=false;await renderUpload() check(document.querySelector('.conversation-input-area__input-stack')?.textContent?.includes('upload.txt'),'completed attachment restored after approval') + // Empty drafts restore the ordinary stop button, not a send button, after takeover. + const renderEmpty=(approval:boolean)=>act(async()=>root.render({}} onSend={()=>{sends++}} onStop={async()=>{stops++}} + approvalKey="empty-approval" approvalSlot={approval?{}}/>:undefined}/>)) + await renderEmpty(true) + check(!document.querySelector('button[aria-label="停止"],button[aria-label="发送"]'),'empty draft also has no normal stop or send during approval') + await renderEmpty(false) + check(!!document.querySelector('button[aria-label="停止"]')&&!document.querySelector('button[aria-label="发送"]'),'empty draft restores normal stop without send after approval') + await act(async()=>click('button[aria-label="停止"]')) + check(stops===3&&sends===0,'restored empty-draft stop still invokes cancellation without sending') await act(async()=>root.unmount());dom.window.close() console.log(`Permission intercept DOM: ${checks} checks passed (not pixel/SDK verification)`) process.exit(0) diff --git a/src/renderer/src/workbuddy/composer/ComposerControls.tsx b/src/renderer/src/workbuddy/composer/ComposerControls.tsx index 7e56363..c3b03ac 100644 --- a/src/renderer/src/workbuddy/composer/ComposerControls.tsx +++ b/src/renderer/src/workbuddy/composer/ComposerControls.tsx @@ -146,24 +146,10 @@ export function readReferenceUris(transfer: Pick): stri return transfer.getData('text/uri-list').split(/\r\n?|\n/).filter((value) => value.trim() && !value.startsWith('#')) } -/** Keyed by request identity so a late cancellation result cannot affect its successor. */ -function ApprovalComposerSurface({ children, onStop }: { children: ReactNode; onStop?: () => void | Promise }): React.JSX.Element { - const [error, setError] = useState(null) - const [busy, setBusy] = useState(false) - const busyRef = useRef(false) - const mounted = useRef(true) - useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, []) +/** Keep each approval subtree scoped to its request identity. */ +function ApprovalComposerSurface({ children }: { children: ReactNode }): React.JSX.Element { return
{children} - {onStop && } - {error &&
{error}
}
} @@ -556,7 +542,7 @@ export function ComposerControls({ } }} > - {approvalTakeover && {approvalSlot}} + {approvalTakeover && {approvalSlot}} {!approvalTakeover && floatingSlot &&
{floatingSlot}
}