feat: tool approval improvements — diff preview, error feedback, provisioning fix
- Add diff preview for Write/Edit tools: reads current file via new IPC (TEAM_TOOL_APPROVAL_READ_FILE), shows unified diff using existing DiffViewer - Fix Allow button doing nothing: re-throw errors from store instead of silently swallowing, show error message in UI, add 10s safety timeout - Fix "No active process" during provisioning: use getTrackedRunId() to find process in both provisioning and alive maps - Add 5s stdin.write timeout to prevent hanging when process dies - Add syntax highlighting for tool input preview (JSON, bash, etc.) - Add team color/name badge from ToolApprovalRequest (works during provisioning) - Conditionally show team badge only when user is on a different team page - Format elapsed time as Xm Ys when over 60 seconds - Replace native <select> with Radix UI Select in settings panel
This commit is contained in:
parent
6d066831eb
commit
a6fc7f82d3
12 changed files with 895 additions and 100 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
||||||
import { getAppIconPath } from '@main/utils/appIcon';
|
import { getAppIconPath } from '@main/utils/appIcon';
|
||||||
import { getAppDataPath } from '@main/utils/pathDecoder';
|
import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
import { stripMarkdown } from '@main/utils/textFormatting';
|
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||||
import {
|
import {
|
||||||
TEAM_ADD_MEMBER,
|
TEAM_ADD_MEMBER,
|
||||||
|
|
@ -48,8 +48,11 @@ import {
|
||||||
TEAM_SOFT_DELETE_TASK,
|
TEAM_SOFT_DELETE_TASK,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_STOP,
|
TEAM_STOP,
|
||||||
|
TEAM_TOOL_APPROVAL_READ_FILE,
|
||||||
TEAM_TOOL_APPROVAL_RESPOND,
|
TEAM_TOOL_APPROVAL_RESPOND,
|
||||||
TEAM_TOOL_APPROVAL_SETTINGS,
|
TEAM_TOOL_APPROVAL_SETTINGS,
|
||||||
|
TEAM_GET_SAVED_REQUEST,
|
||||||
|
TEAM_DELETE_DRAFT,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||||
|
|
@ -84,6 +87,8 @@ import {
|
||||||
isAgentActionMode,
|
isAgentActionMode,
|
||||||
} from '../services/team/actionModeInstructions';
|
} from '../services/team/actionModeInstructions';
|
||||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||||
|
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
||||||
|
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||||
|
|
||||||
|
|
@ -141,6 +146,7 @@ import type {
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamUpdateConfigRequest,
|
TeamUpdateConfigRequest,
|
||||||
|
ToolApprovalFileContent,
|
||||||
ToolApprovalSettings,
|
ToolApprovalSettings,
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -261,6 +267,7 @@ let teamBackupService: TeamBackupService | null = null;
|
||||||
|
|
||||||
const attachmentStore = new TeamAttachmentStore();
|
const attachmentStore = new TeamAttachmentStore();
|
||||||
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
||||||
|
const teamMetaStore = new TeamMetaStore();
|
||||||
|
|
||||||
const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
|
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
|
||||||
|
|
@ -340,8 +347,11 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
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);
|
ipcMain.handle(TEAM_TOOL_APPROVAL_RESPOND, handleToolApprovalRespond);
|
||||||
|
ipcMain.handle(TEAM_TOOL_APPROVAL_READ_FILE, handleToolApprovalReadFile);
|
||||||
ipcMain.handle(TEAM_VALIDATE_CLI_ARGS, handleValidateCliArgs);
|
ipcMain.handle(TEAM_VALIDATE_CLI_ARGS, handleValidateCliArgs);
|
||||||
ipcMain.handle(TEAM_TOOL_APPROVAL_SETTINGS, handleToolApprovalSettings);
|
ipcMain.handle(TEAM_TOOL_APPROVAL_SETTINGS, handleToolApprovalSettings);
|
||||||
|
ipcMain.handle(TEAM_GET_SAVED_REQUEST, handleGetSavedRequest);
|
||||||
|
ipcMain.handle(TEAM_DELETE_DRAFT, handleDeleteDraft);
|
||||||
logger.info('Team handlers registered');
|
logger.info('Team handlers registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,8 +408,11 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
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);
|
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND);
|
||||||
|
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_READ_FILE);
|
||||||
ipcMain.removeHandler(TEAM_VALIDATE_CLI_ARGS);
|
ipcMain.removeHandler(TEAM_VALIDATE_CLI_ARGS);
|
||||||
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_SETTINGS);
|
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_SETTINGS);
|
||||||
|
ipcMain.removeHandler(TEAM_GET_SAVED_REQUEST);
|
||||||
|
ipcMain.removeHandler(TEAM_DELETE_DRAFT);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTeamDataService(): TeamDataService {
|
function getTeamDataService(): TeamDataService {
|
||||||
|
|
@ -482,6 +495,13 @@ async function handleGetData(
|
||||||
) {
|
) {
|
||||||
return { success: false, error: 'TEAM_PROVISIONING' };
|
return { success: false, error: 'TEAM_PROVISIONING' };
|
||||||
}
|
}
|
||||||
|
// Draft team: team.meta.json exists but config.json doesn't (provisioning failed before TeamCreate)
|
||||||
|
if (message === `Team not found: ${tn}`) {
|
||||||
|
const meta = await teamMetaStore.getMeta(tn);
|
||||||
|
if (meta) {
|
||||||
|
return { success: false, error: 'TEAM_DRAFT' };
|
||||||
|
}
|
||||||
|
}
|
||||||
logger.error(`[teams:getData] ${message}`);
|
logger.error(`[teams:getData] ${message}`);
|
||||||
return { success: false, error: message };
|
return { success: false, error: message };
|
||||||
}
|
}
|
||||||
|
|
@ -907,6 +927,58 @@ async function handleLaunchTeam(
|
||||||
return { success: false, error: 'model must be a string' };
|
return { success: false, error: 'model must be a string' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect draft team: team.meta.json exists but config.json doesn't.
|
||||||
|
// This happens when user created team config without launching (launchTeam=false),
|
||||||
|
// or when provisioning failed before TeamCreate could run.
|
||||||
|
// Redirect to createTeam so TeamCreate runs properly.
|
||||||
|
const tn = validatedTeamName.value!;
|
||||||
|
const configPath = path.join(getTeamsBasePath(), tn, 'config.json');
|
||||||
|
let isDraft = false;
|
||||||
|
try {
|
||||||
|
await fs.promises.access(configPath, fs.constants.F_OK);
|
||||||
|
} catch {
|
||||||
|
const meta = await teamMetaStore.getMeta(tn);
|
||||||
|
if (meta) isDraft = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDraft) {
|
||||||
|
const meta = await teamMetaStore.getMeta(tn);
|
||||||
|
const membersStore = new TeamMembersMetaStore();
|
||||||
|
const members = await membersStore.getMembers(tn);
|
||||||
|
|
||||||
|
const createRequest: TeamCreateRequest = {
|
||||||
|
teamName: tn,
|
||||||
|
displayName: meta?.displayName,
|
||||||
|
description: meta?.description,
|
||||||
|
color: meta?.color,
|
||||||
|
cwd,
|
||||||
|
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||||
|
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||||
|
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||||
|
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
|
||||||
|
skipPermissions:
|
||||||
|
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||||
|
worktree:
|
||||||
|
typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
|
||||||
|
extraCliArgs:
|
||||||
|
typeof payload.extraCliArgs === 'string'
|
||||||
|
? payload.extraCliArgs.trim() || undefined
|
||||||
|
: undefined,
|
||||||
|
members: members.map((m) => ({ name: m.name, role: m.role, workflow: m.workflow })),
|
||||||
|
};
|
||||||
|
|
||||||
|
return wrapTeamHandler('create', () =>
|
||||||
|
getTeamProvisioningService().createTeam(createRequest, (progress) => {
|
||||||
|
try {
|
||||||
|
event.sender.send(TEAM_PROVISIONING_PROGRESS, progress);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.warn(`Failed to emit draft launch provisioning progress: ${message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return wrapTeamHandler('launch', () =>
|
return wrapTeamHandler('launch', () =>
|
||||||
getTeamProvisioningService().launchTeam(
|
getTeamProvisioningService().launchTeam(
|
||||||
{
|
{
|
||||||
|
|
@ -2723,3 +2795,137 @@ async function handleToolApprovalSettings(
|
||||||
}
|
}
|
||||||
return { success: true, data: undefined };
|
return { success: true, data: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Max file size for tool approval diff preview (2MB). */
|
||||||
|
const TOOL_APPROVAL_MAX_FILE_SIZE = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
async function handleToolApprovalReadFile(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
filePath: unknown
|
||||||
|
): Promise<IpcResult<ToolApprovalFileContent>> {
|
||||||
|
if (typeof filePath !== 'string' || filePath.trim().length === 0) {
|
||||||
|
return { success: false, error: 'filePath must be a non-empty string' };
|
||||||
|
}
|
||||||
|
if (!path.isAbsolute(filePath)) {
|
||||||
|
return { success: false, error: 'filePath must be an absolute path' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let stats: fs.Stats;
|
||||||
|
try {
|
||||||
|
stats = await fs.promises.stat(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { content: '', exists: false, truncated: false, isBinary: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { content: '', exists: true, truncated: false, isBinary: false, error: 'Not a file' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = stats.size > TOOL_APPROVAL_MAX_FILE_SIZE;
|
||||||
|
const readSize = truncated ? TOOL_APPROVAL_MAX_FILE_SIZE : stats.size;
|
||||||
|
|
||||||
|
// Read file (potentially truncated)
|
||||||
|
const fd = await fs.promises.open(filePath, 'r');
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(readSize);
|
||||||
|
await fd.read(buffer, 0, readSize, 0);
|
||||||
|
|
||||||
|
// Binary detection: check first 8KB for null bytes
|
||||||
|
const checkSize = Math.min(readSize, 8192);
|
||||||
|
for (let i = 0; i < checkSize; i++) {
|
||||||
|
if (buffer[i] === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { content: '', exists: true, truncated: false, isBinary: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { content: buffer.toString('utf-8'), exists: true, truncated, isBinary: false },
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await fd.close();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { content: '', exists: true, truncated: false, isBinary: false, error: msg },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetSavedRequest(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown
|
||||||
|
): Promise<IpcResult<TeamCreateRequest | null>> {
|
||||||
|
const validated = validateTeamName(teamName);
|
||||||
|
if (!validated.valid) {
|
||||||
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
|
}
|
||||||
|
const tn = validated.value!;
|
||||||
|
|
||||||
|
const meta = await teamMetaStore.getMeta(tn);
|
||||||
|
if (!meta) {
|
||||||
|
return { success: true, data: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const membersStore = new TeamMembersMetaStore();
|
||||||
|
const members = await membersStore.getMembers(tn);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
teamName: tn,
|
||||||
|
displayName: meta.displayName,
|
||||||
|
description: meta.description,
|
||||||
|
color: meta.color,
|
||||||
|
cwd: meta.cwd,
|
||||||
|
prompt: meta.prompt,
|
||||||
|
model: meta.model,
|
||||||
|
effort: meta.effort as TeamCreateRequest['effort'],
|
||||||
|
skipPermissions: meta.skipPermissions,
|
||||||
|
worktree: meta.worktree,
|
||||||
|
extraCliArgs: meta.extraCliArgs,
|
||||||
|
limitContext: meta.limitContext,
|
||||||
|
members: members.map((m) => ({
|
||||||
|
name: m.name,
|
||||||
|
role: m.role,
|
||||||
|
workflow: m.workflow,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteDraft(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown
|
||||||
|
): Promise<IpcResult<void>> {
|
||||||
|
const validated = validateTeamName(teamName);
|
||||||
|
if (!validated.valid) {
|
||||||
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
|
}
|
||||||
|
return wrapTeamHandler('deleteDraft', async () => {
|
||||||
|
// Only allow deleting draft teams (no config.json)
|
||||||
|
const configPath = path.join(getTeamsBasePath(), validated.value!, 'config.json');
|
||||||
|
try {
|
||||||
|
await fs.promises.access(configPath, fs.constants.F_OK);
|
||||||
|
throw new Error('Cannot delete draft: team has config.json (use deleteTeam instead)');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
await getTeamDataService().permanentlyDeleteTeam(validated.value!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import { withFileLock } from './fileLock';
|
||||||
import { withInboxLock } from './inboxLock';
|
import { withInboxLock } from './inboxLock';
|
||||||
import { TeamConfigReader } from './TeamConfigReader';
|
import { TeamConfigReader } from './TeamConfigReader';
|
||||||
import { TeamInboxReader } from './TeamInboxReader';
|
import { TeamInboxReader } from './TeamInboxReader';
|
||||||
|
import { TeamMetaStore } from './TeamMetaStore';
|
||||||
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
|
|
@ -908,21 +909,21 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName)
|
||||||
members: request.members,
|
members: request.members,
|
||||||
});
|
});
|
||||||
|
|
||||||
return `agent_teams_ui [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”] — team config has been pre-created. Proceed with provisioning.
|
return `agent_teams_ui [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”] — team does NOT exist yet. You must create it.
|
||||||
|
|
||||||
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
||||||
CRITICAL: Execute ALL steps directly yourself in sequence. Do NOT delegate any step to a sub-agent via the Agent tool. The ONLY valid use of the Agent tool is spawning individual teammates in step 2.
|
CRITICAL: Execute ALL steps directly yourself in sequence. Do NOT delegate any step to a sub-agent via the Agent tool. The ONLY valid use of the Agent tool is spawning individual teammates in step 2.
|
||||||
Do NOT call mcp__agent-teams__team_launch, mcp__agent-teams__team_stop, or any other mcp__agent-teams__ runtime tool during provisioning. MCP board tools (task_create, task_set_status, etc.) are allowed only in step 3.
|
CRITICAL: For step 1, use the BUILT-IN TeamCreate tool — NOT any mcp__agent-teams__* MCP tool. Do NOT call mcp__agent-teams__team_launch, mcp__agent-teams__team_stop, or any other mcp__agent-teams__ runtime tool during provisioning. MCP board tools (task_create, task_set_status, etc.) are allowed only in step 3.
|
||||||
You are “${leadName}”, the team lead.
|
You are “${leadName}”, the team lead.
|
||||||
|
|
||||||
Goal: Provision Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}.
|
Goal: Create and provision a NEW Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}.
|
||||||
The team config has been pre-created (config.json, members metadata). Proceed with member provisioning.
|
The team does NOT exist yet — no config, no state, nothing. Step 1 is MANDATORY.
|
||||||
${userPromptBlock}
|
${userPromptBlock}
|
||||||
${persistentContext}
|
${persistentContext}
|
||||||
|
|
||||||
Steps (execute in this exact order — do NOT skip any step):
|
Steps (execute in this exact order — do NOT skip any step):
|
||||||
|
|
||||||
1) The team config has been pre-created. Proceed directly to step 2.
|
1) MANDATORY FIRST STEP: Call the BUILT-IN TeamCreate tool (not any MCP tool) with team_name=”${request.teamName}”. This creates the team config and in-memory state. Without this step, teammate spawns will FAIL. Do NOT assume the team already exists.
|
||||||
|
|
||||||
${step2Block}
|
${step2Block}
|
||||||
|
|
||||||
|
|
@ -1263,7 +1264,8 @@ export class TeamProvisioningService {
|
||||||
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
||||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||||
_sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(),
|
_sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(),
|
||||||
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder()
|
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder(),
|
||||||
|
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
setCrossTeamSender(
|
setCrossTeamSender(
|
||||||
|
|
@ -2438,6 +2440,10 @@ export class TeamProvisioningService {
|
||||||
if (silenceMs < STALL_WARNING_THRESHOLD_MS) return;
|
if (silenceMs < STALL_WARNING_THRESHOLD_MS) return;
|
||||||
if (lastWarningAt > 0 && now - lastWarningAt < STALL_WARNING_REPEAT_MS) return;
|
if (lastWarningAt > 0 && now - lastWarningAt < STALL_WARNING_REPEAT_MS) return;
|
||||||
|
|
||||||
|
// Don't show stall warnings if CLI has already produced output —
|
||||||
|
// silence between tool calls is normal (e.g. waiting for teammate spawn).
|
||||||
|
if (run.claudeLogLines.length > 0) return;
|
||||||
|
|
||||||
lastWarningAt = now;
|
lastWarningAt = now;
|
||||||
const silenceSec = Math.round(silenceMs / 1000);
|
const silenceSec = Math.round(silenceMs / 1000);
|
||||||
|
|
||||||
|
|
@ -2846,7 +2852,7 @@ export class TeamProvisioningService {
|
||||||
waitingTasksSince: null,
|
waitingTasksSince: null,
|
||||||
provisioningComplete: false,
|
provisioningComplete: false,
|
||||||
isLaunch: false,
|
isLaunch: false,
|
||||||
fsPhase: 'waiting_members',
|
fsPhase: 'waiting_config',
|
||||||
leadRelayCapture: null,
|
leadRelayCapture: null,
|
||||||
activeCrossTeamReplyHints: [],
|
activeCrossTeamReplyHints: [],
|
||||||
leadMsgSeq: 0,
|
leadMsgSeq: 0,
|
||||||
|
|
@ -2905,7 +2911,7 @@ export class TeamProvisioningService {
|
||||||
'--mcp-config',
|
'--mcp-config',
|
||||||
mcpConfigPath,
|
mcpConfigPath,
|
||||||
'--disallowedTools',
|
'--disallowedTools',
|
||||||
'TeamDelete,TodoWrite,TeamCreate',
|
'TeamDelete,TodoWrite',
|
||||||
// Explicit --permission-mode overrides user's defaultMode in ~/.claude/settings.json
|
// Explicit --permission-mode overrides user's defaultMode in ~/.claude/settings.json
|
||||||
// (e.g. "acceptEdits") which otherwise takes precedence over CLI flags
|
// (e.g. "acceptEdits") which otherwise takes precedence over CLI flags
|
||||||
...(request.skipPermissions !== false
|
...(request.skipPermissions !== false
|
||||||
|
|
@ -2917,14 +2923,46 @@ export class TeamProvisioningService {
|
||||||
...parseCliArgs(request.extraCliArgs),
|
...parseCliArgs(request.extraCliArgs),
|
||||||
];
|
];
|
||||||
try {
|
try {
|
||||||
await this.preCreateConfig(request);
|
// Pre-save our meta files before spawn — CLI doesn't touch these.
|
||||||
|
// If provisioning fails before TeamCreate, user can retry without re-entering config.
|
||||||
|
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
||||||
|
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
||||||
|
await fs.promises.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.promises.mkdir(tasksDir, { recursive: true });
|
||||||
|
await this.teamMetaStore.writeMeta(request.teamName, {
|
||||||
|
displayName: request.displayName,
|
||||||
|
description: request.description,
|
||||||
|
color: request.color,
|
||||||
|
cwd: request.cwd,
|
||||||
|
prompt: request.prompt,
|
||||||
|
model: request.model,
|
||||||
|
effort: request.effort,
|
||||||
|
skipPermissions: request.skipPermissions,
|
||||||
|
worktree: request.worktree,
|
||||||
|
extraCliArgs: request.extraCliArgs,
|
||||||
|
limitContext: request.limitContext,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
await this.membersMetaStore.writeMembers(
|
||||||
|
request.teamName,
|
||||||
|
request.members.map((m) => ({
|
||||||
|
name: m.name.trim(),
|
||||||
|
role: m.role?.trim() || undefined,
|
||||||
|
workflow: m.workflow?.trim() || undefined,
|
||||||
|
agentType: 'general-purpose' as const,
|
||||||
|
color: getMemberColorByName(m.name.trim()),
|
||||||
|
joinedAt: Date.now(),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
child = spawnCli(claudePath, spawnArgs, {
|
child = spawnCli(claudePath, spawnArgs, {
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
env: { ...shellEnv },
|
env: { ...shellEnv },
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Clean up pre-saved files (may not exist if preCreateConfig failed partway)
|
// Clean up pre-saved meta files if spawn failed (instant failure, not transient)
|
||||||
|
await this.teamMetaStore.deleteMeta(request.teamName).catch(() => {});
|
||||||
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
||||||
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
||||||
await fs.promises.rm(teamDir, { recursive: true, force: true }).catch(() => {});
|
await fs.promises.rm(teamDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
|
@ -2936,7 +2974,6 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
updateProgress(run, 'spawning', 'Starting Claude CLI process', {
|
updateProgress(run, 'spawning', 'Starting Claude CLI process', {
|
||||||
pid: child.pid ?? undefined,
|
pid: child.pid ?? undefined,
|
||||||
configReady: true,
|
|
||||||
});
|
});
|
||||||
run.onProgress(run.progress);
|
run.onProgress(run.progress);
|
||||||
run.child = child;
|
run.child = child;
|
||||||
|
|
@ -5124,12 +5161,17 @@ export class TeamProvisioningService {
|
||||||
const configLead = config.members.find((m) => isLeadAgentType(m?.agentType));
|
const configLead = config.members.find((m) => isLeadAgentType(m?.agentType));
|
||||||
leadName = configLead?.name?.trim() || 'team-lead';
|
leadName = configLead?.name?.trim() || 'team-lead';
|
||||||
// Convert config members (excluding lead) to TeamCreateRequest member format.
|
// Convert config members (excluding lead) to TeamCreateRequest member format.
|
||||||
currentMembers = config.members
|
const configTeammates = config.members
|
||||||
.filter((m) => !isLeadAgentType(m?.agentType) && m?.name)
|
.filter((m) => !isLeadAgentType(m?.agentType) && m?.name)
|
||||||
.map((m) => ({
|
.map((m) => ({
|
||||||
name: m.name,
|
name: m.name,
|
||||||
role: m.role ?? undefined,
|
role: m.role ?? undefined,
|
||||||
}));
|
}));
|
||||||
|
// When config.members only has the lead (pre-created config without
|
||||||
|
// TeamCreate), fall back to run.request.members for the teammate list.
|
||||||
|
if (configTeammates.length > 0) {
|
||||||
|
currentMembers = configTeammates;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
leadName =
|
leadName =
|
||||||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||||
|
|
@ -5266,6 +5308,8 @@ export class TeamProvisioningService {
|
||||||
toolName,
|
toolName,
|
||||||
toolInput,
|
toolInput,
|
||||||
receivedAt: new Date().toISOString(),
|
receivedAt: new Date().toISOString(),
|
||||||
|
teamColor: run.request.color,
|
||||||
|
teamDisplayName: run.request.displayName,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check auto-allow rules before prompting user
|
// Check auto-allow rules before prompting user
|
||||||
|
|
@ -5445,7 +5489,8 @@ export class TeamProvisioningService {
|
||||||
allow: boolean,
|
allow: boolean,
|
||||||
message?: string
|
message?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const currentRunId = this.getAliveRunId(teamName);
|
// Look in both provisioning and alive runs — control_requests arrive during provisioning too
|
||||||
|
const currentRunId = this.getTrackedRunId(teamName);
|
||||||
if (!currentRunId) throw new Error(`No active process for team "${teamName}"`);
|
if (!currentRunId) throw new Error(`No active process for team "${teamName}"`);
|
||||||
const run = this.runs.get(currentRunId);
|
const run = this.runs.get(currentRunId);
|
||||||
if (!run) throw new Error(`Run not found for team "${teamName}"`);
|
if (!run) throw new Error(`Run not found for team "${teamName}"`);
|
||||||
|
|
@ -5494,13 +5539,25 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
|
|
||||||
const stdin = run.child.stdin;
|
const stdin = run.child.stdin;
|
||||||
|
const responseJson = JSON.stringify(response) + '\n';
|
||||||
|
logger.info(
|
||||||
|
`[${teamName}] Writing control_response for ${requestId}: ${allow ? 'allow' : 'deny'}`
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
stdin.write(JSON.stringify(response) + '\n', (err) => {
|
// Safety timeout — if stdin.write callback is never called (e.g. process died
|
||||||
|
// between the writable check and the write), reject instead of hanging forever.
|
||||||
|
const writeTimeout = setTimeout(() => {
|
||||||
|
reject(new Error(`Timeout writing control_response to stdin (process may have exited)`));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
stdin.write(responseJson, (err) => {
|
||||||
|
clearTimeout(writeTimeout);
|
||||||
if (err) {
|
if (err) {
|
||||||
logger.error(`[${teamName}] Failed to write control_response: ${err.message}`);
|
logger.error(`[${teamName}] Failed to write control_response: ${err.message}`);
|
||||||
reject(err);
|
reject(err);
|
||||||
} else {
|
} else {
|
||||||
|
logger.info(`[${teamName}] control_response written successfully for ${requestId}`);
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -5692,6 +5749,9 @@ export class TeamProvisioningService {
|
||||||
run.request.color
|
run.request.color
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clean up team.meta.json — provisioning succeeded, config.json is now authoritative.
|
||||||
|
await this.teamMetaStore.deleteMeta(run.teamName).catch(() => {});
|
||||||
|
|
||||||
// Audit: flag any expected member not registered in config.json after provisioning.
|
// Audit: flag any expected member not registered in config.json after provisioning.
|
||||||
await this.auditMemberSpawnStatuses(run);
|
await this.auditMemberSpawnStatuses(run);
|
||||||
|
|
||||||
|
|
@ -7199,41 +7259,6 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-saves team config.json + members-meta.json + tasks/ dir to disk
|
|
||||||
* BEFORE spawning CLI. This guarantees the team persists even if CLI
|
|
||||||
* crashes (e.g. 429 rate limit) before it can call TeamCreate.
|
|
||||||
*/
|
|
||||||
private async preCreateConfig(request: TeamCreateRequest): Promise<void> {
|
|
||||||
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
|
||||||
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
|
||||||
await fs.promises.mkdir(teamDir, { recursive: true });
|
|
||||||
await fs.promises.mkdir(tasksDir, { recursive: true });
|
|
||||||
|
|
||||||
const config: Record<string, unknown> = {
|
|
||||||
name: request.displayName?.trim() || request.teamName,
|
|
||||||
description: request.description?.trim() || undefined,
|
|
||||||
color: request.color?.trim() || undefined,
|
|
||||||
};
|
|
||||||
if (request.cwd?.trim()) {
|
|
||||||
config.projectPath = request.cwd.trim();
|
|
||||||
config.projectPathHistory = [request.cwd.trim()];
|
|
||||||
}
|
|
||||||
await atomicWriteAsync(path.join(teamDir, 'config.json'), JSON.stringify(config, null, 2));
|
|
||||||
|
|
||||||
const joinedAt = Date.now();
|
|
||||||
await this.membersMetaStore.writeMembers(
|
|
||||||
request.teamName,
|
|
||||||
request.members.map((m) => ({
|
|
||||||
name: m.name.trim(),
|
|
||||||
role: m.role?.trim() || undefined,
|
|
||||||
agentType: 'general-purpose' as const,
|
|
||||||
color: getMemberColorByName(m.name.trim()),
|
|
||||||
joinedAt,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persistMembersMeta(teamName: string, request: TeamCreateRequest): Promise<void> {
|
private async persistMembersMeta(teamName: string, request: TeamCreateRequest): Promise<void> {
|
||||||
const teammateMembers = request.members.filter((member) => {
|
const teammateMembers = request.members.filter((member) => {
|
||||||
const trimmed = member.name.trim();
|
const trimmed = member.name.trim();
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,12 @@ export const TEAM_VALIDATE_CLI_ARGS = 'team:validateCliArgs';
|
||||||
/** Invoke: update tool approval settings (renderer → main) */
|
/** Invoke: update tool approval settings (renderer → main) */
|
||||||
export const TEAM_TOOL_APPROVAL_SETTINGS = 'team:toolApprovalSettings';
|
export const TEAM_TOOL_APPROVAL_SETTINGS = 'team:toolApprovalSettings';
|
||||||
|
|
||||||
|
/** Invoke: read file content for tool approval diff preview (renderer → main) */
|
||||||
|
export const TEAM_TOOL_APPROVAL_READ_FILE = 'team:toolApprovalReadFile';
|
||||||
|
|
||||||
|
export const TEAM_GET_SAVED_REQUEST = 'team:getSavedRequest';
|
||||||
|
export const TEAM_DELETE_DRAFT = 'team:deleteDraft';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Cross-Team Communication Channels
|
// Cross-Team Communication Channels
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,8 @@ import {
|
||||||
TEAM_LIST,
|
TEAM_LIST,
|
||||||
TEAM_MEMBER_SPAWN_STATUSES,
|
TEAM_MEMBER_SPAWN_STATUSES,
|
||||||
TEAM_PERMANENTLY_DELETE,
|
TEAM_PERMANENTLY_DELETE,
|
||||||
|
TEAM_GET_SAVED_REQUEST,
|
||||||
|
TEAM_DELETE_DRAFT,
|
||||||
TEAM_PREPARE_PROVISIONING,
|
TEAM_PREPARE_PROVISIONING,
|
||||||
TEAM_PROCESS_ALIVE,
|
TEAM_PROCESS_ALIVE,
|
||||||
TEAM_PROCESS_SEND,
|
TEAM_PROCESS_SEND,
|
||||||
|
|
@ -151,6 +153,7 @@ import {
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_STOP,
|
TEAM_STOP,
|
||||||
TEAM_TOOL_APPROVAL_EVENT,
|
TEAM_TOOL_APPROVAL_EVENT,
|
||||||
|
TEAM_TOOL_APPROVAL_READ_FILE,
|
||||||
TEAM_TOOL_APPROVAL_RESPOND,
|
TEAM_TOOL_APPROVAL_RESPOND,
|
||||||
TEAM_TOOL_APPROVAL_SETTINGS,
|
TEAM_TOOL_APPROVAL_SETTINGS,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
|
|
@ -275,6 +278,7 @@ import type {
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamUpdateConfigRequest,
|
TeamUpdateConfigRequest,
|
||||||
ToolApprovalEvent,
|
ToolApprovalEvent,
|
||||||
|
ToolApprovalFileContent,
|
||||||
ToolApprovalSettings,
|
ToolApprovalSettings,
|
||||||
TriggerTestResult,
|
TriggerTestResult,
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
|
|
@ -806,6 +810,12 @@ const electronAPI: ElectronAPI = {
|
||||||
permanentlyDeleteTeam: async (teamName: string) => {
|
permanentlyDeleteTeam: async (teamName: string) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_PERMANENTLY_DELETE, teamName);
|
return invokeIpcWithResult<void>(TEAM_PERMANENTLY_DELETE, teamName);
|
||||||
},
|
},
|
||||||
|
getSavedRequest: async (teamName: string) => {
|
||||||
|
return invokeIpcWithResult<TeamCreateRequest | null>(TEAM_GET_SAVED_REQUEST, teamName);
|
||||||
|
},
|
||||||
|
deleteDraft: async (teamName: string) => {
|
||||||
|
return invokeIpcWithResult<void>(TEAM_DELETE_DRAFT, teamName);
|
||||||
|
},
|
||||||
prepareProvisioning: async (cwd?: string) => {
|
prepareProvisioning: async (cwd?: string) => {
|
||||||
return invokeIpcWithResult<TeamProvisioningPrepareResult>(TEAM_PREPARE_PROVISIONING, cwd);
|
return invokeIpcWithResult<TeamProvisioningPrepareResult>(TEAM_PREPARE_PROVISIONING, cwd);
|
||||||
},
|
},
|
||||||
|
|
@ -1093,6 +1103,9 @@ const electronAPI: ElectronAPI = {
|
||||||
updateToolApprovalSettings: async (settings: ToolApprovalSettings) => {
|
updateToolApprovalSettings: async (settings: ToolApprovalSettings) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_TOOL_APPROVAL_SETTINGS, settings);
|
return invokeIpcWithResult<void>(TEAM_TOOL_APPROVAL_SETTINGS, settings);
|
||||||
},
|
},
|
||||||
|
readFileForToolApproval: async (filePath: string) => {
|
||||||
|
return invokeIpcWithResult<ToolApprovalFileContent>(TEAM_TOOL_APPROVAL_READ_FILE, filePath);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
crossTeam: {
|
crossTeam: {
|
||||||
send: async (request: CrossTeamSendRequest) => {
|
send: async (request: CrossTeamSendRequest) => {
|
||||||
|
|
|
||||||
|
|
@ -684,6 +684,13 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
permanentlyDeleteTeam: async (_teamName: string): Promise<void> => {
|
permanentlyDeleteTeam: async (_teamName: string): Promise<void> => {
|
||||||
throw new Error('Permanent team deletion is not available in browser mode');
|
throw new Error('Permanent team deletion is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
getSavedRequest: async (_teamName: string): Promise<TeamCreateRequest | null> => {
|
||||||
|
console.warn('[HttpAPIClient] getSavedRequest is not available in browser mode');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
deleteDraft: async (_teamName: string): Promise<void> => {
|
||||||
|
throw new Error('Draft team deletion is not available in browser mode');
|
||||||
|
},
|
||||||
prepareProvisioning: async (_cwd?: string): Promise<TeamProvisioningPrepareResult> => {
|
prepareProvisioning: async (_cwd?: string): Promise<TeamProvisioningPrepareResult> => {
|
||||||
throw new Error('Team provisioning is not available in browser mode');
|
throw new Error('Team provisioning is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
|
@ -916,6 +923,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
updateToolApprovalSettings: async (): Promise<void> => {
|
updateToolApprovalSettings: async (): Promise<void> => {
|
||||||
console.warn('[HttpAPIClient] updateToolApprovalSettings is not available in browser mode');
|
console.warn('[HttpAPIClient] updateToolApprovalSettings is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
readFileForToolApproval: async () => {
|
||||||
|
throw new Error('Tool approval file read not available in browser mode');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cross-team communication API stubs
|
// Cross-team communication API stubs
|
||||||
|
|
|
||||||
154
src/renderer/components/team/ToolApprovalDiffPreview.tsx
Normal file
154
src/renderer/components/team/ToolApprovalDiffPreview.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { DiffViewer } from '@renderer/components/chat/viewers/DiffViewer';
|
||||||
|
import { useToolApprovalDiff } from '@renderer/hooks/useToolApprovalDiff';
|
||||||
|
import { AlertTriangle, ChevronDown, ChevronRight, FileDiff, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface ToolApprovalDiffPreviewProps {
|
||||||
|
toolName: string;
|
||||||
|
toolInput: Record<string, unknown>;
|
||||||
|
requestId: string;
|
||||||
|
onExpandedChange?: (expanded: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFF_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const ToolApprovalDiffPreview: React.FC<ToolApprovalDiffPreviewProps> = ({
|
||||||
|
toolName,
|
||||||
|
toolInput,
|
||||||
|
requestId,
|
||||||
|
onExpandedChange,
|
||||||
|
}) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const diff = useToolApprovalDiff(toolName, toolInput, requestId, expanded);
|
||||||
|
|
||||||
|
// Collapse when approval changes
|
||||||
|
useEffect(() => {
|
||||||
|
setExpanded(false);
|
||||||
|
onExpandedChange?.(false);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- onExpandedChange is stable setter, only reset on requestId change
|
||||||
|
}, [requestId]);
|
||||||
|
|
||||||
|
if (!DIFF_TOOLS.has(toolName)) return null;
|
||||||
|
|
||||||
|
const toggleExpanded = (): void => {
|
||||||
|
const next = !expanded;
|
||||||
|
setExpanded(next);
|
||||||
|
onExpandedChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t px-4 py-2" style={{ borderColor: 'var(--color-border)' }}>
|
||||||
|
{/* Toggle button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
className="flex items-center gap-1.5 rounded px-2 py-1 text-[11px] transition-colors"
|
||||||
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
Object.assign(e.currentTarget.style, {
|
||||||
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileDiff className="size-3" />
|
||||||
|
<span>Preview changes</span>
|
||||||
|
{expanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Collapsible content */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-2">
|
||||||
|
{diff.loading && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 rounded-md border px-3 py-3 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface)',
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
<span>Reading file...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diff.isBinary && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 rounded-md border px-3 py-2.5 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(234, 179, 8, 0.08)',
|
||||||
|
borderColor: 'rgba(234, 179, 8, 0.25)',
|
||||||
|
color: 'rgb(234, 179, 8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-3.5 shrink-0" />
|
||||||
|
<span>Binary file — cannot preview</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diff.error && !diff.loading && (
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-2 rounded-md border px-3 py-2.5 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(234, 179, 8, 0.08)',
|
||||||
|
borderColor: 'rgba(234, 179, 8, 0.25)',
|
||||||
|
color: 'rgb(234, 179, 8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<span className="break-words">{diff.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diff.truncated && !diff.loading && (
|
||||||
|
<div
|
||||||
|
className="mb-2 flex items-center gap-2 rounded-md border px-3 py-1.5 text-[10px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(234, 179, 8, 0.06)',
|
||||||
|
borderColor: 'rgba(234, 179, 8, 0.2)',
|
||||||
|
color: 'rgb(234, 179, 8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="size-3 shrink-0" />
|
||||||
|
<span>File truncated at 2MB — diff may be incomplete</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!diff.loading && !diff.isBinary && !diff.error && (diff.oldString || diff.newString) && (
|
||||||
|
<div>
|
||||||
|
{diff.isNewFile && (
|
||||||
|
<span
|
||||||
|
className="mb-1.5 inline-block rounded px-1.5 py-0.5 text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(46, 160, 67, 0.15)',
|
||||||
|
color: 'rgb(46, 160, 67)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New file
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<DiffViewer
|
||||||
|
fileName={diff.fileName}
|
||||||
|
oldString={diff.oldString}
|
||||||
|
newString={diff.newString}
|
||||||
|
maxHeight="max-h-[300px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { FileText, Search, Terminal } from 'lucide-react';
|
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||||
|
import { AlertTriangle, FileText, Search, Terminal } from 'lucide-react';
|
||||||
|
|
||||||
|
import { ToolApprovalDiffPreview } from './ToolApprovalDiffPreview';
|
||||||
import { ToolApprovalSettingsPanel } from './dialogs/ToolApprovalSettingsPanel';
|
import { ToolApprovalSettingsPanel } from './dialogs/ToolApprovalSettingsPanel';
|
||||||
|
|
||||||
import type { ToolApprovalRequest } from '@shared/types';
|
import type { ToolApprovalRequest } from '@shared/types';
|
||||||
|
|
@ -52,6 +54,24 @@ function renderToolInput(toolName: string, input: Record<string, unknown>): stri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Map tool name to a virtual filename for syntax highlighting. */
|
||||||
|
function getToolInputFileName(toolName: string, input: Record<string, unknown>): string {
|
||||||
|
switch (toolName) {
|
||||||
|
case 'Bash':
|
||||||
|
return 'command.sh';
|
||||||
|
case 'Edit':
|
||||||
|
case 'Read':
|
||||||
|
case 'Write':
|
||||||
|
case 'NotebookEdit':
|
||||||
|
return typeof input.file_path === 'string' ? input.file_path : 'input.json';
|
||||||
|
case 'Grep':
|
||||||
|
case 'Glob':
|
||||||
|
return 'pattern.txt';
|
||||||
|
default:
|
||||||
|
return 'input.json';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Elapsed timer hook
|
// Elapsed timer hook
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -78,25 +98,53 @@ function useElapsed(receivedAt: string): number {
|
||||||
// Main component
|
// Main component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Max time (ms) to wait for the IPC before considering it stuck */
|
||||||
|
const RESPOND_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
export const ToolApprovalSheet: React.FC = () => {
|
export const ToolApprovalSheet: React.FC = () => {
|
||||||
const pendingApprovals = useStore((s) => s.pendingApprovals);
|
const pendingApprovals = useStore((s) => s.pendingApprovals);
|
||||||
const respondToToolApproval = useStore((s) => s.respondToToolApproval);
|
const respondToToolApproval = useStore((s) => s.respondToToolApproval);
|
||||||
const teams = useStore((s) => s.teams);
|
const teams = useStore((s) => s.teams);
|
||||||
|
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
|
|
||||||
const current: ToolApprovalRequest | undefined = pendingApprovals[0];
|
const current: ToolApprovalRequest | undefined = pendingApprovals[0];
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [disabled, setDisabled] = useState(false);
|
const [disabled, setDisabled] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [diffExpanded, setDiffExpanded] = useState(false);
|
||||||
|
|
||||||
|
// Clear error and collapse diff when current approval changes
|
||||||
|
useEffect(() => {
|
||||||
|
setError(null);
|
||||||
|
setDiffExpanded(false);
|
||||||
|
}, [current?.requestId]);
|
||||||
|
|
||||||
const handleRespond = useCallback(
|
const handleRespond = useCallback(
|
||||||
(allow: boolean) => {
|
(allow: boolean) => {
|
||||||
if (!current || disabled) return;
|
if (!current || disabled) return;
|
||||||
setDisabled(true);
|
setDisabled(true);
|
||||||
void respondToToolApproval(current.teamName, current.runId, current.requestId, allow).finally(
|
setError(null);
|
||||||
() => {
|
|
||||||
|
// Safety timeout — if IPC hangs (e.g. stdin.write callback never fires),
|
||||||
|
// re-enable the button so the user isn't stuck forever.
|
||||||
|
const safetyTimer = setTimeout(() => {
|
||||||
|
setDisabled(false);
|
||||||
|
setError('Response timed out — process may be unresponsive. Try again or stop the team.');
|
||||||
|
}, RESPOND_TIMEOUT_MS);
|
||||||
|
|
||||||
|
respondToToolApproval(current.teamName, current.runId, current.requestId, allow)
|
||||||
|
.then(() => {
|
||||||
|
clearTimeout(safetyTimer);
|
||||||
|
// Small delay before re-enabling to prevent accidental double-clicks
|
||||||
setTimeout(() => setDisabled(false), 200);
|
setTimeout(() => setDisabled(false), 200);
|
||||||
}
|
})
|
||||||
);
|
.catch((err: unknown) => {
|
||||||
|
clearTimeout(safetyTimer);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(msg);
|
||||||
|
setDisabled(false);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[current, disabled, respondToToolApproval]
|
[current, disabled, respondToToolApproval]
|
||||||
);
|
);
|
||||||
|
|
@ -120,13 +168,17 @@ export const ToolApprovalSheet: React.FC = () => {
|
||||||
|
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
|
||||||
|
// Prefer color from the approval itself (always available, even during provisioning),
|
||||||
|
// fall back to teams list for older approvals without the field.
|
||||||
const teamSummary = teams.find((t) => t.teamName === current.teamName);
|
const teamSummary = teams.find((t) => t.teamName === current.teamName);
|
||||||
const teamColor = teamSummary?.color ? getTeamColorSet(teamSummary.color) : null;
|
const colorName = current.teamColor ?? teamSummary?.color;
|
||||||
|
const teamColor = colorName ? getTeamColorSet(colorName) : null;
|
||||||
|
const displayName = current.teamDisplayName ?? teamSummary?.displayName ?? current.teamName;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
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"
|
className={`fixed bottom-4 left-1/2 z-[55] w-full -translate-x-1/2 rounded-lg border shadow-xl outline-none transition-all duration-200 animate-in fade-in slide-in-from-bottom-4 ${diffExpanded ? 'max-w-[640px]' : 'max-w-[480px]'}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-surface-overlay)',
|
backgroundColor: 'var(--color-surface-overlay)',
|
||||||
borderColor: 'var(--color-border-emphasis)',
|
borderColor: 'var(--color-border-emphasis)',
|
||||||
|
|
@ -144,37 +196,59 @@ export const ToolApprovalSheet: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
{teamColor ? (
|
{selectedTeamName !== current.teamName &&
|
||||||
<span
|
(teamColor ? (
|
||||||
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
|
<span
|
||||||
style={{
|
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||||
backgroundColor: getThemedBadge(teamColor, isLight),
|
style={{
|
||||||
color: teamColor.text,
|
backgroundColor: getThemedBadge(teamColor, isLight),
|
||||||
border: `1px solid ${teamColor.border}`,
|
color: teamColor.text,
|
||||||
}}
|
border: `1px solid ${teamColor.border}`,
|
||||||
>
|
}}
|
||||||
{teamSummary?.displayName ?? current.teamName}
|
>
|
||||||
</span>
|
{displayName}
|
||||||
) : (
|
</span>
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">{current.teamName}</span>
|
) : (
|
||||||
)}
|
<span
|
||||||
|
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
border: '1px solid var(--color-border-emphasis)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
<ElapsedDisplay receivedAt={current.receivedAt} />
|
<ElapsedDisplay receivedAt={current.receivedAt} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tool input preview */}
|
{/* Tool input preview (syntax-highlighted) */}
|
||||||
<div className="px-4 py-2.5">
|
<ToolInputPreview toolName={current.toolName} toolInput={current.toolInput} />
|
||||||
<pre
|
|
||||||
className="custom-scrollbar max-h-[120px] overflow-auto whitespace-pre-wrap break-all rounded-md border p-2 font-mono text-xs"
|
{/* Diff preview (Write/Edit/NotebookEdit only) */}
|
||||||
|
<ToolApprovalDiffPreview
|
||||||
|
toolName={current.toolName}
|
||||||
|
toolInput={current.toolInput}
|
||||||
|
requestId={current.requestId}
|
||||||
|
onExpandedChange={setDiffExpanded}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Error feedback */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="mx-4 mb-1 flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-surface)',
|
backgroundColor: 'rgba(239, 68, 68, 0.08)',
|
||||||
borderColor: 'var(--color-border)',
|
borderColor: 'rgba(239, 68, 68, 0.25)',
|
||||||
color: 'var(--color-text-secondary)',
|
color: 'rgb(248, 113, 113)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderToolInput(current.toolName, current.toolInput)}
|
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||||
</pre>
|
<span className="break-words">{error}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -236,6 +310,46 @@ export const ToolApprovalSheet: React.FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Syntax-highlighted tool input preview
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ToolInputPreview = ({
|
||||||
|
toolName,
|
||||||
|
toolInput,
|
||||||
|
}: {
|
||||||
|
toolName: string;
|
||||||
|
toolInput: Record<string, unknown>;
|
||||||
|
}): React.JSX.Element => {
|
||||||
|
const text = renderToolInput(toolName, toolInput);
|
||||||
|
const fileName = getToolInputFileName(toolName, toolInput);
|
||||||
|
const lines = useMemo(() => highlightLines(text, fileName), [text, fileName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-2.5">
|
||||||
|
<div
|
||||||
|
className="custom-scrollbar max-h-[120px] overflow-auto rounded-md border p-2 font-mono text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface)',
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* highlightLines uses hljs which HTML-escapes all input text, producing only <span class="hljs-*"> tags.
|
||||||
|
This is safe: the source is our own renderToolInput() output, not arbitrary user HTML.
|
||||||
|
Same pattern used in ReviewDiffContent.tsx and DiffViewer for syntax highlighting. */}
|
||||||
|
{lines.map((html, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="whitespace-pre-wrap break-all"
|
||||||
|
style={{ color: 'var(--color-text-secondary)' }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: html || ' ' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Timeout progress bar sub-component
|
// Timeout progress bar sub-component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -268,7 +382,7 @@ const TimeoutProgress = ({ receivedAt }: { receivedAt: string }): React.JSX.Elem
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] tabular-nums" style={{ color: 'var(--color-text-muted)' }}>
|
<span className="text-[10px] tabular-nums" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
Auto-{settings.timeoutAction} in {remaining}s
|
Auto-{settings.timeoutAction} in {formatElapsed(remaining)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -278,9 +392,18 @@ const TimeoutProgress = ({ receivedAt }: { receivedAt: string }): React.JSX.Elem
|
||||||
// Elapsed display sub-component (uses hook)
|
// Elapsed display sub-component (uses hook)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatElapsed(seconds: number): string {
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
const ElapsedDisplay = ({ receivedAt }: { receivedAt: string }): React.JSX.Element => {
|
const ElapsedDisplay = ({ receivedAt }: { receivedAt: string }): React.JSX.Element => {
|
||||||
const elapsed = useElapsed(receivedAt);
|
const elapsed = useElapsed(receivedAt);
|
||||||
return (
|
return (
|
||||||
<span className="text-[11px] tabular-nums text-[var(--color-text-muted)]">{elapsed}s</span>
|
<span className="text-[11px] tabular-nums text-[var(--color-text-muted)]">
|
||||||
|
{formatElapsed(elapsed)}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { ChevronDown, ChevronRight, Settings } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Settings } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -80,22 +87,21 @@ export const ToolApprovalSettingsPanel: React.FC = () => {
|
||||||
style={{ color: 'var(--color-text-secondary)' }}
|
style={{ color: 'var(--color-text-secondary)' }}
|
||||||
>
|
>
|
||||||
<span className="shrink-0">On timeout:</span>
|
<span className="shrink-0">On timeout:</span>
|
||||||
<select
|
<Select
|
||||||
value={settings.timeoutAction}
|
value={settings.timeoutAction}
|
||||||
onChange={(e) =>
|
onValueChange={(value) =>
|
||||||
void updateSettings({ timeoutAction: e.target.value as ToolApprovalTimeoutAction })
|
void updateSettings({ timeoutAction: value as ToolApprovalTimeoutAction })
|
||||||
}
|
}
|
||||||
className="rounded border px-1.5 py-0.5 text-xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<option value="wait">Wait forever</option>
|
<SelectTrigger className="h-7 w-[120px] text-xs">
|
||||||
<option value="allow">Allow</option>
|
<SelectValue />
|
||||||
<option value="deny">Deny</option>
|
</SelectTrigger>
|
||||||
</select>
|
<SelectContent>
|
||||||
|
<SelectItem value="wait">Wait forever</SelectItem>
|
||||||
|
<SelectItem value="allow">Allow</SelectItem>
|
||||||
|
<SelectItem value="deny">Deny</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
{settings.timeoutAction !== 'wait' && (
|
{settings.timeoutAction !== 'wait' && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
212
src/renderer/hooks/useToolApprovalDiff.ts
Normal file
212
src/renderer/hooks/useToolApprovalDiff.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
|
|
||||||
|
import type { ToolApprovalFileContent } from '@shared/types';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface ToolApprovalDiffData {
|
||||||
|
/** Whether this tool type supports diff preview */
|
||||||
|
hasDiff: boolean;
|
||||||
|
/** Loading state (file read in progress) */
|
||||||
|
loading: boolean;
|
||||||
|
/** Error message */
|
||||||
|
error: string | null;
|
||||||
|
/** File name for syntax highlighting */
|
||||||
|
fileName: string;
|
||||||
|
/** Original file content */
|
||||||
|
oldString: string;
|
||||||
|
/** New file content after tool execution */
|
||||||
|
newString: string;
|
||||||
|
/** File doesn't exist yet */
|
||||||
|
isNewFile: boolean;
|
||||||
|
/** File content was truncated at size limit */
|
||||||
|
truncated: boolean;
|
||||||
|
/** File is binary */
|
||||||
|
isBinary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFF_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
|
||||||
|
|
||||||
|
const INITIAL_STATE: ToolApprovalDiffData = {
|
||||||
|
hasDiff: false,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
fileName: '',
|
||||||
|
oldString: '',
|
||||||
|
newString: '',
|
||||||
|
isNewFile: false,
|
||||||
|
truncated: false,
|
||||||
|
isBinary: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function getFilePath(toolInput: Record<string, unknown>): string {
|
||||||
|
const fp = toolInput.file_path ?? toolInput.notebook_path;
|
||||||
|
return typeof fp === 'string' ? fp : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeEditResult(
|
||||||
|
currentContent: string,
|
||||||
|
toolInput: Record<string, unknown>
|
||||||
|
): { newString: string; error: string | null } {
|
||||||
|
const oldStr = typeof toolInput.old_string === 'string' ? toolInput.old_string : '';
|
||||||
|
const newStr = typeof toolInput.new_string === 'string' ? toolInput.new_string : '';
|
||||||
|
const replaceAll = toolInput.replace_all === true;
|
||||||
|
|
||||||
|
if (!oldStr) {
|
||||||
|
return { newString: currentContent, error: 'Edit: old_string is empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentContent.includes(oldStr)) {
|
||||||
|
return { newString: currentContent, error: 'Fragment not found in current file' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = replaceAll
|
||||||
|
? currentContent.replaceAll(oldStr, newStr)
|
||||||
|
: currentContent.replace(oldStr, newStr);
|
||||||
|
|
||||||
|
return { newString: result, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Hook
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy-loading hook that reads file content from disk and computes diff data
|
||||||
|
* for Write/Edit/NotebookEdit tool approvals.
|
||||||
|
*
|
||||||
|
* @param toolName - The tool requesting approval
|
||||||
|
* @param toolInput - The tool's input parameters
|
||||||
|
* @param requestId - Unique approval request ID (used for cancellation)
|
||||||
|
* @param enabled - Only fetch when true (lazy — user expanded the diff section)
|
||||||
|
*/
|
||||||
|
export function useToolApprovalDiff(
|
||||||
|
toolName: string,
|
||||||
|
toolInput: Record<string, unknown>,
|
||||||
|
requestId: string,
|
||||||
|
enabled: boolean
|
||||||
|
): ToolApprovalDiffData {
|
||||||
|
const [state, setState] = useState<ToolApprovalDiffData>(INITIAL_STATE);
|
||||||
|
const activeRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const hasDiff = DIFF_TOOLS.has(toolName);
|
||||||
|
const filePath = getFilePath(toolInput);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset when approval changes
|
||||||
|
setState(INITIAL_STATE);
|
||||||
|
activeRef.current = null;
|
||||||
|
}, [requestId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasDiff || !enabled || !filePath) return;
|
||||||
|
|
||||||
|
// NotebookEdit: no file read needed, just show new_source
|
||||||
|
if (toolName === 'NotebookEdit') {
|
||||||
|
const newSource = typeof toolInput.new_source === 'string' ? toolInput.new_source : '';
|
||||||
|
setState({
|
||||||
|
hasDiff: true,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
fileName: filePath,
|
||||||
|
oldString: '',
|
||||||
|
newString: newSource,
|
||||||
|
isNewFile: false,
|
||||||
|
truncated: false,
|
||||||
|
isBinary: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write / Edit: need to read current file from disk
|
||||||
|
const currentRequestId = requestId;
|
||||||
|
activeRef.current = currentRequestId;
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, hasDiff: true, loading: true, error: null }));
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
let result: ToolApprovalFileContent;
|
||||||
|
try {
|
||||||
|
result = await api.teams.readFileForToolApproval(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
if (activeRef.current !== currentRequestId) return;
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeRef.current !== currentRequestId) return;
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: result.error ?? 'Unknown read error',
|
||||||
|
fileName: filePath,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.isBinary) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
isBinary: true,
|
||||||
|
fileName: filePath,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNewFile = !result.exists;
|
||||||
|
const currentContent = result.content;
|
||||||
|
|
||||||
|
if (toolName === 'Write') {
|
||||||
|
const newContent = typeof toolInput.content === 'string' ? toolInput.content : '';
|
||||||
|
setState({
|
||||||
|
hasDiff: true,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
fileName: filePath,
|
||||||
|
oldString: currentContent,
|
||||||
|
newString: newContent,
|
||||||
|
isNewFile,
|
||||||
|
truncated: result.truncated,
|
||||||
|
isBinary: false,
|
||||||
|
});
|
||||||
|
} else if (toolName === 'Edit') {
|
||||||
|
const { newString, error } = computeEditResult(currentContent, toolInput);
|
||||||
|
setState({
|
||||||
|
hasDiff: true,
|
||||||
|
loading: false,
|
||||||
|
error,
|
||||||
|
fileName: filePath,
|
||||||
|
oldString: currentContent,
|
||||||
|
newString,
|
||||||
|
isNewFile: false,
|
||||||
|
truncated: result.truncated,
|
||||||
|
isBinary: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
activeRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- toolInput is a fresh object each render, use requestId for identity
|
||||||
|
}, [hasDiff, enabled, filePath, requestId, toolName]);
|
||||||
|
|
||||||
|
if (!hasDiff) return INITIAL_STATE;
|
||||||
|
|
||||||
|
return { ...state, hasDiff: true };
|
||||||
|
}
|
||||||
|
|
@ -1231,6 +1231,16 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draft team: team.meta.json exists but config.json doesn't (provisioning failed)
|
||||||
|
if (msg === 'TEAM_DRAFT' || msg.includes('TEAM_DRAFT')) {
|
||||||
|
set({
|
||||||
|
selectedTeamLoading: false,
|
||||||
|
selectedTeamData: null,
|
||||||
|
selectedTeamError: 'TEAM_DRAFT',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
error instanceof IpcError
|
error instanceof IpcError
|
||||||
? error.message
|
? error.message
|
||||||
|
|
@ -1299,6 +1309,15 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg === 'TEAM_DRAFT' || msg.includes('TEAM_DRAFT')) {
|
||||||
|
set({
|
||||||
|
selectedTeamLoading: false,
|
||||||
|
selectedTeamData: null,
|
||||||
|
selectedTeamError: 'TEAM_DRAFT',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`);
|
logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`);
|
||||||
|
|
||||||
// Non-destructive: if we already have data, keep it visible.
|
// Non-destructive: if we already have data, keep it visible.
|
||||||
|
|
@ -2061,9 +2080,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
(a) => !(a.runId === runId && a.requestId === requestId)
|
(a) => !(a.runId === runId && a.requestId === requestId)
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch (err) {
|
||||||
// IPC failed — approval stays in UI, user can retry
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
// Do NOT modify pendingApprovals — nothing was removed, nothing to rollback
|
logger.error(`respondToToolApproval failed for ${teamName}/${requestId}: ${msg}`);
|
||||||
|
// Surface the error so ToolApprovalSheet can show feedback
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ import type {
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamUpdateConfigRequest,
|
TeamUpdateConfigRequest,
|
||||||
ToolApprovalEvent,
|
ToolApprovalEvent,
|
||||||
|
ToolApprovalFileContent,
|
||||||
ToolApprovalSettings,
|
ToolApprovalSettings,
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
} from './team';
|
} from './team';
|
||||||
|
|
@ -419,6 +420,8 @@ export interface TeamsAPI {
|
||||||
deleteTeam: (teamName: string) => Promise<void>;
|
deleteTeam: (teamName: string) => Promise<void>;
|
||||||
restoreTeam: (teamName: string) => Promise<void>;
|
restoreTeam: (teamName: string) => Promise<void>;
|
||||||
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
|
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
|
||||||
|
getSavedRequest: (teamName: string) => Promise<TeamCreateRequest | null>;
|
||||||
|
deleteDraft: (teamName: string) => Promise<void>;
|
||||||
prepareProvisioning: (cwd?: string) => Promise<TeamProvisioningPrepareResult>;
|
prepareProvisioning: (cwd?: string) => Promise<TeamProvisioningPrepareResult>;
|
||||||
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
|
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
|
||||||
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
||||||
|
|
@ -536,6 +539,7 @@ export interface TeamsAPI {
|
||||||
validateCliArgs: (rawArgs: string) => Promise<CliArgsValidationResult>;
|
validateCliArgs: (rawArgs: string) => Promise<CliArgsValidationResult>;
|
||||||
onToolApprovalEvent: (callback: (event: unknown, data: ToolApprovalEvent) => void) => () => void;
|
onToolApprovalEvent: (callback: (event: unknown, data: ToolApprovalEvent) => void) => () => void;
|
||||||
updateToolApprovalSettings: (settings: ToolApprovalSettings) => Promise<void>;
|
updateToolApprovalSettings: (settings: ToolApprovalSettings) => Promise<void>;
|
||||||
|
readFileForToolApproval: (filePath: string) => Promise<ToolApprovalFileContent>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ export interface TeamSummary {
|
||||||
sessionHistory?: string[];
|
sessionHistory?: string[];
|
||||||
/** Propagated from config.deletedAt — set when the team has been soft-deleted. */
|
/** Propagated from config.deletedAt — set when the team has been soft-deleted. */
|
||||||
deletedAt?: string;
|
deletedAt?: string;
|
||||||
|
/** True when team.meta.json exists but config.json doesn't — provisioning failed before TeamCreate. */
|
||||||
|
pendingCreate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
|
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
|
||||||
|
|
@ -803,6 +805,10 @@ export interface ToolApprovalRequest {
|
||||||
toolInput: Record<string, unknown>;
|
toolInput: Record<string, unknown>;
|
||||||
/** ISO timestamp when the request was received. */
|
/** ISO timestamp when the request was received. */
|
||||||
receivedAt: string;
|
receivedAt: string;
|
||||||
|
/** Team color name (from config or create request) for badge rendering. */
|
||||||
|
teamColor?: string;
|
||||||
|
/** Team display name (from config or create request). */
|
||||||
|
teamDisplayName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dismissal event — process died, all pending approvals for this team+run should be removed. */
|
/** Dismissal event — process died, all pending approvals for this team+run should be removed. */
|
||||||
|
|
@ -853,3 +859,12 @@ export type ToolApprovalEvent =
|
||||||
| ToolApprovalRequest
|
| ToolApprovalRequest
|
||||||
| ToolApprovalDismiss
|
| ToolApprovalDismiss
|
||||||
| ToolApprovalAutoResolved;
|
| ToolApprovalAutoResolved;
|
||||||
|
|
||||||
|
/** Result of reading a file for tool approval diff preview. */
|
||||||
|
export interface ToolApprovalFileContent {
|
||||||
|
content: string;
|
||||||
|
exists: boolean;
|
||||||
|
truncated: boolean;
|
||||||
|
isBinary: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue