fix(chat): clarify running and stopped states

This commit is contained in:
matevip 2026-08-19 22:42:12 -04:00
parent 1159dcdbf3
commit aecc619bef
6 changed files with 328 additions and 15 deletions

View File

@ -205,7 +205,7 @@
class="action-btn send-btn"
:class="sendBtnClass"
:disabled="props.streamPhase === 'interrupting' || (!canSend && !loading)"
:title="props.streamPhase === 'interrupting' ? t('chat.streamInterrupting') : undefined"
:title="sendButtonTitle"
@click="handleSubmit"
>
<!-- 有输入时始终显示发送图标运行中发送 = interrupt -->
@ -222,7 +222,10 @@
<!-- 底部信息 -->
<div class="input-footer">
<span class="input-hint">{{ hint }}</span>
<span class="input-footer__left">
<span class="input-hint">{{ hint }}</span>
<span v-if="runtimeActionHint" class="input-action-hint">{{ runtimeActionHint }}</span>
</span>
<span v-if="maxLength" class="input-length">
{{ inputValue.length }}/{{ maxLength }}
</span>
@ -499,6 +502,17 @@ const inputPlaceholder = computed(() => {
return props.placeholder
})
const runtimeActionHint = computed(() => {
if (props.streamPhase === 'interrupting') return t('chat.streamInterrupting')
if (!props.loading) return ''
if (props.queuedMessage && !canSend.value) return t('chat.streamQueuedWaitingAction')
if (props.queuedMessage && canSend.value) return t('chat.streamReplaceQueuedAction')
if (canSend.value) return t('chat.streamQueueAction')
return t('chat.streamStopAction')
})
const sendButtonTitle = computed(() => runtimeActionHint.value || undefined)
//
const handleSubmit = () => {
if (props.streamPhase === 'interrupting') return
@ -887,16 +901,41 @@ defineExpose({
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-top: 6px;
padding: 0 4px;
}
.input-footer__left {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.input-hint {
font-size: 12px;
color: var(--mc-text-tertiary, #94a3b8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.input-action-hint {
flex: 0 1 auto;
min-width: 0;
padding-left: 8px;
border-left: 1px solid var(--mc-border, #e2e8f0);
color: var(--mc-text-secondary, #64748b);
font-size: 12px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.input-length {
flex: 0 0 auto;
font-size: 12px;
color: var(--mc-text-tertiary, #94a3b8);
}

View File

@ -197,12 +197,6 @@
</div>
<!-- 停止指示器 -->
<div v-if="status === 'stopped' || status === 'interrupted'" class="stopped-indicator">
<el-icon><CloseBold /></el-icon>
<span>{{ status === 'interrupted' ? $t('chat.interrupted') : $t('chat.stopped') }}</span>
</div>
<!-- parse_error content block -->
<div v-if="parseErrorText" class="parse-error-card">
<el-icon class="parse-error-card__icon"><WarningFilled /></el-icon>
@ -228,6 +222,18 @@
</template><!-- /传统合并渲染模式 -->
<!-- Stopped/interrupted status lives outside the rendering fork so
segmented history turns with only thinking/tool output still make
the manual stop visible. -->
<div
v-if="status === 'stopped' || status === 'interrupted'"
class="stopped-indicator"
:class="status === 'interrupted' ? 'stopped-indicator--interrupted' : 'stopped-indicator--stopped'"
>
<el-icon><CloseBold /></el-icon>
<span>{{ status === 'interrupted' ? $t('chat.interrupted') : $t('chat.stopped') }}</span>
</div>
<!--
INCOMPLETE banner: graph emitted finishReason=incomplete after
the thinking-only soft cap stopped the stream.
@ -2256,12 +2262,30 @@ watch(isGenerating, (generating) => {
/* 状态指示器 */
.stopped-indicator {
display: flex;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 0 2px;
margin-top: 8px;
padding: 6px 10px;
border: 1px solid var(--mc-border, #e2e8f0);
border-radius: 8px;
background: var(--mc-bg-sunken, #f8fafc);
font-size: 12px;
color: var(--mc-text-tertiary, #94a3b8);
font-weight: 500;
line-height: 1.4;
color: var(--mc-text-secondary, #64748b);
}
.stopped-indicator--stopped {
border-color: color-mix(in srgb, var(--mc-danger, #dc2626) 24%, transparent);
background: color-mix(in srgb, var(--mc-danger, #dc2626) 8%, transparent);
color: var(--mc-danger, #dc2626);
}
.stopped-indicator--interrupted {
border-color: color-mix(in srgb, #d97706 26%, transparent);
background: color-mix(in srgb, #d97706 8%, transparent);
color: #b45309;
}
/* 错误卡片 */

View File

@ -0,0 +1,110 @@
// @vitest-environment happy-dom
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, describe, expect, it } from 'vitest'
import ChatInput from '../ChatInput.vue'
const apps: Array<ReturnType<typeof createApp>> = []
const messages = {
chat: {
messagePlaceholder: 'Type a message',
streamStopAction: 'Stop generation',
streamQueueAction: 'Send after current response',
streamReplaceQueuedAction: 'Replace queued message',
streamQueuedWaitingAction: 'Queued message waiting for current response',
streamInterrupting: 'Interrupting...',
queuedSending: 'Sending queued message...',
queuedWillSend: 'Queued, will send after current step',
queuedCancel: 'Cancel',
queuedReplace: 'Message queued. Press Enter to replace...',
thinkingOn: 'Deep thinking enabled',
thinkingOff: 'Click to enable deep thinking',
thinkingUnsupported: 'Current model does not support deep thinking',
},
}
function mountChatInput(props: Record<string, unknown>) {
const events: string[] = []
const payloads: unknown[] = []
const host = document.createElement('div')
document.body.appendChild(host)
const app = createApp(ChatInput, {
placeholder: messages.chat.messagePlaceholder,
...props,
onSubmit: (value: string) => {
events.push('submit')
payloads.push(value)
},
onStop: () => {
events.push('stop')
},
})
app.use(createI18n({ legacy: false, locale: 'en', messages: { en: messages } }))
app.mount(host)
apps.push(app)
return { host, events, payloads }
}
afterEach(() => {
apps.splice(0).forEach(app => app.unmount())
document.body.innerHTML = ''
})
describe('ChatInput running-state actions', () => {
it('labels and emits stop when running with empty input', () => {
const { host, events } = mountChatInput({ loading: true, modelValue: '' })
const send = host.querySelector<HTMLButtonElement>('.send-btn')
expect(host.textContent).toContain('Stop generation')
expect(send?.title).toBe('Stop generation')
send?.click()
expect(events).toEqual(['stop'])
})
it('labels and submits queued content when running with text', () => {
const { host, events, payloads } = mountChatInput({ loading: true, modelValue: 'next task' })
const send = host.querySelector<HTMLButtonElement>('.send-btn')
expect(host.textContent).toContain('Send after current response')
expect(send?.title).toBe('Send after current response')
send?.click()
expect(events).toEqual(['submit'])
expect(payloads).toEqual(['next task'])
})
it('labels queued waiting state and does not stop on an empty repeated submit', () => {
const { host, events } = mountChatInput({
loading: true,
modelValue: '',
queuedMessage: { id: 'q1', text: 'queued task', status: 'sending' },
queueSize: 1,
})
const send = host.querySelector<HTMLButtonElement>('.send-btn')
expect(host.textContent).toContain('Queued message waiting for current response')
expect(send?.title).toBe('Queued message waiting for current response')
send?.click()
expect(events).toEqual([])
})
it('labels replacement when running with queued content and new text', () => {
const { host, events, payloads } = mountChatInput({
loading: true,
modelValue: 'replacement task',
queuedMessage: { id: 'q1', text: 'queued task', status: 'queued' },
queueSize: 1,
})
const send = host.querySelector<HTMLButtonElement>('.send-btn')
expect(host.textContent).toContain('Replace queued message')
expect(send?.title).toBe('Replace queued message')
send?.click()
expect(events).toEqual(['submit'])
expect(payloads).toEqual(['replacement task'])
})
})

View File

@ -0,0 +1,132 @@
import { createApp, defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import { createPinia } from 'pinia'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Message } from '@/types'
vi.mock('@/composables/useStreamingMarkdown', () => ({
useStreamingMarkdown: (content: unknown) => ({
renderedContent: content,
copyCode: vi.fn(),
}),
}))
vi.mock('@/composables/useAuthenticatedAttachment', () => ({
useAuthenticatedAttachment: () => ({
blobUrls: {},
loadAllImages: vi.fn(),
loadAllVideos: vi.fn(),
loadAllAudios: vi.fn(),
loadAllModels: vi.fn(),
downloadFile: vi.fn(),
openImage: vi.fn(),
getDisplayUrl: vi.fn((url: string) => url),
revokeAll: vi.fn(),
}),
}))
vi.mock('@/composables/useMcToast', () => ({
mcToast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
}))
vi.mock('@/composables/useToolLabel', () => ({
useToolLabel: () => ({ getToolLabel: (name: string) => name }),
}))
vi.mock('@/api', () => ({ http: { get: vi.fn(), post: vi.fn() } }))
vi.mock('@/utils/clipboard', () => ({ copyToClipboard: vi.fn() }))
vi.mock('@/utils/generatedFileLinks', () => ({
buildGeneratedFileNameMap: vi.fn(() => new Map()),
linkifyGeneratedFileUrls: vi.fn((content: string) => content),
}))
vi.mock('@/utils/lazyModelViewer', () => ({ ensureModelViewer: vi.fn() }))
vi.mock('../preview/previewKind', () => ({ previewKindOf: vi.fn(() => null) }))
vi.mock('../preview/previewBus', () => ({ openFilePreview: vi.fn() }))
vi.mock('@/components/goal/GoalAvatarRing.vue', () => ({
default: defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) }),
}))
vi.mock('../ToolCallSegment.vue', () => ({
default: defineComponent({ props: ['segment'], setup: props => () => h('div', props.segment.toolName) }),
}))
vi.mock('../ThinkingSegment.vue', () => ({
default: defineComponent({ props: ['segment'], setup: props => () => h('div', props.segment.thinkingText) }),
}))
vi.mock('../ContentSegment.vue', () => ({
default: defineComponent({ props: ['segment'], setup: props => () => h('div', props.segment.text) }),
}))
vi.mock('../PlanStepsPanel.vue', () => ({ default: defineComponent({ setup: () => () => null }) }))
vi.mock('../BrowserTimeline.vue', () => ({ default: defineComponent({ setup: () => () => null }) }))
vi.mock('../TypingCursor.vue', () => ({ default: defineComponent({ setup: () => () => null }) }))
vi.mock('../UserMessageContent.vue', () => ({
default: defineComponent({ props: ['content'], setup: props => () => h('div', props.content) }),
}))
import MessageBubble from '../MessageBubble.vue'
const apps: Array<ReturnType<typeof createApp>> = []
function mountMessage(message: Message, locale = 'zh-CN') {
const host = document.createElement('div')
document.body.appendChild(host)
const app = createApp(MessageBubble, { message })
app.use(createPinia())
app.use(createI18n({
legacy: false,
locale,
messages: {
'zh-CN': {
chat: {
stopped: '已被用户手动中止',
interrupted: '已中断并继续处理下一条消息',
},
},
en: {
chat: {
stopped: 'Stopped manually by user',
interrupted: 'Interrupted and continued with the next message',
},
},
},
}))
app.mount(host)
apps.push(app)
return host
}
afterEach(() => {
apps.splice(0).forEach(app => app.unmount())
document.body.innerHTML = ''
})
describe('MessageBubble stop indicator', () => {
it('shows a manual-stop status bar for segmented stopped assistant messages', () => {
const host = mountMessage({
id: '2',
conversationId: 'conv',
role: 'assistant',
content: '',
contentParts: [],
status: 'stopped',
metadata: {
segments: [
{ id: 'think-1', type: 'thinking', status: 'completed', thinkingText: '分析中', seq: 0 },
{ id: 'tool-1', type: 'tool_call', status: 'completed', toolName: 'shell', seq: 1 },
],
},
})
expect(host.querySelector('.segments-view')).not.toBeNull()
expect(host.textContent).toContain('已被用户手动中止')
expect(host.querySelector('.stopped-indicator--stopped')).not.toBeNull()
})
it('distinguishes interrupted turns from user-stopped turns', () => {
const host = mountMessage({
id: '3',
conversationId: 'conv',
role: 'assistant',
content: 'partial',
contentParts: [],
status: 'interrupted',
})
expect(host.textContent).toContain('已中断并继续处理下一条消息')
expect(host.querySelector('.stopped-indicator--interrupted')).not.toBeNull()
})
})

View File

@ -256,8 +256,8 @@ export default {
resumed: 'Resumed',
processing: 'Processing',
},
stopped: 'Generation stopped',
interrupted: 'Interrupted',
stopped: 'Stopped manually by user',
interrupted: 'Interrupted and continued with the next message',
subagentStalled: 'Subagent stalled — no progress',
subagentAsync: 'Running in background — result via task_output',
executionPlan: 'Execution Plan',
@ -548,6 +548,10 @@ export default {
queuedReplace: 'Message queued. Press Enter to replace...',
queuedBadge: '{count} queued',
// Stream status
streamStopAction: 'Stop generation',
streamQueueAction: 'Send after current response',
streamReplaceQueuedAction: 'Replace queued message',
streamQueuedWaitingAction: 'Queued message waiting for current response',
streamPreparingContext: 'Preparing context...',
streamReadingMemory: 'Reading memory...',
streamReasoning: 'Analyzing...',

View File

@ -256,8 +256,8 @@ export default {
resumed: '已恢复',
processing: '处理中',
},
stopped: '已停止生成',
interrupted: '已中断',
stopped: '已被用户手动中止',
interrupted: '已中断并继续处理下一条消息',
subagentStalled: '子 Agent 无进展',
subagentAsync: '后台运行中,结果稍后获取',
executionPlan: '执行计划',
@ -548,6 +548,10 @@ export default {
queuedReplace: '消息已排队,按回车替换...',
queuedBadge: '{count} 条排队',
// 流状态
streamStopAction: '停止生成',
streamQueueAction: '当前回复结束后发送',
streamReplaceQueuedAction: '替换已排队消息',
streamQueuedWaitingAction: '已排队,等待当前回复结束',
streamPreparingContext: '准备上下文...',
streamReadingMemory: '读取记忆...',
streamReasoning: '分析问题...',