优化: 会话入口与对话交互打磨
- 完成卡点开子代理轨迹浮层,任务抽屉关联会话跳转 - 提及片段编辑差分三向判定,不再错位 - 长回复折叠完成瞬间锚定消息顶,视口不跳 - 空编辑/技能无匹配/回填覆盖草稿均有提示确认 - 队列编辑态切会话重置,刷新快捷键不再误触 - git 变更面板去重缓存,项目卡骨架与溢出重测
This commit is contained in:
@@ -368,4 +368,22 @@ export const aiApi = {
|
|||||||
updateConversationGoals(conversationId: string, goals: string[]): Promise<void> {
|
updateConversationGoals(conversationId: string, goals: string[]): Promise<void> {
|
||||||
return invoke('ai_update_conversation_goals', { conversationId, goals })
|
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 })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
: (pendingSkill ? (pendingSkill.argument_hint || $t('aiChat.placeholderWithSkill')) : $t('aiChat.placeholderDefault'))"
|
: (pendingSkill ? (pendingSkill.argument_hint || $t('aiChat.placeholderWithSkill')) : $t('aiChat.placeholderDefault'))"
|
||||||
rows="1"
|
rows="1"
|
||||||
@keydown="handleKeydown"
|
@keydown="handleKeydown"
|
||||||
@input="autoResize"
|
@beforeinput="onBeforeInputText"
|
||||||
|
@input="onInputText"
|
||||||
@paste="onPaste"
|
@paste="onPaste"
|
||||||
@dragover="onDragOver"
|
@dragover="onDragOver"
|
||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
@@ -401,6 +402,38 @@ const mentionStart = ref(-1)
|
|||||||
// 后端据 mentionSpans resolve 投影成 Augmentation 注入;MessageList 据此精确切区间渲染 chip。
|
// 后端据 mentionSpans resolve 投影成 Augmentation 注入;MessageList 据此精确切区间渲染 chip。
|
||||||
const pendingMentionSpans = ref<MentionSpan[]>([])
|
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 预览) ──
|
// ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ──
|
||||||
// 当用户 @[项目:xxx] 后,从 projectsStore 反查项目+关联任务/灵感,
|
// 当用户 @[项目:xxx] 后,从 projectsStore 反查项目+关联任务/灵感,
|
||||||
// 在输入区下方显式 badge + 可展开的上下文参考面板。
|
// 在输入区下方显式 badge + 可展开的上下文参考面板。
|
||||||
@@ -685,6 +718,14 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
// P1-1 输入法组词守卫:拼音/双拼组词未上屏时 Enter=选词、Esc=取消组词,均非用户发送/取消意图,
|
// P1-1 输入法组词守卫:拼音/双拼组词未上屏时 Enter=选词、Esc=取消组词,均非用户发送/取消意图,
|
||||||
// 直接放行给 IME。isComposing 覆盖标准浏览器;keyCode===229 兜部分环境 keydown 阶段不上报 isComposing。
|
// 直接放行给 IME。isComposing 覆盖标准浏览器;keyCode===229 兜部分环境 keydown 阶段不上报 isComposing。
|
||||||
if (e.isComposing || e.keyCode === 229) return
|
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 消费分支 stopPropagation:P1-2 双层防,分离窗口 AiChat 的 window 级 Esc 会关整个窗口,
|
||||||
// 内层已消费的 Esc 不再外抛(主防线是 AiChat 侧的 e.target 输入控件守卫,此处为辅)
|
// 内层已消费的 Esc 不再外抛(主防线是 AiChat 侧的 e.target 输入控件守卫,此处为辅)
|
||||||
// 编辑态 Escape 取消编辑(最高优先级)
|
// 编辑态 Escape 取消编辑(最高优先级)
|
||||||
@@ -732,6 +773,14 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
return
|
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 (mentionOpen.value && mentionItems.value.length) {
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
@@ -775,7 +824,12 @@ async function handleSend() {
|
|||||||
const text = inputText.value.trim()
|
const text = inputText.value.trim()
|
||||||
// UX-09 编辑态:走 editMessage(不 push 新 user,编辑末条 user 后重生成)
|
// UX-09 编辑态:走 editMessage(不 push 新 user,编辑末条 user 后重生成)
|
||||||
if (props.editingMsgId) {
|
if (props.editingMsgId) {
|
||||||
if (!text) return
|
// UX-P3 空编辑静默:清空文本后 Enter 原来静默 return,用户无反馈卡在编辑态。
|
||||||
|
// 仍阻止空提交(editMessage 空文本语义不明),但补轻提示告知原因。
|
||||||
|
if (!text) {
|
||||||
|
emit('error', { msg: t('aiChat.editEmptyBlocked'), type: 'warning' })
|
||||||
|
return
|
||||||
|
}
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
if (inputEl.value) inputEl.value.style.height = 'auto'
|
if (inputEl.value) inputEl.value.style.height = 'auto'
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
|
|||||||
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
|
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
|
||||||
import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
|
import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
|
||||||
import { getConvAgentFinished, type AgentFinishedCard } from '../../composables/ai/aiShared'
|
import { getConvAgentFinished, type AgentFinishedCard } from '../../composables/ai/aiShared'
|
||||||
|
import { aiApi } from '../../api'
|
||||||
import ToolCardList from '../ToolCardList.vue'
|
import ToolCardList from '../ToolCardList.vue'
|
||||||
import MessageItem from './MessageItem.vue'
|
import MessageItem from './MessageItem.vue'
|
||||||
import EmptyState from './EmptyState.vue'
|
import EmptyState from './EmptyState.vue'
|
||||||
@@ -273,9 +274,32 @@ watch(() => store.state.streaming, (s) => {
|
|||||||
saveSelection()
|
saveSelection()
|
||||||
clearStreamingState()
|
clearStreamingState()
|
||||||
nextTick(() => restoreSelection())
|
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 状态变化)时,
|
// ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化)时,
|
||||||
// 收起旧的已完成/已拒绝卡片,保留 running/pending/write_file 活跃卡片。
|
// 收起旧的已完成/已拒绝卡片,保留 running/pending/write_file 活跃卡片。
|
||||||
// 折叠态由 ToolCardList 自治,父级仅经 expose 调 collapseInactive ──
|
// 折叠态由 ToolCardList 自治,父级仅经 expose 调 collapseInactive ──
|
||||||
@@ -1083,6 +1107,68 @@ function agentFinSummary(summary: string): string {
|
|||||||
return s.length > 160 ? s.slice(0, 160) + '…' : s
|
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 置顶,只做跳转不做统计)
|
// UX 密度改造:轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转不做统计)
|
||||||
const traceNav = computed(() =>
|
const traceNav = computed(() =>
|
||||||
renderItems.value.filter((it): it is Extract<RenderItem, { kind: 'trace' }> => it.kind === 'trace')
|
renderItems.value.filter((it): it is Extract<RenderItem, { kind: 'trace' }> => it.kind === 'trace')
|
||||||
@@ -1230,9 +1316,16 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
<!-- S1 子代理完成卡(AiAgentFinished):状态点/状态词三态区分(completed 中性/truncated
|
<!-- S1 子代理完成卡(AiAgentFinished):状态点/状态词三态区分(completed 中性/truncated
|
||||||
警示/failed 错误),摘要单行截断 + 轮数 + tokens;骨架对齐 ai-step-line/ai-retry-line
|
警示/failed 错误),摘要单行截断 + 轮数 + tokens;骨架对齐 ai-step-line/ai-retry-line
|
||||||
轻量细条范式,非消息数据不进气泡 -->
|
轻量细条范式,非消息数据不进气泡。
|
||||||
|
S3:整卡可点,点击 peek 子会话轨迹开只读浮层(数据关即弃,见 openAgentTrace) -->
|
||||||
<div v-else-if="item.kind === 'agentFin'" class="ai-msg-slot">
|
<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-dot"></span>
|
||||||
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
|
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
|
||||||
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card.status) }}</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>
|
<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>
|
</button>
|
||||||
</Transition>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -2268,7 +2390,8 @@ defineExpose({
|
|||||||
|
|
||||||
/* S1 子代理完成卡(AiAgentFinished):三态色分层——completed 中性灰(常态结果不抢视线)
|
/* S1 子代理完成卡(AiAgentFinished):三态色分层——completed 中性灰(常态结果不抢视线)
|
||||||
/ truncated 琥珀(结果不完整,与 retry 细条同警示层)/ failed 红(需关注,对齐错误卡层)。
|
/ truncated 琥珀(结果不完整,与 retry 细条同警示层)/ failed 红(需关注,对齐错误卡层)。
|
||||||
骨架对齐 .ai-retry-line 轻量细条;摘要单行截断,全文经 title hover 查看 */
|
骨架对齐 .ai-retry-line 轻量细条;摘要单行截断,全文经 title hover 查看。
|
||||||
|
S3:整卡可点开轨迹浮层,cursor pointer + hover 提示可交互 */
|
||||||
.ai-agent-fin {
|
.ai-agent-fin {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2280,6 +2403,10 @@ defineExpose({
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--df-text-secondary);
|
color: var(--df-text-secondary);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ai-agent-fin:hover {
|
||||||
|
background: color-mix(in srgb, var(--df-text-secondary) 12%, transparent);
|
||||||
}
|
}
|
||||||
.ai-agent-fin-dot {
|
.ai-agent-fin-dot {
|
||||||
width: 5px;
|
width: 5px;
|
||||||
@@ -2323,4 +2450,106 @@ defineExpose({
|
|||||||
font-family: var(--df-font-mono);
|
font-family: var(--df-font-mono);
|
||||||
font-size: 10.5px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -185,8 +185,6 @@ const loading = ref(false)
|
|||||||
const gitStatus = ref<GitStatusResult | null>(null)
|
const gitStatus = ref<GitStatusResult | null>(null)
|
||||||
const showHistory = ref(false)
|
const showHistory = ref(false)
|
||||||
const selectedFile = ref('')
|
const selectedFile = ref('')
|
||||||
const diffContent = ref('')
|
|
||||||
const diffLoading = ref(false)
|
|
||||||
// 行内错误提示:loadError 主区展示(loadStatus 失败),actionError 分支条下方展示(loadMore/selectFile/切分支/提交详情 失败)
|
// 行内错误提示:loadError 主区展示(loadStatus 失败),actionError 分支条下方展示(loadMore/selectFile/切分支/提交详情 失败)
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const actionError = ref('')
|
const actionError = ref('')
|
||||||
@@ -194,6 +192,12 @@ const actionError = ref('')
|
|||||||
// (对齐 ProjectDetail taskReqSeq 模式)
|
// (对齐 ProjectDetail taskReqSeq 模式)
|
||||||
let statusReqSeq = 0
|
let statusReqSeq = 0
|
||||||
|
|
||||||
|
// UX走查 P3-4: 点文件 diff 单槽去重缓存 — 重复点击同一文件不再重复发 git diff IPC,
|
||||||
|
// 直接复用上次结果回填。切模块/refreshKey 重拉状态均经 loadStatus,缓存在彼处失效,
|
||||||
|
// 防 AI 改文件后旧 diff 被复用(单槽不设 Map,免过期条目堆积)
|
||||||
|
let lastDiffKey = ''
|
||||||
|
let lastDiffContent = ''
|
||||||
|
|
||||||
/** 单条提交记录形状(对齐 getModuleCommits / GitRecentCommit)。 */
|
/** 单条提交记录形状(对齐 getModuleCommits / GitRecentCommit)。 */
|
||||||
interface CommitItem {
|
interface CommitItem {
|
||||||
hash: string
|
hash: string
|
||||||
@@ -353,6 +357,10 @@ async function loadStatus() {
|
|||||||
const mid = props.moduleId
|
const mid = props.moduleId
|
||||||
if (!mid) return
|
if (!mid) return
|
||||||
const seq = ++statusReqSeq
|
const seq = ++statusReqSeq
|
||||||
|
// UX走查 P3-4: 状态重拉(切模块/外部 refreshKey,如 AI 改文件)即失效 diff 去重缓存,
|
||||||
|
// 防改文件后旧 diff 被复用
|
||||||
|
lastDiffKey = ''
|
||||||
|
lastDiffContent = ''
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
actionError.value = ''
|
actionError.value = ''
|
||||||
@@ -448,23 +456,35 @@ async function switchToHistory() {
|
|||||||
|
|
||||||
async function selectFile(path: string) {
|
async function selectFile(path: string) {
|
||||||
selectedFile.value = path
|
selectedFile.value = path
|
||||||
// 先切预览到该文件(内容视图),diff 拉到后回填,点文件即展示变化
|
// 先切预览到该文件(内容视图兜底),diff 拉到后回填切 diff 视图,点文件即展示变化
|
||||||
emit('select-file', path)
|
emit('select-file', path)
|
||||||
diffContent.value = ''
|
|
||||||
actionError.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 {
|
try {
|
||||||
const res = await moduleApi.getModuleFileDiff(props.moduleId, path)
|
const res = await moduleApi.getModuleFileDiff(props.moduleId, path)
|
||||||
diffContent.value = res.diff || ''
|
diff = res.diff || ''
|
||||||
|
if (seqAtReq === statusReqSeq) {
|
||||||
|
lastDiffKey = key
|
||||||
|
lastDiffContent = diff
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
diffContent.value = ''
|
// 失败清缓存使下次点击可重试;不回填 diff,预览停在内容视图(错误经 actionError 条就近提示)
|
||||||
|
lastDiffKey = ''
|
||||||
|
lastDiffContent = ''
|
||||||
actionError.value = String(e)
|
actionError.value = String(e)
|
||||||
} finally {
|
return
|
||||||
diffLoading.value = false
|
|
||||||
}
|
}
|
||||||
// 仅当仍是当前选中文件时才回填 diff,防快速切换时旧响应覆盖新文件
|
// 仅当仍是当前选中文件时才回填 diff,防快速切换时旧响应覆盖新文件
|
||||||
if (selectedFile.value === path) {
|
if (selectedFile.value === path) {
|
||||||
emit('select-file', path, diffContent.value)
|
emit('select-file', path, diff)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,10 @@
|
|||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<div class="footer-stat">
|
<div class="footer-stat">
|
||||||
<span class="stat-icon">📁</span>
|
<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>
|
||||||
<div class="footer-stat">
|
<div class="footer-stat">
|
||||||
<span class="stat-icon">📅</span>
|
<span class="stat-icon">📅</span>
|
||||||
@@ -48,7 +51,7 @@
|
|||||||
// 依赖均为无副作用纯函数 + 共享常量;删除走 emit 交回父组件处理(confirm 弹层 + store 调用)。
|
// 依赖均为无副作用纯函数 + 共享常量;删除走 emit 交回父组件处理(confirm 弹层 + store 调用)。
|
||||||
// view prop:'list'(紧凑,默认)/'card'(宽松);描述 max-height 截断 + 展开/收起。
|
// view prop:'list'(紧凑,默认)/'card'(宽松);描述 max-height 截断 + 展开/收起。
|
||||||
import { useRouter } from 'vue-router'
|
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 { formatDate } from '@/utils/time'
|
||||||
import { parseStack } from '@/utils/project'
|
import { parseStack } from '@/utils/project'
|
||||||
import { stripMd } from '@/utils/markdown'
|
import { stripMd } from '@/utils/markdown'
|
||||||
@@ -71,11 +74,14 @@ const router = useRouter()
|
|||||||
|
|
||||||
// 工程数(异步拉取,展示在卡片底部)
|
// 工程数(异步拉取,展示在卡片底部)
|
||||||
const moduleCount = ref(0)
|
const moduleCount = ref(0)
|
||||||
|
// UX走查 P3-6: 加载态标记,模板据此显骨架占位而非 0(成功/失败都收 loading,失败仍显 0 属既有约定)
|
||||||
|
const moduleCountLoading = ref(true)
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await moduleApi.listProjectModules(props.project.id)
|
const list = await moduleApi.listProjectModules(props.project.id)
|
||||||
moduleCount.value = list.length
|
moduleCount.value = list.length
|
||||||
} catch { /* 老项目无工程记录不报错 */ }
|
} catch { /* 老项目无工程记录不报错 */ }
|
||||||
|
moduleCountLoading.value = false
|
||||||
// DOM 渲染完成后测描述是否溢出(决定是否显示「展开」)
|
// DOM 渲染完成后测描述是否溢出(决定是否显示「展开」)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
measureDesc()
|
measureDesc()
|
||||||
@@ -91,6 +97,8 @@ const descExpanded = ref(false)
|
|||||||
function measureDesc() {
|
function measureDesc() {
|
||||||
const el = descRef.value
|
const el = descRef.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
// 展开态无行截断,测出的一律是"未溢出",会误收起按钮致无法「收起」;展开期间维持原判定
|
||||||
|
if (descExpanded.value) return
|
||||||
// scrollHeight > clientHeight 说明被 max-height 截断了
|
// scrollHeight > clientHeight 说明被 max-height 截断了
|
||||||
descOverflow.value = el.scrollHeight - el.clientHeight > 1
|
descOverflow.value = el.scrollHeight - el.clientHeight > 1
|
||||||
}
|
}
|
||||||
@@ -98,6 +106,13 @@ function measureDesc() {
|
|||||||
// 描述/视图模式变化后重新测量(描述异步入库或切 view 行数阈值变,按钮显隐需重算)
|
// 描述/视图模式变化后重新测量(描述异步入库或切 view 行数阈值变,按钮显隐需重算)
|
||||||
watch(() => props.project.description, () => nextTick(measureDesc))
|
watch(() => props.project.description, () => nextTick(measureDesc))
|
||||||
watch(() => props.view, () => 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -196,6 +211,22 @@ watch(() => props.view, () => nextTick(measureDesc))
|
|||||||
}
|
}
|
||||||
.stat-icon { font-size: 13px; }
|
.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-top-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
.card-del-btn {
|
.card-del-btn {
|
||||||
background: transparent; border: none; cursor: pointer;
|
background: transparent; border: none; cursor: pointer;
|
||||||
|
|||||||
@@ -65,6 +65,19 @@
|
|||||||
<router-link :to="`/ideas/${task.idea_id}`" class="project-link" @click="$emit('close')">{{ ideaTitle }}</router-link>
|
<router-link :to="`/ideas/${task.idea_id}`" class="project-link" @click="$emit('close')">{{ ideaTitle }}</router-link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div v-if="task.branch_name" class="info-item">
|
||||||
<span class="label">{{ $t('taskDetail.branch') }}</span>
|
<span class="label">{{ $t('taskDetail.branch') }}</span>
|
||||||
<span class="value">
|
<span class="value">
|
||||||
@@ -169,10 +182,11 @@ import { ref, computed, watch } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Drawer } from '@arco-design/web-vue'
|
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 { useProjectStore } from '@/stores/project'
|
||||||
import { formatDate } from '@/utils/time'
|
import { formatDate } from '@/utils/time'
|
||||||
import { useRendered } from '@/composables/useMarkdown'
|
import { useRendered } from '@/composables/useMarkdown'
|
||||||
|
import { useAiConversations } from '@/composables/ai/useAiConversations'
|
||||||
import { taskStatusLabel, taskStatusClass, priorityLabel, priorityClass, ADVANCE_MAP } from '@/constants/project'
|
import { taskStatusLabel, taskStatusClass, priorityLabel, priorityClass, ADVANCE_MAP } from '@/constants/project'
|
||||||
import type { AdvanceAction } from '@/constants/project'
|
import type { AdvanceAction } from '@/constants/project'
|
||||||
import type { TaskRecord, ProjectRecord, IdeaRecord } from '@/api/types'
|
import type { TaskRecord, ProjectRecord, IdeaRecord } from '@/api/types'
|
||||||
@@ -192,6 +206,11 @@ const { t } = useI18n()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useProjectStore()
|
const store = useProjectStore()
|
||||||
const { showToast } = useToast()
|
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 loading = ref(true)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
@@ -248,6 +267,9 @@ async function load() {
|
|||||||
if (!tid) return
|
if (!tid) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
|
taskConversations.value = [] // 新一轮加载先清旧引用,防切换任务后残留上一任务的会话
|
||||||
|
// 关联会话为辅助入口,旁路加载不阻塞主数据;失败静默(入口不显示即降级)
|
||||||
|
void loadTaskConversations(tid)
|
||||||
try {
|
try {
|
||||||
const [t, ps, ideasList] = await Promise.all([
|
const [t, ps, ideasList] = await Promise.all([
|
||||||
taskApi.get(tid),
|
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() {
|
async function loadChildren() {
|
||||||
const cur = task.value
|
const cur = task.value
|
||||||
if (!cur || cur.parent_id) { children.value = []; return }
|
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-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
.dr-header-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
.dr-header-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||||||
.loading-hint { text-align: center; padding: 40px 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>
|
</style>
|
||||||
@@ -656,6 +656,17 @@ function toggleSidebar() {
|
|||||||
persistUiState()
|
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() {
|
export function useAiConversations() {
|
||||||
return {
|
return {
|
||||||
loadConversations,
|
loadConversations,
|
||||||
@@ -670,6 +681,7 @@ export function useAiConversations() {
|
|||||||
toggleArchivedFold,
|
toggleArchivedFold,
|
||||||
toggleGroupFold,
|
toggleGroupFold,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
|
openConversation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -541,7 +541,11 @@ async function quickDelete(task: TaskRecord, childCount = 0) {
|
|||||||
// deleteTask 返成功布尔,失败 toast(store.error 同时走 error-banner)
|
// deleteTask 返成功布尔,失败 toast(store.error 同时走 error-banner)
|
||||||
const ok = await store.deleteTask(task.id)
|
const ok = await store.deleteTask(task.id)
|
||||||
if (!ok) showToast(t('tasks.err.deleteFailed'), 'error')
|
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 }
|
function closeQuickMenu() { quickMenuId.value = null }
|
||||||
|
|||||||
Reference in New Issue
Block a user