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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 11:16:19 +08:00
parent 2f243fe920
commit d7b8fb032c
17 changed files with 807 additions and 6 deletions

View File

@ -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()
})
})

View File

@ -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 (
<ExitPlanModePermissionDialog
sessionId={targetSessionId}
requestId={requestId}
input={input}
description={description}
isPending={isPending}
/>
)
}
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
</div>
)
}
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 (
<div className={`mb-4 overflow-hidden rounded-[var(--radius-lg)] border ${
isPending
? 'border-[var(--color-brand)]/60 bg-[var(--color-surface-container-lowest)]'
: 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'
}`}>
<div className={`flex items-center gap-3 px-4 py-3 ${
isPending
? 'bg-[var(--color-surface-container)]'
: 'bg-[var(--color-surface-container-low)]'
}`}>
<div className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-brand)]/15">
<span className="material-symbols-outlined text-[18px] text-[var(--color-brand)]">architecture</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('permission.planReadyTitle')}
</span>
{isPending ? (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--color-brand)]/15 px-2 py-0.5 text-[10px] font-bold uppercase text-[var(--color-brand)]">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-brand)] animate-pulse-dot" />
{t('permission.awaitingApproval')}
</span>
) : (
<span className="inline-flex items-center rounded-full bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[10px] font-bold uppercase text-[var(--color-text-tertiary)]">
{t('permission.responded')}
</span>
)}
</div>
{description ? (
<p className="mt-0.5 truncate text-xs text-[var(--color-text-secondary)]">{description}</p>
) : null}
</div>
</div>
<div className="space-y-3 border-t border-[var(--color-outline-variant)]/20 px-4 py-3">
<PlanPreviewCard
title={t('permission.planPreviewTitle')}
plan={preview.plan}
filePath={preview.filePath}
allowedPrompts={preview.allowedPrompts}
requestedPermissionsTitle={t('permission.planRequestedPermissions')}
emptyLabel={t('permission.planEmpty')}
/>
{isPending ? (
<textarea
value={feedback}
onChange={(event) => setFeedback(event.target.value)}
placeholder={t('permission.planFeedbackPlaceholder')}
rows={3}
className="min-h-[72px] w-full resize-y rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] outline-none transition-colors placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-brand)]/60 focus:ring-2 focus:ring-[var(--color-brand)]/15"
/>
) : null}
</div>
{isPending ? (
<div className="flex items-center gap-2 border-t border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] px-4 py-3">
<Button
variant="primary"
size="sm"
onClick={() => sessionId && respondToPermission(sessionId, requestId, true, permissionUpdates.length ? { permissionUpdates } : undefined)}
icon={<span aria-hidden="true" className="material-symbols-outlined text-[14px]">check</span>}
>
{t('permission.planApprove')}
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={() => sessionId && respondToPermission(sessionId, requestId, false, trimmedFeedback ? { denyMessage: trimmedFeedback } : undefined)}
icon={<span aria-hidden="true" className="material-symbols-outlined text-[14px]">edit_note</span>}
>
{t('permission.planKeepPlanning')}
</Button>
</div>
) : null}
</div>
)
}

View File

