fix(composer): show only approval card during authorization

This commit is contained in:
Codex committed 2026-10-21 16:04:52 +09:00
1 parent b4757dd843
commit 4a7f17961d
4 files changed
+32 -34

No files matched your search

+28 -15
View File
@@ -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(<ComposerControls key={session} sessionId={session} value={draft} onChange={()=>{changes++}} onSend={()=>{sends++}}
attachments={attachments} resources={resources} quotes={quotes} onStop={()=>{stops++;if(stopFails)throw new Error('stop fixture failed');if(stopPending)return new Promise<void>((_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?<div data-queue="true">two queued messages</div>:undefined}
approvalKey={request} approvalSlot={!composition.input.visible?<PermissionConfirm pendingId={session} toolName="Bash" options={options} resolve={pending} reject={async()=>{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<HTMLElement>('[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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<typeof attachments[number]>(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(<ComposerControls key="empty-session" sessionId="empty-session" value="" onChange={()=>{}} onSend={()=>{sends++}} onStop={async()=>{stops++}}
approvalKey="empty-approval" approvalSlot={approval?<PermissionConfirm pendingId="empty-approval" toolName="Bash" options={options} resolve={pending} reject={async()=>{}}/>: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)
@@ -146,24 +146,10 @@ export function readReferenceUris(transfer: Pick<DataTransfer, 'getData'>): 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<void> }): React.JSX.Element {
const [error, setError] = useState<string | null>(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 <div className="conversation-input-area__approval" data-composer-approval="true">
{children}
{onStop && <button type="button" className="wb-composer__approval-stop" aria-label="停止当前运行" disabled={busy} onClick={async () => {
if (busyRef.current) return
busyRef.current = true
setBusy(true)
setError(null)
try { await onStop() } catch (reason) { if (mounted.current) setError(errorText(reason)) }
finally { if (mounted.current) { busyRef.current = false; setBusy(false) } }
}}>停止当前运行</button>}
{error && <div className="wb-composer__error" role="alert">{error}</div>}
</div>
}
@@ -556,7 +542,7 @@ export function ComposerControls({
}
}}
>
{approvalTakeover && <ApprovalComposerSurface key={approvalKey ?? sessionId} onStop={onStop}>{approvalSlot}</ApprovalComposerSurface>}
{approvalTakeover && <ApprovalComposerSurface key={approvalKey ?? sessionId}>{approvalSlot}</ApprovalComposerSurface>}
{!approvalTakeover && floatingSlot && <div className="conversation-input-area__custom-floating">{floatingSlot}</div>}
<div ref={inputStackRef} hidden={approvalTakeover} inert={approvalTakeover} aria-hidden={approvalTakeover || undefined}
onFocusCapture={event => { if (approvalTakeover) { event.stopPropagation(); (event.target as HTMLElement).blur() } }}
@@ -550,4 +550,3 @@
/* B03: approval owns the bottom surface; keep draft state mounted, never visible/focusable. */
.wb-composer .conversation-input-area__input-stack[hidden] { display: none; }
.conversation-input-area__approval { width: 100%; min-width: 0; }
.wb-composer__approval-stop { display: block; margin: var(--yc-space-3) 0 0 auto; cursor: pointer; }
@@ -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. The running generation keeps its stop action reachable in every state.
* 5. Running state keeps its stop action semantics; approval takeover hides the normal toolbar and renders only the approval card.
*/
export function composeComposer(input: ComposerCompositionInput = {}): ComposerComposition {
const interaction = isActiveComposerInteraction(input.interaction) ? input.interaction : null