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

+18 -7
View File
@@ -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' && <QuestionFloating key={question.id} config={{ questions: question.questions, title: question.title, toolCallId: question.toolCallId }}
responding={question.status === 'responding'} error={question.error}
floatingSlot={activeInteraction?.kind === 'question' ? <QuestionFloating key={activeInteraction.id} config={{ questions: activeInteraction.questions, title: activeInteraction.title, toolCallId: activeInteraction.toolCallId }}
responding={activeInteraction.status === 'responding'} error={activeInteraction.error}
labels={{ title: '需要你确认', skip: '跳过', next: '下一题', send: '发送回答', customAnswer: '输入其他回答', skipAll: '跳过全部', previousQuestion: '上一题', nextQuestion: '下一题', dismiss: '收起', notAnswered: '未回答' }}
onSelect={(_text, answer) => 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' ? <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 } }))}
quotes={entry.quotes ?? []}
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">
{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}
artifactCount={!running && !sending && message.id === lastAssistantId ? artifactCount : 0}
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 { 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<void>
onRespond?(id: string, response: InteractionResponse): Promise<void>
sessionId?: string | null
onBuildPlan?(plan: PlanContent): Promise<void>
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<string | null>(null)
const [liveEventsExpanded, setLiveEventsExpanded] = useState(false)
const perform = (action: () => Promise<unknown>): 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<MessagePart, { type: 'tool' }>): 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<string, unknown> : { 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<void> => {
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} />}
return <ToolCallRenderer key={id} tool={view} sessionId={sessionId ?? undefined} renderMarkdown={(text) => <MarkdownContent content={text} darkMode={darkMode} />}
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 <Fragment key={group.key}>{children}</Fragment>
if (groupedTools.some(tool => activeApprovals.has(tool.id))) return <Fragment key={group.key}>{children}</Fragment>
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 = <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)
? interaction.error && !actionError ? <p key={interaction.id} className="msg__status msg__status--error" role="alert">{interaction.error}</p> : null
: <PermissionConfirm key={interaction.id}
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)}
{message.interactions?.map((interaction) => interaction.kind === 'question' && 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)} />}
@@ -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;