fix(chat): float approvals above composer
This commit is contained in:
1 parent
efb44f9340
commit
1b52df0603
5 files changed
+321
-127
No files matched your search
@@ -110,20 +110,22 @@ if (!process.versions.electron) {
|
||||
options: [{ optionId: 'allow-once', name: 'Once', kind: 'allow_once' }, { optionId: 'allow-always', name: 'Always', kind: 'allow_always' },
|
||||
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }] } }
|
||||
}
|
||||
function approvalSelector(id) { return '.conversation-high-credit-approval[data-pending-id="' + id + '"]' }
|
||||
function approvalSelector(id) { return '.conversation-input-area__custom-floating .permission-floating[data-pending-id="' + id + '"]' }
|
||||
async function waitInteraction(runId, interactionId, status) {
|
||||
const run = 'window.__YCODE_CONVERSATION_STATE_TEST__.runs[' + JSON.stringify(runId) + ']'
|
||||
const condition = run + '?.events.some(event=>event.type==="interaction"&&event.interaction.id===' + JSON.stringify(interactionId) + '&&event.interaction.status===' + JSON.stringify(status) + ')'
|
||||
await waitFor(condition, interactionId + ' interaction event status=' + status)
|
||||
}
|
||||
async function approvalState(toolId, interactionId) {
|
||||
return read('(()=>{const root=document.querySelector(' + JSON.stringify(toolSelector(toolId)) + '),generic=document.querySelector(' + JSON.stringify(approvalSelector(interactionId)) + ');const buttons=root?[...root.querySelectorAll(".cr-tool-btn[data-tool-decision]")]:[];return{toolStatus:root?.dataset.toolStatus??null,inlineButtons:buttons.map(button=>({decision:button.dataset.toolDecision,disabled:button.disabled})),genericCount:document.querySelectorAll(".conversation-high-credit-approval").length,genericForInteraction:!!generic,genericStatus:generic?.className??null}})()')
|
||||
return read('(()=>{const root=document.querySelector(' + JSON.stringify(toolSelector(toolId)) + '),floating=document.querySelector(' + JSON.stringify(approvalSelector(interactionId)) + '),composer=document.querySelector(".conversation-input-area__input-stack"),buttons=floating?[...floating.querySelectorAll(".conversation-high-credit-approval__option")]:[],inlineButtons=root?[...root.querySelectorAll(".cr-tool-btn[data-tool-decision]")]:[];const floatingRect=floating?.getBoundingClientRect(),composerRect=composer?.getBoundingClientRect();return{toolStatus:root?.dataset.toolStatus??null,inlineButtons:inlineButtons.map(button=>({decision:button.dataset.toolDecision,disabled:button.disabled})),floatingCount:document.querySelectorAll(".conversation-input-area__custom-floating .permission-floating").length,floatingForInteraction:!!floating,floatingStatus:floating?.className??null,inConversation:!!document.querySelector(".chat-thread .conversation-high-credit-approval"),aboveComposer:!!floatingRect&&!!composerRect&&floatingRect.bottom<=composerRect.top,floatingGap:floatingRect&&composerRect?composerRect.top-floatingRect.bottom:null,options:buttons.map(button=>({optionId:button.dataset.optionId??null,label:button.textContent,reject:button.classList.contains("conversation-high-credit-approval__option--reject"),disabled:button.disabled}))}})()')
|
||||
}
|
||||
async function approvalButtonStyles(toolId) {
|
||||
return read('(()=>{const root=document.querySelector(' + JSON.stringify(toolSelector(toolId)) + ');const alphaOf=color=>{const canvas=document.createElement("canvas"),context=canvas.getContext("2d");context.clearRect(0,0,1,1);context.fillStyle=color;context.fillRect(0,0,1,1);return context.getImageData(0,0,1,1).data[3]/255};return[...(root?.querySelectorAll(".cr-tool-btn[data-tool-decision]")??[])].map(button=>{const style=getComputedStyle(button);return{decision:button.dataset.toolDecision,className:button.className,height:style.height,minWidth:style.minWidth,fontSize:style.fontSize,padding:style.padding,color:style.color,backgroundColor:style.backgroundColor,backgroundAlpha:alphaOf(style.backgroundColor)}})})()')
|
||||
async function approvalButtonStyles(interactionId) {
|
||||
return read('(()=>{const root=document.querySelector(' + JSON.stringify(approvalSelector(interactionId)) + ');return[...(root?.querySelectorAll(".conversation-high-credit-approval__option")??[])].map(button=>{const style=getComputedStyle(button);return{optionId:button.dataset.optionId??null,reject:button.classList.contains("conversation-high-credit-approval__option--reject"),className:button.className,height:style.height,minHeight:style.minHeight,fontSize:style.fontSize,padding:style.padding,color:style.color,backgroundColor:style.backgroundColor}})})()')
|
||||
}
|
||||
async function clickApproval(toolId, decision, twice = false) {
|
||||
const selector = toolSelector(toolId) + ' .cr-tool-btn[data-tool-decision="' + decision + '"]'
|
||||
async function clickApproval(interactionId, optionId, twice = false) {
|
||||
const selector = optionId.startsWith('reject')
|
||||
? approvalSelector(interactionId) + ' .conversation-high-credit-approval__option--reject'
|
||||
: approvalSelector(interactionId) + ' .conversation-high-credit-approval__option[data-option-id="' + optionId + '"]'
|
||||
await read('(()=>{const button=document.querySelector(' + JSON.stringify(selector) + ');if(!button)throw new Error("Approval action is missing");button.click()' + (twice ? ';button.click()' : '') + '})()')
|
||||
}
|
||||
async function responseRecords(interactionId) {
|
||||
@@ -265,7 +267,7 @@ if (!process.versions.electron) {
|
||||
await waitTool('command-cancelled', 'running', false)
|
||||
await stopRun()
|
||||
await waitTool('command-cancelled', 'cancelled')
|
||||
await waitFor('document.querySelector(".msg--assistant .msg__status")', 'cancel terminal message rendered')
|
||||
await waitFor('document.querySelector(".chat-thread")?.textContent.includes("用户已取消")', 'cancel terminal message rendered')
|
||||
const commandCancelledState = await toolState('command-cancelled')
|
||||
assert.equal(commandRunningState.status, 'running')
|
||||
assert.equal(commandErrorState.status, 'error')
|
||||
@@ -278,55 +280,46 @@ if (!process.versions.electron) {
|
||||
await waitTool('approval-file-tool', 'running', true)
|
||||
await emit(fileApprovalRun, interactionEvent(fileApprovalRun, 'approval-file-request', 'approval-file-tool'))
|
||||
await waitTool('approval-file-tool', 'pending')
|
||||
await emit(fileApprovalRun, { type: 'text', delta: 'approval separator' })
|
||||
const unrelatedTool = toolCall('approval-unrelated-tool', 'mcp_call_tool', 'running', { server_name: 'fixture', tool_name: 'unrelated' }, { output: '' })
|
||||
await emitTool(fileApprovalRun, 'tool_call', unrelatedTool)
|
||||
await emit(fileApprovalRun, interactionEvent(fileApprovalRun, 'approval-unrelated-request', 'approval-unrelated-tool'))
|
||||
await waitInteraction(fileApprovalRun, 'approval-unrelated-request', 'pending')
|
||||
await waitFor('document.querySelector(' + JSON.stringify(toolSelector('approval-file-tool') + ' .cr-tool-btn[data-tool-decision="allow"]') + ')', 'file approval action rendered')
|
||||
await waitFor('document.querySelector(' + JSON.stringify(approvalSelector('approval-unrelated-request')) + ')', 'unrelated approval remains generic')
|
||||
await waitFor('document.querySelector(' + JSON.stringify(approvalSelector('approval-file-request')) + ')', 'file approval floats above the composer')
|
||||
const filePending = await approvalState('approval-file-tool', 'approval-file-request')
|
||||
assert.equal(filePending.toolStatus, 'pending')
|
||||
assert.deepEqual(filePending.inlineButtons.map(button => button.decision).sort(), ['allow', 'reject'])
|
||||
assert.equal(filePending.genericCount, 1, 'only the unrelated request uses a generic confirmation card')
|
||||
assert.equal(filePending.genericForInteraction, false, 'the inline file request has no duplicate generic confirmation')
|
||||
const fileButtonStyles = await approvalButtonStyles('approval-file-tool')
|
||||
assert.equal(filePending.inlineButtons.length, 0, 'file approval actions are not rendered inside the tool row')
|
||||
assert.equal(filePending.floatingForInteraction, true)
|
||||
assert.equal(filePending.floatingCount, 1, 'one active approval is shown at a time')
|
||||
assert.equal(filePending.inConversation, false, 'approval cards are not rendered in the conversation stream')
|
||||
assert.equal(filePending.aboveComposer, true, 'approval card sits above the composer')
|
||||
assert.ok(filePending.floatingGap >= 0 && filePending.floatingGap <= 12, 'approval card stays visually attached to the composer')
|
||||
assert.deepEqual(filePending.options.map(option => option.reject ? 'reject' : option.optionId), ['allow-once', 'allow-always', 'reject'])
|
||||
const fileButtonStyles = await approvalButtonStyles('approval-file-request')
|
||||
for (const button of fileButtonStyles) {
|
||||
assert.equal(button.height, '20px')
|
||||
assert.equal(button.minWidth, '52px')
|
||||
assert.equal(button.fontSize, '12px')
|
||||
assert.equal(button.padding, '0px 10px')
|
||||
assert.equal(button.height, '44px')
|
||||
assert.equal(button.fontSize, '14px')
|
||||
assert.equal(button.padding, '8px 12px')
|
||||
}
|
||||
const fileAcceptStyle = fileButtonStyles.find(button => button.decision === 'allow')
|
||||
const fileRejectStyle = fileButtonStyles.find(button => button.decision === 'reject')
|
||||
assert.ok(fileAcceptStyle.backgroundAlpha > 0)
|
||||
assert.ok(fileRejectStyle.backgroundAlpha > 0)
|
||||
assert.notEqual(fileAcceptStyle.backgroundColor, fileRejectStyle.backgroundColor)
|
||||
const fileAcceptStyle = fileButtonStyles.find(button => button.optionId === 'allow-once')
|
||||
const fileRejectStyle = fileButtonStyles.find(button => button.reject)
|
||||
assert.notEqual(fileAcceptStyle.color, fileRejectStyle.color)
|
||||
const unrelatedPending = await approvalState('approval-unrelated-tool', 'approval-unrelated-request')
|
||||
assert.equal(unrelatedPending.genericForInteraction, true)
|
||||
await screenshot('conversation-file-approval-2026-10-15.png', toolSelector('approval-file-tool'))
|
||||
await screenshot('conversation-file-approval-floating-2026-10-20.png', '.conversation-input-area__custom-floating')
|
||||
|
||||
await read('window.__YCODE_CONVERSATION_STATE_TEST__.queueResponse("failure")')
|
||||
await clickApproval('approval-file-tool', 'allow')
|
||||
await waitFor('document.querySelector(".msg--assistant .msg__status--error[role=\\"alert\\"]")', 'failed file approval response surfaced')
|
||||
await clickApproval('approval-file-request', 'allow-once')
|
||||
await waitFor('document.querySelector(' + JSON.stringify(approvalSelector('approval-file-request') + ' .conversation-high-credit-approval__error') + ')', 'failed floating approval response surfaced')
|
||||
const fileRetry = await approvalState('approval-file-tool', 'approval-file-request')
|
||||
assert.ok(fileRetry.inlineButtons.some(button => button.decision === 'allow' && !button.disabled), 'failed approval can be retried')
|
||||
assert.ok(fileRetry.options.some(option => option.optionId === 'allow-once' && !option.disabled), 'failed approval can be retried')
|
||||
const fileFirstAttempt = await responseRecords('approval-file-request')
|
||||
assert.equal(fileFirstAttempt.length, 1)
|
||||
assert.equal(fileFirstAttempt[0].response.optionId, 'allow-once', 'file accept selects once before always')
|
||||
|
||||
await read('window.__YCODE_CONVERSATION_STATE_TEST__.queueResponse("defer")')
|
||||
await clickApproval('approval-file-tool', 'allow', true)
|
||||
await waitFor('(()=>{const root=document.querySelector(' + JSON.stringify(toolSelector('approval-file-tool')) + '),buttons=root?[...root.querySelectorAll(".cr-tool-btn[data-tool-decision]")]:[];return buttons.length>0&&buttons.every(button=>button.disabled)})()', 'responding disables all file approval buttons')
|
||||
await clickApproval('approval-file-request', 'allow-once', true)
|
||||
await waitFor('(()=>{const buttons=[...document.querySelectorAll(' + JSON.stringify(approvalSelector('approval-file-request') + ' .conversation-high-credit-approval__option') + ')];return buttons.length>0&&buttons.every(button=>button.disabled)})()', 'responding disables all floating approval options')
|
||||
const fileDeferredResponses = await responseRecords('approval-file-request')
|
||||
assert.equal(fileDeferredResponses.length, 2, 'double click submits only one request while the first response is deferred')
|
||||
await read('window.__YCODE_CONVERSATION_STATE_TEST__.releaseDeferredResponse()')
|
||||
await waitInteraction(fileApprovalRun, 'approval-file-request', 'answered')
|
||||
const fileAcceptedResponses = await responseRecords('approval-file-request')
|
||||
assert.deepEqual(fileAcceptedResponses.map(item => item.response.optionId), ['allow-once', 'allow-once'])
|
||||
await waitFor('!document.querySelector(' + JSON.stringify(toolSelector('approval-file-tool') + ' .cr-tool-btn[data-tool-decision]') + ')', 'accepted file approval actions are removed')
|
||||
assert.ok(await read('document.querySelector(' + JSON.stringify(approvalSelector('approval-unrelated-request')) + ')'), 'unrelated generic approval remains visible')
|
||||
await waitFor('!document.querySelector(' + JSON.stringify(approvalSelector('approval-file-request')) + ')', 'accepted file approval floating card is removed')
|
||||
await stopRun()
|
||||
await waitFor('!document.querySelector(".cr-send-button--stop")', 'approval fixture run cancelled')
|
||||
|
||||
@@ -350,22 +343,21 @@ if (!process.versions.electron) {
|
||||
await emitTool(runId, 'tool_call', call)
|
||||
await waitTool(toolId, 'running', false)
|
||||
await emit(runId, interactionEvent(runId, interactionId, toolId))
|
||||
await waitTool(toolId, 'pending', false)
|
||||
await waitTool(toolId, 'pending')
|
||||
} else {
|
||||
await waitTool(toolId, 'pending', true)
|
||||
await waitTool(toolId, 'pending')
|
||||
}
|
||||
const commandPending = await toolState(toolId)
|
||||
assert.equal(commandPending.expanded, scenario.arrival === 'early' ? 'true' : 'false')
|
||||
await waitFor('document.querySelector(' + JSON.stringify(toolSelector(toolId) + ' .cr-tool-btn[data-tool-decision="' + scenario.decision + '"]') + ')', 'command approval decision rendered')
|
||||
if (scenario.arrival === 'early') commandButtonStyles = await approvalButtonStyles(toolId)
|
||||
if (scenario.arrival === 'early') await screenshot('conversation-command-approval-2026-10-15.png', toolSelector(toolId))
|
||||
assert.ok(!await read('document.querySelector(' + JSON.stringify(approvalSelector(interactionId)) + ')'), 'inline command request has no generic duplicate')
|
||||
if (scenario.arrival === 'delayed') {
|
||||
await clickTool(toolId)
|
||||
await waitTool(toolId, 'pending', true)
|
||||
}
|
||||
await clickApproval(toolId, scenario.decision)
|
||||
await waitFor('document.querySelector(' + JSON.stringify(approvalSelector(interactionId)) + ')', 'command approval floats above the composer')
|
||||
if (scenario.arrival === 'early') commandButtonStyles = await approvalButtonStyles(interactionId)
|
||||
const commandApproval = await approvalState(toolId, interactionId)
|
||||
assert.equal(commandApproval.inlineButtons.length, 0, 'command approval actions are not rendered inside the tool row')
|
||||
assert.equal(commandApproval.inConversation, false, 'command approval cards are not rendered in the conversation stream')
|
||||
assert.equal(commandApproval.aboveComposer, true)
|
||||
if (scenario.arrival === 'early') await screenshot('conversation-command-approval-floating-2026-10-20.png', '.conversation-input-area__custom-floating')
|
||||
await clickApproval(interactionId, scenario.optionId)
|
||||
await waitInteraction(runId, interactionId, scenario.status)
|
||||
await waitFor('!document.querySelector(' + JSON.stringify(approvalSelector(interactionId)) + ')', 'command approval floating card is removed')
|
||||
const responses = await responseRecords(interactionId)
|
||||
assert.equal(responses.length, 1)
|
||||
assert.equal(responses[0].response.optionId, scenario.optionId)
|
||||
@@ -376,23 +368,15 @@ if (!process.versions.electron) {
|
||||
initialExpanded: commandPending.expanded, optionId: responses[0].response.optionId, status: interaction.status })
|
||||
}
|
||||
for (const button of commandButtonStyles) {
|
||||
assert.equal(button.height, '20px')
|
||||
assert.equal(button.minWidth, '52px')
|
||||
assert.equal(button.fontSize, '12px')
|
||||
assert.equal(button.padding, '0px 10px')
|
||||
assert.equal(button.height, '44px')
|
||||
assert.equal(button.fontSize, '14px')
|
||||
assert.equal(button.padding, '8px 12px')
|
||||
}
|
||||
const commandRunStyle = commandButtonStyles.find(button => button.decision === 'allow')
|
||||
const commandSkipStyle = commandButtonStyles.find(button => button.decision === 'skip')
|
||||
const commandRejectStyle = commandButtonStyles.find(button => button.decision === 'reject')
|
||||
assert.ok(commandRunStyle.backgroundAlpha > 0)
|
||||
assert.ok(commandRejectStyle.backgroundAlpha > 0)
|
||||
assert.notEqual(commandRunStyle.backgroundColor, commandRejectStyle.backgroundColor)
|
||||
assert.ok(commandSkipStyle.className.includes('cr-tool-btn--default'))
|
||||
assert.ok(commandSkipStyle.backgroundAlpha > 0)
|
||||
assert.notEqual(commandSkipStyle.backgroundColor, commandRunStyle.backgroundColor)
|
||||
assert.notEqual(commandSkipStyle.backgroundColor, commandRejectStyle.backgroundColor)
|
||||
assert.notEqual(commandSkipStyle.color, commandRunStyle.color)
|
||||
assert.notEqual(commandSkipStyle.color, commandRejectStyle.color)
|
||||
const commandRunStyle = commandButtonStyles.find(button => button.optionId === 'allow-once')
|
||||
const commandRejectStyle = commandButtonStyles.find(button => button.reject)
|
||||
assert.ok(commandRunStyle)
|
||||
assert.ok(commandRejectStyle)
|
||||
assert.notEqual(commandRunStyle.color, commandRejectStyle.color)
|
||||
|
||||
const largeRun = await send('write-large-motion')
|
||||
const largeText = Array.from({ length: 180 }, (_, index) => 'export const value' + index + ' = ' + index + ';').join('\n')
|
||||
@@ -450,10 +434,12 @@ if (!process.versions.electron) {
|
||||
reducedClosed: { height: reducedClosed.height, transition: reducedClosed.transition } },
|
||||
commands: { running: commandRunningState.status, error: commandErrorState.status,
|
||||
emptySuccess: commandEmptySuccessState.status, cancelled: commandCancelledState.status },
|
||||
approvals: { filePending: { toolStatus: filePending.toolStatus, inlineDecisions: filePending.inlineButtons.map(button => button.decision), genericCount: filePending.genericCount },
|
||||
approvals: { filePending: { toolStatus: filePending.toolStatus, inlineDecisionCount: filePending.inlineButtons.length,
|
||||
floatingCount: filePending.floatingCount, aboveComposer: filePending.aboveComposer, floatingGap: filePending.floatingGap,
|
||||
inConversation: filePending.inConversation },
|
||||
fileButtonStyles, commandButtonStyles,
|
||||
fileRetryOptionIds: fileAcceptedResponses.map(item => item.response.optionId), deferredDoubleClickRequestCount: fileDeferredResponses.length,
|
||||
unrelatedApprovalRemainsGeneric: unrelatedPending.genericForInteraction, commandDecisions: commandApprovalResults,
|
||||
commandDecisions: commandApprovalResults,
|
||||
commandSkipRejectTransportLimitation: { skip: 'reject-once', reject: 'reject-once', distinctKernelResponse: false } },
|
||||
consoleErrors: errors
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user