feat: enhance team message handling and UI components
- Updated `dev:kill` script to use a dedicated Node.js script for improved process termination. - Enhanced `TeamProvisioningService` to trigger team refresh events for live lead replies, improving message handling. - Refactored message deduplication logic in `handleGetData` to prevent duplicate messages from lead sessions and lead processes. - Introduced `validateOpenPathUserSelected` function to allow user-selected paths while enforcing security checks. - Improved UI components in `TeamListView` and `ActivityItem` for better user experience and accessibility. - Added progress bar for task completion in `DashboardView`, enhancing task tracking visibility.
This commit is contained in:
parent
4fdfabd5f1
commit
a6eabc840c
22 changed files with 469 additions and 57 deletions
|
|
@ -5,9 +5,7 @@
|
|||
<h1 align="center">Claude Agent Teams UI</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong><code>Terminal tells you nothing. This shows you everything.</code></strong>
|
||||
<br />
|
||||
You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other's code. You just look at the kanban board and drink coffee.
|
||||
<strong><code>You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other's code. You just look at the kanban board and drink coffee.</code></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
|
|
|||
20
bin/kill-dev.js
Normal file
20
bin/kill-dev.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
if (isWindows) {
|
||||
const r = spawnSync('taskkill', ['/F', '/IM', 'electron.exe'], {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
});
|
||||
if (r.status != null && r.status !== 0 && r.status !== 128 && r.signal == null) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
const r = spawnSync('pkill', ['-f', 'electron-vite|electron \\.'], { stdio: 'inherit' });
|
||||
if (r.status != null && r.status !== 0 && r.status !== 1 && r.signal == null) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
console.log('Done');
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
"main": "dist-electron/main/index.cjs",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:kill": "pkill -f 'electron-vite|electron \\.' 2>/dev/null; echo 'Done'",
|
||||
"dev:kill": "node bin/kill-dev.js",
|
||||
"build": "electron-vite build",
|
||||
"dist": "electron-builder --mac --win --linux",
|
||||
"dist:mac": "electron-builder --mac --publish always",
|
||||
|
|
|
|||
|
|
@ -311,6 +311,14 @@ function initializeServices(): void {
|
|||
void new TeamAgentToolsInstaller().ensureInstalled();
|
||||
httpServer = new HttpServer();
|
||||
|
||||
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
|
||||
teamProvisioningService.setTeamChangeEmitter((event) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(TEAM_CHANGE, event);
|
||||
}
|
||||
httpServer?.broadcast('team-change', event);
|
||||
});
|
||||
|
||||
// Initialize IPC handlers with registry
|
||||
initializeIpcHandlers(
|
||||
contextRegistry,
|
||||
|
|
|
|||
|
|
@ -182,16 +182,58 @@ async function handleGetData(
|
|||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
return wrapTeamHandler('getData', async () => {
|
||||
const data = await getTeamDataService().getTeamData(validated.value!);
|
||||
const isAlive = getTeamProvisioningService().isTeamAlive(validated.value!);
|
||||
const tn = validated.value!;
|
||||
const data = await getTeamDataService().getTeamData(tn);
|
||||
const provisioning = getTeamProvisioningService();
|
||||
const isAlive = provisioning.isTeamAlive(tn);
|
||||
|
||||
if (isAlive) {
|
||||
try {
|
||||
await getTeamProvisioningService().relayLeadInboxMessages(validated.value!);
|
||||
} catch {
|
||||
// Best-effort: never fail getData due to relay issues
|
||||
}
|
||||
// Fire-and-forget: relay can take time (waits for lead reply).
|
||||
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
|
||||
}
|
||||
|
||||
const live = provisioning.getLiveLeadProcessMessages(tn);
|
||||
if (live.length === 0) {
|
||||
return { ...data, isAlive };
|
||||
}
|
||||
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const leadSessionTextFingerprints = new Set<string>();
|
||||
for (const msg of data.messages) {
|
||||
if ((msg as { source?: unknown }).source !== 'lead_session') continue;
|
||||
if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
|
||||
leadSessionTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`);
|
||||
}
|
||||
|
||||
const keyFor = (m: {
|
||||
messageId?: string;
|
||||
timestamp: string;
|
||||
from: string;
|
||||
text: string;
|
||||
}): string => {
|
||||
if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) {
|
||||
return m.messageId;
|
||||
}
|
||||
return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`;
|
||||
};
|
||||
|
||||
const merged: typeof data.messages = [];
|
||||
const seen = new Set<string>();
|
||||
for (const msg of [...data.messages, ...live]) {
|
||||
if ((msg as { source?: unknown }).source === 'lead_process') {
|
||||
const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
||||
if (leadSessionTextFingerprints.has(fp)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const key = keyFor(msg);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(msg);
|
||||
}
|
||||
merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||
|
||||
return { ...data, isAlive, messages: merged };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -242,7 +284,9 @@ async function handleUpdateConfig(
|
|||
}
|
||||
|
||||
function isProvisioningTeamName(teamName: string): boolean {
|
||||
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(teamName) && teamName.length <= 64;
|
||||
if (teamName.length > 64) return false;
|
||||
const parts = teamName.split('-');
|
||||
return parts.every((p) => /^[a-z0-9]+$/.test(p));
|
||||
}
|
||||
|
||||
async function validateProvisioningRequest(
|
||||
|
|
|
|||
|
|
@ -12,12 +12,21 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
|
||||
import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
|
||||
import {
|
||||
type ClaudeMdFileInfo,
|
||||
readAgentConfigs,
|
||||
readAllClaudeMdFiles,
|
||||
readDirectoryClaudeMd,
|
||||
} from '../services';
|
||||
|
||||
import type { AgentConfig } from '@shared/types/api';
|
||||
|
||||
const logger = createLogger('IPC:utility');
|
||||
import { validateFilePath, validateOpenPath } from '../utils/pathValidation';
|
||||
import {
|
||||
validateFilePath,
|
||||
validateOpenPath,
|
||||
validateOpenPathUserSelected,
|
||||
} from '../utils/pathValidation';
|
||||
import { countTokens } from '../utils/tokenizer';
|
||||
|
||||
/**
|
||||
|
|
@ -96,15 +105,19 @@ async function handleShellOpenExternal(
|
|||
* Handler for 'shell:openPath' IPC call.
|
||||
* Opens a folder or file in the system's default application (Finder on macOS).
|
||||
* Validates path security before opening.
|
||||
* When userSelectedFromDialog is true, path was chosen via system folder picker —
|
||||
* only sensitive-pattern checks apply, not project/claude directory restriction.
|
||||
*/
|
||||
async function handleShellOpenPath(
|
||||
_event: IpcMainInvokeEvent,
|
||||
targetPath: string,
|
||||
projectRoot?: string
|
||||
projectRoot?: string,
|
||||
userSelectedFromDialog?: boolean
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Validate path security
|
||||
const validation = validateOpenPath(targetPath, projectRoot ?? null);
|
||||
const validation = userSelectedFromDialog
|
||||
? validateOpenPathUserSelected(targetPath)
|
||||
: validateOpenPath(targetPath, projectRoot ?? null);
|
||||
if (!validation.valid) {
|
||||
logger.error(`shell:openPath - validation failed: ${validation.error ?? 'Unknown error'}`);
|
||||
return { success: false, error: validation.error };
|
||||
|
|
|
|||
|
|
@ -469,7 +469,7 @@ export class TeamDataService {
|
|||
|
||||
for (const msg of messages) {
|
||||
if (!msg.messageId || !msg.summary || msg.from === 'user') continue;
|
||||
if (msg.source === 'lead_session') continue;
|
||||
if (msg.source === 'lead_session' || msg.source === 'lead_process') continue;
|
||||
|
||||
const textKey = `${msg.from}\0${msg.text}`;
|
||||
if (processedTexts.has(textKey)) continue;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import { TeamInboxReader } from './TeamInboxReader';
|
|||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
|
||||
import type {
|
||||
InboxMessage,
|
||||
TeamChangeEvent,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamLaunchRequest,
|
||||
|
|
@ -107,6 +109,14 @@ interface ProvisioningRun {
|
|||
waitingTasksSince: number | null;
|
||||
provisioningComplete: boolean;
|
||||
isLaunch: boolean;
|
||||
leadRelayCapture: {
|
||||
leadName: string;
|
||||
startedAt: string;
|
||||
textParts: string[];
|
||||
resolve: (text: string) => void;
|
||||
reject: (error: string) => void;
|
||||
timeoutHandle: NodeJS.Timeout;
|
||||
} | null;
|
||||
}
|
||||
|
||||
type ProvisioningAuthSource =
|
||||
|
|
@ -242,18 +252,18 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string {
|
|||
function buildTaskStatusProtocol(teamName: string): string {
|
||||
return `MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
|
||||
1. Use this command to mark task started:
|
||||
node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task start <taskId>
|
||||
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start <taskId>
|
||||
2. Use this command to mark task completed BEFORE sending your final reply:
|
||||
node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task complete <taskId>
|
||||
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task complete <taskId>
|
||||
3. If you are asked to review and task is accepted, move it to APPROVED (not DONE):
|
||||
node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" review approve <taskId>
|
||||
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review approve <taskId>
|
||||
4. If review fails and changes are needed:
|
||||
node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" review request-changes <taskId> --comment \\"<what to fix>\\"
|
||||
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review request-changes <taskId> --comment "<what to fix>"
|
||||
5. NEVER skip status updates. A task is NOT done until completed status is written.
|
||||
6. To reply to a comment on a task:
|
||||
node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task comment <taskId> --text \\"<your reply>\\" --from \\"<your-name>\\"
|
||||
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment <taskId> --text "<your reply>" --from "<your-name>"
|
||||
7. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment:
|
||||
node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task comment <taskId> --text \\"<summary of your finding or decision>\\" --from \\"<your-name>\\"
|
||||
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment <taskId> --text "<summary of your finding or decision>" --from "<your-name>"
|
||||
Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task.
|
||||
8. When sending a message about a specific task, include #<taskId> in your SendMessage summary field for traceability.
|
||||
Failure to follow this protocol means the task board will show incorrect status.`;
|
||||
|
|
@ -278,11 +288,19 @@ Goal: Provision a Claude Code agent team with live teammates.
|
|||
${userPromptBlock}
|
||||
Constraints:
|
||||
- Do NOT call TeamDelete under any circumstances.
|
||||
- Do NOT use TodoWrite — use TaskCreate for tasks.
|
||||
- Do NOT use TodoWrite.
|
||||
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
|
||||
- Do NOT shut down, terminate, or clean up the team or its members.
|
||||
- Keep assistant text minimal.
|
||||
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
|
||||
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
|
||||
- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).
|
||||
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
|
||||
|
||||
Communication protocol (CRITICAL — you are running headless, no one sees your text output):
|
||||
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
|
||||
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
|
||||
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
|
||||
|
||||
Task board operations — use teamctl.js via Bash:
|
||||
- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}"
|
||||
|
|
@ -297,11 +315,16 @@ Steps (execute in this exact order):
|
|||
- team_name: "${request.teamName}"
|
||||
- name: the member's name
|
||||
- subagent_type: "general-purpose"
|
||||
- prompt: "You are {name}, a {role} on team \\"${displayName}\\" (${request.teamName}). Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale. Then wait for task assignments.
|
||||
- prompt:
|
||||
You are {name}, a {role} on team "${displayName}" (${request.teamName}).
|
||||
Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale.
|
||||
Then wait for task assignments.
|
||||
|
||||
${taskProtocol}"
|
||||
${taskProtocol}
|
||||
|
||||
3) If user instructions above mention tasks or work for members — create each task via teamctl.js (see "Task board operations"). The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.
|
||||
3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks via teamctl.js (see "Task board operations").
|
||||
- Prefer fewer, broader tasks over many micro-tasks.
|
||||
- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.
|
||||
|
||||
4) After all steps, output a short summary.
|
||||
|
||||
|
|
@ -329,11 +352,19 @@ Goal: Reconnect with existing team "${request.teamName}".
|
|||
${userPromptBlock}
|
||||
Constraints:
|
||||
- Do NOT call TeamDelete under any circumstances.
|
||||
- Do NOT use TodoWrite — use TaskCreate for tasks.
|
||||
- Do NOT use TodoWrite.
|
||||
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
|
||||
- Do NOT shut down, terminate, or clean up the team or its members.
|
||||
- Keep assistant text minimal.
|
||||
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
|
||||
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
|
||||
- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).
|
||||
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
|
||||
|
||||
Communication protocol (CRITICAL — you are running headless, no one sees your text output):
|
||||
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
|
||||
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
|
||||
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
|
||||
|
||||
Task board operations — use teamctl.js via Bash:
|
||||
- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}"
|
||||
|
|
@ -343,17 +374,22 @@ Steps (execute in this exact order):
|
|||
|
||||
1) Read team config at ~/.claude/teams/${request.teamName}/config.json — understand current team state.
|
||||
|
||||
2) Read the task list via TaskList — understand pending work.
|
||||
2) Read tasks from ~/.claude/tasks/${request.teamName}/ (JSON files) and kanban state from ~/.claude/teams/${request.teamName}/kanban-state.json — understand pending work.
|
||||
|
||||
3) Spawn each existing member as a live teammate using the Task tool:
|
||||
- team_name: "${request.teamName}"
|
||||
- name: the member's name
|
||||
- subagent_type: "general-purpose"
|
||||
- prompt: "You are {name}, a {role} on team \\"${request.teamName}\\". The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale. Then check TaskList for pending work and resume.
|
||||
- prompt:
|
||||
You are {name}, a {role} on team "${request.teamName}".
|
||||
The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale.
|
||||
Then resume any pending work you own (if any) and wait for new assignments.
|
||||
|
||||
${taskProtocol}"
|
||||
${taskProtocol}
|
||||
|
||||
4) If user instructions above mention tasks or work for members — create each task via teamctl.js (see "Task board operations"). The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.
|
||||
4) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks via teamctl.js (see "Task board operations").
|
||||
- Prefer fewer, broader tasks over many micro-tasks.
|
||||
- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.
|
||||
|
||||
5) After all steps, output a short summary.
|
||||
|
||||
|
|
@ -449,6 +485,8 @@ export class TeamProvisioningService {
|
|||
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
|
||||
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
|
||||
private readonly relayedLeadInboxFallbackKeys = new Map<string, Set<string>>();
|
||||
private readonly liveLeadProcessMessages = new Map<string, InboxMessage[]>();
|
||||
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||
|
|
@ -456,6 +494,14 @@ export class TeamProvisioningService {
|
|||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
|
||||
) {}
|
||||
|
||||
setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void {
|
||||
this.teamChangeEmitter = emitter;
|
||||
}
|
||||
|
||||
getLiveLeadProcessMessages(teamName: string): InboxMessage[] {
|
||||
return [...(this.liveLeadProcessMessages.get(teamName) ?? [])];
|
||||
}
|
||||
|
||||
async warmup(): Promise<void> {
|
||||
try {
|
||||
const claudePath = await ClaudeBinaryResolver.resolve();
|
||||
|
|
@ -611,6 +657,7 @@ export class TeamProvisioningService {
|
|||
provisioningComplete: false,
|
||||
isLaunch: false,
|
||||
fsPhase: 'waiting_config',
|
||||
leadRelayCapture: null,
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -878,6 +925,7 @@ export class TeamProvisioningService {
|
|||
provisioningComplete: false,
|
||||
isLaunch: true,
|
||||
fsPhase: 'waiting_members',
|
||||
leadRelayCapture: null,
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -1177,9 +1225,28 @@ export class TeamProvisioningService {
|
|||
}),
|
||||
].join('\n');
|
||||
|
||||
const captureTimeoutMs = 60_000;
|
||||
const capturePromise = new Promise<string>((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
reject(new Error('Timed out waiting for lead reply'));
|
||||
}, captureTimeoutMs);
|
||||
run.leadRelayCapture = {
|
||||
leadName,
|
||||
startedAt: nowIso(),
|
||||
textParts: [],
|
||||
resolve,
|
||||
reject,
|
||||
timeoutHandle,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await this.sendMessageToTeam(teamName, message);
|
||||
} catch {
|
||||
if (run.leadRelayCapture) {
|
||||
clearTimeout(run.leadRelayCapture.timeoutHandle);
|
||||
run.leadRelayCapture = null;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
@ -1199,6 +1266,36 @@ export class TeamProvisioningService {
|
|||
// Best-effort: relay succeeded; marking read failed.
|
||||
}
|
||||
|
||||
let replyText: string | null = null;
|
||||
try {
|
||||
replyText = (await capturePromise).trim() || null;
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
if (run.leadRelayCapture) {
|
||||
clearTimeout(run.leadRelayCapture.timeoutHandle);
|
||||
run.leadRelayCapture = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (replyText) {
|
||||
this.pushLiveLeadProcessMessage(teamName, {
|
||||
from: leadName,
|
||||
to: 'user',
|
||||
text: replyText,
|
||||
timestamp: nowIso(),
|
||||
read: true,
|
||||
summary: 'Lead reply',
|
||||
messageId: `lead-process-${runId}-${Date.now()}`,
|
||||
source: 'lead_process',
|
||||
});
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'inbox',
|
||||
teamName,
|
||||
detail: 'lead-process-reply',
|
||||
});
|
||||
}
|
||||
|
||||
return batch.length;
|
||||
})();
|
||||
|
||||
|
|
@ -1297,13 +1394,23 @@ export class TeamProvisioningService {
|
|||
return next;
|
||||
}
|
||||
|
||||
private pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
|
||||
const MAX = 100;
|
||||
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
|
||||
list.push(message);
|
||||
if (list.length > MAX) {
|
||||
list.splice(0, list.length - MAX);
|
||||
}
|
||||
this.liveLeadProcessMessages.set(teamName, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the running process for a team. No-op if team is not running.
|
||||
*/
|
||||
stopTeam(teamName: string): void {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
if (!runId) {
|
||||
throw new Error(`No active process for team "${teamName}"`);
|
||||
return;
|
||||
}
|
||||
const run = this.runs.get(runId);
|
||||
if (!run) {
|
||||
|
|
@ -1336,6 +1443,9 @@ export class TeamProvisioningService {
|
|||
if (textParts.length > 0) {
|
||||
const text = textParts.join('');
|
||||
logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`);
|
||||
if (run.leadRelayCapture) {
|
||||
run.leadRelayCapture.textParts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1343,6 +1453,11 @@ export class TeamProvisioningService {
|
|||
const subtype = msg.subtype as string | undefined;
|
||||
if (subtype === 'success') {
|
||||
logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`);
|
||||
if (run.leadRelayCapture) {
|
||||
const capture = run.leadRelayCapture;
|
||||
const combined = capture.textParts.join('').trim();
|
||||
capture.resolve(combined);
|
||||
}
|
||||
if (!run.provisioningComplete) {
|
||||
void this.handleProvisioningTurnComplete(run);
|
||||
}
|
||||
|
|
@ -1350,6 +1465,9 @@ export class TeamProvisioningService {
|
|||
const errorMsg =
|
||||
typeof msg.error === 'string' ? msg.error : JSON.stringify(msg.error ?? 'unknown');
|
||||
logger.warn(`[${run.teamName}] stream-json result: error — ${errorMsg}`);
|
||||
if (run.leadRelayCapture) {
|
||||
run.leadRelayCapture.reject(errorMsg);
|
||||
}
|
||||
if (!run.provisioningComplete) {
|
||||
const progress = updateProgress(
|
||||
run,
|
||||
|
|
@ -1454,6 +1572,7 @@ export class TeamProvisioningService {
|
|||
this.leadInboxRelayInFlight.delete(run.teamName);
|
||||
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
||||
this.relayedLeadInboxFallbackKeys.delete(run.teamName);
|
||||
this.liveLeadProcessMessages.delete(run.teamName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
const writeLocks = new Map<string, Promise<void>>();
|
||||
const WRITE_LOCKS = new Map<string, Promise<void>>();
|
||||
|
||||
export async function withInboxLock<T>(inboxPath: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = writeLocks.get(inboxPath) ?? Promise.resolve();
|
||||
const prev = WRITE_LOCKS.get(inboxPath) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const mine = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
writeLocks.set(inboxPath, mine);
|
||||
WRITE_LOCKS.set(inboxPath, mine);
|
||||
await prev;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
if (writeLocks.get(inboxPath) === mine) {
|
||||
writeLocks.delete(inboxPath);
|
||||
if (WRITE_LOCKS.get(inboxPath) === mine) {
|
||||
WRITE_LOCKS.delete(inboxPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,6 +198,45 @@ export function validateFilePath(
|
|||
return { valid: true, normalizedPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a path for opening when it was explicitly chosen by the user
|
||||
* via the system folder picker. Only checks sensitive patterns, not
|
||||
* allowed-directories (project / ~/.claude).
|
||||
*
|
||||
* @param targetPath - The path to open
|
||||
* @returns Validation result
|
||||
*/
|
||||
export function validateOpenPathUserSelected(targetPath: string): PathValidationResult {
|
||||
if (!targetPath || typeof targetPath !== 'string') {
|
||||
return { valid: false, error: 'Invalid path' };
|
||||
}
|
||||
|
||||
const expandedPath = targetPath.startsWith('~')
|
||||
? path.join(os.homedir(), targetPath.slice(1))
|
||||
: targetPath;
|
||||
|
||||
const normalizedPath = path.resolve(path.normalize(expandedPath));
|
||||
|
||||
if (!path.isAbsolute(normalizedPath)) {
|
||||
return { valid: false, error: 'Path must be absolute' };
|
||||
}
|
||||
|
||||
if (matchesSensitivePattern(normalizedPath)) {
|
||||
return { valid: false, error: 'Cannot open sensitive files' };
|
||||
}
|
||||
|
||||
const realTargetPath = resolveRealPathIfExists(normalizedPath);
|
||||
if (realTargetPath) {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const normalizedRealTarget = normalizeForCompare(realTargetPath, isWindows);
|
||||
if (matchesSensitivePattern(normalizedRealTarget)) {
|
||||
return { valid: false, error: 'Cannot open sensitive files' };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, normalizedPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a path for shell:openPath operation.
|
||||
* More permissive than file reading - allows opening project directories
|
||||
|
|
|
|||
|
|
@ -388,8 +388,8 @@ const electronAPI: ElectronAPI = {
|
|||
},
|
||||
|
||||
// Shell operations
|
||||
openPath: (targetPath: string, projectRoot?: string) =>
|
||||
ipcRenderer.invoke('shell:openPath', targetPath, projectRoot),
|
||||
openPath: (targetPath: string, projectRoot?: string, userSelectedFromDialog?: boolean) =>
|
||||
ipcRenderer.invoke('shell:openPath', targetPath, projectRoot, userSelectedFromDialog),
|
||||
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
|
||||
|
||||
// Window controls (when title bar is hidden, e.g. Windows / Linux)
|
||||
|
|
|
|||
|
|
@ -214,6 +214,40 @@ const RepositoryCard = ({
|
|||
<span className="text-text-muted">·</span>
|
||||
<span className="text-[10px] text-text-muted">{lastActivity}</span>
|
||||
</div>
|
||||
|
||||
{/* Tasks progress bar */}
|
||||
{taskCounts &&
|
||||
(() => {
|
||||
const pending = taskCounts.pending ?? 0;
|
||||
const inProgress = taskCounts.inProgress ?? 0;
|
||||
const completed = taskCounts.completed ?? 0;
|
||||
const totalTasks = pending + inProgress + completed;
|
||||
if (totalTasks === 0) return null;
|
||||
const completedRatio = completed / totalTasks;
|
||||
const progressPercent = Math.round(completedRatio * 100);
|
||||
return (
|
||||
<div className="mt-2 w-full space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
|
||||
role="progressbar"
|
||||
aria-valuenow={completed}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={totalTasks}
|
||||
aria-label={`Tasks ${completed}/${totalTasks} completed`}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
|
||||
{completed}/{totalTasks}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -250,7 +284,7 @@ const NewProjectCard = (): React.JSX.Element => {
|
|||
}
|
||||
|
||||
// No match found - open the folder in file manager as fallback
|
||||
const result = await api.openPath(selectedPath);
|
||||
const result = await api.openPath(selectedPath, undefined, true);
|
||||
if (!result.success) {
|
||||
logger.error('Failed to open folder:', result.error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -827,6 +827,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
await sendTeamMessage(teamName, { member, text, summary });
|
||||
} catch {
|
||||
setPendingRepliesByMember((prev) => {
|
||||
if (prev[member] !== sentAtMs) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[member];
|
||||
return next;
|
||||
|
|
|
|||
|
|
@ -250,6 +250,8 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
try {
|
||||
await api.teams.stop(teamName);
|
||||
setAliveTeams((prev) => prev.filter((n) => n !== teamName));
|
||||
} catch (err) {
|
||||
console.error('Failed to stop team:', err);
|
||||
} finally {
|
||||
setStoppingTeamName(null);
|
||||
}
|
||||
|
|
@ -415,12 +417,26 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
{filteredTeams.map((team) => {
|
||||
const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns);
|
||||
const teamColorSet = team.color ? getTeamColorSet(team.color) : null;
|
||||
const matchesCurrentProject =
|
||||
!!currentProjectPath &&
|
||||
(() => {
|
||||
if (team.projectPath && normalizePath(team.projectPath) === currentProjectPath)
|
||||
return true;
|
||||
return (
|
||||
team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ??
|
||||
false
|
||||
);
|
||||
})();
|
||||
return (
|
||||
<div
|
||||
key={team.teamName}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="group relative cursor-pointer overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
|
||||
className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
|
||||
matchesCurrentProject
|
||||
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
|
||||
: 'border-[var(--color-border)]'
|
||||
}`}
|
||||
style={
|
||||
teamColorSet
|
||||
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ export const ActivityItem = ({
|
|||
};
|
||||
|
||||
const summaryText = message.summary || autoSummary || '';
|
||||
const HeaderTag = systemLabel ? 'button' : 'div';
|
||||
|
||||
return (
|
||||
<article
|
||||
|
|
@ -186,13 +187,12 @@ export const ActivityItem = ({
|
|||
}}
|
||||
>
|
||||
{/* Header — clickable when system message to toggle expand */}
|
||||
<div
|
||||
<HeaderTag
|
||||
type={systemLabel ? 'button' : undefined}
|
||||
className={[
|
||||
'flex items-center gap-2 px-3 py-2',
|
||||
systemLabel ? 'cursor-pointer select-none' : '',
|
||||
systemLabel ? 'w-full cursor-pointer select-none border-0 bg-transparent text-left' : '',
|
||||
].join(' ')}
|
||||
role={systemLabel ? 'button' : undefined}
|
||||
tabIndex={systemLabel ? 0 : undefined}
|
||||
onClick={systemLabel ? () => setIsExpanded((v) => !v) : undefined}
|
||||
onKeyDown={
|
||||
systemLabel
|
||||
|
|
@ -216,7 +216,7 @@ export const ActivityItem = ({
|
|||
/>
|
||||
) : null}
|
||||
|
||||
{message.source === 'lead_session' ? (
|
||||
{message.source === 'lead_session' || message.source === 'lead_process' ? (
|
||||
<Bot className="size-3.5 shrink-0" style={{ color: colors.border }} />
|
||||
) : (
|
||||
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
|
||||
|
|
@ -275,6 +275,10 @@ export const ActivityItem = ({
|
|||
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
|
||||
session
|
||||
</span>
|
||||
) : message.source === 'lead_process' ? (
|
||||
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
|
||||
live
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{/* Recipient — badge like sender, clickable to open member popup */}
|
||||
|
|
@ -370,7 +374,7 @@ export const ActivityItem = ({
|
|||
{timestamp}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</HeaderTag>
|
||||
|
||||
{/* Content — collapsed for system messages, expanded for others */}
|
||||
{isExpanded ? (
|
||||
|
|
|
|||
|
|
@ -148,12 +148,16 @@ export const TaskCommentsSection = ({
|
|||
{reply ? (
|
||||
<ReplyQuoteBlock
|
||||
reply={reply}
|
||||
bodyMaxHeight={needsExpandCollapse && !expanded ? 'max-h-56' : undefined}
|
||||
bodyMaxHeight={
|
||||
needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownViewer
|
||||
content={comment.text}
|
||||
maxHeight={needsExpandCollapse && !expanded ? collapsedHeight : undefined}
|
||||
maxHeight={
|
||||
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showCollapsed && (
|
||||
|
|
|
|||
|
|
@ -139,6 +139,12 @@ export const MemberCard = ({
|
|||
e.stopPropagation();
|
||||
onOpenTask?.();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
#{currentTask.id} {currentTask.subject.slice(0, 36)}
|
||||
{currentTask.subject.length > 36 ? '…' : ''}
|
||||
|
|
|
|||
|
|
@ -462,7 +462,8 @@ export interface ElectronAPI {
|
|||
// Shell operations
|
||||
openPath: (
|
||||
targetPath: string,
|
||||
projectRoot?: string
|
||||
projectRoot?: string,
|
||||
userSelectedFromDialog?: boolean
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
openExternal: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export interface InboxMessage {
|
|||
summary?: string;
|
||||
color?: string;
|
||||
messageId?: string;
|
||||
source?: 'inbox' | 'lead_session';
|
||||
source?: 'inbox' | 'lead_session' | 'lead_process';
|
||||
}
|
||||
|
||||
export interface SendMessageRequest {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ describe('ipc teams handlers', () => {
|
|||
launchTeam: vi.fn(async () => ({ runId: 'run-2' })),
|
||||
sendMessageToTeam: vi.fn(async () => undefined),
|
||||
isTeamAlive: vi.fn(() => true),
|
||||
relayLeadInboxMessages: vi.fn(async () => 0),
|
||||
getLiveLeadProcessMessages: vi.fn(() => []),
|
||||
getAliveTeams: vi.fn(() => ['my-team']),
|
||||
stopTeam: vi.fn(() => undefined),
|
||||
};
|
||||
|
|
@ -207,6 +209,43 @@ describe('ipc teams handlers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('dedups live lead replies when lead_session already has same text', async () => {
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
to: 'user',
|
||||
text: 'Hello there',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_session',
|
||||
},
|
||||
],
|
||||
});
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
from: 'team-lead',
|
||||
to: 'user',
|
||||
text: 'Hello there',
|
||||
timestamp: '2026-02-23T10:00:01.000Z',
|
||||
read: true,
|
||||
source: 'lead_process',
|
||||
messageId: 'live-1',
|
||||
},
|
||||
]);
|
||||
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data: { messages: { source?: string }[] };
|
||||
};
|
||||
expect(result.success).toBe(true);
|
||||
const sources = result.data.messages.map((m) => m.source);
|
||||
expect(sources.filter((s) => s === 'lead_process')).toHaveLength(0);
|
||||
expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('createTask prompt validation', () => {
|
||||
it('accepts valid prompt string', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE_TASK)!;
|
||||
|
|
|
|||
|
|
@ -90,11 +90,27 @@ function attachAliveRun(
|
|||
processKilled: false,
|
||||
cancelRequested: false,
|
||||
provisioningComplete: true,
|
||||
leadRelayCapture: null,
|
||||
});
|
||||
|
||||
return { writeSpy };
|
||||
}
|
||||
|
||||
async function waitForCapture(service: TeamProvisioningService): Promise<any> {
|
||||
const runs = (service as unknown as { runs: Map<string, unknown> }).runs;
|
||||
const run = runs.get('run-1') as any;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (run?.leadRelayCapture) return run;
|
||||
// Progress async awaits in relayLeadInboxMessages
|
||||
await Promise.resolve();
|
||||
}
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (run?.leadRelayCapture) return run;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.files.clear();
|
||||
|
|
@ -119,13 +135,24 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
]);
|
||||
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
const relayed = await service.relayLeadInboxMessages(teamName);
|
||||
|
||||
const relayPromise = service.relayLeadInboxMessages(teamName);
|
||||
const run = await waitForCapture(service);
|
||||
expect(run?.leadRelayCapture).toBeTruthy();
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'OK, will do.' }],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
|
||||
const relayed = await relayPromise;
|
||||
|
||||
expect(relayed).toBe(1);
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||
expect(payload).toContain('"type":"user"');
|
||||
expect(payload).toContain('Please assign this to Alice.');
|
||||
expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('dedups by messageId even if markRead fails', async () => {
|
||||
|
|
@ -146,7 +173,15 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
hoisted.setAtomicWriteShouldFail(true);
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
|
||||
const first = await service.relayLeadInboxMessages(teamName);
|
||||
const firstPromise = service.relayLeadInboxMessages(teamName);
|
||||
const run = await waitForCapture(service);
|
||||
expect(run?.leadRelayCapture).toBeTruthy();
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Acknowledged.' }],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
const first = await firstPromise;
|
||||
const second = await service.relayLeadInboxMessages(teamName);
|
||||
|
||||
expect(first).toBe(1);
|
||||
|
|
@ -180,9 +215,18 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
processKilled: false,
|
||||
cancelRequested: false,
|
||||
provisioningComplete: true,
|
||||
leadRelayCapture: null,
|
||||
});
|
||||
|
||||
const second = await service.relayLeadInboxMessages(teamName);
|
||||
const secondPromise = service.relayLeadInboxMessages(teamName);
|
||||
const run = await waitForCapture(service);
|
||||
expect(run?.leadRelayCapture).toBeTruthy();
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Hi.' }],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
const second = await secondPromise;
|
||||
expect(second).toBe(1);
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
isPathWithinAllowedDirectories,
|
||||
validateFilePath,
|
||||
validateOpenPath,
|
||||
validateOpenPathUserSelected,
|
||||
} from '../../../src/main/utils/pathValidation';
|
||||
|
||||
describe('pathValidation', () => {
|
||||
|
|
@ -299,4 +300,25 @@ describe('pathValidation', () => {
|
|||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOpenPathUserSelected', () => {
|
||||
it('should allow path outside project when chosen by user', () => {
|
||||
const outsidePath = path.join(homeDir, 'some-other-project');
|
||||
const result = validateOpenPathUserSelected(outsidePath);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.normalizedPath).toBe(path.resolve(outsidePath));
|
||||
});
|
||||
|
||||
it('should reject sensitive paths', () => {
|
||||
const result = validateOpenPathUserSelected(path.join(homeDir, '.ssh', 'id_rsa'));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Cannot open sensitive files');
|
||||
});
|
||||
|
||||
it('should reject empty path', () => {
|
||||
const result = validateOpenPathUserSelected('');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Invalid path');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue