优化: 会话入口与对话交互打磨

- 完成卡点开子代理轨迹浮层,任务抽屉关联会话跳转
- 提及片段编辑差分三向判定,不再错位
- 长回复折叠完成瞬间锚定消息顶,视口不跳
- 空编辑/技能无匹配/回填覆盖草稿均有提示确认
- 队列编辑态切会话重置,刷新快捷键不再误触
- git 变更面板去重缓存,项目卡骨架与溢出重测
This commit is contained in:
lxy
2026-10-03 17:57:43 +08:00
parent 890ceef0ec
commit f67e65633b
8 changed files with 433 additions and 19 deletions
+18
View File
@@ -368,4 +368,22 @@ export const aiApi = {
updateConversationGoals(conversationId: string, goals: string[]): Promise<void> {
return invoke('ai_update_conversation_goals', { conversationId, goals })
},
/**
* 只读窥视子代理轨迹(S3):后端复用 switch 读路径但**不切任何会话状态**。
* 子会话(origin='sub_agent')不进侧栏、不可 switch,完成卡浮层经此拉轨迹;
* 返回全量消息 JSON 字符串(不分页)。
*/
peekConversation(conversationId: string): Promise<{ title: string | null; messages: string }> {
return invoke('ai_conversation_peek', { conversationId })
},
/**
* S4 TaskDrawer「关联会话」:按任务 id 反查发起过 create/update/advance_task 的会话。
* 后端从 ai_tool_executions 审计表推导(result JSON 顶层 id 精确判等,非 LIKE),
* 已过滤子代理会话(origin='sub_agent')并按最近命中倒序去重,v1 只列主会话。
*/
listTaskConversations(taskId: string): Promise<{ conversation_id: string; title: string | null; tool_name: string; requested_at: string }[]> {
return invoke('list_task_conversations', { taskId })
},
}
+56 -2
View File
@@ -25,7 +25,8 @@
: (pendingSkill ? (pendingSkill.argument_hint || $t('aiChat.placeholderWithSkill')) : $t('aiChat.placeholderDefault'))"
rows="1"
@keydown="handleKeydown"
@input="autoResize"
@beforeinput="onBeforeInputText"
@input="onInputText"
@paste="onPaste"
@dragover="onDragOver"
@drop="onDrop"
@@ -401,6 +402,38 @@ const mentionStart = ref(-1)
// 后端据 mentionSpans resolve 投影成 Augmentation 注入;MessageList 据此精确切区间渲染 chip。
const pendingMentionSpans = ref<MentionSpan[]>([])
// ── UX-P3: mention span 编辑平移 ──
// span.start 是 selectMention 时刻的绝对偏移,用户在 chip 文本之前增删字符后全部 span 错位
// (MessageList 渲染守卫对不上文本即降级纯文本,chip 全灭)。借 beforeinput(仅用户编辑触发,
// 程序赋值不派发事件)快照旧文本,input 时做公共前后缀差分求出编辑区间,按区间相对各 span
// 的位置处理:编辑点在 span 前→整体平移;在 span 后→不动;落在 span 文本内→丢弃该 span
// (文本已被改写区间失真,宁丢一枚 chip 也不让后端 resolve 到错位区间)。
let mentionTextBeforeEdit = ''
function onBeforeInputText(): void {
mentionTextBeforeEdit = inputEl.value?.value ?? inputText.value
}
function onInputText(): void {
autoResize()
shiftMentionSpans(mentionTextBeforeEdit, inputEl.value?.value ?? inputText.value)
}
function shiftMentionSpans(oldText: string, newText: string): void {
if (pendingMentionSpans.value.length === 0 || oldText === newText) return
// 公共前后缀夹出旧文本中被编辑的区间(粘贴/剪切的多字符增删同为一段区间,该差分通用)
const minLen = Math.min(oldText.length, newText.length)
let p = 0
while (p < minLen && oldText[p] === newText[p]) p++
let s = 0
while (s < minLen - p && oldText[oldText.length - 1 - s] === newText[newText.length - 1 - s]) s++
const editStart = p
const editEnd = oldText.length - s
const delta = newText.length - oldText.length
pendingMentionSpans.value = pendingMentionSpans.value.flatMap((sp) => {
if (editEnd <= sp.start) return [{ ...sp, start: sp.start + delta }]
if (editStart >= sp.start + sp.length) return [sp]
return []
})
}
// ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ──
// 当用户 @[项目:xxx] 后,从 projectsStore 反查项目+关联任务/灵感,
// 在输入区下方显式 badge + 可展开的上下文参考面板。
@@ -685,6 +718,14 @@ function handleKeydown(e: KeyboardEvent) {
// P1-1 输入法组词守卫:拼音/双拼组词未上屏时 Enter=选词、Esc=取消组词,均非用户发送/取消意图,
// 直接放行给 IME。isComposing 覆盖标准浏览器;keyCode===229 兜部分环境 keydown 阶段不上报 isComposing。
if (e.isComposing || e.keyCode === 229) return
// UX-P3: AI 输入框聚焦时 Ctrl/Cmd+R 会触发 WebView 刷新(浏览器直开/开发态整页刷新丢会话态),
// 此处 preventDefault 掐掉刷新默认行为。只拦输入框聚焦这一场景(本 handler 仅 textarea 触发,
// 全局其他区域不拦);不 stopPropagation,冒泡到 AiChat onGlobalKeydown 的 Ctrl+R 分支,
// 重新生成语义照常生效。
if ((e.ctrlKey || e.metaKey) && (e.key === 'r' || e.key === 'R')) {
e.preventDefault()
return
}
// 各 Esc 消费分支 stopPropagation:P1-2 双层防,分离窗口 AiChat 的 window 级 Esc 会关整个窗口,
// 内层已消费的 Esc 不再外抛(主防线是 AiChat 侧的 e.target 输入控件守卫,此处为辅)
// 编辑态 Escape 取消编辑(最高优先级)
@@ -732,6 +773,14 @@ function handleKeydown(e: KeyboardEvent) {
return
}
}
// UX-P3: 技能联想浮层开着但无匹配时,Enter 原会落到下方发送分支把 "/xxx" 原文当普通
// 消息发出。改为不发送 + toast 提示无此技能;确要发送斜杠原文可先 Esc 关浮层再 Enter。
if (skillOpen.value && e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
e.stopPropagation()
emit('error', { msg: t('aiChat.skillNoMatchQuery', { q: skillQuery.value.trim() }), type: 'warning' })
return
}
// @ 实体引用浮层导航(仅有匹配项时拦截方向/回车;与技能联想互斥)
if (mentionOpen.value && mentionItems.value.length) {
if (e.key === 'ArrowDown') {
@@ -775,7 +824,12 @@ async function handleSend() {
const text = inputText.value.trim()
// UX-09 编辑态:走 editMessage(不 push 新 user,编辑末条 user 后重生成)
if (props.editingMsgId) {
if (!text) return
// UX-P3 空编辑静默:清空文本后 Enter 原来静默 return,用户无反馈卡在编辑态。
// 仍阻止空提交(editMessage 空文本语义不明),但补轻提示告知原因。
if (!text) {
emit('error', { msg: t('aiChat.editEmptyBlocked'), type: 'warning' })
return
}
inputText.value = ''
if (inputEl.value) inputEl.value.style.height = 'auto'
try {
+232 -3
View File
@@ -23,6 +23,7 @@ import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
import { getConvAgentFinished, type AgentFinishedCard } from '../../composables/ai/aiShared'
import { aiApi } from '../../api'
import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue'
import EmptyState from './EmptyState.vue'
@@ -273,9 +274,32 @@ watch(() => store.state.streaming, (s) => {
saveSelection()
clearStreamingState()
nextTick(() => restoreSelection())
anchorCollapseScroll()
}
})
// ── UX-P3: 长回复完成瞬间折叠的滚动锚 ──
// 末条 AI 回复超阈值(LONG_MSG_THRESHOLD)时,完成瞬间 shouldCollapseLong 从 false 翻 true
// (流式中不折叠的约束解除),气泡收缩到 400px;跟随意图下的 scrollToBottom 把视口钉在收缩后
// 的底部,内容整体上跳,刚流完的答案开头/结尾都看不全。改锚消息顶:本回调在流式结束当拍注册
// nextTick(此时折叠 class 尚未渲染),渲染拍后把该消息顶对齐视口顶,视口落点确定(答案开头,
// 回底按钮照常出现),不再随内容收缩被浏览器 clamp 到任意位置。
// 仅跟随意图成立时干预(用户上滑读历史不拉扯);手动展开过的消息不会折叠,无收跳不处理。
// 注册时序:currentText watch(先注册)的 scrollToBottom nextTick 先入队,本 nextTick 后入队
// 后执行,锚定是滚动链的最后一手,不被钉底覆盖。
const COLLAPSE_ANCHOR_PAD_PX = 8
function anchorCollapseScroll(): void {
if (!isFollowingBottom.value) return
const last = store.state.messages[store.state.messages.length - 1]
if (!last || !shouldCollapseLong(last)) return
nextTick(() => {
const c = messagesContainer.value
const el = c?.querySelector(`[data-msg-id="${last.id}"]`)
if (!c || !el) return
c.scrollTop += el.getBoundingClientRect().top - c.getBoundingClientRect().top - COLLAPSE_ANCHOR_PAD_PX
})
}
// ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化)时,
// 收起旧的已完成/已拒绝卡片,保留 running/pending/write_file 活跃卡片。
// 折叠态由 ToolCardList 自治,父级仅经 expose 调 collapseInactive ──
@@ -1083,6 +1107,68 @@ function agentFinSummary(summary: string): string {
return s.length > 160 ? s.slice(0, 160) + '…' : s
}
// ── S3 子代理轨迹浮层(完成卡点击展开,只读) ──
// 状态全部组件局部:浮层关即弃、不缓存轨迹(每次点开重新 peek,宁少勿预留),
// 不进 aiShared/stores——子会话轨迹纯只读,与主会话状态机(消息流/生成态)完全无关。
interface AgentPeekMsg {
role: 'user' | 'assistant' | 'tool' | 'system'
text: string
toolNames: string[]
}
const agentPeekVisible = ref(false)
const agentPeekLoading = ref(false)
const agentPeekMsgs = ref<AgentPeekMsg[]>([])
// 单条内容截断上限:tool 结果(文件全文/长输出)可能巨大,简版渲染按 4000 字截断兜底,
// 完整数据仍在库,不做全文渲染(不要求富文本)。
const AGENT_PEEK_TEXT_MAX = 4000
/**
* 完成卡点击:peek 子会话轨迹并开浮层。
* agentId 即子会话 conv id(形态 {parent}::agent-{n},后端 spawn_agent 落库 origin='sub_agent')。
* 加载中/已开时忽略重复点击(防连点重复 IPC);失败走既有 toast 通道给可见反馈,不开空浮层。
*/
async function openAgentTrace(card: AgentFinishedCard): Promise<void> {
if (agentPeekVisible.value || agentPeekLoading.value) return
agentPeekLoading.value = true
try {
const res = await aiApi.peekConversation(card.agentId)
const raw: any[] = typeof res.messages === 'string' ? JSON.parse(res.messages) : (res.messages || [])
agentPeekMsgs.value = raw.map((m) => {
const text = String(m.content || '')
const role = m.role === 'user' || m.role === 'assistant' || m.role === 'tool' || m.role === 'system'
? m.role
: 'assistant'
return {
role,
text: text.length > AGENT_PEEK_TEXT_MAX ? text.slice(0, AGENT_PEEK_TEXT_MAX) + '…' : text,
toolNames: Array.isArray(m.tool_calls)
? m.tool_calls.map((tc: any) => tc?.function?.name || '').filter(Boolean)
: [],
}
})
agentPeekVisible.value = true
} catch (e) {
console.error('[AI] 拉取子代理轨迹失败:', e)
emit('toast', { msg: t('ai.agentTrace.loadFail'), type: 'error' })
} finally {
agentPeekLoading.value = false
}
}
/** 关浮层即弃:清空局部消息态,不缓存轨迹 */
function closeAgentTrace(): void {
agentPeekVisible.value = false
agentPeekMsgs.value = []
}
/** role → 本地化角色名(简版渲染,无头像气泡) */
function agentPeekRoleText(role: AgentPeekMsg['role']): string {
if (role === 'user') return t('ai.agentTrace.roleUser')
if (role === 'tool') return t('ai.agentTrace.roleTool')
if (role === 'system') return t('ai.agentTrace.roleSystem')
return t('ai.agentTrace.roleAssistant')
}
// UX 密度改造:轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转不做统计)
const traceNav = computed(() =>
renderItems.value.filter((it): it is Extract<RenderItem, { kind: 'trace' }> => it.kind === 'trace')
@@ -1230,9 +1316,16 @@ defineExpose({
</div>
<!-- S1 子代理完成卡(AiAgentFinished):状态点/状态词三态区分(completed 中性/truncated
警示/failed 错误),摘要单行截断 + 轮数 + tokens;骨架对齐 ai-step-line/ai-retry-line
轻量细条范式,非消息数据不进气泡 -->
轻量细条范式,非消息数据不进气泡。
S3:整卡可点,点击 peek 子会话轨迹开只读浮层(数据关即弃,见 openAgentTrace) -->
<div v-else-if="item.kind === 'agentFin'" class="ai-msg-slot">
<div class="ai-agent-fin" :class="'ai-agent-fin--' + agentFinTone(item.card.status)">
<div
class="ai-agent-fin"
:class="'ai-agent-fin--' + agentFinTone(item.card.status)"
role="button"
:title="$t('ai.agentFinished.viewHint')"
@click="openAgentTrace(item.card)"
>
<span class="ai-agent-fin-dot"></span>
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card.status) }}</span>
@@ -1485,6 +1578,35 @@ defineExpose({
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
</button>
</Transition>
<!-- S3 子代理轨迹浮层(只读,关即弃):与回底部按钮同为模板根级兄弟,position:fixed
不受 .ai-messages 滚动容器 overflow 裁剪;backdrop 点击关闭,数据不缓存 -->
<div v-if="agentPeekVisible" class="ai-agent-peek-mask" @click.self="closeAgentTrace">
<div class="ai-agent-peek" role="dialog" :aria-label="$t('ai.agentTrace.title')">
<div class="ai-agent-peek-head">
<span class="ai-agent-peek-title">{{ $t('ai.agentTrace.title') }}</span>
<button class="ai-agent-peek-close" :title="$t('common.close')" @click="closeAgentTrace">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="ai-agent-peek-body">
<div v-if="agentPeekMsgs.length === 0" class="ai-agent-peek-empty">{{ $t('ai.agentTrace.empty') }}</div>
<!-- 简版卡片流:role 名 + 工具调用名 chips + 纯文本内容(不做完整 markdown 富渲染) -->
<div
v-for="(m, i) in agentPeekMsgs"
:key="i"
class="ai-agent-peek-msg"
:class="'ai-agent-peek-msg--' + m.role"
>
<div class="ai-agent-peek-role">
<span>{{ agentPeekRoleText(m.role) }}</span>
<span v-for="name in m.toolNames" :key="name" class="ai-agent-peek-tool">{{ name }}</span>
</div>
<div v-if="m.text" class="ai-agent-peek-text">{{ m.text }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@@ -2268,7 +2390,8 @@ defineExpose({
/* S1 子代理完成卡(AiAgentFinished):三态色分层——completed 中性灰(常态结果不抢视线)
/ truncated 琥珀(结果不完整,与 retry 细条同警示层)/ failed 红(需关注,对齐错误卡层)。
骨架对齐 .ai-retry-line 轻量细条;摘要单行截断,全文经 title hover 查看 */
骨架对齐 .ai-retry-line 轻量细条;摘要单行截断,全文经 title hover 查看。
S3:整卡可点开轨迹浮层,cursor pointer + hover 提示可交互 */
.ai-agent-fin {
display: flex;
align-items: center;
@@ -2280,6 +2403,10 @@ defineExpose({
font-size: 11px;
color: var(--df-text-secondary);
user-select: none;
cursor: pointer;
}
.ai-agent-fin:hover {
background: color-mix(in srgb, var(--df-text-secondary) 12%, transparent);
}
.ai-agent-fin-dot {
width: 5px;
@@ -2323,4 +2450,106 @@ defineExpose({
font-family: var(--df-font-mono);
font-size: 10.5px;
}
/* ═══ S3 子代理轨迹浮层(只读,关即弃) ═══ */
.ai-agent-peek-mask {
position: fixed;
inset: 0;
z-index: 300;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
}
.ai-agent-peek {
width: min(680px, 86vw);
max-height: 76vh;
display: flex;
flex-direction: column;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border-strong);
border-radius: var(--df-radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.ai-agent-peek-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 0.5px solid var(--df-border);
flex-shrink: 0;
}
.ai-agent-peek-title {
font-size: 12.5px;
font-weight: 600;
color: var(--df-text);
}
.ai-agent-peek-close {
border: none;
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
padding: 4px;
border-radius: var(--df-radius-sm);
display: flex;
}
.ai-agent-peek-close:hover {
color: var(--df-text);
background: color-mix(in srgb, var(--df-text-secondary) 10%, transparent);
}
.ai-agent-peek-body {
overflow-y: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ai-agent-peek-empty {
color: var(--df-text-dim);
font-size: 12px;
text-align: center;
padding: 24px 0;
}
.ai-agent-peek-msg {
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
padding: 8px 10px;
}
/* role 左侧色条:user 中性/accent、assistant 默认、tool 结果弱化、system 弱化斜体 */
.ai-agent-peek-msg--user { border-left: 2px solid var(--df-accent); }
.ai-agent-peek-msg--assistant { border-left: 2px solid var(--df-success); }
.ai-agent-peek-msg--tool,
.ai-agent-peek-msg--system { border-left: 2px solid var(--df-border-strong); }
.ai-agent-peek-role {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 10.5px;
font-weight: 600;
color: var(--df-text-secondary);
margin-bottom: 4px;
}
.ai-agent-peek-tool {
font-family: var(--df-font-mono);
font-size: 10px;
font-weight: 500;
color: var(--df-info);
background: var(--df-info-bg);
border-radius: var(--df-radius-sm);
padding: 1px 6px;
}
.ai-agent-peek-text {
font-size: 12px;
line-height: 1.55;
color: var(--df-text);
white-space: pre-wrap;
word-break: break-word;
}
.ai-agent-peek-msg--tool .ai-agent-peek-text,
.ai-agent-peek-msg--system .ai-agent-peek-text {
color: var(--df-text-secondary);
font-size: 11.5px;
}
</style>
+30 -10
View File
@@ -185,8 +185,6 @@ const loading = ref(false)
const gitStatus = ref<GitStatusResult | null>(null)
const showHistory = ref(false)
const selectedFile = ref('')
const diffContent = ref('')
const diffLoading = ref(false)
// 行内错误提示:loadError 主区展示(loadStatus 失败),actionError 分支条下方展示(loadMore/selectFile/切分支/提交详情 失败)
const loadError = ref('')
const actionError = ref('')
@@ -194,6 +192,12 @@ const actionError = ref('')
// (对齐 ProjectDetail taskReqSeq 模式)
let statusReqSeq = 0
// UX走查 P3-4: 点文件 diff 单槽去重缓存 — 重复点击同一文件不再重复发 git diff IPC,
// 直接复用上次结果回填。切模块/refreshKey 重拉状态均经 loadStatus,缓存在彼处失效,
// 防 AI 改文件后旧 diff 被复用(单槽不设 Map,免过期条目堆积)
let lastDiffKey = ''
let lastDiffContent = ''
/** 单条提交记录形状(对齐 getModuleCommits / GitRecentCommit)。 */
interface CommitItem {
hash: string
@@ -353,6 +357,10 @@ async function loadStatus() {
const mid = props.moduleId
if (!mid) return
const seq = ++statusReqSeq
// UX走查 P3-4: 状态重拉(切模块/外部 refreshKey,如 AI 改文件)即失效 diff 去重缓存,
// 防改文件后旧 diff 被复用
lastDiffKey = ''
lastDiffContent = ''
loading.value = true
loadError.value = ''
actionError.value = ''
@@ -448,23 +456,35 @@ async function switchToHistory() {
async function selectFile(path: string) {
selectedFile.value = path
// 先切预览到该文件(内容视图),diff 拉到后回填,点文件即展示变化
// 先切预览到该文件(内容视图兜底),diff 拉到后回填切 diff 视图,点文件即展示变化
emit('select-file', path)
diffContent.value = ''
actionError.value = ''
diffLoading.value = true
// UX走查 P3-4 同参数去重:重复点击同一文件直接复用缓存 diff,不再发 IPC
const key = `${props.moduleId}::${path}`
if (key === lastDiffKey) {
emit('select-file', path, lastDiffContent)
return
}
// 捕获请求时的状态序号:期间 refreshKey 重拉(缓存已失效)则本次结果不入缓存,防改文件前旧 diff 滞留
const seqAtReq = statusReqSeq
let diff = ''
try {
const res = await moduleApi.getModuleFileDiff(props.moduleId, path)
diffContent.value = res.diff || ''
diff = res.diff || ''
if (seqAtReq === statusReqSeq) {
lastDiffKey = key
lastDiffContent = diff
}
} catch (e) {
diffContent.value = ''
// 失败清缓存使下次点击可重试;不回填 diff,预览停在内容视图(错误经 actionError 条就近提示)
lastDiffKey = ''
lastDiffContent = ''
actionError.value = String(e)
} finally {
diffLoading.value = false
return
}
// 仅当仍是当前选中文件时才回填 diff,防快速切换时旧响应覆盖新文件
if (selectedFile.value === path) {
emit('select-file', path, diffContent.value)
emit('select-file', path, diff)
}
}
+33 -2
View File
@@ -24,7 +24,10 @@
<div class="card-footer">
<div class="footer-stat">
<span class="stat-icon">📁</span>
<span>{{ moduleCount }}</span>
<!-- UX走查 P3-6: 工程数异步入场,加载中显骨架条占位(对齐 GitChanges shimmer 范式),
防未加载数先显 0 再跳变——真 0 与"还没加载"不可混淆 -->
<span v-if="moduleCountLoading" class="stat-skeleton" aria-hidden="true"></span>
<span v-else>{{ moduleCount }}</span>
</div>
<div class="footer-stat">
<span class="stat-icon">📅</span>
@@ -48,7 +51,7 @@
// 依赖均为无副作用纯函数 + 共享常量;删除走 emit 交回父组件处理(confirm 弹层 + store 调用)。
// view prop:'list'(紧凑,默认)/'card'(宽松);描述 max-height 截断 + 展开/收起。
import { useRouter } from 'vue-router'
import { ref, onMounted, watch, nextTick } from 'vue'
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { formatDate } from '@/utils/time'
import { parseStack } from '@/utils/project'
import { stripMd } from '@/utils/markdown'
@@ -71,11 +74,14 @@ const router = useRouter()
// 工程数(异步拉取,展示在卡片底部)
const moduleCount = ref(0)
// UX走查 P3-6: 加载态标记,模板据此显骨架占位而非 0(成功/失败都收 loading,失败仍显 0 属既有约定)
const moduleCountLoading = ref(true)
onMounted(async () => {
try {
const list = await moduleApi.listProjectModules(props.project.id)
moduleCount.value = list.length
} catch { /* 老项目无工程记录不报错 */ }
moduleCountLoading.value = false
// DOM 渲染完成后测描述是否溢出(决定是否显示「展开」)
await nextTick()
measureDesc()
@@ -91,6 +97,8 @@ const descExpanded = ref(false)
function measureDesc() {
const el = descRef.value
if (!el) return
// 展开态无行截断,测出的一律是"未溢出",会误收起按钮致无法「收起」;展开期间维持原判定
if (descExpanded.value) return
// scrollHeight > clientHeight 说明被 max-height 截断了
descOverflow.value = el.scrollHeight - el.clientHeight > 1
}
@@ -98,6 +106,13 @@ function measureDesc() {
// 描述/视图模式变化后重新测量(描述异步入库或切 view 行数阈值变,按钮显隐需重算)
watch(() => props.project.description, () => nextTick(measureDesc))
watch(() => props.view, () => nextTick(measureDesc))
// 收起后重测:展开期间判定被冻结,若窗口已放宽到不溢出,收起时自校正防残留多余「展开」按钮
watch(descExpanded, (v) => { if (!v) nextTick(measureDesc) })
// UX走查 P3-6: 窗口尺寸变化会改描述可用宽度,截断与否需重测,否则「展开」按钮显隐失准
function onWinResize() { measureDesc() }
onMounted(() => window.addEventListener('resize', onWinResize))
onBeforeUnmount(() => window.removeEventListener('resize', onWinResize))
</script>
<style scoped>
@@ -196,6 +211,22 @@ watch(() => props.view, () => nextTick(measureDesc))
}
.stat-icon { font-size: 13px; }
/* UX走查 P3-6: 工程数加载占位骨架条(shimmer 配方对齐 GitChanges .gc-skeleton-row),
宽度约两位数字占位,高度对齐 12px 字号,防 0 起显跳变 */
.stat-skeleton {
display: inline-block;
width: 18px;
height: 12px;
border-radius: 3px;
background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 100%);
background-size: 200% 100%;
animation: pc-shimmer 1.5s infinite linear;
}
@keyframes pc-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.card-top-actions { display: flex; align-items: center; gap: 8px; }
.card-del-btn {
background: transparent; border: none; cursor: pointer;
+47 -1
View File
@@ -65,6 +65,19 @@
<router-link :to="`/ideas/${task.idea_id}`" class="project-link" @click="$emit('close')">{{ ideaTitle }}</router-link>
</span>
</div>
<!-- S4 关联会话:后端审计表反查的主会话,无关联时入口隐藏;点击关抽屉 + 跳会话 -->
<div v-if="taskConversations.length > 0" class="info-item">
<span class="label">{{ $t('taskDetail.relatedConversations') }}</span>
<span class="value conv-links">
<a
v-for="c in taskConversations"
:key="c.conversation_id"
class="conv-link"
:title="`${c.tool_name} · ${formatDate(c.requested_at)}`"
@click.prevent="handleOpenConversation(c.conversation_id)"
>{{ c.title || c.conversation_id }}</a>
</span>
</div>
<div v-if="task.branch_name" class="info-item">
<span class="label">{{ $t('taskDetail.branch') }}</span>
<span class="value">
@@ -169,10 +182,11 @@ import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Drawer } from '@arco-design/web-vue'
import { taskApi, projectApi, ideaApi } from '@/api'
import { taskApi, projectApi, ideaApi, aiApi } from '@/api'
import { useProjectStore } from '@/stores/project'
import { formatDate } from '@/utils/time'
import { useRendered } from '@/composables/useMarkdown'
import { useAiConversations } from '@/composables/ai/useAiConversations'
import { taskStatusLabel, taskStatusClass, priorityLabel, priorityClass, ADVANCE_MAP } from '@/constants/project'
import type { AdvanceAction } from '@/constants/project'
import type { TaskRecord, ProjectRecord, IdeaRecord } from '@/api/types'
@@ -192,6 +206,11 @@ const { t } = useI18n()
const router = useRouter()
const store = useProjectStore()
const { showToast } = useToast()
const { openConversation } = useAiConversations()
// 关联会话引用(S4:审计表反查,无关联=空数组 → 入口隐藏)
type TaskConvRef = { conversation_id: string; title: string | null; tool_name: string; requested_at: string }
const taskConversations = ref<TaskConvRef[]>([])
const loading = ref(true)
const errorMsg = ref('')
@@ -248,6 +267,9 @@ async function load() {
if (!tid) return
loading.value = true
errorMsg.value = ''
taskConversations.value = [] // 新一轮加载先清旧引用,防切换任务后残留上一任务的会话
// 关联会话为辅助入口,旁路加载不阻塞主数据;失败静默(入口不显示即降级)
void loadTaskConversations(tid)
try {
const [t, ps, ideasList] = await Promise.all([
taskApi.get(tid),
@@ -268,6 +290,21 @@ async function load() {
}
}
/** S4:拉当前任务的关联会话(独立于主 try,查失败不拖垮抽屉);响应回来校验 taskId 未变 */
async function loadTaskConversations(tid: string) {
try {
const refs = await aiApi.listTaskConversations(tid)
if (tid !== props.taskId) return // 任务已切换,旧任务响应丢弃
taskConversations.value = refs
} catch { taskConversations.value = [] }
}
/** 点击关联会话:先关抽屉(面板在抽屉下层,不关会被遮挡),再走 openConversation 跳转 */
async function handleOpenConversation(convId: string) {
emit('close')
await openConversation(convId)
}
async function loadChildren() {
const cur = task.value
if (!cur || cur.parent_id) { children.value = []; return }
@@ -356,4 +393,13 @@ async function confirmSubtask() {
.dr-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.dr-header-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.loading-hint { text-align: center; padding: 40px 0; }
/* 关联会话:多会话纵向排列,链接样式对齐 .project-link 语义(TaskDrawer 内无其 scoped 定义) */
.conv-links { display: flex; flex-direction: column; gap: 4px; }
.conv-link {
color: var(--df-accent);
font-weight: 500;
cursor: pointer;
word-break: break-all;
}
.conv-link:hover { text-decoration: underline; }
</style>
+12
View File
@@ -656,6 +656,17 @@ function toggleSidebar() {
persistUiState()
}
/** 跨视图跳转会话(S4 TaskDrawer「关联会话」入口):AI 无路由,跳转 = 面板展开 + 切会话。
* 主窗口面板未展开时先展开再切换;detached(分离窗口)下主窗口无面板,只切会话不动面板。 */
async function openConversation(id: string) {
// 已展开不重复写 persistUiState(省一次 debounced SQLite 写)
if (!state.detached && !state.panelOpen) {
state.panelOpen = true
persistUiState()
}
await switchConversation(id)
}
export function useAiConversations() {
return {
loadConversations,
@@ -670,6 +681,7 @@ export function useAiConversations() {
toggleArchivedFold,
toggleGroupFold,
toggleSidebar,
openConversation,
}
}
+5 -1
View File
@@ -541,7 +541,11 @@ async function quickDelete(task: TaskRecord, childCount = 0) {
// deleteTask 返成功布尔,失败 toast(store.error 同时走 error-banner)
const ok = await store.deleteTask(task.id)
if (!ok) showToast(t('tasks.err.deleteFailed'), 'error')
} catch (e) { console.error('删除失败:', e) }
} catch (e) {
// UX走查 P3-3: throw 型异常原仅 console.error 静默,用户无感知;对齐 quickAdvance/quickPriority 的 toast 反馈
console.error('删除失败:', e)
showToast(e?.toString() ?? t('tasks.err.deleteFailed'), 'error')
}
}
// 点击外部关闭快捷菜单
function closeQuickMenu() { quickMenuId.value = null }