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:
iliya 2026-03-20 18:41:01 +02:00
parent 6d066831eb
commit a6fc7f82d3
12 changed files with 895 additions and 100 deletions

View file

@ -1,6 +1,6 @@
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
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 {
TEAM_ADD_MEMBER,
@ -48,8 +48,11 @@ import {
TEAM_SOFT_DELETE_TASK,
TEAM_START_TASK,
TEAM_STOP,
TEAM_TOOL_APPROVAL_READ_FILE,
TEAM_TOOL_APPROVAL_RESPOND,
TEAM_TOOL_APPROVAL_SETTINGS,
TEAM_GET_SAVED_REQUEST,
TEAM_DELETE_DRAFT,
TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
@ -84,6 +87,8 @@ import {
isAgentActionMode,
} from '../services/team/actionModeInstructions';
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 { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
@ -141,6 +146,7 @@ import type {
TeamTask,
TeamTaskStatus,
TeamUpdateConfigRequest,
ToolApprovalFileContent,
ToolApprovalSettings,
UpdateKanbanPatch,
} from '@shared/types';
@ -261,6 +267,7 @@ let teamBackupService: TeamBackupService | null = null;
const attachmentStore = new TeamAttachmentStore();
const taskAttachmentStore = new TeamTaskAttachmentStore();
const teamMetaStore = new TeamMetaStore();
const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
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_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment);
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_TOOL_APPROVAL_SETTINGS, handleToolApprovalSettings);
ipcMain.handle(TEAM_GET_SAVED_REQUEST, handleGetSavedRequest);
ipcMain.handle(TEAM_DELETE_DRAFT, handleDeleteDraft);
logger.info('Team handlers registered');
}
@ -398,8 +408,11 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT);
ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT);
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND);
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_READ_FILE);
ipcMain.removeHandler(TEAM_VALIDATE_CLI_ARGS);
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_SETTINGS);
ipcMain.removeHandler(TEAM_GET_SAVED_REQUEST);
ipcMain.removeHandler(TEAM_DELETE_DRAFT);
}
function getTeamDataService(): TeamDataService {
@ -482,6 +495,13 @@ async function handleGetData(
) {
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}`);
return { success: false, error: message };
}
@ -907,6 +927,58 @@ async function handleLaunchTeam(
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', () =>
getTeamProvisioningService().launchTeam(
{
@ -2723,3 +2795,137 @@ async function handleToolApprovalSettings(
}
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!);
});
}

View file

@ -54,6 +54,7 @@ import { withFileLock } from './fileLock';
import { withInboxLock } from './inboxLock';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamMetaStore } from './TeamMetaStore';
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
@ -908,21 +909,21 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, leadName)
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.
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.
Goal: Provision 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.
Goal: Create and provision a NEW Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}.
The team does NOT exist yet no config, no state, nothing. Step 1 is MANDATORY.
${userPromptBlock}
${persistentContext}
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}
@ -1263,7 +1264,8 @@ export class TeamProvisioningService {
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
_sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(),
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder()
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder(),
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore()
) {}
setCrossTeamSender(
@ -2438,6 +2440,10 @@ export class TeamProvisioningService {
if (silenceMs < STALL_WARNING_THRESHOLD_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;
const silenceSec = Math.round(silenceMs / 1000);
@ -2846,7 +2852,7 @@ export class TeamProvisioningService {
waitingTasksSince: null,
provisioningComplete: false,
isLaunch: false,
fsPhase: 'waiting_members',
fsPhase: 'waiting_config',
leadRelayCapture: null,
activeCrossTeamReplyHints: [],
leadMsgSeq: 0,
@ -2905,7 +2911,7 @@ export class TeamProvisioningService {
'--mcp-config',
mcpConfigPath,
'--disallowedTools',
'TeamDelete,TodoWrite,TeamCreate',
'TeamDelete,TodoWrite',
// Explicit --permission-mode overrides user's defaultMode in ~/.claude/settings.json
// (e.g. "acceptEdits") which otherwise takes precedence over CLI flags
...(request.skipPermissions !== false
@ -2917,14 +2923,46 @@ export class TeamProvisioningService {
...parseCliArgs(request.extraCliArgs),
];
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, {
cwd: request.cwd,
env: { ...shellEnv },
stdio: ['pipe', 'pipe', 'pipe'],
});
} 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 tasksDir = path.join(getTasksBasePath(), request.teamName);
await fs.promises.rm(teamDir, { recursive: true, force: true }).catch(() => {});
@ -2936,7 +2974,6 @@ export class TeamProvisioningService {
updateProgress(run, 'spawning', 'Starting Claude CLI process', {
pid: child.pid ?? undefined,
configReady: true,
});
run.onProgress(run.progress);
run.child = child;
@ -5124,12 +5161,17 @@ export class TeamProvisioningService {
const configLead = config.members.find((m) => isLeadAgentType(m?.agentType));
leadName = configLead?.name?.trim() || 'team-lead';
// Convert config members (excluding lead) to TeamCreateRequest member format.
currentMembers = config.members
const configTeammates = config.members
.filter((m) => !isLeadAgentType(m?.agentType) && m?.name)
.map((m) => ({
name: m.name,
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 {
leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
@ -5266,6 +5308,8 @@ export class TeamProvisioningService {
toolName,
toolInput,
receivedAt: new Date().toISOString(),
teamColor: run.request.color,
teamDisplayName: run.request.displayName,
};
// Check auto-allow rules before prompting user
@ -5445,7 +5489,8 @@ export class TeamProvisioningService {
allow: boolean,
message?: string
): 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}"`);
const run = this.runs.get(currentRunId);
if (!run) throw new Error(`Run not found for team "${teamName}"`);
@ -5494,13 +5539,25 @@ export class TeamProvisioningService {
};
const stdin = run.child.stdin;
const responseJson = JSON.stringify(response) + '\n';
logger.info(
`[${teamName}] Writing control_response for ${requestId}: ${allow ? 'allow' : 'deny'}`
);
try {
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) {
logger.error(`[${teamName}] Failed to write control_response: ${err.message}`);
reject(err);
} else {
logger.info(`[${teamName}] control_response written successfully for ${requestId}`);
resolve();
}
});
@ -5692,6 +5749,9 @@ export class TeamProvisioningService {
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.
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> {
const teammateMembers = request.members.filter((member) => {
const trimmed = member.name.trim();

View file

@ -373,6 +373,12 @@ export const TEAM_VALIDATE_CLI_ARGS = 'team:validateCliArgs';
/** Invoke: update tool approval settings (renderer → main) */
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
// =============================================================================

View file

@ -132,6 +132,8 @@ import {
TEAM_LIST,
TEAM_MEMBER_SPAWN_STATUSES,
TEAM_PERMANENTLY_DELETE,
TEAM_GET_SAVED_REQUEST,
TEAM_DELETE_DRAFT,
TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE,
TEAM_PROCESS_SEND,
@ -151,6 +153,7 @@ import {
TEAM_START_TASK,
TEAM_STOP,
TEAM_TOOL_APPROVAL_EVENT,
TEAM_TOOL_APPROVAL_READ_FILE,
TEAM_TOOL_APPROVAL_RESPOND,
TEAM_TOOL_APPROVAL_SETTINGS,
TEAM_UPDATE_CONFIG,
@ -275,6 +278,7 @@ import type {
TeamTaskStatus,
TeamUpdateConfigRequest,
ToolApprovalEvent,
ToolApprovalFileContent,
ToolApprovalSettings,
TriggerTestResult,
UpdateKanbanPatch,
@ -806,6 +810,12 @@ const electronAPI: ElectronAPI = {
permanentlyDeleteTeam: async (teamName: string) => {
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) => {
return invokeIpcWithResult<TeamProvisioningPrepareResult>(TEAM_PREPARE_PROVISIONING, cwd);
},
@ -1093,6 +1103,9 @@ const electronAPI: ElectronAPI = {
updateToolApprovalSettings: async (settings: ToolApprovalSettings) => {
return invokeIpcWithResult<void>(TEAM_TOOL_APPROVAL_SETTINGS, settings);
},
readFileForToolApproval: async (filePath: string) => {
return invokeIpcWithResult<ToolApprovalFileContent>(TEAM_TOOL_APPROVAL_READ_FILE, filePath);
},
},
crossTeam: {
send: async (request: CrossTeamSendRequest) => {

View file

@ -684,6 +684,13 @@ export class HttpAPIClient implements ElectronAPI {
permanentlyDeleteTeam: async (_teamName: string): Promise<void> => {
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> => {
throw new Error('Team provisioning is not available in browser mode');
},
@ -916,6 +923,9 @@ export class HttpAPIClient implements ElectronAPI {
updateToolApprovalSettings: async (): Promise<void> => {
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

View 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>
);
};

View file

@ -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 { useTheme } from '@renderer/hooks/useTheme';
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 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
// ---------------------------------------------------------------------------
@ -78,25 +98,53 @@ function useElapsed(receivedAt: string): number {
// 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 = () => {
const pendingApprovals = useStore((s) => s.pendingApprovals);
const respondToToolApproval = useStore((s) => s.respondToToolApproval);
const teams = useStore((s) => s.teams);
const selectedTeamName = useStore((s) => s.selectedTeamName);
const { isLight } = useTheme();
const current: ToolApprovalRequest | undefined = pendingApprovals[0];
const containerRef = useRef<HTMLDivElement>(null);
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(
(allow: boolean) => {
if (!current || disabled) return;
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);
}
);
})
.catch((err: unknown) => {
clearTimeout(safetyTimer);
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
setDisabled(false);
});
},
[current, disabled, respondToToolApproval]
);
@ -120,13 +168,17 @@ export const ToolApprovalSheet: React.FC = () => {
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 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 (
<div
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={{
backgroundColor: 'var(--color-surface-overlay)',
borderColor: 'var(--color-border-emphasis)',
@ -144,37 +196,59 @@ export const ToolApprovalSheet: React.FC = () => {
</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: getThemedBadge(teamColor, isLight),
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>
)}
{selectedTeamName !== current.teamName &&
(teamColor ? (
<span
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: getThemedBadge(teamColor, isLight),
color: teamColor.text,
border: `1px solid ${teamColor.border}`,
}}
>
{displayName}
</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} />
</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"
{/* Tool input preview (syntax-highlighted) */}
<ToolInputPreview toolName={current.toolName} toolInput={current.toolInput} />
{/* 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={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
backgroundColor: 'rgba(239, 68, 68, 0.08)',
borderColor: 'rgba(239, 68, 68, 0.25)',
color: 'rgb(248, 113, 113)',
}}
>
{renderToolInput(current.toolName, current.toolInput)}
</pre>
</div>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="break-words">{error}</span>
</div>
)}
{/* Actions */}
<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 || '&nbsp;' }}
/>
))}
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Timeout progress bar sub-component
// ---------------------------------------------------------------------------
@ -268,7 +382,7 @@ const TimeoutProgress = ({ receivedAt }: { receivedAt: string }): React.JSX.Elem
/>
</div>
<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>
</div>
);
@ -278,9 +392,18 @@ const TimeoutProgress = ({ receivedAt }: { receivedAt: string }): React.JSX.Elem
// 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 elapsed = useElapsed(receivedAt);
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>
);
};

View file

@ -1,6 +1,13 @@
import React, { useState } from 'react';
import { Checkbox } from '@renderer/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import { ChevronDown, ChevronRight, Settings } from 'lucide-react';
@ -80,22 +87,21 @@ export const ToolApprovalSettingsPanel: React.FC = () => {
style={{ color: 'var(--color-text-secondary)' }}
>
<span className="shrink-0">On timeout:</span>
<select
<Select
value={settings.timeoutAction}
onChange={(e) =>
void updateSettings({ timeoutAction: e.target.value as ToolApprovalTimeoutAction })
onValueChange={(value) =>
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>
<option value="allow">Allow</option>
<option value="deny">Deny</option>
</select>
<SelectTrigger className="h-7 w-[120px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="wait">Wait forever</SelectItem>
<SelectItem value="allow">Allow</SelectItem>
<SelectItem value="deny">Deny</SelectItem>
</SelectContent>
</Select>
{settings.timeoutAction !== 'wait' && (
<>

View 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 };
}

View file

@ -1231,6 +1231,16 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
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 =
error instanceof IpcError
? error.message
@ -1299,6 +1309,15 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return;
}
if (msg === 'TEAM_DRAFT' || msg.includes('TEAM_DRAFT')) {
set({
selectedTeamLoading: false,
selectedTeamData: null,
selectedTeamError: 'TEAM_DRAFT',
});
return;
}
logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`);
// 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)
),
}));
} catch {
// IPC failed — approval stays in UI, user can retry
// Do NOT modify pendingApprovals — nothing was removed, nothing to rollback
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.error(`respondToToolApproval failed for ${teamName}/${requestId}: ${msg}`);
// Surface the error so ToolApprovalSheet can show feedback
throw err;
}
},

View file

@ -74,6 +74,7 @@ import type {
TeamTaskStatus,
TeamUpdateConfigRequest,
ToolApprovalEvent,
ToolApprovalFileContent,
ToolApprovalSettings,
UpdateKanbanPatch,
} from './team';
@ -419,6 +420,8 @@ export interface TeamsAPI {
deleteTeam: (teamName: string) => Promise<void>;
restoreTeam: (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>;
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
@ -536,6 +539,7 @@ export interface TeamsAPI {
validateCliArgs: (rawArgs: string) => Promise<CliArgsValidationResult>;
onToolApprovalEvent: (callback: (event: unknown, data: ToolApprovalEvent) => void) => () => void;
updateToolApprovalSettings: (settings: ToolApprovalSettings) => Promise<void>;
readFileForToolApproval: (filePath: string) => Promise<ToolApprovalFileContent>;
}
// =============================================================================

View file

@ -53,6 +53,8 @@ export interface TeamSummary {
sessionHistory?: string[];
/** Propagated from config.deletedAt — set when the team has been soft-deleted. */
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';
@ -803,6 +805,10 @@ export interface ToolApprovalRequest {
toolInput: Record<string, unknown>;
/** ISO timestamp when the request was received. */
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. */
@ -853,3 +859,12 @@ export type ToolApprovalEvent =
| ToolApprovalRequest
| ToolApprovalDismiss
| ToolApprovalAutoResolved;
/** Result of reading a file for tool approval diff preview. */
export interface ToolApprovalFileContent {
content: string;
exists: boolean;
truncated: boolean;
isBinary: boolean;
error?: string;
}