mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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 type { TranslationKey } from '../../i18n'
|
||||||
import { Button } from '../shared/Button'
|
import { Button } from '../shared/Button'
|
||||||
import { DiffViewer } from './DiffViewer'
|
import { DiffViewer } from './DiffViewer'
|
||||||
|
import {
|
||||||
|
PlanPreviewCard,
|
||||||
|
buildPromptPermissionUpdates,
|
||||||
|
extractPlanPreview,
|
||||||
|
isExitPlanModeTool,
|
||||||
|
} from './PlanModePreview'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
sessionId?: string | null
|
sessionId?: string | null
|
||||||
@ -121,6 +127,18 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
|
|||||||
const isPending = pendingPermission?.requestId === requestId
|
const isPending = pendingPermission?.requestId === requestId
|
||||||
const [showRaw, setShowRaw] = useState(false)
|
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 meta = TOOL_META[toolName] || { icon: 'shield', label: toolName, color: 'var(--color-text-tertiary)' }
|
||||||
const details = extractToolDetails(toolName, input, t)
|
const details = extractToolDetails(toolName, input, t)
|
||||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
|
const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
|
||||||
@ -262,3 +280,104 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
|
|||||||
</div>
|
</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 type { TranslationKey } from '../../i18n'
|
||||||
import { InlineImageGallery } from './InlineImageGallery'
|
import { InlineImageGallery } from './InlineImageGallery'
|
||||||
import type { AgentTaskNotification } from '../../types/chat'
|
import type { AgentTaskNotification } from '../../types/chat'
|
||||||
|
import { PlanPreviewCard, extractPlanPreview, isExitPlanModeTool } from './PlanModePreview'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
toolName: string
|
toolName: string
|
||||||
@ -37,7 +38,8 @@ const WRITER_PREVIEW_MAX_LINES = 120
|
|||||||
const WRITER_PREVIEW_MAX_CHARS = 30000
|
const WRITER_PREVIEW_MAX_CHARS = 30000
|
||||||
|
|
||||||
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, isPending = false, partialInput }: Props) {
|
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 t = useTranslation()
|
||||||
const obj = input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
|
const obj = input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
|
||||||
const icon = TOOL_ICONS[toolName] || 'build'
|
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 hasWritePreview = toolName === 'Write' && typeof obj.content === 'string'
|
||||||
const expandable = hasEditPreview || hasWritePreview || hasResultDetails || Boolean(isPending && partialInput)
|
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 (
|
return (
|
||||||
<div className={`overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)] ${
|
<div className={`overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)] ${
|
||||||
compact ? 'mb-0' : 'mb-2'
|
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(
|
function renderPreview(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
obj: Record<string, unknown>,
|
obj: Record<string, unknown>,
|
||||||
|
|||||||
@ -1344,6 +1344,15 @@ export const en = {
|
|||||||
'permission.hideDetails': 'Hide details',
|
'permission.hideDetails': 'Hide details',
|
||||||
'permission.showFullInput': 'Show full input',
|
'permission.showFullInput': 'Show full input',
|
||||||
'permission.replacingContent': 'Replacing content in file',
|
'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 ──────────────────────────────────────
|
// ─── Computer Use Approval ──────────────────────────────────────
|
||||||
'computerUseApproval.titleApps': 'Computer Use wants to control these apps',
|
'computerUseApproval.titleApps': 'Computer Use wants to control these apps',
|
||||||
|
|||||||
@ -1346,6 +1346,15 @@ export const jp: Record<TranslationKey, string> = {
|
|||||||
'permission.hideDetails': '詳細を非表示',
|
'permission.hideDetails': '詳細を非表示',
|
||||||
'permission.showFullInput': '入力全体を表示',
|
'permission.showFullInput': '入力全体を表示',
|
||||||
'permission.replacingContent': 'ファイル内のコンテンツを置換中',
|
'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 ──────────────────────────────────────
|
// ─── Computer Use Approval ──────────────────────────────────────
|
||||||
'computerUseApproval.titleApps': 'コンピューター操作がこれらのアプリを操作しようとしています',
|
'computerUseApproval.titleApps': 'コンピューター操作がこれらのアプリを操作しようとしています',
|
||||||
|
|||||||
@ -1346,6 +1346,15 @@ export const kr: Record<TranslationKey, string> = {
|
|||||||
'permission.hideDetails': '세부 정보 숨기기',
|
'permission.hideDetails': '세부 정보 숨기기',
|
||||||
'permission.showFullInput': '전체 입력 표시',
|
'permission.showFullInput': '전체 입력 표시',
|
||||||
'permission.replacingContent': '파일 내용 교체 중',
|
'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 ──────────────────────────────────────
|
// ─── Computer Use Approval ──────────────────────────────────────
|
||||||
'computerUseApproval.titleApps': '컴퓨터 사용이 이 앱들을 제어하려고 합니다',
|
'computerUseApproval.titleApps': '컴퓨터 사용이 이 앱들을 제어하려고 합니다',
|
||||||
|
|||||||
@ -1346,6 +1346,15 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'permission.hideDetails': '隱藏詳情',
|
'permission.hideDetails': '隱藏詳情',
|
||||||
'permission.showFullInput': '顯示完整輸入',
|
'permission.showFullInput': '顯示完整輸入',
|
||||||
'permission.replacingContent': '替換檔案內容',
|
'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 ──────────────────────────────────────
|
// ─── Computer Use Approval ──────────────────────────────────────
|
||||||
'computerUseApproval.titleApps': 'Computer Use 想控制這些應用',
|
'computerUseApproval.titleApps': 'Computer Use 想控制這些應用',
|
||||||
|
|||||||
@ -1346,6 +1346,15 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'permission.hideDetails': '隐藏详情',
|
'permission.hideDetails': '隐藏详情',
|
||||||
'permission.showFullInput': '显示完整输入',
|
'permission.showFullInput': '显示完整输入',
|
||||||
'permission.replacingContent': '替换文件内容',
|
'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 ──────────────────────────────────────
|
// ─── Computer Use Approval ──────────────────────────────────────
|
||||||
'computerUseApproval.titleApps': 'Computer Use 想控制这些应用',
|
'computerUseApproval.titleApps': 'Computer Use 想控制这些应用',
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import type {
|
|||||||
UIMessage,
|
UIMessage,
|
||||||
ServerMessage,
|
ServerMessage,
|
||||||
TokenUsage,
|
TokenUsage,
|
||||||
|
PermissionUpdate,
|
||||||
} from '../types/chat'
|
} from '../types/chat'
|
||||||
|
|
||||||
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
|
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
|
||||||
@ -146,6 +147,8 @@ type ChatStore = {
|
|||||||
options?: {
|
options?: {
|
||||||
rule?: string
|
rule?: string
|
||||||
updatedInput?: Record<string, unknown>
|
updatedInput?: Record<string, unknown>
|
||||||
|
denyMessage?: string
|
||||||
|
permissionUpdates?: PermissionUpdate[]
|
||||||
},
|
},
|
||||||
) => void
|
) => void
|
||||||
respondToComputerUsePermission: (
|
respondToComputerUsePermission: (
|
||||||
@ -1004,6 +1007,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
allowed,
|
allowed,
|
||||||
...(options?.rule ? { rule: options.rule } : {}),
|
...(options?.rule ? { rule: options.rule } : {}),
|
||||||
...(options?.updatedInput ? { updatedInput: options.updatedInput } : {}),
|
...(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' })) }))
|
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) }))
|
||||||
},
|
},
|
||||||
|
|||||||
@ -14,6 +14,8 @@ export type ClientMessage =
|
|||||||
allowed: boolean
|
allowed: boolean
|
||||||
rule?: string
|
rule?: string
|
||||||
updatedInput?: Record<string, unknown>
|
updatedInput?: Record<string, unknown>
|
||||||
|
denyMessage?: string
|
||||||
|
permissionUpdates?: PermissionUpdate[]
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'computer_use_permission_response'
|
type: 'computer_use_permission_response'
|
||||||
@ -38,6 +40,24 @@ export type AttachmentRef = {
|
|||||||
quote?: string
|
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 = {
|
export type UIAttachment = {
|
||||||
type: 'file' | 'image'
|
type: 'file' | 'image'
|
||||||
name: string
|
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', () => {
|
it('should send set_permission_mode requests to active sessions', () => {
|
||||||
const svc = new ConversationService()
|
const svc = new ConversationService()
|
||||||
const sent: unknown[] = []
|
const sent: unknown[] = []
|
||||||
|
|||||||
@ -712,13 +712,13 @@ describe('Business Flow: WebSocket Chat', () => {
|
|||||||
if (msg.type === 'connected') {
|
if (msg.type === 'connected') {
|
||||||
ws.send(JSON.stringify({ type: 'user_message', content: 'test message' }))
|
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()
|
ws.close()
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ws.onerror = () => { 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)
|
const types = messages.map((m) => m.type)
|
||||||
@ -731,7 +731,7 @@ describe('Business Flow: WebSocket Chat', () => {
|
|||||||
// Should have thinking state first
|
// Should have thinking state first
|
||||||
const statusMsgs = messages.filter((m) => m.type === 'status')
|
const statusMsgs = messages.filter((m) => m.type === 'status')
|
||||||
expect(statusMsgs[0].state).toBe('thinking')
|
expect(statusMsgs[0].state).toBe('thinking')
|
||||||
})
|
}, 20000)
|
||||||
|
|
||||||
it('should handle ping/pong', async () => {
|
it('should handle ping/pong', async () => {
|
||||||
const messages: any[] = []
|
const messages: any[] = []
|
||||||
|
|||||||
@ -465,6 +465,8 @@ export class ConversationService {
|
|||||||
allowed: boolean,
|
allowed: boolean,
|
||||||
rule?: string,
|
rule?: string,
|
||||||
updatedInput?: Record<string, unknown>,
|
updatedInput?: Record<string, unknown>,
|
||||||
|
denyMessage?: string,
|
||||||
|
permissionUpdates?: unknown[],
|
||||||
): boolean {
|
): boolean {
|
||||||
const session = this.sessions.get(sessionId)
|
const session = this.sessions.get(sessionId)
|
||||||
const pendingRequest = session?.pendingPermissionRequests.get(requestId)
|
const pendingRequest = session?.pendingPermissionRequests.get(requestId)
|
||||||
@ -481,7 +483,9 @@ export class ConversationService {
|
|||||||
? {
|
? {
|
||||||
behavior: 'allow',
|
behavior: 'allow',
|
||||||
updatedInput: updatedInput ?? {},
|
updatedInput: updatedInput ?? {},
|
||||||
...(rule === 'always' && pendingRequest
|
...(Array.isArray(permissionUpdates) && permissionUpdates.length > 0
|
||||||
|
? { updatedPermissions: permissionUpdates }
|
||||||
|
: rule === 'always' && pendingRequest
|
||||||
? {
|
? {
|
||||||
updatedPermissions: [
|
updatedPermissions: [
|
||||||
...normalizeSessionPermissionUpdates(
|
...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
|
allowed: boolean
|
||||||
rule?: string
|
rule?: string
|
||||||
updatedInput?: Record<string, unknown>
|
updatedInput?: Record<string, unknown>
|
||||||
|
denyMessage?: string
|
||||||
|
permissionUpdates?: unknown[]
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'computer_use_permission_response'
|
type: 'computer_use_permission_response'
|
||||||
|
|||||||
@ -570,6 +570,8 @@ function handlePermissionResponse(
|
|||||||
message.allowed,
|
message.allowed,
|
||||||
message.rule,
|
message.rule,
|
||||||
message.updatedInput,
|
message.updatedInput,
|
||||||
|
message.denyMessage,
|
||||||
|
message.permissionUpdates,
|
||||||
)
|
)
|
||||||
console.log(`[WS] Permission response for ${message.requestId}: ${message.allowed}`)
|
console.log(`[WS] Permission response for ${message.requestId}: ${message.allowed}`)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user