Align MCP OAuth and tool approval cards with agent tool timeline UX.
Build and Push Docker Image / build-ui (push) Has been cancelled
Build and Push Docker Image / merge (push) Has been cancelled
Build and Push Docker Image / build-docreader (push) Has been cancelled
Build and Push Docker Image / build-sandbox (push) Has been cancelled
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled

Unify inline interaction styling with tool steps: timeline icon placement, shimmer pending state, collapsible JSON args, and lighter status rows.
This commit is contained in:
wizardchen
2026-06-29 23:58:02 +08:00
committed by lyingbug
parent b5a1c88f75
commit 77e4f93e3e
7 changed files with 214 additions and 124 deletions
+2
View File
@@ -5036,6 +5036,7 @@ export default {
toolApproval: {
banner: 'This MCP tool requires human approval. Review parameters before execution.',
waiting: 'Awaiting review · {target}',
waitingStatus: 'Awaiting review',
targetWithTool: '{service} › {tool}',
titleWithTarget: 'Review · {service} › {tool}',
resolvedApproved: 'Approved · {target}',
@@ -5058,6 +5059,7 @@ export default {
mcpOAuth: {
banner: 'This MCP service requires OAuth authorization before it can be used',
waiting: 'Awaiting authorization · {target}',
waitingStatus: 'Awaiting authorization',
targetWithTool: '{service} › {tool}',
titleWithService: 'OAuth · {service}',
titleWithTool: 'OAuth · {service} › {tool}',
+2
View File
@@ -5053,6 +5053,7 @@ export default {
toolApproval: {
banner: '이 MCP 도구는 수동 승인이 필요합니다. 실행 전 매개변수를 확인하세요.',
waiting: '승인 대기 · {target}',
waitingStatus: '승인 대기',
targetWithTool: '{service} › {tool}',
titleWithTarget: '검토 · {service} › {tool}',
resolvedApproved: '승인됨 · {target}',
@@ -5075,6 +5076,7 @@ export default {
mcpOAuth: {
banner: '이 MCP 서비스는 OAuth 인증 후에 사용할 수 있습니다',
waiting: '인증 대기 · {target}',
waitingStatus: '인증 대기',
targetWithTool: '{service} › {tool}',
titleWithService: 'OAuth · {service}',
titleWithTool: 'OAuth · {service} › {tool}',
+2
View File
@@ -4553,6 +4553,7 @@ export default {
toolApproval: {
banner: 'Этот инструмент MCP требует подтверждения. Проверьте параметры.',
waiting: 'Ожидание проверки · {target}',
waitingStatus: 'Ожидание проверки',
targetWithTool: '{service} › {tool}',
titleWithTarget: 'Проверка · {service} › {tool}',
resolvedApproved: 'Подтверждено · {target}',
@@ -4575,6 +4576,7 @@ export default {
mcpOAuth: {
banner: 'Этот MCP-сервис требует авторизации OAuth перед использованием',
waiting: 'Ожидание авторизации · {target}',
waitingStatus: 'Ожидание авторизации',
targetWithTool: '{service} › {tool}',
titleWithService: 'OAuth · {service}',
titleWithTool: 'OAuth · {service} › {tool}',
+2
View File
@@ -5049,6 +5049,7 @@ export default {
toolApproval: {
banner: "该 MCP 工具已标记为「需人工审核」,确认参数后再执行",
waiting: "等待审核 · {target}",
waitingStatus: "等待审核",
targetWithTool: "{service} › {tool}",
titleWithTarget: "人工审核 · {service} › {tool}",
resolvedApproved: "已通过 · {target}",
@@ -5071,6 +5072,7 @@ export default {
mcpOAuth: {
banner: "该 MCP 服务需要 OAuth 授权后才能调用",
waiting: "等待授权 · {target}",
waitingStatus: "等待授权",
targetWithTool: "{service} › {tool}",
titleWithService: "OAuth 授权 · {service}",
titleWithTool: "OAuth 授权 · {service} › {tool}",
@@ -1,35 +1,30 @@
<template>
<div class="action-card interaction-inline" :class="cardClass">
<div class="action-card interaction-inline" :class="{ 'action-pending': !resolved }">
<div class="action-header no-results">
<div class="action-title">
<t-icon class="action-title-icon" :name="headerIcon" />
<span class="action-name">{{ titleLine }}</span>
<span class="action-name">{{ mainTitle }}</span>
</div>
</div>
<div class="interaction-status-summary">
<div class="results-summary-text">
<span v-if="resolved" class="status-label">{{ statusLine }}</span>
<span v-if="!resolved" class="inline-actions">
<button
type="button"
class="inline-action"
:disabled="canceling || authorizing"
@click="skip"
>
<button type="button" class="inline-action" :disabled="canceling || authorizing" @click="skip">
{{ $t('agentStream.mcpOAuth.skip') }}
</button>
<span class="inline-dot">·</span>
<button
type="button"
class="inline-action is-primary"
:disabled="authorizing || canceling"
@click="authorize"
>
<button type="button" class="inline-action is-primary" :disabled="authorizing || canceling"
@click="authorize">
{{ $t('agentStream.mcpOAuth.authorize') }}
</button>
</span>
<span
v-if="!resolved && secondsLeft >= 0"
class="action-timer"
:class="timerClass"
>
{{ formatCountdown(secondsLeft) }}
</span>
<template v-if="!resolved && secondsLeft >= 0">
<span class="inline-dot">·</span>
<span class="action-timer" :class="timerClass">
{{ formatCountdown(secondsLeft) }}
</span>
</template>
</div>
</div>
</div>
@@ -98,22 +93,21 @@ const targetLabel = computed(() => (
: props.serviceName
))
const titleLine = computed(() => {
const mainTitle = computed(() => {
if (!props.resolved) {
return t('agentStream.mcpOAuth.waiting', { target: targetLabel.value })
}
if (props.authorized) {
return t('agentStream.mcpOAuth.resolvedAuthorized', { target: targetLabel.value })
}
if (props.timedOut) return t('agentStream.mcpOAuth.resolvedTimedOut', { target: targetLabel.value })
return t('agentStream.mcpOAuth.resolvedCanceled', { target: targetLabel.value })
return props.mcpToolName
? t('agentStream.mcpOAuth.titleWithTool', { service: props.serviceName, tool: props.mcpToolName })
: t('agentStream.mcpOAuth.titleWithService', { service: props.serviceName })
})
const cardClass = computed(() => ({
'action-pending': !props.resolved,
'action-success': !!props.resolved && !!props.authorized,
'action-error': !!props.resolved && !props.authorized,
}))
const statusLine = computed(() => {
if (!props.resolved) return t('agentStream.mcpOAuth.waitingStatus')
if (props.authorized) return t('agentStream.mcpOAuth.authorizedTag')
if (props.timedOut) return t('agentStream.mcpOAuth.timedOutTag')
return t('agentStream.mcpOAuth.canceledTag')
})
function formatCountdown(s: number): string {
if (s < 60) return t('agentStream.mcpOAuth.countdownShort', { seconds: s })
@@ -1,21 +1,36 @@
<template>
<div class="action-card interaction-inline" :class="cardClass">
<div class="action-card interaction-inline" :class="{ 'action-pending': !resolved }">
<div class="action-header no-results">
<div class="action-title">
<t-icon class="action-title-icon" :name="headerIcon" />
<span class="action-name">{{ titleLine }}</span>
<span
v-if="!resolved && secondsLeft >= 0"
class="action-timer"
:class="timerClass"
>
{{ formatCountdown(secondsLeft) }}
</span>
<span class="action-name">{{ mainTitle }}</span>
</div>
</div>
<div v-if="!resolved" class="interaction-panel">
<div v-if="!isJsonValid || argsDirty" class="interaction-args-meta">
<div class="interaction-status-summary">
<div class="results-summary-text">
<span v-if="resolved" class="status-label">{{ statusLine }}</span>
<span v-if="!resolved" class="inline-actions">
<button type="button" class="inline-action" :disabled="submitting" @click.stop="submit('reject')">
{{ $t('agentStream.toolApproval.reject') }}
</button>
<span class="inline-dot">·</span>
<button type="button" class="inline-action is-primary" :disabled="submitting || !isJsonValid"
@click.stop="submit('approve')">
{{ $t('agentStream.toolApproval.approve') }}
</button>
</span>
<template v-if="!resolved && secondsLeft >= 0">
<span class="inline-dot">·</span>
<span class="action-timer" :class="timerClass">
{{ formatCountdown(secondsLeft) }}
</span>
</template>
</div>
</div>
<div v-if="!resolved && !argsExpanded && (!isJsonValid || argsDirty)" class="interaction-status-summary">
<div class="results-summary-text">
<span v-if="!isJsonValid" class="args-status args-invalid">
<t-icon name="error-circle" />
{{ $t('agentStream.toolApproval.invalidJson') }}
@@ -24,37 +39,32 @@
{{ $t('agentStream.toolApproval.argsModified') }}
</span>
</div>
<t-textarea
v-model="argsText"
class="interaction-args-input"
:autosize="{ minRows: 2, maxRows: 8 }"
placeholder="{}"
/>
<div class="interaction-panel-actions">
<button
type="button"
class="inline-action"
:disabled="submitting"
@click="submit('reject')"
>
{{ $t('agentStream.toolApproval.reject') }}
</button>
<span class="inline-dot">·</span>
<button
type="button"
class="inline-action is-primary"
:disabled="submitting || !isJsonValid"
@click="submit('approve')"
>
{{ $t('agentStream.toolApproval.approve') }}
</button>
</div>
<div v-if="hasArgs" class="interaction-status-summary is-clickable" @click="toggleArgs">
<div class="results-summary-text args-toggle-row">
<span>{{ $t('agentStream.toolApproval.argsLabel') }}</span>
<t-icon class="args-toggle-icon" :name="argsExpanded ? 'chevron-down' : 'chevron-right'" />
</div>
</div>
<div v-else-if="argsText" class="interaction-panel">
<div class="detail-output-wrapper">
<div class="detail-output">{{ argsText }}</div>
<div v-if="hasArgs && argsExpanded" class="action-details">
<div v-if="!resolved && (!isJsonValid || argsDirty)" class="interaction-args-meta">
<span v-if="!isJsonValid" class="args-status args-invalid">
<t-icon name="error-circle" />
{{ $t('agentStream.toolApproval.invalidJson') }}
</span>
<span v-else-if="argsDirty" class="args-status args-dirty">
{{ $t('agentStream.toolApproval.argsModified') }}
</span>
</div>
<div v-if="!resolved" class="interaction-args-block">
<t-textarea v-model="argsText" class="interaction-args-input" :autosize="{ minRows: 2, maxRows: 8 }"
placeholder="{}" @click.stop />
</div>
<div v-else class="interaction-args-preview">{{ argsText }}</div>
</div>
</div>
</template>
@@ -90,10 +100,13 @@ function formatJson(raw: string): string {
const initialArgs = formatJson(props.argsJson || '{}')
const argsText = ref(initialArgs)
const argsExpanded = ref(false)
const submitting = ref(false)
const now = ref(Date.now())
let timer: ReturnType<typeof setInterval> | null = null
const hasArgs = computed(() => argsText.value.trim().length > 0)
const isJsonValid = computed(() => {
if (!argsText.value.trim()) return true
try {
@@ -136,17 +149,25 @@ const targetLabel = computed(() => (
})
))
const titleLine = computed(() => {
if (!props.resolved) return t('agentStream.toolApproval.waiting', { target: targetLabel.value })
if (props.approved) return t('agentStream.toolApproval.resolvedApproved', { target: targetLabel.value })
return t('agentStream.toolApproval.resolvedRejected', { target: targetLabel.value })
const mainTitle = computed(() => {
if (!props.resolved) {
return t('agentStream.toolApproval.waiting', { target: targetLabel.value })
}
return t('agentStream.toolApproval.titleWithTarget', {
service: props.serviceName,
tool: props.mcpToolName,
})
})
const cardClass = computed(() => ({
'action-pending': !props.resolved,
'action-success': !!props.resolved && !!props.approved,
'action-error': !!props.resolved && !props.approved,
}))
const statusLine = computed(() => {
if (!props.resolved) return t('agentStream.toolApproval.waitingStatus')
if (props.approved) return t('agentStream.toolApproval.approvedTag')
return t('agentStream.toolApproval.rejectedTag')
})
function toggleArgs() {
argsExpanded.value = !argsExpanded.value
}
function formatCountdown(s: number): string {
if (s < 60) return t('agentStream.toolApproval.countdownShort', { seconds: s })
@@ -174,6 +195,7 @@ const submit = async (decision: 'approve' | 'reject') => {
try {
modified = JSON.parse(argsText.value || '{}') as Record<string, unknown>
} catch {
argsExpanded.value = true
MessagePlugin.error(t('agentStream.toolApproval.invalidJson'))
return
}
@@ -1,4 +1,5 @@
// Inline interaction rows for OAuth / tool-approval in agent stream.
@import '../../../components/css/chat-timeline-loading.less';
.action-card.interaction-inline {
gap: 0;
@@ -19,13 +20,62 @@
box-shadow: none;
display: flex;
flex-direction: column;
}
&.action-error .action-title-icon.t-icon {
color: var(--td-error-color);
.interaction-status-summary {
padding: 2px 0 0;
&.is-clickable {
cursor: pointer;
user-select: none;
.results-summary-text {
color: var(--td-text-color-placeholder);
transition: color 0.15s ease;
}
&:hover .results-summary-text {
color: var(--td-text-color-secondary);
}
}
&.action-success .action-title-icon.t-icon {
color: var(--td-success-color);
.results-summary-text {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: var(--agent-step-summary-size, 13px);
font-weight: 400;
line-height: 1.55;
color: var(--td-text-color-secondary);
}
.status-label {
flex-shrink: 0;
}
.inline-actions {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.action-timer {
font-size: 12px;
line-height: 1.55;
font-variant-numeric: tabular-nums;
color: var(--td-text-color-placeholder);
white-space: nowrap;
flex-shrink: 0;
&.timer-warning {
color: var(--td-warning-color);
}
&.timer-critical {
color: var(--td-error-color);
}
}
}
@@ -70,12 +120,16 @@
display: flex;
align-items: center;
gap: 12px;
position: relative;
flex: 1;
min-width: 0;
flex-wrap: wrap;
row-gap: 2px;
.action-title-icon.t-icon {
position: absolute;
left: -42px;
top: 3px;
flex-shrink: 0;
width: 18px;
height: 18px;
@@ -99,7 +153,6 @@
}
.action-timer {
margin-left: auto;
font-size: 12px;
line-height: 1.55;
font-variant-numeric: tabular-nums;
@@ -117,24 +170,39 @@
}
}
.interaction-panel {
padding: 4px 0 0 30px;
display: flex;
flex-direction: column;
gap: 6px;
.interaction-args-block {
padding: 4px 0 0;
}
.interaction-args-preview {
padding: 4px 0 0;
font-family: var(--app-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: var(--agent-step-summary-size, 13px);
line-height: 1.55;
color: var(--td-text-color-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 160px;
overflow: auto;
}
.interaction-args-input {
:deep(.t-textarea__inner) {
font-family: var(--app-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11px;
font-size: var(--agent-step-summary-size, 13px);
line-height: 1.55;
background: var(--td-bg-color-container);
border-color: var(--td-component-stroke);
color: var(--td-text-color-primary);
padding: 8px 10px;
border-radius: 6px;
min-height: 48px;
background: transparent;
border: 0;
box-shadow: none;
color: var(--td-text-color-secondary);
padding: 0;
border-radius: 0;
min-height: 36px;
resize: vertical;
&:focus {
box-shadow: none;
}
}
}
@@ -142,49 +210,47 @@
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-size: var(--agent-step-summary-size, 13px);
color: var(--td-text-color-placeholder);
.args-status {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
font-size: var(--agent-step-summary-size, 13px);
}
}
.args-invalid {
.args-status {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: var(--agent-step-summary-size, 13px);
&.args-invalid {
color: var(--td-error-color);
}
.args-dirty {
&.args-dirty {
color: var(--td-warning-color);
}
}
.detail-output-wrapper {
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-stroke);
border-radius: 6px;
overflow: hidden;
.detail-output {
font-family: var(--app-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11px;
color: var(--td-text-color-primary);
padding: 10px 12px;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.55;
max-height: 160px;
overflow: auto;
background: var(--td-bg-color-container);
}
.action-details {
padding: 4px 0 0;
border-top: 0;
background: transparent;
display: flex;
flex-direction: column;
gap: 4px;
}
.interaction-panel-actions {
display: inline-flex;
align-items: center;
gap: 6px;
.args-toggle-row {
gap: 4px;
}
.args-toggle-icon {
flex-shrink: 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
}