diff --git a/src/composables/ai/aiShared.ts b/src/composables/ai/aiShared.ts index e7f8a0b..460594d 100644 --- a/src/composables/ai/aiShared.ts +++ b/src/composables/ai/aiShared.ts @@ -175,13 +175,8 @@ export function startApprovalTimer( denyP.catch(e => { console.error('[AI] 审批超时自动拒绝 IPC 未送达:', e) }) - getMessages().push({ - id: `approval-timeout-${nextMsgId()}` as MessageId, - role: 'assistant', - content: t('ai.approvalTimeout', { toolName }), - isError: true, - timestamp: Date.now(), - }) + // UX-P2-A3:气泡推送改经 per-conv 守卫(函数追加在本文件尾部),后台会话超时不串当前视图 + pushApprovalTimeoutBubble(toolName, toolCallId) }, timeoutMs) _approvalTimers.set(toolCallId, { timer, startedAt: Date.now(), timeoutMs }) } @@ -665,3 +660,37 @@ export function clearConvAgentFinished(convId: string | null | undefined): void if (!convId) return convAgentFinished.delete(convId) } + +// ── UX-P2-A3 审批超时错误气泡 per-conv 守卫 ── +// +// 背景:startApprovalTimer 到点把「审批超时已自动拒绝」错误气泡直接推进 getMessages() +// (当前活跃视图),后台会话 A 的审批超时,气泡落在用户正查看的会话 B 里(跨会话串扰)。 +// 本守卫按审批所属 conv 过滤:仅当审批所属会话是当前活跃会话(或无法归属)才推视图气泡; +// 后台会话的超时拒绝 IPC 照常执行,只是不串视图。 +// 归属反查:到点时刻从 per-conv 待审批集合(convPendingApprovals)按 toolCallId 反查 convId—— +// 此时拒绝 IPC 才刚发出,审批结果事件未到,集合项必在;查不到(旧数据/已被清)保守回落为推, +// 不改变无归属场景的原行为。 +export function pushApprovalTimeoutBubble(toolName: string, toolCallId: string): void { + let convId: string | null = null + for (const [k, arr] of convPendingApprovals) { + if (arr.some(p => p.id === toolCallId)) { + convId = k + break + } + } + const active = getState()?.activeConversationId ?? null + if (convId && convId !== active) return + getMessages().push({ + id: `approval-timeout-${nextMsgId()}` as MessageId, + role: 'assistant', + content: t('ai.approvalTimeout', { toolName }), + isError: true, + timestamp: Date.now(), + }) +} + +/** 队列被清空的统一提示(UX-P2-A2:AiError/regenerate/editMessage 清队从静默改 toast, + * 清 0 条不提示;经 ai-toast 事件总线,AiChat 本地 toast 消费) */ +export function notifyQueueCleared(n: number): void { + if (n > 0) void emit('ai-toast', { msg: t('ai.queueCleared', { n }), type: 'warning' }) +} diff --git a/src/composables/ai/streamingGuard.ts b/src/composables/ai/streamingGuard.ts index e6997e1..c294386 100644 --- a/src/composables/ai/streamingGuard.ts +++ b/src/composables/ai/streamingGuard.ts @@ -21,7 +21,7 @@ //! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。 import { state } from '@/stores/ai' -import { convStates, getConvStreamState, setConvStreaming } from './aiShared' +import { convStates, getConvStreamState, setConvStreaming, setConvCurrentText } from './aiShared' import { useAppSettingsStore } from '@/stores/appSettings' /// feature flag key(appSettings KV)。关=回退散布语义(灰度/回退用)。 @@ -88,10 +88,9 @@ export function setStreaming( * TD-260621-01 per-conv:看门狗超时携带 convId 时,经 setStreaming 仅复位该会话 streaming 态; * 该会话的 convStates 项由各自会话收尾事件精确管理(on/off 后端均会 emit 收尾态)。 * - * 注:currentText/queue 仍是单例(F-09 域,本批不动),per-conv 超时在多会话并发下 - * 会清当前累积的 currentText——但 currentText 仅活跃视图写入(useAiEvents handleEvent delta 累积), - * 而超时仅发生在该 conv 长时间无数据,此场景 currentText 多为空或已 flush, - * 实际误伤面远小于 generatingConvs 全清(队列 queue 的清理由 F-09 统一改,见 TD-260621-01 注释)。 + * 注:currentText/queue 已随 UX-P2-A4 改 per-conv 键控:带 convId 的兜底只清该会话的 + * 排队项与流式缓冲,不再连带清其他会话的排队消息(多会话并发下 queue 是跨会话混排数组, + * 原全清会把 B 会话排队一并丢掉);无 convId 全局兜底语义不变(全清)。 * * @param reason 兜底来源(日志可观测) * @param convId 关联会话 id(看门狗 per-conv 超时传;省略=全局兜底) @@ -99,8 +98,16 @@ export function setStreaming( export function forceResetStreaming(reason: string, convId?: string): void { console.warn(`[AI-Guard] forceResetStreaming(卡死兜底): ${reason} (convId=${convId ?? 'none'})`) setStreaming(false, { convId: convId ?? null, reason }) - state.currentText = '' - state.queue = [] + // UX-P2-A4:per-conv 键控清理——带 convId 只清该会话排队项(currentText 同步走 per-conv 键, + // state.currentText 是委派活跃会话的 accessor,后台 conv 超时原写法会误清当前视图缓冲); + // 无 convId 全局兜底保持全清(卡死恢复场景,精确性不重要)。 + if (convId) { + state.queue = state.queue.filter(q => q.conversationId !== convId) + setConvCurrentText(convId, '') + } else { + state.currentText = '' + state.queue = [] + } // BUG-2026-07-17 根治:同时清理 convStates 项。 // 原实现仅清 streaming/currentText/queue,但按钮 stopBtnState 优先读 convStates // (ChatInput.vue:243),convStates=generating 时仍显红色停止按钮。后端未发 diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index a91f0be..947831d 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -4,6 +4,7 @@ //! - 多处调 useAiEvents.notifyConversationChanged //! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id +import { ref } from 'vue' import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' @@ -545,9 +546,22 @@ type ConvOpFailKey = | 'renameConvFail' | 'archiveConvFail' | 'pinConvFail' | 'newConvFail' | 'deleteConvFail' | 'switchConvFail' -/** M30:写后端 IPC 失败时推送错误气泡(对齐 loadConversations/switchConversation 的失败反馈), - * 保持侧栏已有交互不被无声吞掉(原仅向上抛,调用方 store 直调无 try → 异常进 unhandledrejection)。 */ +/** UX走查 P2-7:rename/archive/pin 失败的侧栏就近通知(发布-订阅)。每次失败发布新对象, + * ConversationSidebar watch 到即弹 toast;本模块不感知展示细节(翻译/计时归订阅方)。 */ +export const convOpFailNotice = ref<{ + key: ConvOpFailKey + params?: Record +} | null>(null) + +/** M30:写后端 IPC 失败时推送反馈,保持侧栏已有交互不被无声吞掉(原仅向上抛,调用方 store + * 直调无 try → 异常进 unhandledrejection)。UX走查 P2-7 分流:rename/archive/pin 发起与 + * 视线焦点都在侧栏,改发 convOpFailNotice 由侧栏弹就近 toast,不再推进聊天流(错误气泡 + * 视线错位,且消息区被清空/切换后不可见);new/delete/switch 与聊天区强关联,仍走错误气泡。 */ function pushConvOpFail(key: ConvOpFailKey, params?: Record) { + if (key === 'renameConvFail' || key === 'archiveConvFail' || key === 'pinConvFail') { + convOpFailNotice.value = { key, params } + return + } state.messages.push({ id: `conv-op-fail-${nextMsgId()}`, role: 'assistant', diff --git a/src/composables/ai/useAiLifecycleEvents.ts b/src/composables/ai/useAiLifecycleEvents.ts index 1e3104f..2b45bb9 100644 --- a/src/composables/ai/useAiLifecycleEvents.ts +++ b/src/composables/ai/useAiLifecycleEvents.ts @@ -9,7 +9,7 @@ import { emit } from '@tauri-apps/api/event' import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' import { t } from '@/i18n/i18n-helpers' -import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals } from './aiShared' +import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared } from './aiShared' import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState' import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream' import { setStreaming } from './streamingGuard' @@ -176,10 +176,14 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean { case 'AiError': { cleanupTerminatedConversation(event.conversation_id || '', 'AiError') - // 只清除出错会话的队列项,不误伤其他会话的排队消息 + // 只清除出错会话的队列项,不误伤其他会话的排队消息; + // UX-P2-A2:清队从静默改 toast 提示条数(用户排队的消息无声消失原无任何反馈) if (event.conversation_id) { + const dropped = state.queue.filter(q => q.conversationId === event.conversation_id).length state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id) + notifyQueueCleared(dropped) } else { + notifyQueueCleared(state.queue.length) state.queue = [] } state.messages.push({ diff --git a/src/composables/ai/useAiSend.ts b/src/composables/ai/useAiSend.ts index b87c804..8df354d 100644 --- a/src/composables/ai/useAiSend.ts +++ b/src/composables/ai/useAiSend.ts @@ -24,7 +24,16 @@ import { t } from '@/i18n/i18n-helpers' import { resetStreamWatchdog, clearStreamWatchdog, clearResumeAttempts } from './useAiStream' import { setStreaming } from './streamingGuard' import { startListener } from './useAiEvents' -import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText } from './aiShared' +import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText, notifyQueueCleared, classifySendError, type SendErrorKind } from './aiShared' + +/// UX-P2-A5:drainQueue 失败气泡的错误分类文案映射(分类逻辑 aiShared.classifySendError, +/// 键复用 ChatInput 发送失败 toast 同一批 aiChat.sendErr* 文案,原始错误经 { msg } 细节保留) +const QUEUED_SEND_ERR_KEY: Record = { + rate_limit: 'aiChat.sendErrRateLimit', + quota: 'aiChat.sendErrQuota', + network: 'aiChat.sendErrNetwork', + other: 'aiChat.toastSendFail', +} /// 待发送队列上限(超过抛错提示用户) const QUEUE_LIMIT = 10 @@ -163,9 +172,12 @@ async function regenerate() { setStreaming(true, { convId: state.activeConversationId, reason: 'regenerate' }) state.currentText = '' - // 只清当前会话的排队消息,不误伤其他会话 + // 只清当前会话的排队消息,不误伤其他会话; + // UX-P2-A2:清队从静默改 toast 提示条数(排队消息无声消失原无任何反馈) const regenConvId = state.activeConversationId + const droppedQueued = state.queue.filter(q => q.conversationId === regenConvId).length state.queue = state.queue.filter(q => q.conversationId !== regenConvId) + notifyQueueCleared(droppedQueued) // 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback resetStreamWatchdog(regenConvId || undefined) @@ -236,9 +248,12 @@ async function editMessage(newMessage: string) { setStreaming(true, { convId: state.activeConversationId, reason: 'editMessage' }) state.currentText = '' - // 只清当前会话的排队消息,不误伤其他会话 + // 只清当前会话的排队消息,不误伤其他会话; + // UX-P2-A2:清队从静默改 toast 提示条数(排队消息无声消失原无任何反馈) const editConvId = state.activeConversationId + const droppedQueued = state.queue.filter(q => q.conversationId === editConvId).length state.queue = state.queue.filter(q => q.conversationId !== editConvId) + notifyQueueCleared(droppedQueued) // 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback resetStreamWatchdog(editConvId || undefined) @@ -301,10 +316,12 @@ export function drainQueue(convId?: string | null) { // 仅目标会话为当前视图才推错误气泡(后台会话续发失败不污染当前视图;错误消息留在队列 // 供切回后编辑/重试/取消) if (targetId === state.activeConversationId) { + // UX-P2-A5:失败气泡接入 classifySendError 分类文案(对齐 ChatInput 发送失败 toast 四类), + // 替代原 raw 错误直出的 ai.queuedSendFailed;原始错误串经 { msg } 细节保留 state.messages.push({ id: `err-${nextMsgId()}`, role: 'assistant', - content: t('ai.queuedSendFailed', { error: errMsg }), + content: t(QUEUED_SEND_ERR_KEY[classifySendError(errMsg)], { msg: errMsg }), isError: true, timestamp: Date.now(), } as AiMessage) diff --git a/src/composables/task/useTaskKanban.ts b/src/composables/task/useTaskKanban.ts index 0457ce3..541df7b 100644 --- a/src/composables/task/useTaskKanban.ts +++ b/src/composables/task/useTaskKanban.ts @@ -2,7 +2,7 @@ import { ref } from 'vue' import Sortable from 'sortablejs' import { useI18n } from 'vue-i18n' import { taskApi } from '@/api/task' -import { TASK_STATUS_TRANSITIONS } from '@/constants/project' +import { TASK_STATUS_TRANSITIONS, taskStatusLabel } from '@/constants/project' import { useToast } from '../useToast' export function useTaskKanban(opts: { @@ -15,6 +15,8 @@ export function useTaskKanban(opts: { let s: Sortable[] = [] let _dragId: string | null = null let _executing = false + // 最近一次被拒的流转方向:onMove 只记录不提示(避免拖拽悬停过程误报),onEnd 弹回时消费并清空 + let _rejected: { from: string; to: string } | null = null function destroy() { s.forEach(x => x.destroy()); s = [] } @@ -30,12 +32,16 @@ export function useTaskKanban(opts: { onMove: (e) => { const from = (e.from as HTMLElement).dataset.status ?? '' const to = ((e.to as HTMLElement).closest('[data-status]') as HTMLElement)?.dataset.status ?? '' - if (from === to) return true - return TASK_STATUS_TRANSITIONS[from]?.includes(to) ?? false + if (from === to) { _rejected = null; return true } + const legal = TASK_STATUS_TRANSITIONS[from]?.includes(to) ?? false + // UX走查 P2-5:非法流转只记录方向;合法流转清掉旧拒绝,避免陈旧标记污染后续 onEnd 判断 + _rejected = legal ? null : { from, to } + return legal }, onStart: (e) => { _dragId = (e.item as HTMLElement).dataset.taskId ?? null _executing = false + _rejected = null }, onEnd: async (e) => { if (_executing) return @@ -44,7 +50,22 @@ export function useTaskKanban(opts: { const to = ((e.to as HTMLElement).closest('[data-status]') as HTMLElement)?.dataset.status const id = _dragId _dragId = null - if (!to || !from || to === from) { _executing = false; return } + if (!to || !from || to === from) { + // UX走查 P2-5:非法流转被 onMove 拒绝时卡片弹回原列(落点 to===from), + // 此处补一条原因 toast,替代原先的静默弹回(原因与父卡 tooltip 同源:状态机流转表) + if (_rejected) { + showToast( + t('tasks.kanban.illegalMove', { + from: t(taskStatusLabel(_rejected.from)), + to: t(taskStatusLabel(_rejected.to)), + }), + 'error', + ) + _rejected = null + } + _executing = false + return + } if (!id) { _executing = false; return } kanbanBusy.value = true try {