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:
parent
d92eb9b72c
commit
3e605622f7
17 changed files with 730 additions and 31 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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 =====
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
237
src/renderer/components/team/ToolApprovalSheet.tsx
Normal file
237
src/renderer/components/team/ToolApprovalSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)' }}
|
||||
|
|
|
|||
|
|
@ -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') ? (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'll approve or deny each tool call in real-time.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue