From d7b8fb032cd71b447d1b9a236c91822b8677ca3a 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, 12 Jun 2026 11:16:19 +0800 Subject: [PATCH] fix(desktop): preview exit plan approvals (#793) Show ExitPlanMode approvals as a rendered plan preview in desktop chat, forward plan feedback and requested prompt permissions through the desktop WebSocket permission response, and keep permission-mode restoration owned by the CLI runtime. Tested: bun run verify Confidence: high Scope-risk: moderate --- adapters/whatsapp/__tests__/session.test.ts | 63 ++++++ .../src/components/chat/PermissionDialog.tsx | 119 +++++++++++ .../chat/PlanModePermissionDialog.test.tsx | 190 ++++++++++++++++++ .../src/components/chat/PlanModePreview.tsx | 173 ++++++++++++++++ desktop/src/components/chat/ToolCallBlock.tsx | 89 +++++++- desktop/src/i18n/locales/en.ts | 9 + desktop/src/i18n/locales/jp.ts | 9 + desktop/src/i18n/locales/kr.ts | 9 + desktop/src/i18n/locales/zh-TW.ts | 9 + desktop/src/i18n/locales/zh.ts | 9 + desktop/src/stores/chatStore.ts | 5 + desktop/src/types/chat.ts | 20 ++ src/server/__tests__/conversations.test.ts | 91 +++++++++ .../__tests__/e2e/business-flow.test.ts | 6 +- src/server/services/conversationService.ts | 8 +- src/server/ws/events.ts | 2 + src/server/ws/handler.ts | 2 + 17 files changed, 807 insertions(+), 6 deletions(-) create mode 100644 adapters/whatsapp/__tests__/session.test.ts create mode 100644 desktop/src/components/chat/PlanModePermissionDialog.test.tsx create mode 100644 desktop/src/components/chat/PlanModePreview.tsx diff --git a/adapters/whatsapp/__tests__/session.test.ts b/adapters/whatsapp/__tests__/session.test.ts new file mode 100644 index 00000000..a93d9d4a --- /dev/null +++ b/adapters/whatsapp/__tests__/session.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { + clearWhatsAppAuth, + closeWhatsAppSocket, + getWhatsAppDisconnectStatus, + hasWhatsAppAuth, + isWhatsAppLoggedOut, + waitForWhatsAppCredsSave, +} from '../session.js' +import type { WhatsAppSocket } from '../session.js' + +function makeTempAuthDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'wa-session-test-')) +} + +describe('whatsapp session helpers', () => { + it('detects and clears persisted auth credentials', () => { + const authDir = makeTempAuthDir() + + expect(hasWhatsAppAuth(authDir)).toBe(false) + + fs.writeFileSync(path.join(authDir, 'creds.json'), '{}') + expect(hasWhatsAppAuth(authDir)).toBe(true) + + clearWhatsAppAuth(authDir) + expect(fs.existsSync(authDir)).toBe(false) + }) + + it('extracts disconnect status from Baileys and direct error shapes', () => { + expect(getWhatsAppDisconnectStatus({ output: { statusCode: 401 } })).toBe(401) + expect(getWhatsAppDisconnectStatus({ statusCode: 515 })).toBe(515) + expect(getWhatsAppDisconnectStatus({ output: { statusCode: '401' } })).toBeUndefined() + expect(getWhatsAppDisconnectStatus(null)).toBeUndefined() + expect(isWhatsAppLoggedOut({ output: { statusCode: 401 } })).toBe(true) + expect(isWhatsAppLoggedOut({ output: { statusCode: 515 } })).toBe(false) + }) + + it('closes sockets best-effort with a reason', () => { + let received: Error | undefined + const sock = { + end: (error?: Error) => { + received = error + }, + } as unknown as WhatsAppSocket + + closeWhatsAppSocket(sock, 'test close') + expect(received?.message).toBe('test close') + + expect(() => closeWhatsAppSocket({} as WhatsAppSocket)).not.toThrow() + expect(() => closeWhatsAppSocket({ + end: () => { + throw new Error('already closed') + }, + } as unknown as WhatsAppSocket)).not.toThrow() + }) + + it('returns immediately when no credential save is queued', async () => { + await expect(waitForWhatsAppCredsSave(makeTempAuthDir())).resolves.toBeUndefined() + }) +}) diff --git a/desktop/src/components/chat/PermissionDialog.tsx b/desktop/src/components/chat/PermissionDialog.tsx index 1297b596..703cf8de 100644 --- a/desktop/src/components/chat/PermissionDialog.tsx +++ b/desktop/src/components/chat/PermissionDialog.tsx @@ -5,6 +5,12 @@ import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n' import { Button } from '../shared/Button' import { DiffViewer } from './DiffViewer' +import { + PlanPreviewCard, + buildPromptPermissionUpdates, + extractPlanPreview, + isExitPlanModeTool, +} from './PlanModePreview' type Props = { sessionId?: string | null @@ -121,6 +127,18 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri const isPending = pendingPermission?.requestId === requestId const [showRaw, setShowRaw] = useState(false) + if (isExitPlanModeTool(toolName)) { + return ( + + ) + } + const meta = TOOL_META[toolName] || { icon: 'shield', label: toolName, color: 'var(--color-text-tertiary)' } const details = extractToolDetails(toolName, input, t) const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2) @@ -262,3 +280,104 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri ) } + +function ExitPlanModePermissionDialog({ + sessionId, + requestId, + input, + description, + isPending, +}: { + sessionId?: string | null + requestId: string + input: unknown + description?: string + isPending: boolean +}) { + const { respondToPermission } = useChatStore() + const t = useTranslation() + const [feedback, setFeedback] = useState('') + const preview = extractPlanPreview(input) + const permissionUpdates = buildPromptPermissionUpdates(preview.allowedPrompts) + const trimmedFeedback = feedback.trim() + + return ( +
+
+
+ architecture +
+
+
+ + {t('permission.planReadyTitle')} + + {isPending ? ( + + + {t('permission.awaitingApproval')} + + ) : ( + + {t('permission.responded')} + + )} +
+ {description ? ( +

{description}

+ ) : null} +
+
+ +
+ + {isPending ? ( +