mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
2f243fe920
commit
d7b8fb032c
63
adapters/whatsapp/__tests__/session.test.ts
Normal file
63
adapters/whatsapp/__tests__/session.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
190
desktop/src/components/chat/PlanModePermissionDialog.test.tsx
Normal file
190
desktop/src/components/chat/PlanModePermissionDialog.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
173
desktop/src/components/chat/PlanModePreview.tsx
Normal file
173
desktop/src/components/chat/PlanModePreview.tsx
Normal 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 ''
|
||||
}
|
||||
@ -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>,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': 'コンピューター操作がこれらのアプリを操作しようとしています',
|
||||
|
||||
@ -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': '컴퓨터 사용이 이 앱들을 제어하려고 합니다',
|
||||
|
||||
@ -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 想控制這些應用',
|
||||
|
||||
@ -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 想控制这些应用',
|
||||
|
||||
@ -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' })) }))
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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[] = []
|
||||
|
||||
@ -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[] = []
|
||||
|
||||
@ -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' },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ export type ClientMessage =
|
||||
allowed: boolean
|
||||
rule?: string
|
||||
updatedInput?: Record<string, unknown>
|
||||
denyMessage?: string
|
||||
permissionUpdates?: unknown[]
|
||||
}
|
||||
| {
|
||||
type: 'computer_use_permission_response'
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user