feat(chat): first-token pending placeholder in the assistant bubble

This commit is contained in:
matevip 2026-08-04 23:28:17 -04:00
parent fefb3b25b8
commit c31a216cbd
3 changed files with 73 additions and 2 deletions

View File

@ -35,6 +35,15 @@
paths, so plan-mode progress is never buried in a collapsed panel. -->
<PlanStepsPanel v-if="planMeta" :plan="planMeta" :is-generating="isGenerating" />
<!-- 首包前占位从发送到第一个 delta 之间上下文准备 + LLM token,
可达数秒气泡完全空白会读作"卡死"占位动效让气泡从第一帧起就是
活的任何真实内容thinking/tool/content/phase 面板出现后自动让位 -->
<div v-if="showPendingPlaceholder" class="pending-placeholder">
<span class="pending-placeholder__dots"><i /><i /><i /></span>
<span class="pending-placeholder__text">{{ $t('chat.pendingReply') }}</span>
<span v-if="phaseElapsed" class="pending-placeholder__elapsed">{{ phaseElapsed }}</span>
</div>
<!-- ===== 分段式渲染模式Claude Code 风格===== -->
<template v-if="useSegmentedView">
<div class="segments-view">
@ -1433,6 +1442,24 @@ onBeforeUnmount(() => {
}
})
/**
* First-token wait state: the assistant bubble exists (placeholder created
* synchronously on send) but nothing renderable has arrived no segments, no
* thinking, no content, no phase-driven execution panel. Without this the
* bubble sits blank for the whole context-prep + LLM-prefill window (multiple
* seconds) and reads as a hang; the placeholder yields automatically the
* moment any real block renders.
*/
const showPendingPlaceholder = computed(() =>
role.value === 'assistant'
&& isGenerating.value
&& segments.value.length === 0
&& !thinkingContent.value
&& !displayContent.value
&& !showExecutionPanel.value
&& !planMeta.value
)
const phaseElapsed = computed(() => {
// Only meaningful while waiting on the model with nothing else to show.
if (!isGenerating.value || toolCallsMeta.value.length) return ''
@ -1565,6 +1592,48 @@ watch(isGenerating, (generating) => {
font-size: 11px;
}
.pending-placeholder {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
padding: 8px 14px;
margin: 4px 0;
font-family: var(--mc-font-body, sans-serif);
font-size: var(--mc-text-sm, 13px);
color: var(--mc-text-secondary, #665245);
background: var(--mc-bg-muted, #f1e8df);
border-radius: var(--mc-radius-md, 12px);
}
.pending-placeholder__dots {
display: inline-flex;
gap: 4px;
}
.pending-placeholder__dots i {
width: 6px;
height: 6px;
border-radius: var(--mc-radius-full, 9999px);
background: var(--mc-primary, #d96d46);
opacity: 0.35;
animation: pending-dot-pulse 1.2s ease-in-out infinite;
}
.pending-placeholder__dots i:nth-child(2) { animation-delay: 0.2s; }
.pending-placeholder__dots i:nth-child(3) { animation-delay: 0.4s; }
@keyframes pending-dot-pulse {
0%, 60%, 100% { opacity: 0.35; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-3px); }
}
.pending-placeholder__elapsed {
font-size: var(--mc-text-xs, 11px);
color: var(--mc-text-tertiary, #9b7d6c);
font-variant-numeric: tabular-nums;
}
.superseded-toggle {
display: inline-flex;
align-items: center;

View File

@ -531,7 +531,8 @@ export default {
// Per-iteration grouping
iterationEmpty: 'Iteration {index} interrupted (no output)',
contentRepetitionWarning: 'Repetitive content detected near the end (model artifact)',
supersededPreviewCollapsed: 'Model preview replaced by the actual tool result',
supersededPreviewCollapsed: 'Collapsed: content drafted before tool execution (may not match actual results)',
pendingReply: 'Preparing a reply…',
expand: 'Expand',
// INCOMPLETE truncation card (finishReason=incomplete)
incompleteTitle: 'Answer auto-truncated after repeated output was detected',

View File

@ -531,7 +531,8 @@ export default {
// 按轮次分组渲染
iterationEmpty: '第 {index} 轮被中断(无输出)',
contentRepetitionWarning: '检测到内容尾部重复(疑似模型输出 artifact',
supersededPreviewCollapsed: '过程预演已被实际工具结果替换',
supersededPreviewCollapsed: '已折叠:模型在工具执行前预写的内容(可能与实际结果不符)',
pendingReply: '正在准备回复…',
expand: '展开',
// INCOMPLETE 截断卡片finishReason=incomplete
incompleteTitle: '回答因检测到重复输出已被自动截断',