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:
iliya 2026-02-23 17:29:31 +02:00
parent 1e33e82ce1
commit 4a85647433
23 changed files with 475 additions and 63 deletions

View file

@ -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
View 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');

View file

@ -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",

View file

@ -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,

View file

@ -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);
}
return { ...data, isAlive };
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(

View file

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

View file

@ -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;

View file

@ -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);
}
/**

View file

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

View file

@ -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

View file

@ -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)

View file

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

View file

@ -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;

View file

@ -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 }

View file

@ -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 ? (

View file

@ -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 && (

View file

@ -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 ? '…' : ''}

View file

@ -14,11 +14,10 @@ export function useTeamMessagesRead(teamName: string): {
);
useEffect(() => {
if (!teamName) {
queueMicrotask(() => setReadSet(new Set()));
return;
}
queueMicrotask(() => setReadSet(getReadSetStorage(teamName)));
if (!teamName) return;
const next = getReadSetStorage(teamName);
const t = setTimeout(() => setReadSet(next), 0);
return () => clearTimeout(t);
}, [teamName]);
const markRead = useCallback(
@ -35,5 +34,6 @@ export function useTeamMessagesRead(teamName: string): {
[teamName]
);
return { readSet, markRead };
const effectiveReadSet = !teamName ? new Set<string>() : readSet;
return { readSet: effectiveReadSet, markRead };
}

View file

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

View file

@ -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 {

View file

@ -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)!;

View file

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

View file

@ -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');
});
});
});