优化: P1-a 长内容折叠(AI文本气泡+多冲突+错误堆栈)
- MessageList: assistant文本气泡>1500字符折叠(max-height 400px+渐变遮罩+展开按钮),流式中不折叠避免delta重排抖动(M1) - ConflictResolver: 多冲突(>=3)默认折叠,点击展开diff(M6) - HelpRequiredCard: context堆栈max-height 200px滚动+等宽字体(M7)
This commit is contained in:
@@ -4,53 +4,94 @@
|
|||||||
<span class="conflict-title">⚠ {{ t('aiChat.conflictsPending', { n: conflicts.length }) }}</span>
|
<span class="conflict-title">⚠ {{ t('aiChat.conflictsPending', { n: conflicts.length }) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="c in conflicts" :key="c.id" class="conflict-item">
|
<div v-for="c in conflicts" :key="c.id" class="conflict-item">
|
||||||
<div class="conflict-file">
|
<div class="conflict-file conflict-file--toggle" @click="toggleConflict(c.id)">
|
||||||
|
<span class="conflict-arrow" :class="{ 'conflict-arrow--open': isExpanded(c.id) }">▸</span>
|
||||||
<span class="conflict-type-badge" :class="'badge--' + c.conflict_type">
|
<span class="conflict-type-badge" :class="'badge--' + c.conflict_type">
|
||||||
{{ c.conflict_type === 'semantic' ? '语义' : '文件' }}
|
{{ c.conflict_type === 'semantic' ? '语义' : '文件' }}
|
||||||
</span>
|
</span>
|
||||||
<code>{{ c.file_path }}</code>
|
<code class="conflict-filepath">{{ c.file_path }}</code>
|
||||||
|
<span class="conflict-toggle-text">{{ isExpanded(c.id) ? t('aiChat.collapse') : t('aiChat.expand') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="conflict-diffs" v-if="c.diff_a || c.diff_b">
|
<template v-if="isExpanded(c.id)">
|
||||||
<div class="diff-panel diff-panel--a">
|
<div class="conflict-diffs" v-if="c.diff_a || c.diff_b">
|
||||||
<div class="diff-panel-label">Agent A ({{ c.subtask_a || '?' }})</div>
|
<div class="diff-panel diff-panel--a">
|
||||||
<pre class="diff-content">{{ c.diff_a || '(无改动)' }}</pre>
|
<div class="diff-panel-label">Agent A ({{ c.subtask_a || '?' }})</div>
|
||||||
|
<pre class="diff-content">{{ c.diff_a || '(无改动)' }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="diff-panel diff-panel--b">
|
||||||
|
<div class="diff-panel-label">Agent B ({{ c.subtask_b || '?' }})</div>
|
||||||
|
<pre class="diff-content">{{ c.diff_b || '(无改动)' }}</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="diff-panel diff-panel--b">
|
<div class="conflict-actions">
|
||||||
<div class="diff-panel-label">Agent B ({{ c.subtask_b || '?' }})</div>
|
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'a' })">
|
||||||
<pre class="diff-content">{{ c.diff_b || '(无改动)' }}</pre>
|
{{ t('aiChat.acceptA') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'b' })">
|
||||||
|
{{ t('aiChat.acceptB') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'merged' })">
|
||||||
|
{{ t('aiChat.mergeBoth') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'manual' })">
|
||||||
|
{{ t('aiChat.manualResolve') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<div class="conflict-actions">
|
|
||||||
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'a' })">
|
|
||||||
{{ t('aiChat.acceptA') }}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'b' })">
|
|
||||||
{{ t('aiChat.acceptB') }}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'merged' })">
|
|
||||||
{{ t('aiChat.mergeBoth') }}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'manual' })">
|
|
||||||
{{ t('aiChat.manualResolve') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { ConflictInfo } from '@/api/types'
|
import type { ConflictInfo } from '@/api/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
conflicts: ConflictInfo[]
|
conflicts: ConflictInfo[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'resolve', payload: { id: string; resolution: string }): void
|
(e: 'resolve', payload: { id: string; resolution: string }): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// 多冲突折叠态:以稳定 id 为 key(避免 index 在列表变化时错位)
|
||||||
|
const expandedConflicts = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// 阈值:冲突数 >= 3 时默认全折叠(M6 撑爆对话区只在多冲突场景发生);
|
||||||
|
// 单/双冲突信息量小,直接展开省一次点击
|
||||||
|
const COLLAPSE_THRESHOLD = 3
|
||||||
|
|
||||||
|
function isExpanded(id: string): boolean {
|
||||||
|
return expandedConflicts.value.has(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleConflict(id: string): void {
|
||||||
|
const next = new Set(expandedConflicts.value)
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id)
|
||||||
|
} else {
|
||||||
|
next.add(id)
|
||||||
|
}
|
||||||
|
expandedConflicts.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
// 冲突列表变化(新增/减少)时重置默认态:< 阈值全展开,否则全折叠
|
||||||
|
const defaultExpanded = computed(() => props.conflicts.length < COLLAPSE_THRESHOLD)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.conflicts.map((c) => c.id).join('|'),
|
||||||
|
() => {
|
||||||
|
const next = new Set<string>()
|
||||||
|
if (defaultExpanded.value) {
|
||||||
|
props.conflicts.forEach((c) => next.add(c.id))
|
||||||
|
}
|
||||||
|
expandedConflicts.value = next
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -76,6 +117,29 @@ const emit = defineEmits<{
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
.conflict-file--toggle {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.conflict-file--toggle:hover .conflict-filepath,
|
||||||
|
.conflict-file--toggle:hover .conflict-toggle-text {
|
||||||
|
color: var(--df-primary);
|
||||||
|
}
|
||||||
|
.conflict-arrow {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
.conflict-arrow--open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.conflict-toggle-text {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
}
|
||||||
.conflict-type-badge {
|
.conflict-type-badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ function handleSelectOption(opt: string): void {
|
|||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
/* M7:context 含错误堆栈,长栈撑高卡片。等宽字体利堆栈行对齐;max-height + overflow-y:auto
|
||||||
|
短内容自动不显滚动条,长堆栈滚动扫看(比折叠实用,无需展开按钮)。 */
|
||||||
|
font-family: var(--df-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.ai-help-required-options {
|
.ai-help-required-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -548,6 +548,31 @@ function toggleSegment(key: string): void {
|
|||||||
expandedSegmentIds.value = next
|
expandedSegmentIds.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M1:assistant 纯文本气泡超长折叠,避免单条 AI 回复撑爆对话区。
|
||||||
|
// 仅对「已完成(非流式)」assistant 文本生效;流式中的消息不折叠(delta 重排抖动)。
|
||||||
|
// 阈值 1500 字符:约 markdown 渲染后一屏(400px@13px/1.6≈35行)的源文本量,超此即判长。
|
||||||
|
// 折叠 max-height 同步 400px(见 CSS .ai-msg-bubble--collapsed,两处一致)。
|
||||||
|
const LONG_MSG_THRESHOLD = 1500
|
||||||
|
/// 展开的长消息 id 集合(点击底部按钮切换);默认折叠
|
||||||
|
const expandedLongIds = ref<Set<string>>(new Set())
|
||||||
|
function toggleLong(id: string): void {
|
||||||
|
const next = new Set(expandedLongIds.value)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
expandedLongIds.value = next
|
||||||
|
}
|
||||||
|
function isLongMsg(msg: AiMessage): boolean {
|
||||||
|
return msg.role === 'assistant' && !!msg.content && msg.content.length > LONG_MSG_THRESHOLD
|
||||||
|
}
|
||||||
|
/// 是否折叠展示该 assistant 文本气泡:长消息 + 未手动展开 + 非流式(当前未在流式生成)。
|
||||||
|
function shouldCollapseLong(msg: AiMessage): boolean {
|
||||||
|
if (!isLongMsg(msg)) return false
|
||||||
|
if (expandedLongIds.value.has(msg.id)) return false
|
||||||
|
// 流式约束:正在生成的当前消息不折叠(避免 delta 重排抖动),参考 L92 同款 streaming 判定。
|
||||||
|
if (isLastAi(msg) && store.state.streaming) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
type RenderItem =
|
type RenderItem =
|
||||||
| { kind: 'msg'; key: string; msg: AiMessage }
|
| { kind: 'msg'; key: string; msg: AiMessage }
|
||||||
| { kind: 'sep'; key: string; seg: MessageSegment & { key: string } }
|
| { kind: 'sep'; key: string; seg: MessageSegment & { key: string } }
|
||||||
@@ -683,7 +708,7 @@ defineExpose({
|
|||||||
<div
|
<div
|
||||||
v-if="item.msg.content || (isLastAi(item.msg) && store.state.streaming && store.state.currentText)"
|
v-if="item.msg.content || (isLastAi(item.msg) && store.state.streaming && store.state.currentText)"
|
||||||
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
|
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
|
||||||
:class="{ 'ai-msg-bubble--error': item.msg.isError }"
|
:class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg) }"
|
||||||
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
|
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
|
||||||
>
|
>
|
||||||
<!-- 流式:分块渲染(已完成块key稳定→DOM不重建) -->
|
<!-- 流式:分块渲染(已完成块key稳定→DOM不重建) -->
|
||||||
@@ -693,6 +718,12 @@ defineExpose({
|
|||||||
</template>
|
</template>
|
||||||
<!-- 完成/历史:整段v-html(不变) -->
|
<!-- 完成/历史:整段v-html(不变) -->
|
||||||
<div v-else v-html="renderMd(item.msg.content)"></div>
|
<div v-else v-html="renderMd(item.msg.content)"></div>
|
||||||
|
<!-- M1:超长 assistant 文本折叠后的底部展开按钮(仅非流式 + 长消息显示) -->
|
||||||
|
<button
|
||||||
|
v-if="isLongMsg(item.msg) && !(isLastAi(item.msg) && store.state.streaming)"
|
||||||
|
class="ai-msg-bubble-toggle"
|
||||||
|
@click.stop="toggleLong(item.msg.id)"
|
||||||
|
>{{ expandedLongIds.has(item.msg.id) ? $t('aiChat.collapse') : $t('aiChat.expand') }}</button>
|
||||||
<button
|
<button
|
||||||
v-if="item.msg.content"
|
v-if="item.msg.content"
|
||||||
class="ai-copy-btn ai-copy-btn--ai"
|
class="ai-copy-btn ai-copy-btn--ai"
|
||||||
@@ -895,6 +926,44 @@ defineExpose({
|
|||||||
.ai-msg-bubble--ai.ai-md {
|
.ai-msg-bubble--ai.ai-md {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
/* M1:超长 assistant 文本折叠——max-height 截断 + 底部渐变遮罩 + 展开按钮。
|
||||||
|
背景 var(--df-bg-card) 对齐 .ai-msg-bubble--ai(L876):渐变淡出到气泡背景,
|
||||||
|
视觉上内容「沉入」气泡而非被硬切。overflow:hidden 覆盖 .ai-md 的 overflow-x:auto。 */
|
||||||
|
.ai-msg-bubble--collapsed {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ai-msg-bubble--collapsed::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 48px;
|
||||||
|
background: linear-gradient(transparent, var(--df-bg-card));
|
||||||
|
pointer-events: none; /* 让点击穿透到下方展开按钮 */
|
||||||
|
}
|
||||||
|
/* 展开按钮:气泡底部居中,置于渐变遮罩之上 */
|
||||||
|
.ai-msg-bubble-toggle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
padding: 2px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--df-accent);
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.ai-msg-bubble-toggle:hover {
|
||||||
|
background: var(--df-bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Input Augmentation: 用户气泡内 mention chip(圆角徽标,复用 ChatInput .ai-skill-chip-name 风格) ──
|
/* ── Input Augmentation: 用户气泡内 mention chip(圆角徽标,复用 ChatInput .ai-skill-chip-name 风格) ──
|
||||||
用户气泡背景 = --df-accent(主题强调色,如深色蓝/紫),chip 用半透明白底+深字 保证对比度;
|
用户气泡背景 = --df-accent(主题强调色,如深色蓝/紫),chip 用半透明白底+深字 保证对比度;
|
||||||
|
|||||||
Reference in New Issue
Block a user