From 1b52df06039454d7d16fa2d0e69898f882f5b493 Mon Sep 17 00:00:00 2001 From: "xiaohan.yuan" Date: Tue, 20 Oct 2026 16:31:03 +0800 Subject: [PATCH] fix(chat): float approvals above composer --- scripts/conversation-renderer-regression.tsx | 60 ++++++ scripts/verify-conversation-states.cjs | 120 ++++++------ src/renderer/src/components/ChatPane.tsx | 25 ++- src/renderer/src/components/MessageBubble.tsx | 72 ++------ .../src/workbuddy/composer/composer.css | 171 ++++++++++++++++++ 5 files changed, 321 insertions(+), 127 deletions(-) diff --git a/scripts/conversation-renderer-regression.tsx b/scripts/conversation-renderer-regression.tsx index fba2bc1..4d6f423 100644 --- a/scripts/conversation-renderer-regression.tsx +++ b/scripts/conversation-renderer-regression.tsx @@ -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() +assert.doesNotMatch(terminalApprovalMarkup, /conversation-high-credit-approval/, + 'a completed non-inline approval must not leave a terminal card in the conversation') + +const activeApprovalMarkup = renderToStaticMarkup( ({ ...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() +assert.doesNotMatch(activeQuestionMarkup, /ask-user-question/, + 'an active user question must render only in the composer floating slot') + const questionMarkup = renderToStaticMarkup() assert.match(questionMarkup, /Which runtime\?/) assert.match(questionMarkup, /Node\.js/) diff --git a/scripts/verify-conversation-states.cjs b/scripts/verify-conversation-states.cjs index e8ddead..0f4f6cf 100644 --- a/scripts/verify-conversation-states.cjs +++ b/scripts/verify-conversation-states.cjs @@ -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 } diff --git a/src/renderer/src/components/ChatPane.tsx b/src/renderer/src/components/ChatPane.tsx index b390c98..bd07ba1 100644 --- a/src/renderer/src/components/ChatPane.tsx +++ b/src/renderer/src/components/ChatPane.tsx @@ -14,7 +14,7 @@ import { SourceModeSubmenu, SourceReferenceFilesSubmenu } from '../workbuddy/com import type { ResourceEntry } from '@shared/resources' import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls' 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 { UserMessageEditContent } from '../workbuddy/conversation/UserMessageEditor' 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() } 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 settings = useSettingsStore.getState() @@ -309,11 +310,21 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, }} userContent={entry.userContent} onUserContentChange={userContent => setDrafts(all => ({ ...all, [draftKey]: { ...(all[draftKey] ?? { text: '', attachments: [] }), userContent } }))} - floatingSlot={question?.kind === 'question' && respond(question.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)} />} + onSelect={(_text, answer) => respond(activeInteraction.id, { kind: 'question', answer })} + 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' ? 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 } }))} quotes={entry.quotes ?? []} onRemoveQuote={removeQuote} @@ -379,7 +390,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, }}>
{messages.map((message) => ( - window.dispatchEvent(new CustomEvent('ycode:open-artifacts', { detail: { sessionId: currentId } }))} diff --git a/src/renderer/src/components/MessageBubble.tsx b/src/renderer/src/components/MessageBubble.tsx index f5938bb..62ee00b 100644 --- a/src/renderer/src/components/MessageBubble.tsx +++ b/src/renderer/src/components/MessageBubble.tsx @@ -6,8 +6,7 @@ import { CompletedFold, LiveEventDisclosure } from '../workbuddy/conversation/Co import type { CompletedFoldToolCallClassification } from '../workbuddy/conversation/completed-fold' import { resolveSourceToolIcon } from '../workbuddy/conversation/SourceToolIcons' import { SourceToolHeader } from '../workbuddy/conversation/SourceToolHeader' -import { getCapturedToolKind } from '../workbuddy/conversation/ToolCallRenderer' -import { findToolApproval, isActiveToolApproval, sourcePermissionMetadata, toolApprovalOption, type ToolApprovalControl } from '../workbuddy/conversation/tool-approval' +import { findToolApproval, isActiveToolApproval } from '../workbuddy/conversation/tool-approval' import { sourceConversationLabel, type SourceConversationLabelKey } from '../workbuddy/conversation/source-labels' import { computeSourceFoldSummary } from '../workbuddy/conversation/recovered-fold-summary/index.mjs' 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 { UserMessageToolbar } from '../workbuddy/message-actions/UserMessageToolbar' 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 { downloadInHost, openPathInHost } from '../workbuddy/host' 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 StreamingFooter from '../workbuddy/conversation/StreamingFooter' import StreamingTurnDuration from '../workbuddy/conversation/StreamingTurnDuration' @@ -41,7 +40,6 @@ interface Props { onStartEdit?(message: ChatMessage): void onCancelEdit?(): void onSubmitEdit?(message: ChatMessage, text: string, edited?: UserMessageEditContent): Promise - onRespond?(id: string, response: InteractionResponse): Promise sessionId?: string | null onBuildPlan?(plan: PlanContent): Promise buildingPlanId?: string @@ -49,7 +47,7 @@ interface Props { 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(null) const [liveEventsExpanded, setLiveEventsExpanded] = useState(false) const perform = (action: () => Promise): void => { @@ -81,14 +79,12 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote const parts = getMessageParts(message) 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) - const inlineApprovals = new Map(tools.flatMap(tool => { - const kind = getCapturedToolKind(tool.name) + const activeApprovals = new Map(tools.flatMap(tool => { const request = findToolApproval(message.interactions, tool.id) - if (!onRespond || !request || !['write-file', 'command'].includes(kind ?? '') || - !toolApprovalOption(request.options, 'allow') || !toolApprovalOption(request.options, 'reject')) return [] - return [[tool.id, request] as const] + return request && isActiveToolApproval(request) ? [[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): CompletedFoldToolCallClassification | undefined => { 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' } @@ -98,42 +94,24 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote const renderTool = (id: string): React.ReactNode => { const tool = tools.find((item) => item.id === id) if (!tool) return null + if (activeQuestionIds.has(tool.id)) return null const nativeResult = tool.result ?? tool.output ?? tool.summary const result = nativeResult && typeof nativeResult === 'object' && 'result' in nativeResult ? nativeResult : { result: nativeResult } - const request = inlineApprovals.get(id) - const activeRequest = request && isActiveToolApproval(request) ? request : undefined - 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, + const activeApproval = activeApprovals.get(tool.id) + const view: ConversationToolCall = { id: tool.id, name: tool.name, status: activeApproval ? 'pending' : tool.status === 'ok' ? 'success' : tool.status, intent: tool.intent, args: tool.args && typeof tool.args === 'object' ? tool.args as Record : { input: tool.argsRaw }, 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 } 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 => { - 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 } + return } onOpenFile={(request) => 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 ? 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)} 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)} 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} /> @@ -160,10 +138,7 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote const part = unit.part as MessagePart return part.type === 'tool' ? tools.filter(tool => tool.id === part.toolId) : [] }) - if (groupedTools.some(tool => { - const request = inlineApprovals.get(tool.id) - return !!request && isActiveToolApproval(request) - })) return {children} + if (groupedTools.some(tool => activeApprovals.has(tool.id))) return {children} 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 @@ -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' ? { ...toolArgs, target_directory: toolArgs.path } : 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' : latestTool.status === 'ok' ? 'executed' : ['running', 'executing'].includes(latestTool.status) ? 'stream_executing' @@ -195,8 +170,7 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote if (part.type !== 'tool') return const tool = tools.find(item => item.id === part.toolId) if (!tool || classifyTool(part)?.role) return - const approvalRequest = inlineApprovals.get(tool.id) - if (approvalRequest && isActiveToolApproval(approvalRequest)) return + if (activeApprovals.has(tool.id) || activeQuestionIds.has(tool.id)) return liveEventParts.push({ part, index: eventIndex }) }) const activityHeader =
@@ -248,17 +222,9 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote ) }} /> - {message.interactions?.map((interaction) => interaction.kind === 'approval' ? inlineApprovalIds.has(interaction.id) - ? interaction.error && !actionError ?

{interaction.error}

: null - : 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) ? : null)} + {message.interactions?.map((interaction) => interaction.kind === 'question' && interaction.answer && + !message.tools?.some((tool) => tool.id === interaction.toolCallId && tool.metadata?.questionAnswer) + ? : null)} {streaming && } diff --git a/src/renderer/src/workbuddy/composer/composer.css b/src/renderer/src/workbuddy/composer/composer.css index d57a485..89d17c5 100644 --- a/src/renderer/src/workbuddy/composer/composer.css +++ b/src/renderer/src/workbuddy/composer/composer.css @@ -345,6 +345,177 @@ 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) { .wb-composer .cr-input-toolbar { align-items: flex-start;