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 assert from 'node:assert/strict'
import { renderToStaticMarkup } from 'react-dom/server' import { renderToStaticMarkup } from 'react-dom/server'
import MessageBubble from '../src/renderer/src/components/MessageBubble'
import type { ChatMessage } from '../src/shared/types'
import { import {
AskUserQuestion, AskUserQuestion,
PlanCreateBlock, PlanCreateBlock,
@@ -210,6 +212,36 @@ const cancelledPermission = renderToStaticMarkup(
) )
assert.match(cancelledPermission, /Cancelled/) 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 = { const questionConfig = {
title: 'Choose a runtime', title: 'Choose a runtime',
questions: [ 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} />) const questionMarkup = renderToStaticMarkup(<QuestionFloating config={questionConfig} />)
assert.match(questionMarkup, /Which runtime\?/) assert.match(questionMarkup, /Which runtime\?/)
assert.match(questionMarkup, /Node\.js/) 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' }, 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' }] } } { 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) { async function waitInteraction(runId, interactionId, status) {
const run = 'window.__YCODE_CONVERSATION_STATE_TEST__.runs[' + JSON.stringify(runId) + ']' 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) + ')' 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) await waitFor(condition, interactionId + ' interaction event status=' + status)
} }
async function approvalState(toolId, interactionId) { 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) { async function approvalButtonStyles(interactionId) {
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)}})})()') 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) { async function clickApproval(interactionId, optionId, twice = false) {
const selector = toolSelector(toolId) + ' .cr-tool-btn[data-tool-decision="' + decision + '"]' 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()' : '') + '})()') 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) { async function responseRecords(interactionId) {
@@ -265,7 +267,7 @@ if (!process.versions.electron) {
await waitTool('command-cancelled', 'running', false) await waitTool('command-cancelled', 'running', false)
await stopRun() await stopRun()
await waitTool('command-cancelled', 'cancelled') 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') const commandCancelledState = await toolState('command-cancelled')
assert.equal(commandRunningState.status, 'running') assert.equal(commandRunningState.status, 'running')
assert.equal(commandErrorState.status, 'error') assert.equal(commandErrorState.status, 'error')
@@ -278,55 +280,46 @@ if (!process.versions.electron) {
await waitTool('approval-file-tool', 'running', true) await waitTool('approval-file-tool', 'running', true)
await emit(fileApprovalRun, interactionEvent(fileApprovalRun, 'approval-file-request', 'approval-file-tool')) await emit(fileApprovalRun, interactionEvent(fileApprovalRun, 'approval-file-request', 'approval-file-tool'))
await waitTool('approval-file-tool', 'pending') await waitTool('approval-file-tool', 'pending')
await emit(fileApprovalRun, { type: 'text', delta: 'approval separator' }) await waitFor('document.querySelector(' + JSON.stringify(approvalSelector('approval-file-request')) + ')', 'file approval floats above the composer')
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')
const filePending = await approvalState('approval-file-tool', 'approval-file-request') const filePending = await approvalState('approval-file-tool', 'approval-file-request')
assert.equal(filePending.toolStatus, 'pending') assert.equal(filePending.toolStatus, 'pending')
assert.deepEqual(filePending.inlineButtons.map(button => button.decision).sort(), ['allow', 'reject']) assert.equal(filePending.inlineButtons.length, 0, 'file approval actions are not rendered inside the tool row')
assert.equal(filePending.genericCount, 1, 'only the unrelated request uses a generic confirmation card') assert.equal(filePending.floatingForInteraction, true)
assert.equal(filePending.genericForInteraction, false, 'the inline file request has no duplicate generic confirmation') assert.equal(filePending.floatingCount, 1, 'one active approval is shown at a time')
const fileButtonStyles = await approvalButtonStyles('approval-file-tool') 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) { for (const button of fileButtonStyles) {
assert.equal(button.height, '20px') assert.equal(button.height, '44px')
assert.equal(button.minWidth, '52px') assert.equal(button.fontSize, '14px')
assert.equal(button.fontSize, '12px') assert.equal(button.padding, '8px 12px')
assert.equal(button.padding, '0px 10px')
} }
const fileAcceptStyle = fileButtonStyles.find(button => button.decision === 'allow') const fileAcceptStyle = fileButtonStyles.find(button => button.optionId === 'allow-once')
const fileRejectStyle = fileButtonStyles.find(button => button.decision === 'reject') const fileRejectStyle = fileButtonStyles.find(button => button.reject)
assert.ok(fileAcceptStyle.backgroundAlpha > 0)
assert.ok(fileRejectStyle.backgroundAlpha > 0)
assert.notEqual(fileAcceptStyle.backgroundColor, fileRejectStyle.backgroundColor)
assert.notEqual(fileAcceptStyle.color, fileRejectStyle.color) assert.notEqual(fileAcceptStyle.color, fileRejectStyle.color)
const unrelatedPending = await approvalState('approval-unrelated-tool', 'approval-unrelated-request') await screenshot('conversation-file-approval-floating-2026-10-20.png', '.conversation-input-area__custom-floating')
assert.equal(unrelatedPending.genericForInteraction, true)
await screenshot('conversation-file-approval-2026-10-15.png', toolSelector('approval-file-tool'))
await read('window.__YCODE_CONVERSATION_STATE_TEST__.queueResponse("failure")') await read('window.__YCODE_CONVERSATION_STATE_TEST__.queueResponse("failure")')
await clickApproval('approval-file-tool', 'allow') await clickApproval('approval-file-request', 'allow-once')
await waitFor('document.querySelector(".msg--assistant .msg__status--error[role=\\"alert\\"]")', 'failed file approval response surfaced') 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') 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') const fileFirstAttempt = await responseRecords('approval-file-request')
assert.equal(fileFirstAttempt.length, 1) assert.equal(fileFirstAttempt.length, 1)
assert.equal(fileFirstAttempt[0].response.optionId, 'allow-once', 'file accept selects once before always') assert.equal(fileFirstAttempt[0].response.optionId, 'allow-once', 'file accept selects once before always')
await read('window.__YCODE_CONVERSATION_STATE_TEST__.queueResponse("defer")') await read('window.__YCODE_CONVERSATION_STATE_TEST__.queueResponse("defer")')
await clickApproval('approval-file-tool', 'allow', true) await clickApproval('approval-file-request', 'allow-once', 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 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') const fileDeferredResponses = await responseRecords('approval-file-request')
assert.equal(fileDeferredResponses.length, 2, 'double click submits only one request while the first response is deferred') 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 read('window.__YCODE_CONVERSATION_STATE_TEST__.releaseDeferredResponse()')
await waitInteraction(fileApprovalRun, 'approval-file-request', 'answered') await waitInteraction(fileApprovalRun, 'approval-file-request', 'answered')
const fileAcceptedResponses = await responseRecords('approval-file-request') const fileAcceptedResponses = await responseRecords('approval-file-request')
assert.deepEqual(fileAcceptedResponses.map(item => item.response.optionId), ['allow-once', 'allow-once']) 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') await waitFor('!document.querySelector(' + JSON.stringify(approvalSelector('approval-file-request')) + ')', 'accepted file approval floating card is removed')
assert.ok(await read('document.querySelector(' + JSON.stringify(approvalSelector('approval-unrelated-request')) + ')'), 'unrelated generic approval remains visible')
await stopRun() await stopRun()
await waitFor('!document.querySelector(".cr-send-button--stop")', 'approval fixture run cancelled') 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 emitTool(runId, 'tool_call', call)
await waitTool(toolId, 'running', false) await waitTool(toolId, 'running', false)
await emit(runId, interactionEvent(runId, interactionId, toolId)) await emit(runId, interactionEvent(runId, interactionId, toolId))
await waitTool(toolId, 'pending', false) await waitTool(toolId, 'pending')
} else { } else {
await waitTool(toolId, 'pending', true) await waitTool(toolId, 'pending')
} }
const commandPending = await toolState(toolId) const commandPending = await toolState(toolId)
assert.equal(commandPending.expanded, scenario.arrival === 'early' ? 'true' : 'false') await waitFor('document.querySelector(' + JSON.stringify(approvalSelector(interactionId)) + ')', 'command approval floats above the composer')
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(interactionId)
if (scenario.arrival === 'early') commandButtonStyles = await approvalButtonStyles(toolId) const commandApproval = await approvalState(toolId, interactionId)
if (scenario.arrival === 'early') await screenshot('conversation-command-approval-2026-10-15.png', toolSelector(toolId)) assert.equal(commandApproval.inlineButtons.length, 0, 'command approval actions are not rendered inside the tool row')
assert.ok(!await read('document.querySelector(' + JSON.stringify(approvalSelector(interactionId)) + ')'), 'inline command request has no generic duplicate') assert.equal(commandApproval.inConversation, false, 'command approval cards are not rendered in the conversation stream')
if (scenario.arrival === 'delayed') { assert.equal(commandApproval.aboveComposer, true)
await clickTool(toolId) if (scenario.arrival === 'early') await screenshot('conversation-command-approval-floating-2026-10-20.png', '.conversation-input-area__custom-floating')
await waitTool(toolId, 'pending', true) await clickApproval(interactionId, scenario.optionId)
}
await clickApproval(toolId, scenario.decision)
await waitInteraction(runId, interactionId, scenario.status) 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) const responses = await responseRecords(interactionId)
assert.equal(responses.length, 1) assert.equal(responses.length, 1)
assert.equal(responses[0].response.optionId, scenario.optionId) 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 }) initialExpanded: commandPending.expanded, optionId: responses[0].response.optionId, status: interaction.status })
} }
for (const button of commandButtonStyles) { for (const button of commandButtonStyles) {
assert.equal(button.height, '20px') assert.equal(button.height, '44px')
assert.equal(button.minWidth, '52px') assert.equal(button.fontSize, '14px')
assert.equal(button.fontSize, '12px') assert.equal(button.padding, '8px 12px')
assert.equal(button.padding, '0px 10px')
} }
const commandRunStyle = commandButtonStyles.find(button => button.decision === 'allow') const commandRunStyle = commandButtonStyles.find(button => button.optionId === 'allow-once')
const commandSkipStyle = commandButtonStyles.find(button => button.decision === 'skip') const commandRejectStyle = commandButtonStyles.find(button => button.reject)
const commandRejectStyle = commandButtonStyles.find(button => button.decision === 'reject') assert.ok(commandRunStyle)
assert.ok(commandRunStyle.backgroundAlpha > 0) assert.ok(commandRejectStyle)
assert.ok(commandRejectStyle.backgroundAlpha > 0) assert.notEqual(commandRunStyle.color, commandRejectStyle.color)
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 largeRun = await send('write-large-motion') const largeRun = await send('write-large-motion')
const largeText = Array.from({ length: 180 }, (_, index) => 'export const value' + index + ' = ' + index + ';').join('\n') 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 } }, reducedClosed: { height: reducedClosed.height, transition: reducedClosed.transition } },
commands: { running: commandRunningState.status, error: commandErrorState.status, commands: { running: commandRunningState.status, error: commandErrorState.status,
emptySuccess: commandEmptySuccessState.status, cancelled: commandCancelledState.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, fileButtonStyles, commandButtonStyles,
fileRetryOptionIds: fileAcceptedResponses.map(item => item.response.optionId), deferredDoubleClickRequestCount: fileDeferredResponses.length, 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 } }, commandSkipRejectTransportLimitation: { skip: 'reject-once', reject: 'reject-once', distinctKernelResponse: false } },
consoleErrors: errors consoleErrors: errors
} }
+18 -7
View File
@@ -14,7 +14,7 @@ import { SourceModeSubmenu, SourceReferenceFilesSubmenu } from '../workbuddy/com
import type { ResourceEntry } from '@shared/resources' 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 { QuestionFloating } from '../workbuddy/conversation' import { PermissionConfirm, QuestionFloating } from '../workbuddy/conversation'
import type { AgentMode, ChatAttachment, MessageQuote, PermissionMode, UserContentBlock } from '@shared/conversation' import type { AgentMode, ChatAttachment, MessageQuote, PermissionMode, UserContentBlock } from '@shared/conversation'
import type { UserMessageEditContent } from '../workbuddy/conversation/UserMessageEditor' import type { UserMessageEditContent } from '../workbuddy/conversation/UserMessageEditor'
import type { ChatMessage } from '@shared/types' import type { ChatMessage } from '@shared/types'
@@ -255,7 +255,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
return preceding ? send(preceding.content, { attachments: preceding.attachments, resources: preceding.resources, quotes: preceding.quotes, userContent: preceding.userContent, permissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve() return preceding ? send(preceding.content, { attachments: preceding.attachments, resources: preceding.resources, quotes: preceding.quotes, userContent: preceding.userContent, permissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve()
} }
const lastAssistantId = messages.findLast((message) => message.role === 'assistant')?.id const lastAssistantId = messages.findLast((message) => message.role === 'assistant')?.id
const question = messages.flatMap((message) => message.interactions ?? []).find((item) => item.kind === 'question' && ['pending', 'responding', 'error'].includes(item.status)) const activeInteraction = messages.flatMap((message) => message.interactions ?? []).findLast((item) =>
(item.kind === 'question' || item.kind === 'approval') && ['pending', 'responding', 'error'].includes(item.status))
const bindDraftWorkspace = (ownerDraftId: number, workspaceId = useSettingsStore.getState().selectedWorkspaceId): void => { const bindDraftWorkspace = (ownerDraftId: number, workspaceId = useSettingsStore.getState().selectedWorkspaceId): void => {
const settings = useSettingsStore.getState() const settings = useSettingsStore.getState()
@@ -309,11 +310,21 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
}} }}
userContent={entry.userContent} userContent={entry.userContent}
onUserContentChange={userContent => setDrafts(all => ({ ...all, [draftKey]: { ...(all[draftKey] ?? { text: '', attachments: [] }), userContent } }))} onUserContentChange={userContent => setDrafts(all => ({ ...all, [draftKey]: { ...(all[draftKey] ?? { text: '', attachments: [] }), userContent } }))}
floatingSlot={question?.kind === 'question' && <QuestionFloating key={question.id} config={{ questions: question.questions, title: question.title, toolCallId: question.toolCallId }} floatingSlot={activeInteraction?.kind === 'question' ? <QuestionFloating key={activeInteraction.id} config={{ questions: activeInteraction.questions, title: activeInteraction.title, toolCallId: activeInteraction.toolCallId }}
responding={question.status === 'responding'} error={question.error} responding={activeInteraction.status === 'responding'} error={activeInteraction.error}
labels={{ title: '需要你确认', skip: '跳过', next: '下一题', send: '发送回答', customAnswer: '输入其他回答', skipAll: '跳过全部', previousQuestion: '上一题', nextQuestion: '下一题', dismiss: '收起', notAnswered: '未回答' }} labels={{ title: '需要你确认', skip: '跳过', next: '下一题', send: '发送回答', customAnswer: '输入其他回答', skipAll: '跳过全部', previousQuestion: '上一题', nextQuestion: '下一题', dismiss: '收起', notAnswered: '未回答' }}
onSelect={(_text, answer) => respond(question.id, { kind: 'question', answer })} onSelect={(_text, answer) => respond(activeInteraction.id, { kind: 'question', answer })}
onSkip={() => void respond(question.id, { kind: 'question', answer: { questions: question.questions.map((item) => ({ id: item.id, question: item.question, answers: [] })) }, skipped: true }).catch(() => undefined)} />} onSkip={() => void respond(activeInteraction.id, { kind: 'question', answer: { questions: activeInteraction.questions.map((item) => ({ id: item.id, question: item.question, answers: [] })) }, skipped: true }).catch(() => undefined)} />
: activeInteraction?.kind === 'approval' ? <PermissionConfirm key={activeInteraction.id} className="permission-floating"
pendingId={activeInteraction.id} toolName={activeInteraction.toolCallId} title={activeInteraction.title}
options={activeInteraction.options} status={activeInteraction.status} error={activeInteraction.error}
labels={{ confirm: '允许一次', alwaysAllow: '始终允许', reject: '拒绝', working: '提交中', approved: '已允许', rejected: '已拒绝', cancelled: '已取消', failed: '提交失败' }}
resolve={(id, decision) => respond(id, { kind: 'approval', optionId: decision.optionId })}
reject={(id) => {
const option = activeInteraction.options.find((item) => item.kind.startsWith('reject'))
return option ? respond(id, { kind: 'approval', optionId: option.optionId }) : Promise.reject(new Error('审批选项不可用'))
}} />
: undefined}
onResourcesChange={(resources) => setDrafts((all) => ({ ...all, [draftKey]: { ...(all[draftKey] ?? { text: '', attachments: [] }), resources } }))} onResourcesChange={(resources) => setDrafts((all) => ({ ...all, [draftKey]: { ...(all[draftKey] ?? { text: '', attachments: [] }), resources } }))}
quotes={entry.quotes ?? []} quotes={entry.quotes ?? []}
onRemoveQuote={removeQuote} onRemoveQuote={removeQuote}
@@ -379,7 +390,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
}}> }}>
<div className="chat-thread cr-message-list__content"><div className="cr-document"> <div className="chat-thread cr-message-list__content"><div className="cr-document">
{messages.map((message) => ( {messages.map((message) => (
<MessageBubble key={message.id} message={message} sessionId={currentId} onQuote={quoteTable} darkMode={darkMode} onRespond={respond} <MessageBubble key={message.id} message={message} sessionId={currentId} onQuote={quoteTable} darkMode={darkMode}
quotedSourceIds={quotedSourceIds} quotedSourceIds={quotedSourceIds}
artifactCount={!running && !sending && message.id === lastAssistantId ? artifactCount : 0} artifactCount={!running && !sending && message.id === lastAssistantId ? artifactCount : 0}
onShowArtifacts={() => window.dispatchEvent(new CustomEvent('ycode:open-artifacts', { detail: { sessionId: currentId } }))} onShowArtifacts={() => window.dispatchEvent(new CustomEvent('ycode:open-artifacts', { detail: { sessionId: currentId } }))}
+19 -53
View File
@@ -6,8 +6,7 @@ import { CompletedFold, LiveEventDisclosure } from '../workbuddy/conversation/Co
import type { CompletedFoldToolCallClassification } from '../workbuddy/conversation/completed-fold' import type { CompletedFoldToolCallClassification } from '../workbuddy/conversation/completed-fold'
import { resolveSourceToolIcon } from '../workbuddy/conversation/SourceToolIcons' import { resolveSourceToolIcon } from '../workbuddy/conversation/SourceToolIcons'
import { SourceToolHeader } from '../workbuddy/conversation/SourceToolHeader' import { SourceToolHeader } from '../workbuddy/conversation/SourceToolHeader'
import { getCapturedToolKind } from '../workbuddy/conversation/ToolCallRenderer' import { findToolApproval, isActiveToolApproval } from '../workbuddy/conversation/tool-approval'
import { findToolApproval, isActiveToolApproval, sourcePermissionMetadata, toolApprovalOption, type ToolApprovalControl } from '../workbuddy/conversation/tool-approval'
import { sourceConversationLabel, type SourceConversationLabelKey } from '../workbuddy/conversation/source-labels' import { sourceConversationLabel, type SourceConversationLabelKey } from '../workbuddy/conversation/source-labels'
import { computeSourceFoldSummary } from '../workbuddy/conversation/recovered-fold-summary/index.mjs' import { computeSourceFoldSummary } from '../workbuddy/conversation/recovered-fold-summary/index.mjs'
import { Fragment, useState, type ReactNode } from 'react' import { Fragment, useState, type ReactNode } from 'react'
@@ -15,11 +14,11 @@ import type { MessageActionToolbarProps } from '../workbuddy/message-actions/Mes
import { MessageActionToolbar } from '../workbuddy/message-actions/MessageActionToolbar' import { MessageActionToolbar } from '../workbuddy/message-actions/MessageActionToolbar'
import { UserMessageToolbar } from '../workbuddy/message-actions/UserMessageToolbar' import { UserMessageToolbar } from '../workbuddy/message-actions/UserMessageToolbar'
import { UserMessageEditor, type UserMessageEditContent } from '../workbuddy/conversation/UserMessageEditor' import { UserMessageEditor, type UserMessageEditContent } from '../workbuddy/conversation/UserMessageEditor'
import { PermissionConfirm, QuestionAnswerTranscript, ThinkingBlock, PlanCreateBlock, TaskBlock, ToolCallRenderer, type ConversationToolCall } from '../workbuddy/conversation' import { QuestionAnswerTranscript, ThinkingBlock, PlanCreateBlock, TaskBlock, ToolCallRenderer, type ConversationToolCall } from '../workbuddy/conversation'
import { conversationLabel } from '../workbuddy/conversation/labels' import { conversationLabel } from '../workbuddy/conversation/labels'
import { downloadInHost, openPathInHost } from '../workbuddy/host' import { downloadInHost, openPathInHost } from '../workbuddy/host'
import { openArtifactPreview } from '../stores/resources' import { openArtifactPreview } from '../stores/resources'
import type { InteractionResponse, MessageQuote, PlanContent } from '@shared/conversation' import type { MessageQuote, PlanContent } from '@shared/conversation'
import MarkdownContent from './MarkdownContent' import MarkdownContent from './MarkdownContent'
import StreamingFooter from '../workbuddy/conversation/StreamingFooter' import StreamingFooter from '../workbuddy/conversation/StreamingFooter'
import StreamingTurnDuration from '../workbuddy/conversation/StreamingTurnDuration' import StreamingTurnDuration from '../workbuddy/conversation/StreamingTurnDuration'
@@ -41,7 +40,6 @@ interface Props {
onStartEdit?(message: ChatMessage): void onStartEdit?(message: ChatMessage): void
onCancelEdit?(): void onCancelEdit?(): void
onSubmitEdit?(message: ChatMessage, text: string, edited?: UserMessageEditContent): Promise<void> onSubmitEdit?(message: ChatMessage, text: string, edited?: UserMessageEditContent): Promise<void>
onRespond?(id: string, response: InteractionResponse): Promise<void>
sessionId?: string | null sessionId?: string | null
onBuildPlan?(plan: PlanContent): Promise<void> onBuildPlan?(plan: PlanContent): Promise<void>
buildingPlanId?: string buildingPlanId?: string
@@ -49,7 +47,7 @@ interface Props {
onShowArtifacts?(): void onShowArtifacts?(): void
} }
export default function MessageBubble({ message, assistantAvatar, onQuote, quotedSourceIds, darkMode, actions, editingMessageId, onStartEdit, onCancelEdit, onSubmitEdit, onRespond, sessionId, onBuildPlan, buildingPlanId, artifactCount = 0, onShowArtifacts }: Props): React.JSX.Element { export default function MessageBubble({ message, assistantAvatar, onQuote, quotedSourceIds, darkMode, actions, editingMessageId, onStartEdit, onCancelEdit, onSubmitEdit, sessionId, onBuildPlan, buildingPlanId, artifactCount = 0, onShowArtifacts }: Props): React.JSX.Element {
const [actionError, setActionError] = useState<string | null>(null) const [actionError, setActionError] = useState<string | null>(null)
const [liveEventsExpanded, setLiveEventsExpanded] = useState(false) const [liveEventsExpanded, setLiveEventsExpanded] = useState(false)
const perform = (action: () => Promise<unknown>): void => { const perform = (action: () => Promise<unknown>): void => {
@@ -81,14 +79,12 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
const parts = getMessageParts(message) const parts = getMessageParts(message)
const tools = (message.tools ?? []).map(tool => tool.metadata?.importedHistory ? projectWorkBuddyHistoryTool(tool) const tools = (message.tools ?? []).map(tool => tool.metadata?.importedHistory ? projectWorkBuddyHistoryTool(tool)
: tool.metadata?.capability === 'mcp' ? { ...tool, name: 'mcp_call_tool', args: { serverName: tool.metadata.serverName, toolName: tool.metadata.toolName, arguments: tool.args } } : tool) : tool.metadata?.capability === 'mcp' ? { ...tool, name: 'mcp_call_tool', args: { serverName: tool.metadata.serverName, toolName: tool.metadata.toolName, arguments: tool.args } } : tool)
const inlineApprovals = new Map(tools.flatMap(tool => { const activeApprovals = new Map(tools.flatMap(tool => {
const kind = getCapturedToolKind(tool.name)
const request = findToolApproval(message.interactions, tool.id) const request = findToolApproval(message.interactions, tool.id)
if (!onRespond || !request || !['write-file', 'command'].includes(kind ?? '') || return request && isActiveToolApproval(request) ? [[tool.id, request] as const] : []
!toolApprovalOption(request.options, 'allow') || !toolApprovalOption(request.options, 'reject')) return []
return [[tool.id, request] as const]
})) }))
const inlineApprovalIds = new Set([...inlineApprovals.values()].map(request => request.id)) const activeQuestionIds = new Set((message.interactions ?? []).flatMap(interaction =>
interaction.kind === 'question' && ['pending', 'responding', 'error'].includes(interaction.status) ? [interaction.toolCallId] : []))
const classifyTool = (part: Extract<MessagePart, { type: 'tool' }>): CompletedFoldToolCallClassification | undefined => { const classifyTool = (part: Extract<MessagePart, { type: 'tool' }>): CompletedFoldToolCallClassification | undefined => {
const name = tools.find(tool => tool.id === part.toolId)?.name ?? '' const name = tools.find(tool => tool.id === part.toolId)?.name ?? ''
if (['show_widget', 'visualize:show_widget', 'visualizer:show_widget'].includes(name)) return { role: 'show-widget' } if (['show_widget', 'visualize:show_widget', 'visualizer:show_widget'].includes(name)) return { role: 'show-widget' }
@@ -98,42 +94,24 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
const renderTool = (id: string): React.ReactNode => { const renderTool = (id: string): React.ReactNode => {
const tool = tools.find((item) => item.id === id) const tool = tools.find((item) => item.id === id)
if (!tool) return null if (!tool) return null
if (activeQuestionIds.has(tool.id)) return null
const nativeResult = tool.result ?? tool.output ?? tool.summary const nativeResult = tool.result ?? tool.output ?? tool.summary
const result = nativeResult && typeof nativeResult === 'object' && 'result' in nativeResult ? nativeResult : { result: nativeResult } const result = nativeResult && typeof nativeResult === 'object' && 'result' in nativeResult ? nativeResult : { result: nativeResult }
const request = inlineApprovals.get(id) const activeApproval = activeApprovals.get(tool.id)
const activeRequest = request && isActiveToolApproval(request) ? request : undefined const view: ConversationToolCall = { id: tool.id, name: tool.name, status: activeApproval ? 'pending' : tool.status === 'ok' ? 'success' : tool.status,
const approval: ToolApprovalControl | undefined = activeRequest && onRespond ? {
status: activeRequest.status as ToolApprovalControl['status'],
onDecision: decision => {
const option = toolApprovalOption(activeRequest.options, decision)
if (option) perform(() => onRespond(activeRequest.id, { kind: 'approval', optionId: option.optionId }))
}
} : undefined
const view: ConversationToolCall = { id: tool.id, name: tool.name, status: activeRequest ? 'pending' : tool.status === 'ok' ? 'success' : tool.status,
intent: tool.intent, intent: tool.intent,
args: tool.args && typeof tool.args === 'object' ? tool.args as Record<string, unknown> : { input: tool.argsRaw }, args: tool.args && typeof tool.args === 'object' ? tool.args as Record<string, unknown> : { input: tool.argsRaw },
result, output: tool.metadata?.importedHistory && ['askuserquestion', 'todo_write'].includes(tool.name) ? undefined : tool.output, result, output: tool.metadata?.importedHistory && ['askuserquestion', 'todo_write'].includes(tool.name) ? undefined : tool.output,
metaData: activeRequest ? sourcePermissionMetadata(tool.metadata, activeRequest) : tool.metadata, metaData: tool.metadata,
isFinished: tool.status !== 'running', isLastContent: parts.at(-1)?.type === 'tool' && (parts.at(-1) as { toolId: string }).toolId === id } isFinished: tool.status !== 'running', isLastContent: parts.at(-1)?.type === 'tool' && (parts.at(-1) as { toolId: string }).toolId === id }
const plan = planFromTool(tool.result, tool.args, tool.id, tool.name) const plan = planFromTool(tool.result, tool.args, tool.id, tool.name)
const decideTask = async (index: number, toolCallId: string, data: { toolInfo?: Array<{ permissionRequestId?: string }> } | undefined, approve: boolean): Promise<void> => { return <ToolCallRenderer key={id} tool={view} sessionId={sessionId ?? undefined} renderMarkdown={(text) => <MarkdownContent content={text} darkMode={darkMode} />}
const binding = data?.toolInfo?.[index]?.permissionRequestId || toolCallId
const interaction = message.interactions?.find((item) => item.kind === 'approval' && [item.id, item.requestId, item.toolCallId].includes(binding))
if (interaction?.kind !== 'approval' || !onRespond) throw new Error('审批请求已结束或未连接')
const option = interaction.options.find((item) => approve ? item.kind.startsWith('allow') : item.kind.startsWith('reject'))
if (!option) throw new Error('审批选项不可用')
await onRespond(interaction.id, { kind: 'approval', optionId: option.optionId })
}
const hasApproval = !!onRespond && message.interactions?.some((item) => item.kind === 'approval' && ['pending', 'error', 'responding'].includes(item.status))
return <ToolCallRenderer key={id} tool={view} approval={approval} sessionId={sessionId ?? undefined} renderMarkdown={(text) => <MarkdownContent content={text} darkMode={darkMode} />}
onOpenFile={(request) => request.artifact onOpenFile={(request) => request.artifact
? openArtifactPreview({ kind: 'artifact', path: request.filePath, name: request.title || request.artifact.title || request.filePath, content: request.content ?? '', artifact: request.artifact }) ? openArtifactPreview({ kind: 'artifact', path: request.filePath, name: request.title || request.artifact.title || request.filePath, content: request.content ?? '', artifact: request.artifact })
: request.artifactType && request.content !== undefined : request.artifactType && request.content !== undefined
? openArtifactPreview({ kind: 'artifact', path: request.filePath, name: request.title || request.filePath, content: request.content, artifactType: request.artifactType }) ? openArtifactPreview({ kind: 'artifact', path: request.filePath, name: request.title || request.filePath, content: request.content, artifactType: request.artifactType })
: openPathInHost(request.filePath, 'file', request.startLine ? { start: request.startLine, end: request.endLine ?? request.startLine } : undefined)} : openPathInHost(request.filePath, 'file', request.startLine ? { start: request.startLine, end: request.endLine ?? request.startLine } : undefined)}
onOpenLink={(url) => perform(() => window.ycode.resources.openExternal(url))} onOpenLink={(url) => perform(() => window.ycode.resources.openExternal(url))}
onTaskApprove={hasApproval ? (index, toolCallId, data) => decideTask(index, toolCallId, data, true) : undefined}
onTaskReject={hasApproval ? (index, toolCallId, data) => decideTask(index, toolCallId, data, false) : undefined}
onPlanTaskClick={(request) => viewPlan(plan, request.taskId)} onPlanView={() => viewPlan(plan)} onPlanDownload={() => downloadPlan(plan)} onPlanTaskClick={(request) => viewPlan(plan, request.taskId)} onPlanView={() => viewPlan(plan)} onPlanDownload={() => downloadPlan(plan)}
onPlanBuild={onBuildPlan ? (request) => perform(() => onBuildPlan({ id: request.planId, title: request.planName, steps: request.tasks.map((task) => ({ id: String(task.id), text: task.title, status: 'pending' })) })) : undefined} onPlanBuild={onBuildPlan ? (request) => perform(() => onBuildPlan({ id: request.planId, title: request.planName, steps: request.tasks.map((task) => ({ id: String(task.id), text: task.title, status: 'pending' })) })) : undefined}
isBuildingPlan={buildingPlanId === plan.id} /> isBuildingPlan={buildingPlanId === plan.id} />
@@ -160,10 +138,7 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
const part = unit.part as MessagePart const part = unit.part as MessagePart
return part.type === 'tool' ? tools.filter(tool => tool.id === part.toolId) : [] return part.type === 'tool' ? tools.filter(tool => tool.id === part.toolId) : []
}) })
if (groupedTools.some(tool => { if (groupedTools.some(tool => activeApprovals.has(tool.id))) return <Fragment key={group.key}>{children}</Fragment>
const request = inlineApprovals.get(tool.id)
return !!request && isActiveToolApproval(request)
})) return <Fragment key={group.key}>{children}</Fragment>
const latestProgressIndex = parts.reduce((latest, part, index) => part.type === 'tool' || part.type === 'reasoning' ? index : latest, -1) const latestProgressIndex = parts.reduce((latest, part, index) => part.type === 'tool' || part.type === 'reasoning' ? index : latest, -1)
if (!group.units.some(unit => unit.index === latestProgressIndex)) return null if (!group.units.some(unit => unit.index === latestProgressIndex)) return null
@@ -174,7 +149,7 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
const summaryArgs = latestTool && ['list_dir', 'list_files'].includes(latestTool.name) && !toolArgs.target_directory && !toolArgs.root && typeof toolArgs.path === 'string' const summaryArgs = latestTool && ['list_dir', 'list_files'].includes(latestTool.name) && !toolArgs.target_directory && !toolArgs.root && typeof toolArgs.path === 'string'
? { ...toolArgs, target_directory: toolArgs.path } ? { ...toolArgs, target_directory: toolArgs.path }
: toolArgs : toolArgs
const pendingConfirmation = !!latestTool && inlineApprovals.has(latestTool.id) && isActiveToolApproval(inlineApprovals.get(latestTool.id)!) const pendingConfirmation = !!latestTool && activeApprovals.has(latestTool.id)
const summaryStatus = !latestTool ? 'success' : pendingConfirmation ? 'pending' const summaryStatus = !latestTool ? 'success' : pendingConfirmation ? 'pending'
: latestTool.status === 'ok' ? 'executed' : latestTool.status === 'ok' ? 'executed'
: ['running', 'executing'].includes(latestTool.status) ? 'stream_executing' : ['running', 'executing'].includes(latestTool.status) ? 'stream_executing'
@@ -195,8 +170,7 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
if (part.type !== 'tool') return if (part.type !== 'tool') return
const tool = tools.find(item => item.id === part.toolId) const tool = tools.find(item => item.id === part.toolId)
if (!tool || classifyTool(part)?.role) return if (!tool || classifyTool(part)?.role) return
const approvalRequest = inlineApprovals.get(tool.id) if (activeApprovals.has(tool.id) || activeQuestionIds.has(tool.id)) return
if (approvalRequest && isActiveToolApproval(approvalRequest)) return
liveEventParts.push({ part, index: eventIndex }) liveEventParts.push({ part, index: eventIndex })
}) })
const activityHeader = <div className="cr-captured-tool cr-streaming-activity" role="status" aria-live="polite" data-activity-tool-id={latestTool?.id}> const activityHeader = <div className="cr-captured-tool cr-streaming-activity" role="status" aria-live="polite" data-activity-tool-id={latestTool?.id}>
@@ -248,17 +222,9 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote
) )
}} /> }} />
{message.interactions?.map((interaction) => interaction.kind === 'approval' ? inlineApprovalIds.has(interaction.id) {message.interactions?.map((interaction) => interaction.kind === 'question' && interaction.answer &&
? interaction.error && !actionError ? <p key={interaction.id} className="msg__status msg__status--error" role="alert">{interaction.error}</p> : null !message.tools?.some((tool) => tool.id === interaction.toolCallId && tool.metadata?.questionAnswer)
: <PermissionConfirm key={interaction.id} ? <QuestionAnswerTranscript key={interaction.id} questionAnswer={interaction.answer} /> : null)}
pendingId={interaction.id} toolName={interaction.toolCallId} title={interaction.title} options={interaction.options}
status={interaction.status} error={interaction.error}
labels={{ confirm: '允许一次', alwaysAllow: '始终允许', reject: '拒绝', working: '提交中', approved: '已允许', rejected: '已拒绝', cancelled: '已取消', failed: '提交失败' }}
resolve={(id, decision) => onRespond?.(id, { kind: 'approval', optionId: decision.optionId })}
reject={(id) => {
const option = interaction.options.find((item) => item.kind.startsWith('reject'))
return option ? onRespond?.(id, { kind: 'approval', optionId: option.optionId }) : undefined
}} /> : interaction.answer && !message.tools?.some((tool) => tool.id === interaction.toolCallId && tool.metadata?.questionAnswer) ? <QuestionAnswerTranscript key={interaction.id} questionAnswer={interaction.answer} /> : null)}
{streaming && <StreamingFooter prepareText={resolvePrepareText(message.prepareStage)} />} {streaming && <StreamingFooter prepareText={resolvePrepareText(message.prepareStage)} />}
@@ -345,6 +345,177 @@
font-size: var(--yc-text-caption-size, 12px); font-size: var(--yc-text-caption-size, 12px);
} }
.conversation-input-area__custom-floating {
width: 100%;
min-width: 0;
padding-bottom: 8px;
}
.conversation-input-area__custom-floating > * {
width: 100%;
max-width: 100%;
}
.conversation-high-credit-approval.permission-floating {
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
border: 1px solid var(--cb-question-floating-card-border, var(--yc-border-primary, #e6e6e6));
border-radius: 24px;
background: var(--cb-question-floating-card-bg, var(--yc-bg-primary, #fff));
color: var(--cb-question-floating-text-primary, var(--yc-text-primary, #222));
box-shadow: 0 6px 16px #00000005, 0 1px 4px #00000008;
animation: permission-floating-in .2s ease-out;
}
.permission-floating .conversation-high-credit-approval__header {
display: flex;
flex-direction: column;
gap: 4px;
padding: 20px 24px 12px;
}
.permission-floating .conversation-high-credit-approval__title-row {
display: flex;
align-items: center;
min-width: 0;
}
.permission-floating .conversation-high-credit-approval__title {
min-width: 0;
overflow: hidden;
color: var(--cb-question-floating-text-primary, var(--yc-text-primary, #222));
font-size: 14px;
font-weight: 500;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.permission-floating .conversation-high-credit-approval__description {
margin: 0;
color: var(--cb-question-floating-text-secondary, var(--yc-text-secondary, #666));
font-size: 13px;
line-height: 20px;
}
.permission-floating .conversation-high-credit-approval__content {
display: flex;
flex-direction: column;
padding: 0 12px;
}
.permission-floating .conversation-high-credit-approval__option {
position: relative;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 44px;
min-height: 44px;
padding: 8px 12px;
border: 0;
border-radius: 12px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background-color .15s, opacity .15s;
}
.permission-floating .conversation-high-credit-approval__option:not(:last-child)::after {
position: absolute;
right: 12px;
bottom: 0;
left: 12px;
height: 1px;
background: var(--cb-question-floating-hover-bg, var(--yc-bg-hover, #f2f2f2));
content: "";
pointer-events: none;
}
.permission-floating .conversation-high-credit-approval__option:hover:not(:disabled),
.permission-floating .conversation-high-credit-approval__option:focus-visible {
background: var(--cb-question-floating-hover-bg, var(--yc-bg-hover, #f2f2f2));
outline: none;
}
.permission-floating .conversation-high-credit-approval__option:disabled {
cursor: default;
opacity: .45;
}
.permission-floating .conversation-high-credit-approval__option-num {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 8px;
background: var(--cb-question-floating-number-bg, var(--yc-bg-secondary, #f2f2f2));
font-size: 14px;
line-height: 14px;
}
.permission-floating .conversation-high-credit-approval__option-text {
flex: 1;
min-width: 0;
font-size: 14px;
line-height: 22px;
}
.permission-floating .conversation-high-credit-approval__option-arrow {
flex: none;
width: 16px;
height: 16px;
color: var(--cb-question-floating-text-secondary, var(--yc-text-secondary, #666));
opacity: 0;
transition: opacity .15s;
}
.permission-floating .conversation-high-credit-approval__option:hover .conversation-high-credit-approval__option-arrow {
opacity: 1;
}
.permission-floating .conversation-high-credit-approval__option--reject {
color: var(--yc-status-error, #cf222e);
}
.permission-floating .conversation-high-credit-approval__working {
padding: 0 24px 16px;
color: var(--cb-question-floating-text-secondary, var(--yc-text-secondary, #666));
font-size: 12px;
line-height: 20px;
}
.permission-floating .conversation-high-credit-approval__error {
margin: 0 24px 8px;
color: var(--yc-status-error, #cf222e);
font-size: 12px;
line-height: 20px;
}
@keyframes permission-floating-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.conversation-high-credit-approval.permission-floating {
animation: none;
}
}
@media (max-width: 480px) { @media (max-width: 480px) {
.wb-composer .cr-input-toolbar { .wb-composer .cr-input-toolbar {
align-items: flex-start; align-items: flex-start;