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,
|
CONTEXT_CHANGED,
|
||||||
SSH_STATUS,
|
SSH_STATUS,
|
||||||
TEAM_CHANGE,
|
TEAM_CHANGE,
|
||||||
|
TEAM_TOOL_APPROVAL_EVENT,
|
||||||
WINDOW_FULLSCREEN_CHANGED,
|
WINDOW_FULLSCREEN_CHANGED,
|
||||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
|
|
@ -634,6 +635,12 @@ function initializeServices(): void {
|
||||||
};
|
};
|
||||||
teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter);
|
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
|
// startProcessHealthPolling() is deferred to after window creation
|
||||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import {
|
||||||
TEAM_SOFT_DELETE_TASK,
|
TEAM_SOFT_DELETE_TASK,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_STOP,
|
TEAM_STOP,
|
||||||
|
TEAM_TOOL_APPROVAL_RESPOND,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
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_SAVE_TASK_ATTACHMENT, handleSaveTaskAttachment);
|
||||||
ipcMain.handle(TEAM_GET_TASK_ATTACHMENT, handleGetTaskAttachment);
|
ipcMain.handle(TEAM_GET_TASK_ATTACHMENT, handleGetTaskAttachment);
|
||||||
ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment);
|
ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment);
|
||||||
|
ipcMain.handle(TEAM_TOOL_APPROVAL_RESPOND, handleToolApprovalRespond);
|
||||||
logger.info('Team handlers registered');
|
logger.info('Team handlers registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -301,6 +303,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_SAVE_TASK_ATTACHMENT);
|
ipcMain.removeHandler(TEAM_SAVE_TASK_ATTACHMENT);
|
||||||
ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT);
|
ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT);
|
||||||
ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT);
|
ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT);
|
||||||
|
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTeamDataService(): TeamDataService {
|
function getTeamDataService(): TeamDataService {
|
||||||
|
|
@ -642,6 +645,8 @@ async function validateProvisioningRequest(
|
||||||
cwd,
|
cwd,
|
||||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||||
model: typeof payload.model === 'string' ? payload.model.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,
|
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||||
clearContext: payload.clearContext === true ? true : undefined,
|
clearContext: payload.clearContext === true ? true : undefined,
|
||||||
|
skipPermissions:
|
||||||
|
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||||
},
|
},
|
||||||
(progress) => {
|
(progress) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -2239,3 +2246,35 @@ async function handleDeleteTaskAttachment(
|
||||||
await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId);
|
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,
|
TeamProvisioningProgress,
|
||||||
TeamProvisioningState,
|
TeamProvisioningState,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
|
ToolApprovalEvent,
|
||||||
|
ToolApprovalRequest,
|
||||||
ToolCallMeta,
|
ToolCallMeta,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -201,6 +203,8 @@ interface ProvisioningRun {
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
/** Pending tool approval requests awaiting user response (control_request protocol). */
|
||||||
|
pendingApprovals: Map<string, ToolApprovalRequest>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LeadActivityState = 'active' | 'idle' | 'offline';
|
type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||||
|
|
@ -1163,6 +1167,16 @@ export class TeamProvisioningService {
|
||||||
this.teamChangeEmitter = emitter;
|
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[] {
|
getLiveLeadProcessMessages(teamName: string): InboxMessage[] {
|
||||||
return [...(this.liveLeadProcessMessages.get(teamName) ?? [])];
|
return [...(this.liveLeadProcessMessages.get(teamName) ?? [])];
|
||||||
}
|
}
|
||||||
|
|
@ -1759,6 +1773,7 @@ export class TeamProvisioningService {
|
||||||
authFailureRetried: false,
|
authFailureRetried: false,
|
||||||
authRetryInProgress: false,
|
authRetryInProgress: false,
|
||||||
spawnContext: null,
|
spawnContext: null,
|
||||||
|
pendingApprovals: new Map(),
|
||||||
progress: {
|
progress: {
|
||||||
runId,
|
runId,
|
||||||
teamName: request.teamName,
|
teamName: request.teamName,
|
||||||
|
|
@ -1787,7 +1802,7 @@ export class TeamProvisioningService {
|
||||||
'user,project,local',
|
'user,project,local',
|
||||||
'--disallowedTools',
|
'--disallowedTools',
|
||||||
'TeamDelete,TodoWrite',
|
'TeamDelete,TodoWrite',
|
||||||
'--dangerously-skip-permissions',
|
...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []),
|
||||||
...(request.model ? ['--model', request.model] : []),
|
...(request.model ? ['--model', request.model] : []),
|
||||||
];
|
];
|
||||||
try {
|
try {
|
||||||
|
|
@ -2018,6 +2033,7 @@ export class TeamProvisioningService {
|
||||||
teamName: request.teamName,
|
teamName: request.teamName,
|
||||||
members: expectedMemberSpecs,
|
members: expectedMemberSpecs,
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
|
skipPermissions: request.skipPermissions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const run: ProvisioningRun = {
|
const run: ProvisioningRun = {
|
||||||
|
|
@ -2059,6 +2075,7 @@ export class TeamProvisioningService {
|
||||||
authFailureRetried: false,
|
authFailureRetried: false,
|
||||||
authRetryInProgress: false,
|
authRetryInProgress: false,
|
||||||
spawnContext: null,
|
spawnContext: null,
|
||||||
|
pendingApprovals: new Map(),
|
||||||
progress: {
|
progress: {
|
||||||
runId,
|
runId,
|
||||||
teamName: request.teamName,
|
teamName: request.teamName,
|
||||||
|
|
@ -2109,7 +2126,7 @@ export class TeamProvisioningService {
|
||||||
'user,project,local',
|
'user,project,local',
|
||||||
'--disallowedTools',
|
'--disallowedTools',
|
||||||
'TeamDelete,TodoWrite',
|
'TeamDelete,TodoWrite',
|
||||||
'--dangerously-skip-permissions',
|
...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []),
|
||||||
];
|
];
|
||||||
if (previousSessionId) {
|
if (previousSessionId) {
|
||||||
launchArgs.push('--resume', 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') {
|
if (msg.type === 'result') {
|
||||||
const subtype =
|
const subtype =
|
||||||
typeof msg.subtype === 'string'
|
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.
|
* Called when the first stream-json turn completes successfully.
|
||||||
* Verifies provisioning files exist and marks as ready.
|
* Verifies provisioning files exist and marks as ready.
|
||||||
|
|
@ -3402,6 +3526,11 @@ export class TeamProvisioningService {
|
||||||
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
||||||
this.relayedLeadInboxFallbackKeys.delete(run.teamName);
|
this.relayedLeadInboxFallbackKeys.delete(run.teamName);
|
||||||
this.liveLeadProcessMessages.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)
|
// Remove from runs Map to free memory (stdoutBuffer, stderrBuffer, claudeLogLines)
|
||||||
this.runs.delete(run.runId);
|
this.runs.delete(run.runId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -358,6 +358,12 @@ export const TEAM_GET_TASK_ATTACHMENT = 'team:getTaskAttachment';
|
||||||
/** Delete an attachment from a task */
|
/** Delete an attachment from a task */
|
||||||
export const TEAM_DELETE_TASK_ATTACHMENT = 'team:deleteTaskAttachment';
|
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
|
// CLI Installer API Channels
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,8 @@ import {
|
||||||
TEAM_SOFT_DELETE_TASK,
|
TEAM_SOFT_DELETE_TASK,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_STOP,
|
TEAM_STOP,
|
||||||
|
TEAM_TOOL_APPROVAL_EVENT,
|
||||||
|
TEAM_TOOL_APPROVAL_RESPOND,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||||
|
|
@ -214,6 +216,7 @@ import type {
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamUpdateConfigRequest,
|
TeamUpdateConfigRequest,
|
||||||
|
ToolApprovalEvent,
|
||||||
TriggerTestResult,
|
TriggerTestResult,
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
WslClaudeRootCandidate,
|
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 =====
|
// ===== Review API =====
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { ConfirmDialog } from './components/common/ConfirmDialog';
|
||||||
import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay';
|
import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay';
|
||||||
import { ErrorBoundary } from './components/common/ErrorBoundary';
|
import { ErrorBoundary } from './components/common/ErrorBoundary';
|
||||||
import { TabbedLayout } from './components/layout/TabbedLayout';
|
import { TabbedLayout } from './components/layout/TabbedLayout';
|
||||||
|
import { ToolApprovalSheet } from './components/team/ToolApprovalSheet';
|
||||||
import { useTheme } from './hooks/useTheme';
|
import { useTheme } from './hooks/useTheme';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import { useStore } from './store';
|
import { useStore } from './store';
|
||||||
|
|
@ -40,6 +41,7 @@ export const App = (): React.JSX.Element => {
|
||||||
<ContextSwitchOverlay />
|
<ContextSwitchOverlay />
|
||||||
<TabbedLayout />
|
<TabbedLayout />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
<ToolApprovalSheet />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -884,6 +884,12 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
): (() => void) => {
|
): (() => void) => {
|
||||||
return () => {};
|
return () => {};
|
||||||
},
|
},
|
||||||
|
respondToToolApproval: async (): Promise<void> => {
|
||||||
|
throw new Error('Tool approval not available in browser mode');
|
||||||
|
},
|
||||||
|
onToolApprovalEvent: (): (() => void) => {
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Review API stubs
|
// 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)}
|
isNew={newItemKeys.has(itemKey)}
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
zebraShade={zebraShadeSet.has(0)}
|
zebraShade={zebraShadeSet.has(0)}
|
||||||
|
forceCollapsed={allCollapsed}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
@ -357,6 +358,7 @@ export const ActivityTimeline = ({
|
||||||
isNew={newItemKeys.has(itemKey)}
|
isNew={newItemKeys.has(itemKey)}
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
zebraShade={zebraShadeSet.has(realIndex)}
|
zebraShade={zebraShadeSet.has(realIndex)}
|
||||||
|
forceCollapsed={allCollapsed}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
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 { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
|
|
@ -86,6 +86,8 @@ interface LeadThoughtsGroupRowProps {
|
||||||
canBeLive?: boolean;
|
canBeLive?: boolean;
|
||||||
/** When true, apply a subtle lighter background for zebra-striped lists. */
|
/** When true, apply a subtle lighter background for zebra-striped lists. */
|
||||||
zebraShade?: boolean;
|
zebraShade?: boolean;
|
||||||
|
/** When true, collapse the thought body — show only the header with expand chevron. */
|
||||||
|
forceCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(timestamp: string): string {
|
function formatTime(timestamp: string): string {
|
||||||
|
|
@ -343,6 +345,7 @@ export const LeadThoughtsGroupRow = ({
|
||||||
onVisible,
|
onVisible,
|
||||||
canBeLive,
|
canBeLive,
|
||||||
zebraShade,
|
zebraShade,
|
||||||
|
forceCollapsed,
|
||||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -406,6 +409,17 @@ export const LeadThoughtsGroupRow = ({
|
||||||
const [isLive, setIsLive] = useState(computeIsLive);
|
const [isLive, setIsLive] = useState(computeIsLive);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [needsTruncation, setNeedsTruncation] = 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(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap
|
// 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 */}
|
{/* 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 */}
|
{/* Live / offline indicator */}
|
||||||
{isLive ? (
|
{isLive ? (
|
||||||
<span className="pointer-events-none relative inline-flex size-2 shrink-0">
|
<span className="pointer-events-none relative inline-flex size-2 shrink-0">
|
||||||
|
|
@ -542,34 +585,36 @@ export const LeadThoughtsGroupRow = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
|
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
|
||||||
<div
|
{isBodyVisible ? (
|
||||||
ref={scrollRef}
|
<div
|
||||||
className="border-t"
|
ref={scrollRef}
|
||||||
style={{
|
className="border-t"
|
||||||
borderColor: 'var(--color-border-subtle)',
|
style={{
|
||||||
maxHeight: expanded || !needsTruncation ? 'none' : `${COLLAPSED_THOUGHTS_HEIGHT}px`,
|
borderColor: 'var(--color-border-subtle)',
|
||||||
overflowY: expanded ? 'visible' : needsTruncation ? 'auto' : 'hidden',
|
maxHeight: expanded || !needsTruncation ? 'none' : `${COLLAPSED_THOUGHTS_HEIGHT}px`,
|
||||||
scrollbarWidth: expanded || !needsTruncation ? undefined : 'thin',
|
overflowY: expanded ? 'visible' : needsTruncation ? 'auto' : 'hidden',
|
||||||
scrollbarColor:
|
scrollbarWidth: expanded || !needsTruncation ? undefined : 'thin',
|
||||||
expanded || !needsTruncation ? undefined : 'var(--scrollbar-thumb) transparent',
|
scrollbarColor:
|
||||||
overflowAnchor: 'none',
|
expanded || !needsTruncation ? undefined : 'var(--scrollbar-thumb) transparent',
|
||||||
overscrollBehavior: 'contain',
|
overflowAnchor: 'none',
|
||||||
}}
|
overscrollBehavior: 'contain',
|
||||||
onScroll={handleScroll}
|
}}
|
||||||
>
|
onScroll={handleScroll}
|
||||||
<div ref={contentRef}>
|
>
|
||||||
{chronologicalThoughts.map((thought, idx) => (
|
<div ref={contentRef}>
|
||||||
<LeadThoughtItem
|
{chronologicalThoughts.map((thought, idx) => (
|
||||||
key={thought.messageId ?? idx}
|
<LeadThoughtItem
|
||||||
thought={thought}
|
key={thought.messageId ?? idx}
|
||||||
showDivider={idx > 0}
|
thought={thought}
|
||||||
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
|
showDivider={idx > 0}
|
||||||
/>
|
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
{!expanded && needsTruncation ? (
|
{isBodyVisible && !expanded && needsTruncation ? (
|
||||||
<div className="flex justify-center pt-1" style={{ transform: 'translateY(-20px)' }}>
|
<div className="flex justify-center pt-1" style={{ transform: 'translateY(-20px)' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -584,7 +629,7 @@ export const LeadThoughtsGroupRow = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{expanded && needsTruncation ? (
|
{isBodyVisible && expanded && needsTruncation ? (
|
||||||
<div
|
<div
|
||||||
className="sticky bottom-0 z-10 flex justify-center pb-1 pt-2"
|
className="sticky bottom-0 z-10 flex justify-center pb-1 pt-2"
|
||||||
style={{ transform: 'translateY(-20px)' }}
|
style={{ transform: 'translateY(-20px)' }}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
|
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
|
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
||||||
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
||||||
|
|
||||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||||
|
|
@ -233,6 +234,9 @@ export const CreateTeamDialog = ({
|
||||||
const [extendedContext, setExtendedContextRaw] = useState(
|
const [extendedContext, setExtendedContextRaw] = useState(
|
||||||
() => localStorage.getItem('team:lastExtendedContext') === 'true'
|
() => localStorage.getItem('team:lastExtendedContext') === 'true'
|
||||||
);
|
);
|
||||||
|
const [skipPermissions, setSkipPermissionsRaw] = useState(
|
||||||
|
() => localStorage.getItem('team:lastSkipPermissions') !== 'false'
|
||||||
|
);
|
||||||
|
|
||||||
const setSelectedModel = (value: string): void => {
|
const setSelectedModel = (value: string): void => {
|
||||||
setSelectedModelRaw(value);
|
setSelectedModelRaw(value);
|
||||||
|
|
@ -244,6 +248,11 @@ export const CreateTeamDialog = ({
|
||||||
localStorage.setItem('team:lastExtendedContext', String(value));
|
localStorage.setItem('team:lastExtendedContext', String(value));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setSkipPermissions = (value: boolean): void => {
|
||||||
|
setSkipPermissionsRaw(value);
|
||||||
|
localStorage.setItem('team:lastSkipPermissions', String(value));
|
||||||
|
};
|
||||||
|
|
||||||
const resetUIState = (): void => {
|
const resetUIState = (): void => {
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
|
|
@ -473,6 +482,7 @@ export const CreateTeamDialog = ({
|
||||||
cwd: effectiveCwd,
|
cwd: effectiveCwd,
|
||||||
prompt: prompt.trim() || undefined,
|
prompt: prompt.trim() || undefined,
|
||||||
model: effectiveModel,
|
model: effectiveModel,
|
||||||
|
skipPermissions,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
sanitizedTeamName,
|
sanitizedTeamName,
|
||||||
|
|
@ -483,6 +493,7 @@ export const CreateTeamDialog = ({
|
||||||
effectiveCwd,
|
effectiveCwd,
|
||||||
prompt,
|
prompt,
|
||||||
effectiveModel,
|
effectiveModel,
|
||||||
|
skipPermissions,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -801,6 +812,13 @@ export const CreateTeamDialog = ({
|
||||||
onCheckedChange={setExtendedContext}
|
onCheckedChange={setExtendedContext}
|
||||||
disabled={selectedModel === 'haiku'}
|
disabled={selectedModel === 'haiku'}
|
||||||
/>
|
/>
|
||||||
|
{launchTeam && (
|
||||||
|
<SkipPermissionsCheckbox
|
||||||
|
id="create-skip-permissions"
|
||||||
|
checked={skipPermissions}
|
||||||
|
onCheckedChange={setSkipPermissions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (
|
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox';
|
import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox';
|
||||||
|
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
|
|
@ -78,6 +79,9 @@ export const LaunchTeamDialog = ({
|
||||||
const [extendedContext, setExtendedContextRaw] = useState(
|
const [extendedContext, setExtendedContextRaw] = useState(
|
||||||
() => localStorage.getItem('team:lastExtendedContext') === 'true'
|
() => localStorage.getItem('team:lastExtendedContext') === 'true'
|
||||||
);
|
);
|
||||||
|
const [skipPermissions, setSkipPermissionsRaw] = useState(
|
||||||
|
() => localStorage.getItem('team:lastSkipPermissions') !== 'false'
|
||||||
|
);
|
||||||
const [clearContext, setClearContext] = useState(false);
|
const [clearContext, setClearContext] = useState(false);
|
||||||
const [conflictDismissed, setConflictDismissed] = useState(false);
|
const [conflictDismissed, setConflictDismissed] = useState(false);
|
||||||
|
|
||||||
|
|
@ -91,6 +95,11 @@ export const LaunchTeamDialog = ({
|
||||||
localStorage.setItem('team:lastExtendedContext', String(value));
|
localStorage.setItem('team:lastExtendedContext', String(value));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setSkipPermissions = (value: boolean): void => {
|
||||||
|
setSkipPermissionsRaw(value);
|
||||||
|
localStorage.setItem('team:lastSkipPermissions', String(value));
|
||||||
|
};
|
||||||
|
|
||||||
const resetFormState = (): void => {
|
const resetFormState = (): void => {
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
@ -283,6 +292,7 @@ export const LaunchTeamDialog = ({
|
||||||
prompt: promptDraft.value.trim() || undefined,
|
prompt: promptDraft.value.trim() || undefined,
|
||||||
model: computeEffectiveTeamModel(selectedModel, extendedContext),
|
model: computeEffectiveTeamModel(selectedModel, extendedContext),
|
||||||
clearContext: clearContext || undefined,
|
clearContext: clearContext || undefined,
|
||||||
|
skipPermissions,
|
||||||
});
|
});
|
||||||
resetFormState();
|
resetFormState();
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -431,6 +441,11 @@ export const LaunchTeamDialog = ({
|
||||||
onCheckedChange={setExtendedContext}
|
onCheckedChange={setExtendedContext}
|
||||||
disabled={selectedModel === 'haiku'}
|
disabled={selectedModel === 'haiku'}
|
||||||
/>
|
/>
|
||||||
|
<SkipPermissionsCheckbox
|
||||||
|
id="launch-skip-permissions"
|
||||||
|
checked={skipPermissions}
|
||||||
|
onCheckedChange={setSkipPermissions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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,
|
CliInstallerProgress,
|
||||||
LeadContextUsage,
|
LeadContextUsage,
|
||||||
TeamChangeEvent,
|
TeamChangeEvent,
|
||||||
|
ToolApprovalDismiss,
|
||||||
|
ToolApprovalEvent,
|
||||||
|
ToolApprovalRequest,
|
||||||
UpdaterStatus,
|
UpdaterStatus,
|
||||||
} from '@shared/types';
|
} 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)
|
// Listen for editor file change events (chokidar watcher → renderer)
|
||||||
if (api.editor?.onEditorChange) {
|
if (api.editor?.onEditorChange) {
|
||||||
const cleanup = api.editor.onEditorChange((event) => {
|
const cleanup = api.editor.onEditorChange((event) => {
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ import type {
|
||||||
TeamSummary,
|
TeamSummary,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
|
ToolApprovalRequest,
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
import type { StateCreator } from 'zustand';
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
@ -353,6 +354,14 @@ export interface TeamSlice {
|
||||||
onProvisioningProgress: (progress: TeamProvisioningProgress) => void;
|
onProvisioningProgress: (progress: TeamProvisioningProgress) => void;
|
||||||
subscribeProvisioningProgress: () => void;
|
subscribeProvisioningProgress: () => void;
|
||||||
unsubscribeProvisioningProgress: () => 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) => ({
|
export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set, get) => ({
|
||||||
|
|
@ -394,6 +403,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
provisioningProgressUnsubscribe: null,
|
provisioningProgressUnsubscribe: null,
|
||||||
deletedTasks: [],
|
deletedTasks: [],
|
||||||
deletedTasksLoading: false,
|
deletedTasksLoading: false,
|
||||||
|
pendingApprovals: [],
|
||||||
|
|
||||||
fetchBranches: async (paths: string[]) => {
|
fetchBranches: async (paths: string[]) => {
|
||||||
const results: Record<string, string | null> = {};
|
const results: Record<string, string | null> = {};
|
||||||
|
|
@ -1158,6 +1168,21 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
set({ provisioningProgressUnsubscribe: unsubscribe });
|
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: () => {
|
unsubscribeProvisioningProgress: () => {
|
||||||
const unsubscribe = get().provisioningProgressUnsubscribe;
|
const unsubscribe = get().provisioningProgressUnsubscribe;
|
||||||
if (unsubscribe) {
|
if (unsubscribe) {
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ import type {
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamUpdateConfigRequest,
|
TeamUpdateConfigRequest,
|
||||||
|
ToolApprovalEvent,
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
} from './team';
|
} from './team';
|
||||||
import type { TerminalAPI } from './terminal';
|
import type { TerminalAPI } from './terminal';
|
||||||
|
|
@ -510,6 +511,14 @@ export interface TeamsAPI {
|
||||||
onProvisioningProgress: (
|
onProvisioningProgress: (
|
||||||
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
||||||
) => () => 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;
|
model?: string;
|
||||||
/** When true, skip --resume and start a fresh session (clears context memory). */
|
/** When true, skip --resume and start a fresh session (clears context memory). */
|
||||||
clearContext?: boolean;
|
clearContext?: boolean;
|
||||||
|
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
|
||||||
|
skipPermissions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamLaunchResponse {
|
export interface TeamLaunchResponse {
|
||||||
|
|
@ -383,6 +385,8 @@ export interface TeamCreateRequest {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
|
||||||
|
skipPermissions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamCreateConfigRequest {
|
export interface TeamCreateConfigRequest {
|
||||||
|
|
@ -519,3 +523,34 @@ export interface TeamMessageNotificationData {
|
||||||
/** Optional sender color for visual context. */
|
/** Optional sender color for visual context. */
|
||||||
color?: string;
|
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