From 25f19fad69ebc995c0931ca9776ecc5d915e0026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 10 Jul 2026 03:11:22 +0800 Subject: [PATCH] fix(desktop): preserve concurrent permission prompts Track tool and Computer Use approvals by request ID so parallel prompts remain independently actionable. Reconcile resolution state across clients and reconnects, including stopped and completed turns. Refs: #975, #980 Tested: bun run check:desktop (1778 tests) Tested: bun run check:server (1482 tests) Tested: agent-browser concurrent permission smoke Not-tested: Windows packaged app manual smoke Confidence: high Scope-risk: moderate --- .../src/components/chat/AskUserQuestion.tsx | 8 +- desktop/src/components/chat/MessageList.tsx | 7 +- .../src/components/chat/PermissionDialog.tsx | 41 +- .../src/components/chat/chatBlocks.test.tsx | 56 +++ desktop/src/stores/chatStore.test.ts | 373 ++++++++++++++++++ desktop/src/stores/chatStore.ts | 316 ++++++++++++--- desktop/src/types/chat.ts | 12 + .../__tests__/websocket-handler.test.ts | 164 ++++++++ .../services/computerUseApprovalService.ts | 8 + src/server/ws/events.ts | 12 + src/server/ws/handler.ts | 76 +++- 11 files changed, 997 insertions(+), 76 deletions(-) diff --git a/desktop/src/components/chat/AskUserQuestion.tsx b/desktop/src/components/chat/AskUserQuestion.tsx index 4ec8d6a5..3178d237 100644 --- a/desktop/src/components/chat/AskUserQuestion.tsx +++ b/desktop/src/components/chat/AskUserQuestion.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef, useState } from 'react' -import { useChatStore } from '../../stores/chatStore' +import { listPendingPermissions, useChatStore } from '../../stores/chatStore' import { useTabStore } from '../../stores/tabStore' import { useTranslation } from '../../i18n' import { Button } from '../shared/Button' @@ -68,7 +68,10 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props) const { respondToPermission } = useChatStore() const activeTabId = useTabStore((s) => s.activeTabId) const targetSessionId = sessionId ?? activeTabId - const pendingPermission = useChatStore((s) => targetSessionId ? s.sessions[targetSessionId]?.pendingPermission : undefined) + const pendingRequest = useChatStore((s) => targetSessionId + ? listPendingPermissions(s.sessions[targetSessionId]) + .find((permission) => permission.toolUseId === toolUseId) ?? null + : null) const t = useTranslation() const questions = parseInput(input) const inputObject = (input && typeof input === 'object') ? input as Record : {} @@ -93,7 +96,6 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props) const hasStructuredAnswers = Object.keys(resultAnswers).length > 0 const hasTerminalResult = hasStructuredAnswers || resultText.length > 0 - const pendingRequest = pendingPermission?.toolUseId === toolUseId ? pendingPermission : null const answeredText = useMemo(() => { if (hasStructuredAnswers) { return questions diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 42a4b6d9..6b5eface 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom' import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react' import { ApiError } from '../../api/client' import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions' -import { useChatStore } from '../../stores/chatStore' +import { listPendingPermissions, useChatStore } from '../../stores/chatStore' import { useSessionStore } from '../../stores/sessionStore' import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' @@ -1378,9 +1378,8 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const agentTaskNotifications = sessionState?.agentTaskNotifications ?? EMPTY_AGENT_TASK_NOTIFICATIONS const hasRunningBackgroundTasks = hasAnyRunningBackgroundTasks(sessionState?.backgroundAgentTasks) const activeAskUserQuestionToolUseId = - sessionState?.pendingPermission?.toolName === 'AskUserQuestion' - ? sessionState.pendingPermission.toolUseId - : null + listPendingPermissions(sessionState) + .find((permission) => permission.toolName === 'AskUserQuestion')?.toolUseId ?? null const shouldFollowContentResize = streamingText.trim().length > 0 || chatState === 'streaming' || diff --git a/desktop/src/components/chat/PermissionDialog.tsx b/desktop/src/components/chat/PermissionDialog.tsx index 703cf8de..681fa5b9 100644 --- a/desktop/src/components/chat/PermissionDialog.tsx +++ b/desktop/src/components/chat/PermissionDialog.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { useChatStore } from '../../stores/chatStore' +import { getPendingPermission, useChatStore } from '../../stores/chatStore' import { useTabStore } from '../../stores/tabStore' import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n' @@ -122,9 +122,11 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri const { respondToPermission } = useChatStore() const activeTabId = useTabStore((s) => s.activeTabId) const targetSessionId = sessionId ?? activeTabId - const pendingPermission = useChatStore((s) => targetSessionId ? s.sessions[targetSessionId]?.pendingPermission : undefined) + const pendingPermission = useChatStore((s) => targetSessionId + ? getPendingPermission(s.sessions[targetSessionId], requestId) + : undefined) const t = useTranslation() - const isPending = pendingPermission?.requestId === requestId + const isPending = Boolean(pendingPermission) const [showRaw, setShowRaw] = useState(false) if (isExitPlanModeTool(toolName)) { @@ -145,13 +147,18 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri const preview = renderPermissionPreview(toolName, input) const title = getPermissionTitle(toolName, input, t) const allowRawToggle = !preview + const permissionContext = (details.primary || description || toolName).slice(0, 160) return ( -
+
{/* Header */}
{isPending && ( - + )} @@ -198,7 +206,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
{details.primary && toolName !== 'Bash' ? (
- + {details.primary} @@ -209,7 +217,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri ) : details.primary ? (
- + {details.primary} @@ -227,7 +235,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri onClick={() => setShowRaw(!showRaw)} className="mt-2 flex cursor-pointer items-center gap-1 text-[11px] text-[var(--color-text-accent)] hover:underline" > - + {showRaw ? t('permission.hideDetails') : t('permission.showFullInput')} @@ -247,9 +255,10 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri