fix(chat): float approvals above composer

This commit is contained in:
xiaohan.yuan committed 2026-10-20 16:31:03 +08:00
1 parent efb44f9340
commit 1b52df0603
5 files changed
+321 -127

No files matched your search

@@ -1,5 +1,7 @@
import assert from 'node:assert/strict'
import { renderToStaticMarkup } from 'react-dom/server'
import MessageBubble from '../src/renderer/src/components/MessageBubble'
import type { ChatMessage } from '../src/shared/types'
import {
AskUserQuestion,
PlanCreateBlock,
@@ -210,6 +212,36 @@ const cancelledPermission = renderToStaticMarkup(
)
assert.match(cancelledPermission, /Cancelled/)
const terminalApprovalMessage: ChatMessage = {
id: 'message-terminal-approval',
role: 'assistant',
content: 'Browser opened.',
parts: [{ type: 'text', text: 'Browser opened.' }],
status: 'done',
createdAt: 1,
interactions: [{
id: 'approval-terminal',
runId: 'run-terminal',
toolCallId: 'browser-tool',
title: 'Allow browser access?',
status: 'answered',
kind: 'approval',
requestId: 'request-terminal',
options: permissionOptions
}]
}
const terminalApprovalMarkup = renderToStaticMarkup(<MessageBubble message={terminalApprovalMessage} />)
assert.doesNotMatch(terminalApprovalMarkup, /conversation-high-credit-approval/,
'a completed non-inline approval must not leave a terminal card in the conversation')
const activeApprovalMarkup = renderToStaticMarkup(<MessageBubble message={{
...terminalApprovalMessage,
status: 'streaming',
interactions: terminalApprovalMessage.interactions?.map(interaction => ({ ...interaction, status: 'pending' as const }))
}} />)
assert.doesNotMatch(activeApprovalMarkup, /conversation-high-credit-approval/,
'an active approval belongs to the composer floating slot, not the conversation stream')
const questionConfig = {
title: 'Choose a runtime',
questions: [
@@ -229,6 +261,34 @@ const questionConfig = {
}
]
}
const activeQuestionMessage: ChatMessage = {
id: 'message-active-question',
role: 'assistant',
content: '',
tools: [{
id: 'question-tool',
name: 'AskUserQuestion',
args: { questions: questionConfig.questions },
argsRaw: '{}',
status: 'running'
}],
parts: [{ type: 'tool', toolId: 'question-tool' }],
status: 'streaming',
createdAt: 2,
interactions: [{
id: 'question-active',
runId: 'run-question',
toolCallId: 'question-tool',
title: 'Need input',
status: 'pending',
kind: 'question',
questions: questionConfig.questions.map(question => ({ id: question.id, question: question.question, options: question.options }))
}]
}
const activeQuestionMarkup = renderToStaticMarkup(<MessageBubble message={activeQuestionMessage} />)
assert.doesNotMatch(activeQuestionMarkup, /ask-user-question/,
'an active user question must render only in the composer floating slot')
const questionMarkup = renderToStaticMarkup(<QuestionFloating config={questionConfig} />)
assert.match(questionMarkup, /Which runtime\?/)
assert.match(questionMarkup, /Node\.js/)
+53 -67
View File
@@ -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
}