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