@ -0,0 +1,190 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
const { sendMock } = vi.hoisted(() => ({
sendMock: vi.fn(),
}))
vi.mock('../../api/websocket', () => ({
wsManager: {
connect: vi.fn(),
disconnect: vi.fn(),
onMessage: vi.fn(() => () => {}),
clearHandlers: vi.fn(),
send: sendMock,
},
}))
import { useChatStore } from '../../stores/chatStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { PermissionDialog } from './PermissionDialog'
import { ToolCallBlock } from './ToolCallBlock'
const PLAN = [
'# Release checklist',
'',
'1. Update the desktop plan modal.',
'2. Run `bun run check:desktop`.',
'',
'```bash',
'bun test desktop/src/components/chat/PlanModePermissionDialog.test.tsx',
'```',
].join('\n')
function seedPendingPlanPermission() {
useChatStore.setState({
sessions: {
'session-1': {
messages: [],
chatState: 'permission_pending',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: {
requestId: 'perm-plan',
toolName: 'ExitPlanMode',
toolUseId: 'toolu-plan',
input: {
plan: PLAN,
planFilePath: '/tmp/claude-plan.md',
allowedPrompts: [{ tool: 'Bash', prompt: 'run tests' }],
},
description: 'Exit plan mode?',
},
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
backgroundAgentTasks: {},
elapsedTimer: null,
composerPrefill: null,
composerInsertion: null,
composerDraft: null,
},
},
})
}
describe('plan mode permission UI', () => {
beforeEach(() => {
sendMock.mockReset()
useSettingsStore.setState({ locale: 'en' })
useTabStore.setState({
activeTabId: 'session-1',
tabs: [{ sessionId: 'session-1', title: 'Test', type: 'session' as const, status: 'idle' }],
})
seedPendingPlanPermission()
})
it('renders ExitPlanMode as a plan preview instead of raw tool input', () => {
const { container } = render(
<PermissionDialog
sessionId="session-1"
requestId="perm-plan"
toolName="ExitPlanMode"
input={{
plan: PLAN,
planFilePath: '/tmp/claude-plan.md',
allowedPrompts: [{ tool: 'Bash', prompt: 'run tests' }],
}}
description="Exit plan mode?"
/>,
)
expect(container.textContent).toContain('Ready to code?')
expect(container.textContent).toContain('Release checklist')
expect(container.textContent).toContain('Update the desktop plan modal.')
expect(container.textContent).toContain('/tmp/claude-plan.md')
expect(container.textContent).toContain('Requested permissions')
expect(container.textContent).toContain('Bash')
expect(container.textContent).toContain('run tests')
expect(screen.getByRole('button', { name: 'Approve plan' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Keep planning' })).toBeTruthy()
expect(container.textContent).not.toContain('"allowedPrompts"')
})
it('sends typed feedback when the user keeps planning', () => {
render(
<PermissionDialog
sessionId="session-1"
requestId="perm-plan"
toolName="ExitPlanMode"
input={{ plan: PLAN, planFilePath: '/tmp/claude-plan.md' }}
/>,
)
fireEvent.change(screen.getByPlaceholderText('Tell Claude what to change'), {
target: { value: 'Add a rollback step before implementation.' },
})
fireEvent.click(screen.getByRole('button', { name: 'Keep planning' }))
expect(sendMock).toHaveBeenCalledWith('session-1', {
type: 'permission_response',
requestId: 'perm-plan',
allowed: false,
denyMessage: 'Add a rollback step before implementation.',
})
})
it('includes requested prompt permissions when approving the plan', () => {
render(
<PermissionDialog
sessionId="session-1"
requestId="perm-plan"
toolName="ExitPlanMode"
input={{
plan: PLAN,
allowedPrompts: [{ tool: 'Bash', prompt: 'run tests' }],
}}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Approve plan' }))
expect(sendMock).toHaveBeenCalledWith('session-1', {
type: 'permission_response',
requestId: 'perm-plan',
allowed: true,
permissionUpdates: [
{
type: 'addRules',
rules: [{ toolName: 'Bash', ruleContent: 'prompt: run tests' }],
behavior: 'allow',
destination: 'session',
},
],
})
})
it('renders approved ExitPlanMode results as a markdown plan card', () => {
const { container } = render(
<ToolCallBlock
toolName="ExitPlanMode"
input={{ plan: PLAN, planFilePath: '/tmp/claude-plan.md' }}
result={{
isError: false,
content: [
'User has approved your plan. You can now start coding.',
'',
'Your plan has been saved to: /tmp/claude-plan.md',
'',
'## Approved Plan:',
PLAN,
].join('\n'),
}}
/>,
)
expect(container.textContent).toContain('Plan approved')
expect(container.textContent).toContain('Release checklist')
expect(container.textContent).toContain('Update the desktop plan modal.')
expect(container.textContent).toContain('/tmp/claude-plan.md')
expect(container.textContent).not.toContain('Tool Output')
})
})

View File

@ -0,0 +1,173 @@
import { FileText, ShieldCheck } from 'lucide-react'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import type { PermissionUpdate } from '../../types/chat'
export const EXIT_PLAN_MODE_TOOL_NAME = 'ExitPlanMode'
export type AllowedPrompt = {
tool: string
prompt: string
}
export type PlanPreviewModel = {
plan: string
filePath: string
allowedPrompts: AllowedPrompt[]
}
type Props = {
title: string
plan: string
filePath?: string
allowedPrompts?: AllowedPrompt[]
requestedPermissionsTitle?: string
emptyLabel?: string
}
export function isExitPlanModeTool(toolName: string): boolean {
return toolName === EXIT_PLAN_MODE_TOOL_NAME
}
export function PlanPreviewCard({
title,
plan,
filePath,
allowedPrompts = [],
requestedPermissionsTitle,
emptyLabel = 'No plan content available.',
}: Props) {
const trimmedPlan = plan.trim()
return (
<div data-testid="plan-preview-card" className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex items-start gap-2 border-b border-[var(--color-border)]/65 bg-[var(--color-surface-container-low)] px-3 py-2.5">
<FileText size={15} strokeWidth={2.1} className="mt-0.5 shrink-0 text-[var(--color-brand)]" aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="text-[12px] font-semibold text-[var(--color-text-primary)]">
{title}
</div>
{filePath ? (
<div className="mt-0.5 truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{filePath}
</div>
) : null}
</div>
</div>
<div className="max-h-[520px] overflow-auto px-3 py-3">
{trimmedPlan ? (
<MarkdownRenderer content={trimmedPlan} variant="compact" />
) : (
<div className="text-xs text-[var(--color-text-tertiary)]">{emptyLabel}</div>
)}
</div>
{allowedPrompts.length > 0 && requestedPermissionsTitle ? (
<div className="border-t border-[var(--color-border)]/65 bg-[var(--color-surface-container-low)] px-3 py-2.5">
<div className="mb-1.5 flex items-center gap-2 text-[11px] font-semibold uppercase text-[var(--color-outline)]">
<ShieldCheck size={13} strokeWidth={2.1} aria-hidden="true" />
{requestedPermissionsTitle}
</div>
<div className="space-y-1">
{allowedPrompts.map((prompt, index) => (
<div
key={`${prompt.tool}-${prompt.prompt}-${index}`}
className="rounded-md border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-2.5 py-1.5 text-[11px] text-[var(--color-text-secondary)]"
>
<span className="font-[var(--font-mono)] font-semibold text-[var(--color-text-primary)]">
{prompt.tool}
</span>
<span className="text-[var(--color-text-tertiary)]"> · </span>
<span>{prompt.prompt}</span>
</div>
))}
</div>
</div>
) : null}
</div>
)
}
export function extractPlanPreview(input: unknown, resultContent?: unknown): PlanPreviewModel {
const inputRecord = asRecord(input)
const resultText = extractTextContent(resultContent)
const approvedPlan = resultText ? extractApprovedPlan(resultText) : ''
return {
plan:
getString(inputRecord, 'plan') ||
getString(inputRecord, 'planContent') ||
approvedPlan,
filePath:
getString(inputRecord, 'planFilePath') ||
getString(inputRecord, 'filePath') ||
(resultText ? extractPlanFilePath(resultText) : ''),
allowedPrompts: extractAllowedPrompts(inputRecord.allowedPrompts),
}
}
export function buildPromptPermissionUpdates(allowedPrompts: AllowedPrompt[]): PermissionUpdate[] {
if (allowedPrompts.length === 0) return []
return [
{
type: 'addRules',
rules: allowedPrompts.map((prompt) => ({
toolName: prompt.tool,
ruleContent: `prompt: ${prompt.prompt.trim()}`,
})),
behavior: 'allow',
destination: 'session',
},
]
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {}
}
function getString(record: Record<string, unknown>, key: string): string {
const value = record[key]
return typeof value === 'string' ? value : ''
}
function extractAllowedPrompts(value: unknown): AllowedPrompt[] {
if (!Array.isArray(value)) return []
return value.flatMap((item) => {
const record = asRecord(item)
const tool = getString(record, 'tool').trim()
const prompt = getString(record, 'prompt').trim()
return tool && prompt ? [{ tool, prompt }] : []
})
}
function extractApprovedPlan(text: string): string {
const match = /## Approved Plan(?: \(edited by user\))?:\s*\n([\s\S]*)$/i.exec(text)
return match?.[1]?.trim() ?? ''
}
function extractPlanFilePath(text: string): string {
const match = /^Your plan has been saved to:\s*(.+)$/m.exec(text)
return match?.[1]?.trim() ?? ''
}
function extractTextContent(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((chunk) => {
if (typeof chunk === 'string') return chunk
if (chunk && typeof chunk === 'object' && 'text' in chunk) {
const text = (chunk as { text?: unknown }).text
return typeof text === 'string' ? text : ''
}
return ''
})
.filter(Boolean)
.join('\n')
}
return ''
}

View File

@ -8,6 +8,7 @@ import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n'
import { InlineImageGallery } from './InlineImageGallery'
import type { AgentTaskNotification } from '../../types/chat'
import { PlanPreviewCard, extractPlanPreview, isExitPlanModeTool } from './PlanModePreview'
type Props = {
toolName: string
@ -37,7 +38,8 @@ const WRITER_PREVIEW_MAX_LINES = 120
const WRITER_PREVIEW_MAX_CHARS = 30000
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, partialInput }: Props) {
const [expanded, setExpanded] = useState(false)
const isPlanTool = isExitPlanModeTool(toolName)
const [expanded, setExpanded] = useState(isPlanTool)
const t = useTranslation()
const obj = input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
const icon = TOOL_ICONS[toolName] || 'build'
@ -60,6 +62,19 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
const hasWritePreview = toolName === 'Write' && typeof obj.content === 'string'
const expandable = hasEditPreview || hasWritePreview || hasResultDetails || Boolean(isPending && partialInput)
if (isPlanTool) {
return (
<PlanToolCallBlock
input={input}
result={result}
compact={compact}
isPending={isPending}
expanded={expanded}
onToggle={() => setExpanded((value) => !value)}
/>
)
}
return (
<div className={`overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)] ${
compact ? 'mb-0' : 'mb-2'
@ -124,6 +139,78 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
)
})
function PlanToolCallBlock({
input,
result,
compact,
isPending,
expanded,
onToggle,
}: {
input: unknown
result?: { content: unknown; isError: boolean } | null
compact: boolean
isPending: boolean
expanded: boolean
onToggle: () => void
}) {
const t = useTranslation()
const preview = extractPlanPreview(input, result?.content)
const title = result?.isError
? t('permission.planRejected')
: result
? t('permission.planApproved')
: t('permission.planReadyTitle')
const hasRawResult = Boolean(result && extractTextContent(result.content))
return (
<div className={`overflow-hidden rounded-lg border border-[var(--color-brand)]/35 bg-[var(--color-surface-container-lowest)] ${
compact ? 'mb-0' : 'mb-2'
}`}>
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">architecture</span>
<span className="min-w-0 flex-1 truncate text-[12px] font-semibold text-[var(--color-text-primary)]">
{title}
</span>
{preview.filePath ? (
<span className="hidden max-w-[40%] truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)] sm:inline">
{preview.filePath}
</span>
) : null}
{isPending ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10px] text-[var(--color-outline)]">
<LoaderCircle size={12} strokeWidth={2.4} className="animate-spin" aria-hidden="true" />
{t('tool.preparingTool')}
</span>
) : null}
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">
{expanded ? 'expand_less' : 'expand_more'}
</span>
</button>
{expanded ? (
<div className="space-y-2.5 border-t border-[var(--color-border)]/60 px-3 py-3">
<PlanPreviewCard
title={t('permission.planPreviewTitle')}
plan={preview.plan}
filePath={preview.filePath}
allowedPrompts={preview.allowedPrompts}
requestedPermissionsTitle={t('permission.planRequestedPermissions')}
emptyLabel={t('permission.planEmpty')}
/>
{result?.isError && hasRawResult ? (
renderResultOutput(result, extractTextContent(result.content) ?? '', t)
) : null}
</div>
) : null}
</div>
)
}
function renderPreview(
toolName: string,
obj: Record<string, unknown>,

View File

@ -1344,6 +1344,15 @@ export const en = {
'permission.hideDetails': 'Hide details',
'permission.showFullInput': 'Show full input',
'permission.replacingContent': 'Replacing content in file',
'permission.planReadyTitle': 'Ready to code?',
'permission.planPreviewTitle': "Claude's plan",
'permission.planRequestedPermissions': 'Requested permissions',
'permission.planApprove': 'Approve plan',
'permission.planKeepPlanning': 'Keep planning',
'permission.planFeedbackPlaceholder': 'Tell Claude what to change',
'permission.planEmpty': 'No plan content available.',
'permission.planApproved': 'Plan approved',
'permission.planRejected': 'Plan rejected',
// ─── Computer Use Approval ──────────────────────────────────────
'computerUseApproval.titleApps': 'Computer Use wants to control these apps',

View File

@ -1346,6 +1346,15 @@ export const jp: Record<TranslationKey, string> = {
'permission.hideDetails': '詳細を非表示',
'permission.showFullInput': '入力全体を表示',
'permission.replacingContent': 'ファイル内のコンテンツを置換中',
'permission.planReadyTitle': 'コーディングを開始しますか?',
'permission.planPreviewTitle': 'Claude の計画',
'permission.planRequestedPermissions': '要求された権限',
'permission.planApprove': '計画を承認',
'permission.planKeepPlanning': '計画を続ける',
'permission.planFeedbackPlaceholder': 'Claude に変更内容を伝える',
'permission.planEmpty': '計画内容はありません。',
'permission.planApproved': '計画が承認されました',
'permission.planRejected': '計画が拒否されました',
// ─── Computer Use Approval ──────────────────────────────────────
'computerUseApproval.titleApps': 'コンピューター操作がこれらのアプリを操作しようとしています',

View File

@ -1346,6 +1346,15 @@ export const kr: Record<TranslationKey, string> = {
'permission.hideDetails': '세부 정보 숨기기',
'permission.showFullInput': '전체 입력 표시',
'permission.replacingContent': '파일 내용 교체 중',
'permission.planReadyTitle': '코딩을 시작할까요?',
'permission.planPreviewTitle': 'Claude의 계획',
'permission.planRequestedPermissions': '요청된 권한',
'permission.planApprove': '계획 승인',
'permission.planKeepPlanning': '계속 계획하기',
'permission.planFeedbackPlaceholder': 'Claude에게 변경할 내용을 알려주세요',
'permission.planEmpty': '계획 내용이 없습니다.',
'permission.planApproved': '계획 승인됨',
'permission.planRejected': '계획 거부됨',
// ─── Computer Use Approval ──────────────────────────────────────
'computerUseApproval.titleApps': '컴퓨터 사용이 이 앱들을 제어하려고 합니다',

View File

@ -1346,6 +1346,15 @@ export const zh: Record<TranslationKey, string> = {
'permission.hideDetails': '隱藏詳情',
'permission.showFullInput': '顯示完整輸入',
'permission.replacingContent': '替換檔案內容',
'permission.planReadyTitle': '準備開始編碼?',
'permission.planPreviewTitle': 'Claude 的計劃',
'permission.planRequestedPermissions': '請求的權限',
'permission.planApprove': '批准計劃',
'permission.planKeepPlanning': '繼續規劃',
'permission.planFeedbackPlaceholder': '告訴 Claude 需要修改什麼',
'permission.planEmpty': '暫無計劃內容。',
'permission.planApproved': '計劃已批准',
'permission.planRejected': '計劃已拒絕',
// ─── Computer Use Approval ──────────────────────────────────────
'computerUseApproval.titleApps': 'Computer Use 想控制這些應用',

View File

@ -1346,6 +1346,15 @@ export const zh: Record<TranslationKey, string> = {
'permission.hideDetails': '隐藏详情',
'permission.showFullInput': '显示完整输入',
'permission.replacingContent': '替换文件内容',
'permission.planReadyTitle': '准备开始编码?',
'permission.planPreviewTitle': 'Claude 的计划',
'permission.planRequestedPermissions': '请求的权限',
'permission.planApprove': '批准计划',
'permission.planKeepPlanning': '继续规划',
'permission.planFeedbackPlaceholder': '告诉 Claude 需要修改什么',
'permission.planEmpty': '暂无计划内容。',
'permission.planApproved': '计划已批准',
'permission.planRejected': '计划已拒绝',
// ─── Computer Use Approval ──────────────────────────────────────
'computerUseApproval.titleApps': 'Computer Use 想控制这些应用',

View File

@ -30,6 +30,7 @@ import type {
UIMessage,
ServerMessage,
TokenUsage,
PermissionUpdate,
} from '../types/chat'
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
@ -146,6 +147,8 @@ type ChatStore = {
options?: {
rule?: string
updatedInput?: Record<string, unknown>
denyMessage?: string
permissionUpdates?: PermissionUpdate[]
},
) => void
respondToComputerUsePermission: (
@ -1004,6 +1007,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
allowed,
...(options?.rule ? { rule: options.rule } : {}),
...(options?.updatedInput ? { updatedInput: options.updatedInput } : {}),
...(options?.denyMessage ? { denyMessage: options.denyMessage } : {}),
...(options?.permissionUpdates?.length ? { permissionUpdates: options.permissionUpdates } : {}),
})
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) }))
},

View File

@ -14,6 +14,8 @@ export type ClientMessage =
allowed: boolean
rule?: string
updatedInput?: Record<string, unknown>
denyMessage?: string
permissionUpdates?: PermissionUpdate[]
}
| {
type: 'computer_use_permission_response'
@ -38,6 +40,24 @@ export type AttachmentRef = {
quote?: string
}
export type PermissionUpdate =
| {
type: 'addRules' | 'replaceRules' | 'removeRules'
rules: Array<{ toolName: string; ruleContent?: string }>
behavior: 'allow' | 'deny' | 'ask'
destination: 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg'
}
| {
type: 'setMode'
mode: PermissionMode
destination: 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg'
}
| {
type: 'addDirectories' | 'removeDirectories'
directories: string[]
destination: 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg'
}
export type UIAttachment = {
type: 'file' | 'image'
name: string

View File

@ -185,6 +185,97 @@ describe('ConversationService', () => {
})
})
it('should forward explicit permission updates from desktop plan approval', () => {
const svc = new ConversationService()
const sent: unknown[] = []
const permissionUpdates = [
{
type: 'addRules',
rules: [{ toolName: 'Bash', ruleContent: 'prompt: run tests' }],
behavior: 'allow',
destination: 'session',
},
]
;(svc as any).sessions.set('session-1', {
proc: null,
outputCallbacks: [],
workDir: process.cwd(),
sdkToken: 'token',
sdkSocket: {
send(data: string) {
sent.push(JSON.parse(data))
},
},
pendingOutbound: [],
stderrLines: [],
sdkMessages: [],
pendingPermissionRequests: new Map(),
})
const result = svc.respondToPermission(
'session-1',
'req-1',
true,
undefined,
undefined,
undefined,
permissionUpdates,
)
expect(result).toBe(true)
expect(sent[0]).toMatchObject({
type: 'control_response',
response: {
response: {
behavior: 'allow',
updatedPermissions: permissionUpdates,
},
},
})
})
it('should forward explicit denial feedback from desktop plan rejection', () => {
const svc = new ConversationService()
const sent: unknown[] = []
;(svc as any).sessions.set('session-1', {
proc: null,
outputCallbacks: [],
workDir: process.cwd(),
sdkToken: 'token',
sdkSocket: {
send(data: string) {
sent.push(JSON.parse(data))
},
},
pendingOutbound: [],
stderrLines: [],
sdkMessages: [],
pendingPermissionRequests: new Map(),
})
const result = svc.respondToPermission(
'session-1',
'req-1',
false,
undefined,
undefined,
'Add rollback steps before implementation.',
)
expect(result).toBe(true)
expect(sent[0]).toMatchObject({
type: 'control_response',
response: {
response: {
behavior: 'deny',
message: 'Add rollback steps before implementation.',
},
},
})
})
it('should send set_permission_mode requests to active sessions', () => {
const svc = new ConversationService()
const sent: unknown[] = []

View File

@ -712,13 +712,13 @@ describe('Business Flow: WebSocket Chat', () => {
if (msg.type === 'connected') {
ws.send(JSON.stringify({ type: 'user_message', content: 'test message' }))
}
if (msg.type === 'status' && msg.state === 'idle' && messages.length > 3) {
if (msg.type === 'message_complete') {
ws.close()
resolve()
}
}
ws.onerror = () => { ws.close(); resolve() }
setTimeout(() => { ws.close(); resolve() }, 5000)
setTimeout(() => { ws.close(); resolve() }, 15000)
})
const types = messages.map((m) => m.type)
@ -731,7 +731,7 @@ describe('Business Flow: WebSocket Chat', () => {
// Should have thinking state first
const statusMsgs = messages.filter((m) => m.type === 'status')
expect(statusMsgs[0].state).toBe('thinking')
})
}, 20000)
it('should handle ping/pong', async () => {
const messages: any[] = []

View File

@ -465,6 +465,8 @@ export class ConversationService {
allowed: boolean,
rule?: string,
updatedInput?: Record<string, unknown>,
denyMessage?: string,
permissionUpdates?: unknown[],
): boolean {
const session = this.sessions.get(sessionId)
const pendingRequest = session?.pendingPermissionRequests.get(requestId)
@ -481,7 +483,9 @@ export class ConversationService {
? {
behavior: 'allow',
updatedInput: updatedInput ?? {},
...(rule === 'always' && pendingRequest
...(Array.isArray(permissionUpdates) && permissionUpdates.length > 0
? { updatedPermissions: permissionUpdates }
: rule === 'always' && pendingRequest
? {
updatedPermissions: [
...normalizeSessionPermissionUpdates(
@ -492,7 +496,7 @@ export class ConversationService {
}
: {}),
}
: { behavior: 'deny', message: 'User denied via UI' },
: { behavior: 'deny', message: denyMessage || 'User denied via UI' },
},
})
}

View File

@ -17,6 +17,8 @@ export type ClientMessage =
allowed: boolean
rule?: string
updatedInput?: Record<string, unknown>
denyMessage?: string
permissionUpdates?: unknown[]
}
| {
type: 'computer_use_permission_response'

View File

@ -570,6 +570,8 @@ function handlePermissionResponse(
message.allowed,
message.rule,
message.updatedInput,
message.denyMessage,
message.permissionUpdates,
)
console.log(`[WS] Permission response for ${message.requestId}: ${message.allowed}`)
}