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.
This commit is contained in:
iliya 2026-03-06 21:12:59 +02:00
parent d92eb9b72c
commit 3e605622f7
17 changed files with 730 additions and 31 deletions

View file

@ -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.

View file

@ -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<IpcResult<void>> {
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
)
);
}

View file

@ -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<string, ToolApprovalRequest>;
}
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<string, unknown>): 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<string, unknown> | 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<string, unknown>;
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<void> {
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<void>((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);
}

View file

@ -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
// =============================================================================

View file

@ -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<void>(
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 =====

View file

@ -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 => {
<ContextSwitchOverlay />
<TabbedLayout />
<ConfirmDialog />
<ToolApprovalSheet />
</TooltipProvider>
</ErrorBoundary>
);

View file

@ -884,6 +884,12 @@ export class HttpAPIClient implements ElectronAPI {
): (() => void) => {
return () => {};
},
respondToToolApproval: async (): Promise<void> => {
throw new Error('Tool approval not available in browser mode');
},
onToolApprovalEvent: (): (() => void) => {
return () => {};
},
};
// Review API stubs

View file

@ -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 <Terminal className={cls} />;
case 'Read':
case 'Edit':
case 'Write':
case 'NotebookEdit':
return <FileText className={cls} />;
case 'Grep':
case 'Glob':
return <Search className={cls} />;
default:
return <Terminal className={cls} />;
}
}
// ---------------------------------------------------------------------------
// Smart input preview
// ---------------------------------------------------------------------------
function renderToolInput(toolName: string, input: Record<string, unknown>): 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<HTMLDivElement>(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 (
<div
ref={containerRef}
tabIndex={0}
onKeyDown={handleKeyDown}
className="fixed bottom-4 left-1/2 z-[55] w-full max-w-[480px] -translate-x-1/2 rounded-lg border shadow-xl outline-none duration-200 animate-in fade-in slide-in-from-bottom-4"
style={{
backgroundColor: 'var(--color-surface-overlay)',
borderColor: 'var(--color-border-emphasis)',
}}
>
{/* Header */}
<div
className="flex items-center justify-between border-b px-4 py-2.5"
style={{ borderColor: 'var(--color-border)' }}
>
<div className="flex items-center gap-2">
{getToolIcon(current.toolName)}
<span className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
{current.toolName}
</span>
</div>
<div className="flex items-center gap-2.5">
{teamColor ? (
<span
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: teamColor.badge,
color: teamColor.text,
border: `1px solid ${teamColor.border}`,
}}
>
{teamSummary?.displayName ?? current.teamName}
</span>
) : (
<span className="text-[10px] text-[var(--color-text-muted)]">{current.teamName}</span>
)}
<ElapsedDisplay receivedAt={current.receivedAt} />
</div>
</div>
{/* Tool input preview */}
<div className="px-4 py-2.5">
<pre
className="custom-scrollbar max-h-[120px] overflow-auto whitespace-pre-wrap break-all rounded-md border p-2 font-mono text-xs"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
{renderToolInput(current.toolName, current.toolInput)}
</pre>
</div>
{/* Actions */}
<div
className="flex items-center justify-between border-t px-4 py-2.5"
style={{ borderColor: 'var(--color-border)' }}
>
<div className="flex items-center gap-2">
<button
type="button"
disabled={disabled}
onClick={() => handleRespond(true)}
className="rounded-md px-3.5 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: 'rgb(5, 150, 105)' }}
onMouseEnter={(e) => {
if (!disabled) e.currentTarget.style.backgroundColor = 'rgb(16, 185, 129)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'rgb(5, 150, 105)';
}}
>
Allow
</button>
<button
type="button"
disabled={disabled}
onClick={() => handleRespond(false)}
className="rounded-md border px-3.5 py-1.5 text-xs font-medium transition-colors disabled:opacity-50"
style={{
borderColor: 'rgba(239, 68, 68, 0.5)',
color: 'rgb(248, 113, 113)',
}}
onMouseEnter={(e) => {
if (!disabled) e.currentTarget.style.backgroundColor = 'rgba(239, 68, 68, 0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
Deny
</button>
</div>
{pendingApprovals.length > 1 && (
<span className="text-[11px] text-[var(--color-text-muted)]">
{pendingApprovals.length - 1} pending
</span>
)}
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Elapsed display sub-component (uses hook)
// ---------------------------------------------------------------------------
function ElapsedDisplay({ receivedAt }: { receivedAt: string }): React.JSX.Element {
const elapsed = useElapsed(receivedAt);
return (
<span className="text-[11px] tabular-nums text-[var(--color-text-muted)]">{elapsed}s</span>
);
}

View file

@ -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}
/>
</React.Fragment>
);

View file

