From 3e605622f78463bdc7b44eb643a47dba1e8640ae Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 6 Mar 2026 21:12:59 +0200 Subject: [PATCH] feat: implement tool approval protocol for team management - Added support for tool approval requests and responses in the team provisioning service. - Introduced IPC channels for handling tool approval events between main and renderer processes. - Enhanced the UI to display tool approval requests and allow user responses through new components. - Updated team launch and creation dialogs to include an option for skipping permissions, integrating with the new tool approval logic. - Implemented state management for pending approvals in the store, ensuring a seamless user experience during tool interactions. --- src/main/index.ts | 7 + src/main/ipc/teams.ts | 39 +++ .../services/team/TeamProvisioningService.ts | 133 +++++++++- src/preload/constants/ipcChannels.ts | 6 + src/preload/index.ts | 33 +++ src/renderer/App.tsx | 2 + src/renderer/api/httpClient.ts | 6 + .../components/team/ToolApprovalSheet.tsx | 237 ++++++++++++++++++ .../team/activity/ActivityTimeline.tsx | 2 + .../team/activity/LeadThoughtsGroup.tsx | 103 +++++--- .../team/dialogs/CreateTeamDialog.tsx | 18 ++ .../team/dialogs/LaunchTeamDialog.tsx | 15 ++ .../team/dialogs/SkipPermissionsCheckbox.tsx | 65 +++++ src/renderer/store/index.ts | 26 ++ src/renderer/store/slices/teamSlice.ts | 25 ++ src/shared/types/api.ts | 9 + src/shared/types/team.ts | 35 +++ 17 files changed, 730 insertions(+), 31 deletions(-) create mode 100644 src/renderer/components/team/ToolApprovalSheet.tsx create mode 100644 src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx diff --git a/src/main/index.ts b/src/main/index.ts index 40db5d09..1f15f9c4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -24,6 +24,7 @@ import { CONTEXT_CHANGED, SSH_STATUS, TEAM_CHANGE, + TEAM_TOOL_APPROVAL_EVENT, WINDOW_FULLSCREEN_CHANGED, // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload } from '@preload/constants/ipcChannels'; @@ -634,6 +635,12 @@ function initializeServices(): void { }; teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter); + teamProvisioningService.setToolApprovalEventEmitter((event) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(TEAM_TOOL_APPROVAL_EVENT, event); + } + }); + // startProcessHealthPolling() is deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index b855253b..ff6034b3 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -48,6 +48,7 @@ import { TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, TEAM_STOP, + TEAM_TOOL_APPROVAL_RESPOND, TEAM_UPDATE_CONFIG, TEAM_UPDATE_KANBAN, TEAM_UPDATE_KANBAN_COLUMN_ORDER, @@ -247,6 +248,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_SAVE_TASK_ATTACHMENT, handleSaveTaskAttachment); ipcMain.handle(TEAM_GET_TASK_ATTACHMENT, handleGetTaskAttachment); ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment); + ipcMain.handle(TEAM_TOOL_APPROVAL_RESPOND, handleToolApprovalRespond); logger.info('Team handlers registered'); } @@ -301,6 +303,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_SAVE_TASK_ATTACHMENT); ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT); ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT); + ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND); } function getTeamDataService(): TeamDataService { @@ -642,6 +645,8 @@ async function validateProvisioningRequest( cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, + skipPermissions: + typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, }, }; } @@ -747,6 +752,8 @@ async function handleLaunchTeam( prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, clearContext: payload.clearContext === true ? true : undefined, + skipPermissions: + typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, }, (progress) => { try { @@ -2239,3 +2246,35 @@ async function handleDeleteTaskAttachment( await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId); }); } + +async function handleToolApprovalRespond( + _event: IpcMainInvokeEvent, + teamName: unknown, + runId: unknown, + requestId: unknown, + allow: unknown, + message?: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + if (typeof runId !== 'string' || runId.trim().length === 0) { + return { success: false, error: 'runId must be a non-empty string' }; + } + if (typeof requestId !== 'string' || requestId.trim().length === 0) { + return { success: false, error: 'requestId must be a non-empty string' }; + } + if (typeof allow !== 'boolean') { + return { success: false, error: 'allow must be a boolean' }; + } + return wrapTeamHandler('toolApprovalRespond', () => + getTeamProvisioningService().respondToToolApproval( + validated.value!, + runId, + requestId, + allow, + typeof message === 'string' ? message : undefined + ) + ); +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index e80a3342..cb6e82ff 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -50,6 +50,8 @@ import type { TeamProvisioningProgress, TeamProvisioningState, TeamTask, + ToolApprovalEvent, + ToolApprovalRequest, ToolCallMeta, } from '@shared/types'; @@ -201,6 +203,8 @@ interface ProvisioningRun { env: NodeJS.ProcessEnv; prompt: string; } | null; + /** Pending tool approval requests awaiting user response (control_request protocol). */ + pendingApprovals: Map; } type LeadActivityState = 'active' | 'idle' | 'offline'; @@ -1163,6 +1167,16 @@ export class TeamProvisioningService { this.teamChangeEmitter = emitter; } + private toolApprovalEventEmitter: ((event: ToolApprovalEvent) => void) | null = null; + + setToolApprovalEventEmitter(emitter: (event: ToolApprovalEvent) => void): void { + this.toolApprovalEventEmitter = emitter; + } + + private emitToolApprovalEvent(event: ToolApprovalEvent): void { + this.toolApprovalEventEmitter?.(event); + } + getLiveLeadProcessMessages(teamName: string): InboxMessage[] { return [...(this.liveLeadProcessMessages.get(teamName) ?? [])]; } @@ -1759,6 +1773,7 @@ export class TeamProvisioningService { authFailureRetried: false, authRetryInProgress: false, spawnContext: null, + pendingApprovals: new Map(), progress: { runId, teamName: request.teamName, @@ -1787,7 +1802,7 @@ export class TeamProvisioningService { 'user,project,local', '--disallowedTools', 'TeamDelete,TodoWrite', - '--dangerously-skip-permissions', + ...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []), ...(request.model ? ['--model', request.model] : []), ]; try { @@ -2018,6 +2033,7 @@ export class TeamProvisioningService { teamName: request.teamName, members: expectedMemberSpecs, cwd: request.cwd, + skipPermissions: request.skipPermissions, }; const run: ProvisioningRun = { @@ -2059,6 +2075,7 @@ export class TeamProvisioningService { authFailureRetried: false, authRetryInProgress: false, spawnContext: null, + pendingApprovals: new Map(), progress: { runId, teamName: request.teamName, @@ -2109,7 +2126,7 @@ export class TeamProvisioningService { 'user,project,local', '--disallowedTools', 'TeamDelete,TodoWrite', - '--dangerously-skip-permissions', + ...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []), ]; if (previousSessionId) { launchArgs.push('--resume', previousSessionId); @@ -3033,6 +3050,12 @@ export class TeamProvisioningService { } } + // Handle control_request — tool approval protocol (only when --dangerously-skip-permissions is NOT set) + if (msg.type === 'control_request') { + this.handleControlRequest(run, msg); + return; + } + if (msg.type === 'result') { const subtype = typeof msg.subtype === 'string' @@ -3197,6 +3220,107 @@ export class TeamProvisioningService { } } + /** + * Handles a control_request message from CLI stream-json output. + * Only `can_use_tool` subtype is processed — others are logged and ignored. + */ + private handleControlRequest(run: ProvisioningRun, msg: Record): void { + const requestId = typeof msg.request_id === 'string' ? msg.request_id : null; + if (!requestId) { + logger.warn(`[${run.teamName}] control_request missing request_id, ignoring`); + return; + } + + const request = msg.request as Record | undefined; + const subtype = request?.subtype; + if (subtype !== 'can_use_tool') { + logger.debug( + `[${run.teamName}] control_request subtype=${String(subtype)}, ignoring (only can_use_tool supported)` + ); + return; + } + + const toolName = typeof request?.tool_name === 'string' ? request.tool_name : 'Unknown'; + const toolInput = (request?.input ?? {}) as Record; + + const approval: ToolApprovalRequest = { + requestId, + runId: run.runId, + teamName: run.teamName, + source: 'lead', + toolName, + toolInput, + receivedAt: new Date().toISOString(), + }; + + run.pendingApprovals.set(requestId, approval); + this.emitToolApprovalEvent(approval); + } + + /** + * Respond to a pending tool approval — sends control_response to CLI stdin. + * Validates runId match and requestId existence before writing. + */ + async respondToToolApproval( + teamName: string, + runId: string, + requestId: string, + allow: boolean, + message?: string + ): Promise { + const currentRunId = this.activeByTeam.get(teamName); + if (!currentRunId) throw new Error(`No active process for team "${teamName}"`); + const run = this.runs.get(currentRunId); + if (!run) throw new Error(`Run not found for team "${teamName}"`); + + if (run.runId !== runId) { + throw new Error(`Stale approval: runId mismatch (expected ${run.runId}, got ${runId})`); + } + + if (!run.pendingApprovals.has(requestId)) { + throw new Error(`No pending approval with requestId "${requestId}"`); + } + + if (!run.child?.stdin?.writable) { + throw new Error(`Team "${teamName}" process stdin is not writable`); + } + + // IMPORTANT: request_id is NESTED inside response, NOT top-level + // (asymmetry with control_request — confirmed by Python SDK, Elixir SDK and issue #29991) + const response = allow + ? { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { behavior: 'allow' }, + }, + } + : { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { behavior: 'deny', message: message ?? 'User denied' }, + }, + }; + + const stdin = run.child.stdin; + await new Promise((resolve, reject) => { + stdin.write(JSON.stringify(response) + '\n', (err) => { + if (err) { + logger.error(`[${teamName}] Failed to write control_response: ${err.message}`); + reject(err); + } else { + resolve(); + } + }); + }); + + // Only delete AFTER successful write + run.pendingApprovals.delete(requestId); + } + /** * Called when the first stream-json turn completes successfully. * Verifies provisioning files exist and marks as ready. @@ -3402,6 +3526,11 @@ export class TeamProvisioningService { this.relayedLeadInboxMessageIds.delete(run.teamName); this.relayedLeadInboxFallbackKeys.delete(run.teamName); this.liveLeadProcessMessages.delete(run.teamName); + // Dismiss any pending tool approvals for this run + if (run.pendingApprovals.size > 0) { + this.emitToolApprovalEvent({ dismissed: true, teamName: run.teamName, runId: run.runId }); + run.pendingApprovals.clear(); + } // Remove from runs Map to free memory (stdoutBuffer, stderrBuffer, claudeLogLines) this.runs.delete(run.runId); } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 72722ee0..690be05b 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -358,6 +358,12 @@ export const TEAM_GET_TASK_ATTACHMENT = 'team:getTaskAttachment'; /** Delete an attachment from a task */ export const TEAM_DELETE_TASK_ATTACHMENT = 'team:deleteTaskAttachment'; +/** Push event: tool approval request or dismissal (main → renderer) */ +export const TEAM_TOOL_APPROVAL_EVENT = 'team:toolApprovalEvent'; + +/** Invoke: respond to a tool approval request (renderer → main) */ +export const TEAM_TOOL_APPROVAL_RESPOND = 'team:toolApprovalRespond'; + // ============================================================================= // CLI Installer API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index 9f361333..c67e5c02 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -103,6 +103,8 @@ import { TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, TEAM_STOP, + TEAM_TOOL_APPROVAL_EVENT, + TEAM_TOOL_APPROVAL_RESPOND, TEAM_UPDATE_CONFIG, TEAM_UPDATE_KANBAN, TEAM_UPDATE_KANBAN_COLUMN_ORDER, @@ -214,6 +216,7 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + ToolApprovalEvent, TriggerTestResult, UpdateKanbanPatch, WslClaudeRootCandidate, @@ -975,6 +978,36 @@ const electronAPI: ElectronAPI = { ); }; }, + respondToToolApproval: async ( + teamName: string, + runId: string, + requestId: string, + allow: boolean, + message?: string + ) => { + return invokeIpcWithResult( + TEAM_TOOL_APPROVAL_RESPOND, + teamName, + runId, + requestId, + allow, + message + ); + }, + onToolApprovalEvent: ( + callback: (event: unknown, data: ToolApprovalEvent) => void + ): (() => void) => { + ipcRenderer.on( + TEAM_TOOL_APPROVAL_EVENT, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + TEAM_TOOL_APPROVAL_EVENT, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, }, // ===== Review API ===== diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a66f4a40..9469af07 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -6,6 +6,7 @@ import { ConfirmDialog } from './components/common/ConfirmDialog'; import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay'; import { ErrorBoundary } from './components/common/ErrorBoundary'; import { TabbedLayout } from './components/layout/TabbedLayout'; +import { ToolApprovalSheet } from './components/team/ToolApprovalSheet'; import { useTheme } from './hooks/useTheme'; import { api } from './api'; import { useStore } from './store'; @@ -40,6 +41,7 @@ export const App = (): React.JSX.Element => { + ); diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 033bebe5..58bb724e 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -884,6 +884,12 @@ export class HttpAPIClient implements ElectronAPI { ): (() => void) => { return () => {}; }, + respondToToolApproval: async (): Promise => { + throw new Error('Tool approval not available in browser mode'); + }, + onToolApprovalEvent: (): (() => void) => { + return () => {}; + }, }; // Review API stubs diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx new file mode 100644 index 00000000..25d2a975 --- /dev/null +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -0,0 +1,237 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useStore } from '@renderer/store'; +import { FileText, Search, Terminal } from 'lucide-react'; + +import type { ToolApprovalRequest } from '@shared/types'; + +// --------------------------------------------------------------------------- +// Tool icon mapping +// --------------------------------------------------------------------------- + +function getToolIcon(toolName: string): React.ReactNode { + const cls = 'size-4 shrink-0'; + switch (toolName) { + case 'Bash': + return ; + case 'Read': + case 'Edit': + case 'Write': + case 'NotebookEdit': + return ; + case 'Grep': + case 'Glob': + return ; + default: + return ; + } +} + +// --------------------------------------------------------------------------- +// Smart input preview +// --------------------------------------------------------------------------- + +function renderToolInput(toolName: string, input: Record): string { + switch (toolName) { + case 'Bash': + return typeof input.command === 'string' ? input.command : JSON.stringify(input, null, 2); + case 'Edit': + case 'Read': + case 'Write': + case 'NotebookEdit': + return typeof input.file_path === 'string' ? input.file_path : JSON.stringify(input, null, 2); + case 'Grep': + case 'Glob': + return typeof input.pattern === 'string' ? input.pattern : JSON.stringify(input, null, 2); + default: + return JSON.stringify(input, null, 2); + } +} + +// --------------------------------------------------------------------------- +// Elapsed timer hook +// --------------------------------------------------------------------------- + +function useElapsed(receivedAt: string): number { + const [elapsed, setElapsed] = useState(() => + Math.max(0, Math.floor((Date.now() - new Date(receivedAt).getTime()) / 1000)) + ); + + useEffect(() => { + setElapsed(Math.max(0, Math.floor((Date.now() - new Date(receivedAt).getTime()) / 1000))); + const id = setInterval(() => { + setElapsed(Math.max(0, Math.floor((Date.now() - new Date(receivedAt).getTime()) / 1000))); + }, 1000); + return () => clearInterval(id); + }, [receivedAt]); + + return elapsed; +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export const ToolApprovalSheet: React.FC = () => { + const pendingApprovals = useStore((s) => s.pendingApprovals); + const respondToToolApproval = useStore((s) => s.respondToToolApproval); + const teams = useStore((s) => s.teams); + + const current: ToolApprovalRequest | undefined = pendingApprovals[0]; + const containerRef = useRef(null); + const [disabled, setDisabled] = useState(false); + + // Auto-focus when new approval arrives + useEffect(() => { + if (current && containerRef.current) { + containerRef.current.focus(); + } + }, [current?.requestId]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleRespond = useCallback( + (allow: boolean) => { + if (!current || disabled) return; + setDisabled(true); + void respondToToolApproval(current.teamName, current.runId, current.requestId, allow).finally( + () => { + setTimeout(() => setDisabled(false), 200); + } + ); + }, + [current, disabled, respondToToolApproval] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleRespond(true); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleRespond(false); + } + }, + [handleRespond] + ); + + if (!current) return null; + + const teamSummary = teams.find((t) => t.teamName === current.teamName); + const teamColor = teamSummary?.color ? getTeamColorSet(teamSummary.color) : null; + + return ( +
+ {/* Header */} +
+
+ {getToolIcon(current.toolName)} + + {current.toolName} + +
+
+ {teamColor ? ( + + {teamSummary?.displayName ?? current.teamName} + + ) : ( + {current.teamName} + )} + +
+
+ + {/* Tool input preview */} +
+
+          {renderToolInput(current.toolName, current.toolInput)}
+        
+
+ + {/* Actions */} +
+
+ + +
+ {pendingApprovals.length > 1 && ( + + {pendingApprovals.length - 1} pending + + )} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Elapsed display sub-component (uses hook) +// --------------------------------------------------------------------------- + +function ElapsedDisplay({ receivedAt }: { receivedAt: string }): React.JSX.Element { + const elapsed = useElapsed(receivedAt); + return ( + {elapsed}s + ); +} diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 056ae1e9..1f94c3fa 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -315,6 +315,7 @@ export const ActivityTimeline = ({ isNew={newItemKeys.has(itemKey)} onVisible={onMessageVisible} zebraShade={zebraShadeSet.has(0)} + forceCollapsed={allCollapsed} /> ); })()} @@ -357,6 +358,7 @@ export const ActivityTimeline = ({ isNew={newItemKeys.has(itemKey)} onVisible={onMessageVisible} zebraShade={zebraShadeSet.has(realIndex)} + forceCollapsed={allCollapsed} /> ); diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index bbfd6a1a..12997803 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { ChevronDown, ChevronUp } from 'lucide-react'; +import { ChevronDown, ChevronRight, ChevronUp } from 'lucide-react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; @@ -86,6 +86,8 @@ interface LeadThoughtsGroupRowProps { canBeLive?: boolean; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; + /** When true, collapse the thought body — show only the header with expand chevron. */ + forceCollapsed?: boolean; } function formatTime(timestamp: string): string { @@ -343,6 +345,7 @@ export const LeadThoughtsGroupRow = ({ onVisible, canBeLive, zebraShade, + forceCollapsed, }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); const scrollRef = useRef(null); @@ -406,6 +409,17 @@ export const LeadThoughtsGroupRow = ({ const [isLive, setIsLive] = useState(computeIsLive); const [expanded, setExpanded] = useState(false); const [needsTruncation, setNeedsTruncation] = useState(false); + const [isBodyVisible, setIsBodyVisible] = useState(!forceCollapsed); + + // Sync body visibility when the global collapse mode toggles (skip initial mount) + const isFirstRenderRef = useRef(false); + useEffect(() => { + if (!isFirstRenderRef.current) { + isFirstRenderRef.current = true; + return; + } + setIsBodyVisible(!forceCollapsed); + }, [forceCollapsed]); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap @@ -505,7 +519,36 @@ export const LeadThoughtsGroupRow = ({ }} > {/* Header */} -
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */} +
setIsBodyVisible((v) => !v) : undefined} + onKeyDown={ + forceCollapsed === true + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsBodyVisible((v) => !v); + } + } + : undefined + } + > + {/* Chevron for collapse mode */} + {forceCollapsed === true ? ( + + ) : null} {/* Live / offline indicator */} {isLive ? ( @@ -542,34 +585,36 @@ export const LeadThoughtsGroupRow = ({
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */} -
-
- {chronologicalThoughts.map((thought, idx) => ( - 0} - shouldAnimate={isLive && idx === chronologicalThoughts.length - 1} - /> - ))} + {isBodyVisible ? ( +
+
+ {chronologicalThoughts.map((thought, idx) => ( + 0} + shouldAnimate={isLive && idx === chronologicalThoughts.length - 1} + /> + ))} +
-
+ ) : null} - {!expanded && needsTruncation ? ( + {isBodyVisible && !expanded && needsTruncation ? (
) : null} - {expanded && needsTruncation ? ( + {isBodyVisible && expanded && needsTruncation ? (
localStorage.getItem('team:lastExtendedContext') === 'true' ); + const [skipPermissions, setSkipPermissionsRaw] = useState( + () => localStorage.getItem('team:lastSkipPermissions') !== 'false' + ); const setSelectedModel = (value: string): void => { setSelectedModelRaw(value); @@ -244,6 +248,11 @@ export const CreateTeamDialog = ({ localStorage.setItem('team:lastExtendedContext', String(value)); }; + const setSkipPermissions = (value: boolean): void => { + setSkipPermissionsRaw(value); + localStorage.setItem('team:lastSkipPermissions', String(value)); + }; + const resetUIState = (): void => { setLocalError(null); setFieldErrors({}); @@ -473,6 +482,7 @@ export const CreateTeamDialog = ({ cwd: effectiveCwd, prompt: prompt.trim() || undefined, model: effectiveModel, + skipPermissions, }), [ sanitizedTeamName, @@ -483,6 +493,7 @@ export const CreateTeamDialog = ({ effectiveCwd, prompt, effectiveModel, + skipPermissions, ] ); @@ -801,6 +812,13 @@ export const CreateTeamDialog = ({ onCheckedChange={setExtendedContext} disabled={selectedModel === 'haiku'} /> + {launchTeam && ( + + )}
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? ( diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index adfadeec..693ca2e2 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox'; +import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { @@ -78,6 +79,9 @@ export const LaunchTeamDialog = ({ const [extendedContext, setExtendedContextRaw] = useState( () => localStorage.getItem('team:lastExtendedContext') === 'true' ); + const [skipPermissions, setSkipPermissionsRaw] = useState( + () => localStorage.getItem('team:lastSkipPermissions') !== 'false' + ); const [clearContext, setClearContext] = useState(false); const [conflictDismissed, setConflictDismissed] = useState(false); @@ -91,6 +95,11 @@ export const LaunchTeamDialog = ({ localStorage.setItem('team:lastExtendedContext', String(value)); }; + const setSkipPermissions = (value: boolean): void => { + setSkipPermissionsRaw(value); + localStorage.setItem('team:lastSkipPermissions', String(value)); + }; + const resetFormState = (): void => { setLocalError(null); setIsSubmitting(false); @@ -283,6 +292,7 @@ export const LaunchTeamDialog = ({ prompt: promptDraft.value.trim() || undefined, model: computeEffectiveTeamModel(selectedModel, extendedContext), clearContext: clearContext || undefined, + skipPermissions, }); resetFormState(); onClose(); @@ -431,6 +441,11 @@ export const LaunchTeamDialog = ({ onCheckedChange={setExtendedContext} disabled={selectedModel === 'haiku'} /> +
diff --git a/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx new file mode 100644 index 00000000..8c6e8c80 --- /dev/null +++ b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +import { Checkbox } from '@renderer/components/ui/checkbox'; +import { Label } from '@renderer/components/ui/label'; +import { AlertTriangle, Info } from 'lucide-react'; + +interface SkipPermissionsCheckboxProps { + id: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +} + +export const SkipPermissionsCheckbox: React.FC = ({ + id, + checked, + onCheckedChange, +}) => ( + <> +
+ onCheckedChange(value === true)} + /> + +
+ {checked ? ( +
+
+ +

+ Autonomous mode — all tools execute without confirmation. Be cautious with untrusted + code. +

+
+
+ ) : ( +
+
+ +

Manual mode — you'll approve or deny each tool call in real-time.

+
+
+ )} + +); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 7ddff600..62e5fedc 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -32,6 +32,9 @@ import type { CliInstallerProgress, LeadContextUsage, TeamChangeEvent, + ToolApprovalDismiss, + ToolApprovalEvent, + ToolApprovalRequest, UpdaterStatus, } from '@shared/types'; @@ -444,6 +447,29 @@ export function initializeNotificationListeners(): () => void { } } + // Tool approval events from CLI control_request protocol + if (api.teams?.onToolApprovalEvent) { + const cleanup = api.teams.onToolApprovalEvent((_event: unknown, data: unknown) => { + const event = data as ToolApprovalEvent; + if ('dismissed' in event && event.dismissed) { + const dismiss = event as ToolApprovalDismiss; + useStore.setState((s) => ({ + pendingApprovals: s.pendingApprovals.filter( + (a) => !(a.teamName === dismiss.teamName && a.runId === dismiss.runId) + ), + })); + } else { + const request = event as ToolApprovalRequest; + useStore.setState((s) => ({ + pendingApprovals: [...s.pendingApprovals, request], + })); + } + }); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } + } + // Listen for editor file change events (chokidar watcher → renderer) if (api.editor?.onEditorChange) { const cleanup = api.editor.onEditorChange((event) => { diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 204f0fe2..21370d07 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -81,6 +81,7 @@ import type { TeamSummary, TeamTask, TeamTaskStatus, + ToolApprovalRequest, UpdateKanbanPatch, } from '@shared/types'; import type { StateCreator } from 'zustand'; @@ -353,6 +354,14 @@ export interface TeamSlice { onProvisioningProgress: (progress: TeamProvisioningProgress) => void; subscribeProvisioningProgress: () => void; unsubscribeProvisioningProgress: () => void; + pendingApprovals: ToolApprovalRequest[]; + respondToToolApproval: ( + teamName: string, + runId: string, + requestId: string, + allow: boolean, + message?: string + ) => Promise; } export const createTeamSlice: StateCreator = (set, get) => ({ @@ -394,6 +403,7 @@ export const createTeamSlice: StateCreator = (set, provisioningProgressUnsubscribe: null, deletedTasks: [], deletedTasksLoading: false, + pendingApprovals: [], fetchBranches: async (paths: string[]) => { const results: Record = {}; @@ -1158,6 +1168,21 @@ export const createTeamSlice: StateCreator = (set, set({ provisioningProgressUnsubscribe: unsubscribe }); }, + respondToToolApproval: async (teamName, runId, requestId, allow, message) => { + try { + await api.teams.respondToToolApproval(teamName, runId, requestId, allow, message); + // Remove ONLY after successful IPC, by runId+requestId pair + set((s) => ({ + pendingApprovals: s.pendingApprovals.filter( + (a) => !(a.runId === runId && a.requestId === requestId) + ), + })); + } catch { + // IPC failed — approval stays in UI, user can retry + // Do NOT modify pendingApprovals — nothing was removed, nothing to rollback + } + }, + unsubscribeProvisioningProgress: () => { const unsubscribe = get().provisioningProgressUnsubscribe; if (unsubscribe) { diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 2b43e502..616ef726 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -60,6 +60,7 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + ToolApprovalEvent, UpdateKanbanPatch, } from './team'; import type { TerminalAPI } from './terminal'; @@ -510,6 +511,14 @@ export interface TeamsAPI { onProvisioningProgress: ( callback: (event: unknown, data: TeamProvisioningProgress) => void ) => () => void; + respondToToolApproval: ( + teamName: string, + runId: string, + requestId: string, + allow: boolean, + message?: string + ) => Promise; + onToolApprovalEvent: (callback: (event: unknown, data: ToolApprovalEvent) => void) => () => void; } // ============================================================================= diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 660eafb4..d87cc48f 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -303,6 +303,8 @@ export interface TeamLaunchRequest { model?: string; /** When true, skip --resume and start a fresh session (clears context memory). */ clearContext?: boolean; + /** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */ + skipPermissions?: boolean; } export interface TeamLaunchResponse { @@ -383,6 +385,8 @@ export interface TeamCreateRequest { cwd: string; prompt?: string; model?: string; + /** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */ + skipPermissions?: boolean; } export interface TeamCreateConfigRequest { @@ -519,3 +523,34 @@ export interface TeamMessageNotificationData { /** Optional sender color for visual context. */ color?: string; } + +// ============================================================================= +// Tool Approval (control_request / control_response protocol) +// ============================================================================= + +/** A pending tool approval request from the CLI control_request protocol. */ +export interface ToolApprovalRequest { + requestId: string; + /** Run ID — prevents stale approvals after stop→launch race. */ + runId: string; + teamName: string; + /** Which process sent this (e.g. 'lead'). */ + source: string; + /** Tool name: 'Bash', 'Edit', 'Write', 'Read', etc. */ + toolName: string; + /** Tool input parameters (e.g. { command: "ls" } for Bash). */ + toolInput: Record; + /** ISO timestamp when the request was received. */ + receivedAt: string; +} + +/** Dismissal event — process died, all pending approvals for this team+run should be removed. */ +export interface ToolApprovalDismiss { + dismissed: true; + teamName: string; + /** Only dismiss approvals from this specific run. */ + runId: string; +} + +/** Union of approval events pushed from main to renderer. */ +export type ToolApprovalEvent = ToolApprovalRequest | ToolApprovalDismiss;