@ -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<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(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 */}
<div className="flex select-none items-center gap-2 px-3 py-1.5">
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */}
<div
role={forceCollapsed === true ? 'button' : undefined}
tabIndex={forceCollapsed === true ? 0 : undefined}
className={[
'flex select-none items-center gap-2 px-3 py-1.5',
forceCollapsed === true ? 'cursor-pointer' : '',
].join(' ')}
onClick={forceCollapsed === true ? () => 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 ? (
<ChevronRight
className="size-3 shrink-0 transition-transform duration-150"
style={{
color: CARD_ICON_MUTED,
transform: isBodyVisible ? 'rotate(90deg)' : undefined,
}}
/>
) : null}
{/* Live / offline indicator */}
{isLive ? (
<span className="pointer-events-none relative inline-flex size-2 shrink-0">
@ -542,34 +585,36 @@ export const LeadThoughtsGroupRow = ({
</div>
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
<div
ref={scrollRef}
className="border-t"
style={{
borderColor: 'var(--color-border-subtle)',
maxHeight: expanded || !needsTruncation ? 'none' : `${COLLAPSED_THOUGHTS_HEIGHT}px`,
overflowY: expanded ? 'visible' : needsTruncation ? 'auto' : 'hidden',
scrollbarWidth: expanded || !needsTruncation ? undefined : 'thin',
scrollbarColor:
expanded || !needsTruncation ? undefined : 'var(--scrollbar-thumb) transparent',
overflowAnchor: 'none',
overscrollBehavior: 'contain',
}}
onScroll={handleScroll}
>
<div ref={contentRef}>
{chronologicalThoughts.map((thought, idx) => (
<LeadThoughtItem
key={thought.messageId ?? idx}
thought={thought}
showDivider={idx > 0}
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
/>
))}
{isBodyVisible ? (
<div
ref={scrollRef}
className="border-t"
style={{
borderColor: 'var(--color-border-subtle)',
maxHeight: expanded || !needsTruncation ? 'none' : `${COLLAPSED_THOUGHTS_HEIGHT}px`,
overflowY: expanded ? 'visible' : needsTruncation ? 'auto' : 'hidden',
scrollbarWidth: expanded || !needsTruncation ? undefined : 'thin',
scrollbarColor:
expanded || !needsTruncation ? undefined : 'var(--scrollbar-thumb) transparent',
overflowAnchor: 'none',
overscrollBehavior: 'contain',
}}
onScroll={handleScroll}
>
<div ref={contentRef}>
{chronologicalThoughts.map((thought, idx) => (
<LeadThoughtItem
key={thought.messageId ?? idx}
thought={thought}
showDivider={idx > 0}
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
/>
))}
</div>
</div>
</div>
) : null}
</article>
{!expanded && needsTruncation ? (
{isBodyVisible && !expanded && needsTruncation ? (
<div className="flex justify-center pt-1" style={{ transform: 'translateY(-20px)' }}>
<button
type="button"
@ -584,7 +629,7 @@ export const LeadThoughtsGroupRow = ({
</button>
</div>
) : null}
{expanded && needsTruncation ? (
{isBodyVisible && expanded && needsTruncation ? (
<div
className="sticky bottom-0 z-10 flex justify-center pb-1 pt-2"
style={{ transform: 'translateY(-20px)' }}

View file

@ -32,6 +32,7 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
import { ProjectPathSelector } from './ProjectPathSelector';
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
@ -233,6 +234,9 @@ export const CreateTeamDialog = ({
const [extendedContext, setExtendedContextRaw] = useState(
() => 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 && (
<SkipPermissionsCheckbox
id="create-skip-permissions"
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
/>
)}
</div>
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (

View file

@ -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'}
/>
<SkipPermissionsCheckbox
id="launch-skip-permissions"
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
/>
</div>
<div className="space-y-2">

View file

@ -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<SkipPermissionsCheckboxProps> = ({
id,
checked,
onCheckedChange,
}) => (
<>
<div className="mt-2 flex items-center gap-2">
<Checkbox
id={id}
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
/>
<Label
htmlFor={id}
className="flex cursor-pointer items-center gap-1.5 text-xs font-normal text-text-secondary"
>
Auto-approve all tools
</Label>
</div>
{checked ? (
<div
className="mt-1.5 rounded-md border px-3 py-2 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<p>
Autonomous mode all tools execute without confirmation. Be cautious with untrusted
code.
</p>
</div>
</div>
) : (
<div
className="mt-1.5 rounded-md border px-3 py-2 text-xs"
style={{
backgroundColor: 'rgba(59, 130, 246, 0.08)',
borderColor: 'rgba(59, 130, 246, 0.2)',
color: 'var(--color-text-secondary)',
}}
>
<div className="flex items-start gap-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-blue-400" />
<p>Manual mode you&apos;ll approve or deny each tool call in real-time.</p>
</div>
</div>
)}
</>
);

View file

@ -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) => {

View file

@ -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<void>;
}
export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set, get) => ({
@ -394,6 +403,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
provisioningProgressUnsubscribe: null,
deletedTasks: [],
deletedTasksLoading: false,
pendingApprovals: [],
fetchBranches: async (paths: string[]) => {
const results: Record<string, string | null> = {};
@ -1158,6 +1168,21 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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) {

View file

@ -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<void>;
onToolApprovalEvent: (callback: (event: unknown, data: ToolApprovalEvent) => void) => () => void;
}
// =============================================================================

View file

@ -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<string, unknown>;
/** 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;