Merge agent_teams_features: skip permissions in headless mode

This commit is contained in:
iliya 2026-02-23 14:18:31 +02:00
commit 3be090add3
39 changed files with 1308 additions and 427 deletions

View file

@ -62,6 +62,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-virtual": "^3.10.8", "@tanstack/react-virtual": "^3.10.8",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View file

@ -47,6 +47,9 @@ importers:
'@radix-ui/react-tabs': '@radix-ui/react-tabs':
specifier: ^1.1.13 specifier: ^1.1.13
version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-virtual': '@tanstack/react-virtual':
specifier: ^3.10.8 specifier: ^3.10.8
version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -1294,6 +1297,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1': '@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies: peerDependencies:
@ -6176,6 +6192,26 @@ snapshots:
'@types/react': 18.3.27 '@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27) '@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.27)(react@18.3.1)': '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.27)(react@18.3.1)':
dependencies: dependencies:
react: 18.3.1 react: 18.3.1

View file

@ -8,6 +8,7 @@ import {
TEAM_DELETE_TEAM, TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS, TEAM_GET_ALL_TASKS,
TEAM_GET_DATA, TEAM_GET_DATA,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS, TEAM_GET_MEMBER_STATS,
TEAM_LAUNCH, TEAM_LAUNCH,
@ -101,6 +102,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList); ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList);
ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig); ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig);
ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs); ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs);
ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask);
ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats); ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats);
ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig); ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig);
ipcMain.handle(TEAM_START_TASK, handleStartTask); ipcMain.handle(TEAM_START_TASK, handleStartTask);
@ -128,6 +130,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_ALIVE_LIST); ipcMain.removeHandler(TEAM_ALIVE_LIST);
ipcMain.removeHandler(TEAM_CREATE_CONFIG); ipcMain.removeHandler(TEAM_CREATE_CONFIG);
ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS); ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS);
ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK);
ipcMain.removeHandler(TEAM_GET_MEMBER_STATS); ipcMain.removeHandler(TEAM_GET_MEMBER_STATS);
ipcMain.removeHandler(TEAM_UPDATE_CONFIG); ipcMain.removeHandler(TEAM_UPDATE_CONFIG);
ipcMain.removeHandler(TEAM_START_TASK); ipcMain.removeHandler(TEAM_START_TASK);
@ -763,6 +766,24 @@ async function handleGetMemberLogs(
); );
} }
async function handleGetLogsForTask(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown
): Promise<IpcResult<MemberLogSummary[]>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
const vTask = validateTaskId(taskId);
if (!vTask.valid) {
return { success: false, error: vTask.error ?? 'Invalid taskId' };
}
return wrapTeamHandler('getLogsForTask', () =>
getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!)
);
}
function getMemberStatsComputer(): MemberStatsComputer { function getMemberStatsComputer(): MemberStatsComputer {
if (!memberStatsComputer) { if (!memberStatsComputer) {
throw new Error('Member stats computer is not initialized'); throw new Error('Member stats computer is not initialized');

View file

@ -274,12 +274,15 @@ function createTask(paths, flags) {
const taskPath = path.join(paths.tasksDir, String(nextId) + '.json'); const taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
if (fs.existsSync(taskPath)) die('Task already exists: ' + String(nextId)); if (fs.existsSync(taskPath)) die('Task already exists: ' + String(nextId));
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined;
const task = { const task = {
id: nextId, id: nextId,
subject, subject,
description: String(description || subject), description: String(description || subject),
activeForm: activeForm ? String(activeForm) : undefined, activeForm: activeForm ? String(activeForm) : undefined,
owner, owner,
createdBy: from,
status, status,
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
@ -419,7 +422,7 @@ function printHelp() {
' node teamctl.js task set-status <id> <pending|in_progress|completed|deleted> [--team <team>]', ' node teamctl.js task set-status <id> <pending|in_progress|completed|deleted> [--team <team>]',
' node teamctl.js task complete <id> [--team <team>]', ' node teamctl.js task complete <id> [--team <team>]',
' node teamctl.js task start <id> [--team <team>]', ' node teamctl.js task start <id> [--team <team>]',
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--notify --from "member"] [--team <team>]', ' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team <team>]',
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]', ' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]', ' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
' node teamctl.js kanban clear <id> [--team <team>]', ' node teamctl.js kanban clear <id> [--team <team>]',

View file

@ -203,6 +203,20 @@ export class TeamDataService {
tasks, tasks,
messages messages
); );
// Auto-sync: create comments from task-related inbox messages
if (tasksLoaded && messages.length > 0) {
try {
const didSync = await this.syncLinkedComments(teamName, tasks, messages);
if (didSync) {
// Re-read tasks only if new comments were actually written
tasks = await this.taskReader.getTasks(teamName);
}
} catch {
warnings.push('Comment sync from messages failed');
}
}
return { return {
teamName, teamName,
config, config,
@ -244,6 +258,7 @@ export class TeamDataService {
subject: request.subject, subject: request.subject,
description, description,
owner: request.owner, owner: request.owner,
createdBy: 'user',
status: shouldStart ? 'in_progress' : 'pending', status: shouldStart ? 'in_progress' : 'pending',
blocks: [], blocks: [],
blockedBy, blockedBy,
@ -436,6 +451,60 @@ export class TeamDataService {
); );
} }
/**
* Scans inbox messages for task-related discussions and auto-creates
* linked comments on disk. Uses deterministic comment ID for dedup.
* Returns true if any new comments were synced (caller should re-read tasks).
*/
private async syncLinkedComments(
teamName: string,
tasks: TeamTask[],
messages: InboxMessage[]
): Promise<boolean> {
const TASK_ID_PATTERN = /#(\d+)/g;
let synced = false;
// Dedup broadcasts: same sender + same text → process only once
const processedTexts = new Set<string>();
for (const msg of messages) {
if (!msg.messageId || !msg.summary || msg.from === 'user') continue;
if (msg.source === 'lead_session') continue;
const textKey = `${msg.from}\0${msg.text}`;
if (processedTexts.has(textKey)) continue;
processedTexts.add(textKey);
const matches = msg.summary.matchAll(TASK_ID_PATTERN);
const taskIds = new Set<string>();
for (const match of matches) {
taskIds.add(match[1]);
}
for (const taskId of taskIds) {
const task = tasks.find((t) => t.id === taskId);
if (!task) continue;
const commentId = `msg-${msg.messageId}`;
const existing = task.comments ?? [];
if (existing.some((c) => c.id === commentId)) continue;
try {
await this.taskWriter.addComment(teamName, taskId, msg.text, {
id: commentId,
author: msg.from,
createdAt: msg.timestamp,
});
synced = true;
} catch {
// Best-effort — don't fail getTeamData() on sync errors
}
}
}
return synced;
}
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> { private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {
if (!config.leadSessionId || !config.projectPath) { if (!config.leadSessionId || !config.projectPath) {
return []; return [];

View file

@ -102,6 +102,68 @@ export class TeamMemberLogsFinder {
); );
} }
/**
* Returns session logs that reference the given task (TaskCreate, TaskUpdate, comments, etc.).
*/
async findLogsForTask(teamName: string, taskId: string): Promise<MemberLogSummary[]> {
const discovery = await this.discoverProjectSessions(teamName);
if (!discovery) return [];
const { projectDir, projectId, config, sessionIds, knownMembers } = discovery;
const results: MemberLogSummary[] = [];
const leadMemberName =
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
if (config.leadSessionId) {
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
try {
await fs.access(leadJsonl);
if (await this.fileMentionsTaskId(leadJsonl, taskId)) {
const leadSummary = await this.parseLeadSessionSummary(
leadJsonl,
projectId,
config.leadSessionId,
leadMemberName
);
if (leadSummary) results.push(leadSummary);
}
} catch {
// file missing or unreadable
}
}
for (const sessionId of sessionIds) {
const subagentsDir = path.join(projectDir, sessionId, 'subagents');
let files: string[];
try {
files = await fs.readdir(subagentsDir);
} catch {
continue;
}
for (const file of files) {
if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue;
if (file.startsWith('agent-acompact')) continue;
const filePath = path.join(subagentsDir, file);
if (!(await this.fileMentionsTaskId(filePath, taskId))) continue;
const attribution = await this.attributeSubagent(filePath, knownMembers);
if (!attribution) continue;
const summary = await this.parseSubagentSummary(
filePath,
projectId,
sessionId,
file,
attribution.detectedMember,
knownMembers
);
if (summary) results.push(summary);
}
}
return results.sort(
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
);
}
/** /**
* Returns absolute paths to all JSONL files belonging to the specified member. * Returns absolute paths to all JSONL files belonging to the specified member.
* Uses the same discovery logic as findMemberLogs but collects file paths. * Uses the same discovery logic as findMemberLogs but collects file paths.
@ -149,16 +211,12 @@ export class TeamMemberLogsFinder {
return paths; return paths;
} }
private async discoverMemberFiles( private async discoverProjectSessions(teamName: string): Promise<{
teamName: string,
memberName: string
): Promise<{
projectDir: string; projectDir: string;
projectId: string; projectId: string;
config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>; config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>;
sessionIds: string[]; sessionIds: string[];
knownMembers: Set<string>; knownMembers: Set<string>;
isLeadMember: boolean;
} | null> { } | null> {
const config = await this.configReader.getConfig(teamName); const config = await this.configReader.getConfig(teamName);
if (!config?.projectPath) { if (!config?.projectPath) {
@ -171,11 +229,6 @@ export class TeamMemberLogsFinder {
const baseDir = extractBaseDir(projectId); const baseDir = extractBaseDir(projectId);
const projectDir = path.join(getProjectsBasePath(), baseDir); const projectDir = path.join(getProjectsBasePath(), baseDir);
const leadMemberName =
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase();
// Collect all known session IDs: current lead + history
const knownSessionIds = new Set<string>(); const knownSessionIds = new Set<string>();
if (config.leadSessionId) { if (config.leadSessionId) {
knownSessionIds.add(config.leadSessionId); knownSessionIds.add(config.leadSessionId);
@ -190,17 +243,14 @@ export class TeamMemberLogsFinder {
let sessionIds: string[]; let sessionIds: string[];
if (knownSessionIds.size > 0) { if (knownSessionIds.size > 0) {
// Verify each known session dir exists, fall back to full scan if none exist
const verified: string[] = []; const verified: string[] = [];
for (const sid of knownSessionIds) { for (const sid of knownSessionIds) {
const sidDir = path.join(projectDir, sid); const sidDir = path.join(projectDir, sid);
try { try {
const stat = await fs.stat(sidDir); const stat = await fs.stat(sidDir);
if (stat.isDirectory()) { if (stat.isDirectory()) verified.push(sid);
verified.push(sid);
}
} catch { } catch {
// dir doesn't exist, skip // dir doesn't exist
} }
} }
sessionIds = verified.length > 0 ? verified : await this.listSessionDirs(projectDir); sessionIds = verified.length > 0 ? verified : await this.listSessionDirs(projectDir);
@ -217,26 +267,69 @@ export class TeamMemberLogsFinder {
const metaMembers = await this.membersMetaStore.getMembers(teamName); const metaMembers = await this.membersMetaStore.getMembers(teamName);
for (const member of metaMembers) { for (const member of metaMembers) {
const normalized = member.name.trim().toLowerCase(); const normalized = member.name.trim().toLowerCase();
if (normalized.length > 0) { if (normalized.length > 0) knownMembers.add(normalized);
knownMembers.add(normalized);
}
} }
} catch { } catch {
// Best-effort enrichment. // best-effort
} }
try { try {
const inboxMembers = await this.inboxReader.listInboxNames(teamName); const inboxMembers = await this.inboxReader.listInboxNames(teamName);
for (const memberNameFromInbox of inboxMembers) { for (const name of inboxMembers) {
const normalized = memberNameFromInbox.trim().toLowerCase(); const normalized = name.trim().toLowerCase();
if (normalized.length > 0) { if (normalized.length > 0) knownMembers.add(normalized);
knownMembers.add(normalized);
}
} }
} catch { } catch {
// Best-effort enrichment. // best-effort
} }
return { projectDir, projectId, config, sessionIds, knownMembers, isLeadMember }; return { projectDir, projectId, config, sessionIds, knownMembers };
}
private async discoverMemberFiles(
teamName: string,
memberName: string
): Promise<{
projectDir: string;
projectId: string;
config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>;
sessionIds: string[];
knownMembers: Set<string>;
isLeadMember: boolean;
} | null> {
const discovery = await this.discoverProjectSessions(teamName);
if (!discovery) return null;
const { config } = discovery;
const leadMemberName =
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase();
return { ...discovery, isLeadMember };
}
private async fileMentionsTaskId(filePath: string, taskId: string): Promise<boolean> {
const escaped = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const patterns = [
new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'),
new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'),
new RegExp(`#${escaped}\\b`),
];
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
for (const re of patterns) {
if (re.test(line)) {
rl.close();
stream.destroy();
return true;
}
}
}
rl.close();
stream.destroy();
} catch {
// ignore
}
return false;
} }
private async listSessionDirs(projectDir: string): Promise<string[]> { private async listSessionDirs(projectDir: string): Promise<string[]> {

View file

@ -251,6 +251,10 @@ function buildTaskStatusProtocol(teamName: string): string {
5. NEVER skip status updates. A task is NOT done until completed status is written. 5. NEVER skip status updates. A task is NOT done until completed status is written.
6. To reply to a comment on a task: 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>\\"
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.`; Failure to follow this protocol means the task board will show incorrect status.`;
} }
@ -263,16 +267,25 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n` ? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: ''; : '';
const leadName =
request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
You are "${leadName}", the team lead.
Goal: Provision a Claude Code agent team with live teammates. Goal: Provision a Claude Code agent team with live teammates.
${userPromptBlock}
Constraints: Constraints:
- Do NOT call TeamDelete under any circumstances. - Do NOT call TeamDelete under any circumstances.
- Do NOT use TodoWrite use TaskCreate for tasks. - Do NOT use TodoWrite use TaskCreate for tasks.
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). - 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. - Do NOT shut down, terminate, or clean up the team or its members.
- Keep assistant text minimal. - Keep assistant text minimal.
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
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}"
- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task set-status <id> <pending|in_progress|completed|deleted>
Steps (execute in this exact order): Steps (execute in this exact order):
@ -287,8 +300,10 @@ Steps (execute in this exact order):
${taskProtocol}" ${taskProtocol}"
3) After spawning all members, output a short summary. 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.
${userPromptBlock}
4) After all steps, output a short summary.
Members: Members:
${members} ${members}
`; `;
@ -304,16 +319,24 @@ function buildLaunchPrompt(
: ''; : '';
const taskProtocol = buildTaskStatusProtocol(request.teamName); const taskProtocol = buildTaskStatusProtocol(request.teamName);
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
You are "${leadName}", the team lead.
Goal: Reconnect with existing team "${request.teamName}". Goal: Reconnect with existing team "${request.teamName}".
${userPromptBlock}
Constraints: Constraints:
- Do NOT call TeamDelete under any circumstances. - Do NOT call TeamDelete under any circumstances.
- Do NOT use TodoWrite use TaskCreate for tasks. - Do NOT use TodoWrite use TaskCreate for tasks.
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). - 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. - Do NOT shut down, terminate, or clean up the team or its members.
- Keep assistant text minimal. - Keep assistant text minimal.
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
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}"
- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task set-status <id> <pending|in_progress|completed|deleted>
Steps (execute in this exact order): Steps (execute in this exact order):
@ -329,8 +352,10 @@ Steps (execute in this exact order):
${taskProtocol}" ${taskProtocol}"
4) After spawning all members, output a short summary. 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.
${userPromptBlock}
5) After all steps, output a short summary.
Members: Members:
${membersBlock} ${membersBlock}
`; `;
@ -619,6 +644,7 @@ export class TeamProvisioningService {
'user,project,local', 'user,project,local',
'--disallowedTools', '--disallowedTools',
'TeamDelete,TodoWrite', 'TeamDelete,TodoWrite',
'--dangerously-skip-permissions',
], ],
{ {
cwd: request.cwd, cwd: request.cwd,
@ -888,6 +914,7 @@ export class TeamProvisioningService {
'user,project,local', 'user,project,local',
'--disallowedTools', '--disallowedTools',
'TeamDelete,TodoWrite', 'TeamDelete,TodoWrite',
'--dangerously-skip-permissions',
]; ];
if (previousSessionId) { if (previousSessionId) {
launchArgs.push('--resume', previousSessionId); launchArgs.push('--resume', previousSessionId);

View file

@ -117,19 +117,28 @@ export class TeamTaskWriter {
}); });
} }
async addComment(teamName: string, taskId: string, text: string): Promise<TaskComment> { async addComment(
teamName: string,
taskId: string,
text: string,
options?: { id?: string; author?: string; createdAt?: string }
): Promise<TaskComment> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
const comment: TaskComment = { const comment: TaskComment = {
id: randomUUID(), id: options?.id ?? randomUUID(),
author: 'user', author: options?.author ?? 'user',
text, text,
createdAt: new Date().toISOString(), createdAt: options?.createdAt ?? new Date().toISOString(),
}; };
await withTaskLock(taskPath, async () => { await withTaskLock(taskPath, async () => {
const raw = await fs.promises.readFile(taskPath, 'utf8'); const raw = await fs.promises.readFile(taskPath, 'utf8');
const task = JSON.parse(raw) as Record<string, unknown>; const task = JSON.parse(raw) as Record<string, unknown>;
const existing = Array.isArray(task.comments) ? (task.comments as TaskComment[]) : []; const existing = Array.isArray(task.comments) ? (task.comments as TaskComment[]) : [];
// Dedup by ID — skip if comment with same ID already exists
if (existing.some((c) => c.id === comment.id)) {
return;
}
task.comments = [...existing, comment]; task.comments = [...existing, comment];
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));

View file

@ -242,6 +242,9 @@ export const TEAM_CREATE_CONFIG = 'team:createConfig';
/** Get member subagent logs */ /** Get member subagent logs */
export const TEAM_GET_MEMBER_LOGS = 'team:getMemberLogs'; export const TEAM_GET_MEMBER_LOGS = 'team:getMemberLogs';
/** Get session logs that reference a task */
export const TEAM_GET_LOGS_FOR_TASK = 'team:getLogsForTask';
/** Update team config (name, description) */ /** Update team config (name, description) */
export const TEAM_UPDATE_CONFIG = 'team:updateConfig'; export const TEAM_UPDATE_CONFIG = 'team:updateConfig';

View file

@ -28,6 +28,7 @@ import {
TEAM_DELETE_TEAM, TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS, TEAM_GET_ALL_TASKS,
TEAM_GET_DATA, TEAM_GET_DATA,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS, TEAM_GET_MEMBER_STATS,
TEAM_LAUNCH, TEAM_LAUNCH,
@ -572,6 +573,9 @@ const electronAPI: ElectronAPI = {
getMemberLogs: async (teamName: string, memberName: string) => { getMemberLogs: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<MemberLogSummary[]>(TEAM_GET_MEMBER_LOGS, teamName, memberName); return invokeIpcWithResult<MemberLogSummary[]>(TEAM_GET_MEMBER_LOGS, teamName, memberName);
}, },
getLogsForTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<MemberLogSummary[]>(TEAM_GET_LOGS_FOR_TASK, teamName, taskId);
},
getMemberStats: async (teamName: string, memberName: string) => { getMemberStats: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<MemberFullStats>(TEAM_GET_MEMBER_STATS, teamName, memberName); return invokeIpcWithResult<MemberFullStats>(TEAM_GET_MEMBER_STATS, teamName, memberName);
}, },

View file

@ -1,5 +1,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { TooltipProvider } from '@renderer/components/ui/tooltip';
import { ConfirmDialog } from './components/common/ConfirmDialog'; import { ConfirmDialog } from './components/common/ConfirmDialog';
import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay'; import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay';
import { ErrorBoundary } from './components/common/ErrorBoundary'; import { ErrorBoundary } from './components/common/ErrorBoundary';
@ -43,9 +45,11 @@ export const App = (): React.JSX.Element => {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ContextSwitchOverlay /> <TooltipProvider delayDuration={300}>
<TabbedLayout /> <ContextSwitchOverlay />
<ConfirmDialog /> <TabbedLayout />
<ConfirmDialog />
</TooltipProvider>
</ErrorBoundary> </ErrorBoundary>
); );
}; };

View file

@ -692,6 +692,9 @@ export class HttpAPIClient implements ElectronAPI {
console.warn('[HttpAPIClient] getMemberLogs is not available in browser mode'); console.warn('[HttpAPIClient] getMemberLogs is not available in browser mode');
return []; return [];
}, },
getLogsForTask: async () => {
return [];
},
getMemberStats: async () => { getMemberStats: async () => {
console.warn('[HttpAPIClient] getMemberStats is not available in browser mode'); console.warn('[HttpAPIClient] getMemberStats is not available in browser mode');
return { return {

View file

@ -24,7 +24,7 @@ const CATEGORY_COLORS: Record<string, { bg: string; text: string; label: string
'tool-output': { bg: 'rgba(251, 191, 36, 0.15)', text: '#fbbf24', label: 'Tool' }, 'tool-output': { bg: 'rgba(251, 191, 36, 0.15)', text: '#fbbf24', label: 'Tool' },
'thinking-text': { bg: 'rgba(167, 139, 250, 0.15)', text: '#a78bfa', label: 'Thinking' }, 'thinking-text': { bg: 'rgba(167, 139, 250, 0.15)', text: '#a78bfa', label: 'Thinking' },
'task-coordination': { bg: 'rgba(251, 146, 60, 0.15)', text: '#fb923c', label: 'Team' }, 'task-coordination': { bg: 'rgba(251, 146, 60, 0.15)', text: '#fb923c', label: 'Team' },
'user-message': { bg: 'rgba(96, 165, 250, 0.15)', text: '#60a5fa', label: 'User' }, 'user-message': { bg: 'rgba(249, 115, 22, 0.15)', text: '#fb923c', label: 'User' },
}; };
// ============================================================================= // =============================================================================

View file

@ -27,7 +27,7 @@ const CATEGORY_COLORS: Record<string, { bg: string; text: string; label: string
'tool-output': { bg: 'rgba(251, 191, 36, 0.15)', text: '#fbbf24', label: 'Tool' }, 'tool-output': { bg: 'rgba(251, 191, 36, 0.15)', text: '#fbbf24', label: 'Tool' },
'thinking-text': { bg: 'rgba(167, 139, 250, 0.15)', text: '#a78bfa', label: 'Thinking' }, 'thinking-text': { bg: 'rgba(167, 139, 250, 0.15)', text: '#a78bfa', label: 'Thinking' },
'task-coordination': { bg: 'rgba(251, 146, 60, 0.15)', text: '#fb923c', label: 'Team' }, 'task-coordination': { bg: 'rgba(251, 146, 60, 0.15)', text: '#fb923c', label: 'Team' },
'user-message': { bg: 'rgba(96, 165, 250, 0.15)', text: '#60a5fa', label: 'User' }, 'user-message': { bg: 'rgba(249, 115, 22, 0.15)', text: '#fb923c', label: 'User' },
}; };
// ============================================================================= // =============================================================================

View file

@ -5,6 +5,7 @@ import { Button } from '@renderer/components/ui/button';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react'; import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -19,11 +20,13 @@ import { KanbanBoard } from './kanban/KanbanBoard';
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover'; import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
import { MemberDetailDialog } from './members/MemberDetailDialog'; import { MemberDetailDialog } from './members/MemberDetailDialog';
import { MemberList } from './members/MemberList'; import { MemberList } from './members/MemberList';
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import { TeamSessionsSection } from './TeamSessionsSection'; import { TeamSessionsSection } from './TeamSessionsSection';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { MessagesFilterState } from './messages/MessagesFilterPopover';
import type { Session } from '@renderer/types/data'; import type { Session } from '@renderer/types/data';
import type { ResolvedTeamMember, TeamTask } from '@shared/types'; import type { ResolvedTeamMember, TeamTask } from '@shared/types';
@ -134,6 +137,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
); );
const [kanbanSearch, setKanbanSearch] = useState(''); const [kanbanSearch, setKanbanSearch] = useState('');
const [messagesSearchQuery, setMessagesSearchQuery] = useState('');
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>({
from: new Set(),
to: new Set(),
});
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!teamName) { if (!teamName) {
@ -251,12 +260,31 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const filteredMessages = useMemo(() => { const filteredMessages = useMemo(() => {
if (!data) return []; if (!data) return [];
if (!timeWindow) return data.messages; let list = data.messages;
return data.messages.filter((m) => { if (timeWindow) {
const ts = new Date(m.timestamp).getTime(); list = list.filter((m) => {
return ts >= timeWindow.start && ts < timeWindow.end; const ts = new Date(m.timestamp).getTime();
}); return ts >= timeWindow.start && ts < timeWindow.end;
}, [data, timeWindow]); });
}
if (messagesFilter.from.size > 0) {
list = list.filter((m) => m.from?.trim() && messagesFilter.from.has(m.from.trim()));
}
if (messagesFilter.to.size > 0) {
list = list.filter((m) => m.to?.trim() && messagesFilter.to.has(m.to.trim()));
}
const q = messagesSearchQuery.trim().toLowerCase();
if (q) {
list = list.filter((m) => {
const text = (m.text ?? '').toLowerCase();
const summary = (m.summary ?? '').toLowerCase();
const from = (m.from ?? '').toLowerCase();
const to = (m.to ?? '').toLowerCase();
return text.includes(q) || summary.includes(q) || from.includes(q) || to.includes(q);
});
}
return list;
}, [data, timeWindow, messagesFilter, messagesSearchQuery]);
const kanbanDisplayTasks = useMemo(() => { const kanbanDisplayTasks = useMemo(() => {
const query = kanbanSearch.trim(); const query = kanbanSearch.trim();
@ -266,6 +294,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]); const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => { const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => {
setCreateTaskDialog({ setCreateTaskDialog({
open: true, open: true,
@ -351,6 +381,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return ( return (
<div className="size-full overflow-auto p-4"> <div className="size-full overflow-auto p-4">
<div className="mb-4 h-10 animate-pulse rounded-md bg-[var(--color-surface-raised)]" /> <div className="mb-4 h-10 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
<TeamProvisioningBanner teamName={teamName} />
<div className="space-y-3"> <div className="space-y-3">
<div className="h-24 animate-pulse rounded-md bg-[var(--color-surface-raised)]" /> <div className="h-24 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
<div className="h-48 animate-pulse rounded-md bg-[var(--color-surface-raised)]" /> <div className="h-48 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
@ -459,6 +490,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<CollapsibleTeamSection title="Members" badge={data.members.length} defaultOpen> <CollapsibleTeamSection title="Members" badge={data.members.length} defaultOpen>
<MemberList <MemberList
members={data.members} members={data.members}
memberTaskCounts={memberTaskCounts}
isTeamAlive={data.isAlive} isTeamAlive={data.isAlive}
onMemberClick={setSelectedMember} onMemberClick={setSelectedMember}
onSendMessage={(member) => { onSendMessage={(member) => {
@ -592,25 +624,47 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
badge={filteredMessages.length} badge={filteredMessages.length}
defaultOpen defaultOpen
action={ action={
<Button <div className="flex items-center gap-2 pl-2">
variant="outline" <div className="flex w-36 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
size="sm" <Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
className="h-7 gap-1.5 px-2.5 text-xs font-medium text-[var(--color-text)]" <input
onClick={(e) => { type="search"
e.stopPropagation(); placeholder="Search..."
setSendDialogRecipient(undefined); value={messagesSearchQuery}
setReplyQuote(undefined); onChange={(e) => setMessagesSearchQuery(e.target.value)}
setSendDialogOpen(true); onPointerDown={(e) => e.stopPropagation()}
}} onClick={(e) => e.stopPropagation()}
> className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
<MessageSquare size={12} /> />
Message </div>
</Button> <MessagesFilterPopover
filter={messagesFilter}
messages={data?.messages ?? []}
open={messagesFilterOpen}
onOpenChange={setMessagesFilterOpen}
onApply={setMessagesFilter}
/>
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs font-medium text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setSendDialogRecipient(undefined);
setReplyQuote(undefined);
setSendDialogOpen(true);
}}
>
<MessageSquare size={12} />
Message
</Button>
</div>
} }
> >
<ActivityTimeline <ActivityTimeline
messages={filteredMessages} messages={filteredMessages}
members={data.members} members={data.members}
onMemberClick={setSelectedMember}
onCreateTaskFromMessage={(subject, description) => { onCreateTaskFromMessage={(subject, description) => {
openCreateTaskDialog(subject, description); openCreateTaskDialog(subject, description);
}} }}

View file

@ -4,11 +4,17 @@ import { api, isElectronMode } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input'; import { Input } from '@renderer/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
import { getBaseName } from '@renderer/utils/pathUtils'; import { getBaseName } from '@renderer/utils/pathUtils';
import { Copy, FolderOpen, Search, Trash2 } from 'lucide-react'; import { CheckCircle, Clock, Copy, FolderOpen, Play, Search, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { CreateTeamDialog } from './dialogs/CreateTeamDialog'; import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
@ -98,10 +104,14 @@ export const TeamListView = (): React.JSX.Element => {
fetchTeams, fetchTeams,
openTeamTab, openTeamTab,
deleteTeam, deleteTeam,
selectedProjectId,
projects, projects,
globalTasks, globalTasks,
fetchAllTasks, fetchAllTasks,
viewMode,
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
activeProjectId,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
teams: s.teams, teams: s.teams,
@ -110,10 +120,14 @@ export const TeamListView = (): React.JSX.Element => {
fetchTeams: s.fetchTeams, fetchTeams: s.fetchTeams,
openTeamTab: s.openTeamTab, openTeamTab: s.openTeamTab,
deleteTeam: s.deleteTeam, deleteTeam: s.deleteTeam,
selectedProjectId: s.selectedProjectId,
projects: s.projects, projects: s.projects,
globalTasks: s.globalTasks, globalTasks: s.globalTasks,
fetchAllTasks: s.fetchAllTasks, fetchAllTasks: s.fetchAllTasks,
viewMode: s.viewMode,
repositoryGroups: s.repositoryGroups,
selectedRepositoryId: s.selectedRepositoryId,
selectedWorktreeId: s.selectedWorktreeId,
activeProjectId: s.activeProjectId,
})) }))
); );
const { connectionMode, createTeam, provisioningError, provisioningRuns } = useStore( const { connectionMode, createTeam, provisioningError, provisioningRuns } = useStore(
@ -144,11 +158,23 @@ export const TeamListView = (): React.JSX.Element => {
}; };
}, [electronMode, teams]); }, [electronMode, teams]);
const selectedProjectPath = useMemo(() => { const currentProjectPath = useMemo(() => {
if (!selectedProjectId) return null; if (viewMode === 'grouped') {
const project = projects.find((p) => p.id === selectedProjectId); const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
const worktree = repo?.worktrees.find((w) => w.id === selectedWorktreeId);
const path = worktree?.path ?? null;
return path ? normalizePath(path) : null;
}
const project = projects.find((p) => p.id === activeProjectId);
return project ? normalizePath(project.path) : null; return project ? normalizePath(project.path) : null;
}, [selectedProjectId, projects]); }, [
viewMode,
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
projects,
activeProjectId,
]);
const filteredTeams = useMemo<TeamSummary[]>(() => { const filteredTeams = useMemo<TeamSummary[]>(() => {
let result = teams; let result = teams;
@ -163,10 +189,10 @@ export const TeamListView = (): React.JSX.Element => {
); );
} }
if (selectedProjectPath) { if (currentProjectPath) {
const matches = (t: TeamSummary): boolean => { const matches = (t: TeamSummary): boolean => {
if (t.projectPath && normalizePath(t.projectPath) === selectedProjectPath) return true; if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true;
return t.projectPathHistory?.some((p) => normalizePath(p) === selectedProjectPath) ?? false; return t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false;
}; };
result = [...result].sort((a, b) => { result = [...result].sort((a, b) => {
const aMatch = matches(a) ? 0 : 1; const aMatch = matches(a) ? 0 : 1;
@ -176,7 +202,7 @@ export const TeamListView = (): React.JSX.Element => {
} }
return result; return result;
}, [teams, searchQuery, selectedProjectPath]); }, [teams, searchQuery, currentProjectPath]);
const handleDeleteTeam = useCallback( const handleDeleteTeam = useCallback(
(teamName: string, e: React.MouseEvent) => { (teamName: string, e: React.MouseEvent) => {
@ -249,6 +275,7 @@ export const TeamListView = (): React.JSX.Element => {
provisioningError={provisioningError} provisioningError={provisioningError}
existingTeamNames={teams.map((t) => t.teamName)} existingTeamNames={teams.map((t) => t.teamName)}
initialData={copyData ?? undefined} initialData={copyData ?? undefined}
defaultProjectPath={currentProjectPath}
onClose={() => { onClose={() => {
setShowCreateDialog(false); setShowCreateDialog(false);
setCopyData(null); setCopyData(null);
@ -356,164 +383,197 @@ export const TeamListView = (): React.JSX.Element => {
} }
return ( return (
<div className="size-full overflow-auto p-4"> <TooltipProvider delayDuration={300}>
{renderHeader()} <div className="size-full overflow-auto p-4">
{renderHeader()}
{filteredTeams.length === 0 && searchQuery.trim() ? ( {filteredTeams.length === 0 && searchQuery.trim() ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]"> <div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
No teams matching &quot;{searchQuery.trim()}&quot; No teams matching &quot;{searchQuery.trim()}&quot;
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredTeams.map((team) => { {filteredTeams.map((team) => {
const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns); const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns);
const teamColorSet = team.color ? getTeamColorSet(team.color) : null; const teamColorSet = team.color ? getTeamColorSet(team.color) : null;
return ( return (
<div <div
key={team.teamName} key={team.teamName}
role="button" role="button"
tabIndex={0} 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 border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
style={ style={
teamColorSet teamColorSet
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border } ? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
: undefined : undefined
}
onClick={() => openTeamTab(team.teamName, team.projectPath)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamTab(team.teamName, team.projectPath);
} }
}} onClick={() => openTeamTab(team.teamName, team.projectPath)}
> onKeyDown={(e) => {
{teamColorSet ? ( if (e.key === 'Enter' || e.key === ' ') {
<div e.preventDefault();
className="pointer-events-none absolute inset-0 z-0 rounded-lg" openTeamTab(team.teamName, team.projectPath);
style={{ backgroundColor: teamColorSet.badge }} }
/> }}
) : null} >
<div className={teamColorSet ? 'relative z-10' : undefined}> {teamColorSet ? (
<div className="flex items-start justify-between"> <div
<div className="flex min-w-0 flex-1 items-center gap-2"> className="pointer-events-none absolute inset-0 z-0 rounded-lg"
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]"> style={{ backgroundColor: teamColorSet.badge }}
{team.displayName} />
</h3> ) : null}
<StatusBadge status={status} /> <div className={teamColorSet ? 'relative z-10' : undefined}>
</div> <div className="flex items-start justify-between">
<div className="flex shrink-0 gap-1"> <div className="flex min-w-0 flex-1 items-center gap-2">
<button <h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
type="button" {team.displayName}
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100" </h3>
onClick={(e) => handleCopyTeam(team.teamName, e)} <StatusBadge status={status} />
title="Copy team" </div>
> <div className="flex shrink-0 gap-1">
<Copy size={14} /> <Tooltip>
</button> <TooltipTrigger asChild>
<button <button
type="button" type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100" className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
onClick={(e) => handleDeleteTeam(team.teamName, e)} onClick={(e) => handleCopyTeam(team.teamName, e)}
title="Delete team"
>
<Trash2 size={14} />
</button>
</div>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? (
team.members.map((m) => {
const memberColor = m.color ? getTeamColorSet(m.color) : null;
return (
<span key={m.name} className="inline-flex items-center gap-1">
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={
memberColor
? {
backgroundColor: memberColor.badge,
color: memberColor.text,
border: `1px solid ${memberColor.border}40`,
}
: undefined
}
> >
{m.name} <Copy size={14} />
</span> </button>
{m.role ? ( </TooltipTrigger>
<span className="text-[9px] text-[var(--color-text-muted)]"> <TooltipContent side="bottom">Copy team</TooltipContent>
{m.role} </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) => handleDeleteTeam(team.teamName, e)}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Delete team</TooltipContent>
</Tooltip>
</div>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? (
team.members.map((m) => {
const memberColor = m.color ? getTeamColorSet(m.color) : null;
return (
<span key={m.name} className="inline-flex items-center gap-1">
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={
memberColor
? {
backgroundColor: memberColor.badge,
color: memberColor.text,
border: `1px solid ${memberColor.border}40`,
}
: undefined
}
>
{m.name}
</span> </span>
) : null} {m.role ? (
</span> <span className="text-[9px] text-[var(--color-text-muted)]">
); {m.role}
}) </span>
) : ( ) : null}
<Badge variant="secondary" className="text-[10px] font-normal"> </span>
Members: {team.memberCount} );
</Badge> })
)} ) : (
{(() => { <Badge variant="secondary" className="text-[10px] font-normal">
const tc = taskCountsByTeam.get(team.teamName); Members: {team.memberCount}
if (!tc || (tc.pending === 0 && tc.inProgress === 0 && tc.completed === 0)) { </Badge>
)}
{(() => {
const tc = taskCountsByTeam.get(team.teamName);
const pending = tc?.pending ?? 0;
const inProgress = tc?.inProgress ?? 0;
const completed = tc?.completed ?? 0;
const totalTasks = pending + inProgress + completed;
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
return ( return (
<Badge variant="secondary" className="text-[10px] font-normal"> <div className="mt-2 w-full space-y-1.5">
Tasks: 0 <div className="flex items-center gap-2">
</Badge> <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: `${Math.round(completedRatio * 100)}%` }}
/>
</div>
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
{completed}/{totalTasks}
</span>
</div>
{totalTasks > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
{inProgress > 0 && (
<span className="inline-flex items-center gap-1">
<Play size={10} className="shrink-0 text-blue-400" />
{inProgress} in_progress
</span>
)}
{pending > 0 && (
<span className="inline-flex items-center gap-1">
<Clock size={10} className="shrink-0 text-amber-400" />
{pending} pending
</span>
)}
{completed > 0 && (
<span className="inline-flex items-center gap-1">
<CheckCircle size={10} className="shrink-0 text-emerald-400" />
{completed} completed
</span>
)}
</div>
)}
</div>
); );
} })()}
</div>
{(() => {
const recentPaths = getRecentProjects(team);
if (recentPaths.length === 0) return null;
return ( return (
<> <div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
{tc.inProgress > 0 && ( <FolderOpen size={10} className="shrink-0" />
<span className="inline-flex items-center rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-400"> <span className="truncate">
{tc.inProgress} active {recentPaths.map((p, i) => (
</span> <span key={p} title={p}>
)} {i === 0 && status === 'running' ? (
{tc.pending > 0 && ( <span className="text-emerald-400">{folderName(p)}</span>
<span className="inline-flex items-center rounded-full bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400"> ) : (
{tc.pending} pending folderName(p)
</span> )}
)} {i < recentPaths.length - 1 ? ', ' : ''}
{tc.completed > 0 && ( </span>
<span className="inline-flex items-center rounded-full bg-green-500/15 px-1.5 py-0.5 text-[10px] font-medium text-green-400"> ))}
{tc.completed} done </span>
</span> </div>
)}
</>
); );
})()} })()}
</div> </div>
{(() => {
const recentPaths = getRecentProjects(team);
if (recentPaths.length === 0) return null;
return (
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<FolderOpen size={10} className="shrink-0" />
<span className="truncate">
{recentPaths.map((p, i) => (
<span key={p} title={p}>
{i === 0 && status === 'running' ? (
<span className="text-emerald-400">{folderName(p)}</span>
) : (
folderName(p)
)}
{i < recentPaths.length - 1 ? ', ' : ''}
</span>
))}
</span>
</div>
);
})()}
</div> </div>
</div> );
); })}
})} </div>
</div> )}
)} {createDialogElement}
{createDialogElement} </div>
</div> </TooltipProvider>
); );
}; };

View file

@ -1,5 +1,6 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatDistanceToNowStrict } from 'date-fns'; import { formatDistanceToNowStrict } from 'date-fns';
import { import {
@ -205,29 +206,40 @@ const SessionRow = ({
</button> </button>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<button <Tooltip>
type="button" <TooltipTrigger asChild>
title={isSelected ? 'Remove filter' : 'Filter by this session'} <button
className={`rounded p-0.5 text-[var(--color-text-muted)] transition-opacity hover:text-blue-400 ${ type="button"
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100' className={`rounded p-0.5 text-[var(--color-text-muted)] transition-opacity hover:text-blue-400 ${
}`} isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
onClick={(e) => { }`}
e.stopPropagation(); onClick={(e) => {
onToggleFilter(); e.stopPropagation();
}} onToggleFilter();
> }}
{isSelected ? <FilterX size={12} /> : <Filter size={12} />} >
</button> {isSelected ? <FilterX size={12} /> : <Filter size={12} />}
<button </button>
type="button" </TooltipTrigger>
className="rounded p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text)] group-hover:opacity-100" <TooltipContent side="left">
onClick={(e) => { {isSelected ? 'Remove filter' : 'Filter by this session'}
e.stopPropagation(); </TooltipContent>
onClick(); </Tooltip>
}} <Tooltip>
> <TooltipTrigger asChild>
<ExternalLink size={12} /> <button
</button> type="button"
className="rounded p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text)] group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<ExternalLink size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Open session</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
); );

View file

@ -29,6 +29,8 @@ interface ActivityItemProps {
message: InboxMessage; message: InboxMessage;
memberRole?: string; memberRole?: string;
memberColor?: string; memberColor?: string;
recipientColor?: string;
onMemberNameClick?: (memberName: string) => void;
onCreateTask?: (subject: string, description: string) => void; onCreateTask?: (subject: string, description: string) => void;
onReply?: (message: InboxMessage) => void; onReply?: (message: InboxMessage) => void;
} }
@ -124,10 +126,13 @@ export const ActivityItem = ({
message, message,
memberRole, memberRole,
memberColor, memberColor,
recipientColor,
onMemberNameClick,
onCreateTask, onCreateTask,
onReply, onReply,
}: ActivityItemProps): React.JSX.Element => { }: ActivityItemProps): React.JSX.Element => {
const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const recipientColors = message.to && recipientColor ? getTeamColorSet(recipientColor) : null;
const formattedRole = formatAgentRole(memberRole); const formattedRole = formatAgentRole(memberRole);
const timestamp = Number.isNaN(Date.parse(message.timestamp)) const timestamp = Number.isNaN(Date.parse(message.timestamp))
@ -217,17 +222,35 @@ export const ActivityItem = ({
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} /> <MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
)} )}
{/* Name badge */} {/* Name badge — clickable to open member popup */}
<span {onMemberNameClick ? (
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide" <button
style={{ type="button"
backgroundColor: colors.badge, className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
color: colors.text, style={{
border: `1px solid ${colors.border}40`, backgroundColor: colors.badge,
}} color: colors.text,
> border: `1px solid ${colors.border}40`,
{message.from} }}
</span> onClick={(e) => {
e.stopPropagation();
onMemberNameClick(message.from);
}}
>
{message.from}
</button>
) : (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: colors.badge,
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
>
{message.from}
</span>
)}
{/* Role */} {/* Role */}
{formattedRole ? ( {formattedRole ? (
@ -254,10 +277,57 @@ export const ActivityItem = ({
</span> </span>
) : null} ) : null}
{/* Recipient */} {/* Recipient — badge like sender, clickable to open member popup */}
{message.to && message.to !== message.from ? ( {message.to && message.to !== message.from && recipientColors ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}> <span className="text-[10px]">
&rarr; {message.to} <span style={{ color: CARD_ICON_MUTED }}>&rarr; </span>
{onMemberNameClick ? (
<button
type="button"
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{
backgroundColor: recipientColors.badge,
color: recipientColors.text,
border: `1px solid ${recipientColors.border}40`,
}}
onClick={(e) => {
e.stopPropagation();
onMemberNameClick(message.to!);
}}
>
{message.to}
</button>
) : (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: recipientColors.badge,
color: recipientColors.text,
border: `1px solid ${recipientColors.border}40`,
}}
>
{message.to}
</span>
)}
</span>
) : message.to && message.to !== message.from ? (
<span className="text-[10px]">
<span style={{ color: CARD_ICON_MUTED }}>&rarr; </span>
{onMemberNameClick ? (
<button
type="button"
className="rounded px-0.5 py-0 font-medium transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
onMemberNameClick(message.to!);
}}
>
{message.to}
</button>
) : (
<span style={{ color: CARD_ICON_MUTED }}>{message.to}</span>
)}
</span> </span>
) : null} ) : null}

View file

@ -1,3 +1,5 @@
import { getMemberColorByName } from '@shared/constants/memberColors';
import { ActivityItem } from './ActivityItem'; import { ActivityItem } from './ActivityItem';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
@ -7,6 +9,7 @@ interface ActivityTimelineProps {
members?: ResolvedTeamMember[]; members?: ResolvedTeamMember[];
onCreateTaskFromMessage?: (subject: string, description: string) => void; onCreateTaskFromMessage?: (subject: string, description: string) => void;
onReplyToMessage?: (message: InboxMessage) => void; onReplyToMessage?: (message: InboxMessage) => void;
onMemberClick?: (member: ResolvedTeamMember) => void;
} }
export const ActivityTimeline = ({ export const ActivityTimeline = ({
@ -14,17 +17,27 @@ export const ActivityTimeline = ({
members, members,
onCreateTaskFromMessage, onCreateTaskFromMessage,
onReplyToMessage, onReplyToMessage,
onMemberClick,
}: ActivityTimelineProps): React.JSX.Element => { }: ActivityTimelineProps): React.JSX.Element => {
const memberInfo = new Map<string, { role?: string; color?: string }>(); const memberInfo = new Map<string, { role?: string; color?: string }>();
if (members) { if (members) {
for (const m of members) { for (const m of members) {
memberInfo.set(m.name, { const info = {
role: m.role ?? (m.agentType !== 'general-purpose' ? m.agentType : undefined), role: m.role ?? (m.agentType !== 'general-purpose' ? m.agentType : undefined),
color: m.color, color: m.color,
}); };
memberInfo.set(m.name, info);
if (m.agentType && m.agentType !== m.name) {
memberInfo.set(m.agentType, info);
}
} }
} }
const handleMemberNameClick = (name: string): void => {
const member = members?.find((m) => m.name === name || m.agentType === name);
if (member) onMemberClick?.(member);
};
if (messages.length === 0) { if (messages.length === 0) {
return ( return (
<div className="rounded-md border border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]"> <div className="rounded-md border border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
@ -38,12 +51,17 @@ export const ActivityTimeline = ({
<div className="space-y-1"> <div className="space-y-1">
{messages.slice(0, 200).map((message, index) => { {messages.slice(0, 200).map((message, index) => {
const info = memberInfo.get(message.from); const info = memberInfo.get(message.from);
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
const recipientColor =
recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined);
return ( return (
<ActivityItem <ActivityItem
key={`${message.messageId ?? index}-${message.timestamp}-${message.from}`} key={`${message.messageId ?? index}-${message.timestamp}-${message.from}`}
message={message} message={message}
memberRole={info?.role} memberRole={info?.role}
memberColor={info?.color} memberColor={info?.color}
recipientColor={recipientColor}
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onCreateTask={onCreateTaskFromMessage} onCreateTask={onCreateTaskFromMessage}
onReply={onReplyToMessage} onReply={onReplyToMessage}
/> />

View file

@ -26,6 +26,7 @@ import {
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { getMemberColor } from '@shared/constants/memberColors'; import { getMemberColor } from '@shared/constants/memberColors';
import { Check, CheckCircle2, Loader2 } from 'lucide-react'; import { Check, CheckCircle2, Loader2 } from 'lucide-react';
@ -61,6 +62,7 @@ interface CreateTeamDialogProps {
provisioningError: string | null; provisioningError: string | null;
existingTeamNames: string[]; existingTeamNames: string[];
initialData?: TeamCopyData; initialData?: TeamCopyData;
defaultProjectPath?: string | null;
onClose: () => void; onClose: () => void;
onCreate: (request: TeamCreateRequest) => Promise<void>; onCreate: (request: TeamCreateRequest) => Promise<void>;
onOpenTeam: (teamName: string, projectPath?: string) => void; onOpenTeam: (teamName: string, projectPath?: string) => void;
@ -230,6 +232,7 @@ export const CreateTeamDialog = ({
provisioningError, provisioningError,
existingTeamNames, existingTeamNames,
initialData, initialData,
defaultProjectPath,
onClose, onClose,
onCreate, onCreate,
onOpenTeam, onOpenTeam,
@ -259,6 +262,15 @@ export const CreateTeamDialog = ({
const [launchTeam, setLaunchTeam] = useState(true); const [launchTeam, setLaunchTeam] = useState(true);
const [teamColor, setTeamColor] = useState(''); const [teamColor, setTeamColor] = useState('');
const resetUIState = (): void => {
setLocalError(null);
setFieldErrors({});
setIsSubmitting(false);
setPrepareState('idle');
setPrepareMessage(null);
setPrepareWarnings([]);
};
const resetFormState = (): void => { const resetFormState = (): void => {
setTeamName(''); setTeamName('');
descriptionDraft.clearDraft(); descriptionDraft.clearDraft();
@ -268,13 +280,8 @@ export const CreateTeamDialog = ({
setCwdMode('project'); setCwdMode('project');
setSelectedProjectPath(''); setSelectedProjectPath('');
setCustomCwd(''); setCustomCwd('');
setLocalError(null);
setFieldErrors({});
setIsSubmitting(false);
setPrepareState('idle');
setPrepareMessage(null);
setPrepareWarnings([]);
setLaunchTeam(true); setLaunchTeam(true);
resetUIState();
}; };
useEffect(() => { useEffect(() => {
@ -420,8 +427,15 @@ export const CreateTeamDialog = ({
if (selectedProjectPath || projects.length === 0) { if (selectedProjectPath || projects.length === 0) {
return; return;
} }
if (defaultProjectPath) {
const match = projects.find((p) => normalizePath(p.path) === defaultProjectPath);
if (match) {
setSelectedProjectPath(match.path);
return;
}
}
setSelectedProjectPath(projects[0].path); setSelectedProjectPath(projects[0].path);
}, [cwdMode, projects, selectedProjectPath]); }, [cwdMode, projects, selectedProjectPath, defaultProjectPath]);
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
@ -552,7 +566,7 @@ export const CreateTeamDialog = ({
open={open} open={open}
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
if (!nextOpen) { if (!nextOpen) {
resetFormState(); resetUIState();
onClose(); onClose();
} }
}} }}

View file

@ -19,6 +19,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
@ -180,13 +181,18 @@ export const SendMessageDialog = ({
{quote ? ( {quote ? (
<div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5"> <div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5">
<button <Tooltip>
type="button" <TooltipTrigger asChild>
className="absolute right-1.5 top-1.5 rounded p-0.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]" <button
onClick={() => setQuote(undefined)} type="button"
> className="absolute right-1.5 top-1.5 rounded p-0.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
<X size={12} /> onClick={() => setQuote(undefined)}
</button> >
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Remove quote</TooltipContent>
</Tooltip>
<span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]"> <span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to @{quote.from} Replying to @{quote.from}
</span> </span>

View file

@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead'; import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
@ -156,13 +157,18 @@ export const TaskCommentsSection = ({
{replyTo.text} {replyTo.text}
</div> </div>
</div> </div>
<button <Tooltip>
type="button" <TooltipTrigger asChild>
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]" <button
onClick={() => setReplyTo(null)} type="button"
> className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
<X size={12} /> onClick={() => setReplyTo(null)}
</button> >
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Cancel reply</TooltipContent>
</Tooltip>
</div> </div>
) : null} ) : null}

View file

@ -12,7 +12,14 @@ import {
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { TASK_STATUS_LABELS, TASK_STATUS_STYLES } from '@renderer/utils/memberHelpers'; import { TASK_STATUS_LABELS, TASK_STATUS_STYLES } from '@renderer/utils/memberHelpers';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { ArrowLeftFromLine, ArrowRightFromLine, Clock, FileText, User } from 'lucide-react'; import {
ArrowLeftFromLine,
ArrowRightFromLine,
Clock,
FileText,
PenLine,
User,
} from 'lucide-react';
import { TaskCommentsSection } from './TaskCommentsSection'; import { TaskCommentsSection } from './TaskCommentsSection';
@ -92,6 +99,12 @@ export const TaskDetailDialog = ({
{currentTask.owner ?? '\u2014'} {currentTask.owner ?? '\u2014'}
</span> </span>
</div> </div>
{currentTask.createdBy ? (
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
<PenLine size={12} />
<span className="text-[var(--color-text-secondary)]">{currentTask.createdBy}</span>
</div>
) : null}
{currentTask.createdAt {currentTask.createdAt
? (() => { ? (() => {
const date = new Date(currentTask.createdAt); const date = new Date(currentTask.createdAt);
@ -204,18 +217,12 @@ export const TaskDetailDialog = ({
{/* Separator */} {/* Separator */}
<div className="border-t border-[var(--color-border)]" /> <div className="border-t border-[var(--color-border)]" />
{/* Session Logs */} {/* Session Logs — sessions that reference this task */}
<div className="min-w-0 overflow-hidden"> <div className="min-w-0 overflow-hidden">
<h4 className="mb-2 text-xs font-medium text-[var(--color-text-muted)]"> <h4 className="mb-2 text-xs font-medium text-[var(--color-text-muted)]">
Execution Logs Execution Logs
</h4> </h4>
{currentTask.owner ? ( <MemberLogsTab teamName={teamName} taskId={currentTask.id} />
<MemberLogsTab teamName={teamName} memberName={currentTask.owner} />
) : (
<p className="py-6 text-center text-xs text-[var(--color-text-muted)]">
Assign a member to see execution logs
</p>
)}
</div> </div>
<DialogFooter> <DialogFooter>

View file

@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { Columns3, LayoutGrid } from 'lucide-react'; import { Columns3, LayoutGrid } from 'lucide-react';
@ -138,36 +139,44 @@ export const KanbanBoard = ({
onFilterChange={onFilterChange} onFilterChange={onFilterChange}
/> />
<div className="inline-flex rounded-md border border-[var(--color-border)]"> <div className="inline-flex rounded-md border border-[var(--color-border)]">
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="sm" <Button
className={cn( variant="ghost"
'h-7 rounded-r-none px-2', size="sm"
viewMode === 'grid' className={cn(
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]' 'h-7 rounded-r-none px-2',
: 'text-[var(--color-text-muted)]' viewMode === 'grid'
)} ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
onClick={() => setViewMode('grid')} : 'text-[var(--color-text-muted)]'
aria-label="Grid view" )}
title="Grid" onClick={() => setViewMode('grid')}
> aria-label="Grid view"
<LayoutGrid size={14} /> >
</Button> <LayoutGrid size={14} />
<Button </Button>
variant="ghost" </TooltipTrigger>
size="sm" <TooltipContent side="bottom">Grid view</TooltipContent>
className={cn( </Tooltip>
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2', <Tooltip>
viewMode === 'columns' <TooltipTrigger asChild>
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]' <Button
: 'text-[var(--color-text-muted)]' variant="ghost"
)} size="sm"
onClick={() => setViewMode('columns')} className={cn(
aria-label="Columns view" 'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
title="Columns" viewMode === 'columns'
> ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
<Columns3 size={14} /> : 'text-[var(--color-text-muted)]'
</Button> )}
onClick={() => setViewMode('columns')}
aria-label="Columns view"
>
<Columns3 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Columns view</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>

View file

@ -3,6 +3,7 @@ import { useMemo } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox'; import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Crown, Filter } from 'lucide-react'; import { Crown, Filter } from 'lucide-react';
import type { Session } from '@renderer/types/data'; import type { Session } from '@renderer/types/data';
@ -57,22 +58,26 @@ export const KanbanFilterPopover = ({
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
variant="ghost" <PopoverTrigger asChild>
size="sm" <Button
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]" variant="ghost"
aria-label="Filter tasks" size="sm"
title="Filter" className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
> aria-label="Filter tasks"
<Filter size={14} /> >
{activeCount > 0 && ( <Filter size={14} />
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white"> {activeCount > 0 && (
{activeCount} <span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
</span> {activeCount}
)} </span>
</Button> )}
</PopoverTrigger> </Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Filter tasks</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-72 p-0"> <PopoverContent align="end" className="w-72 p-0">
{/* Session section */} {/* Session section */}
<div className="border-b border-[var(--color-border)] p-3"> <div className="border-b border-[var(--color-border)] p-3">

View file

@ -1,12 +1,16 @@
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import { ListPlus, MessageSquare } from 'lucide-react'; import { ListPlus, MessageSquare } from 'lucide-react';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { ResolvedTeamMember } from '@shared/types'; import type { ResolvedTeamMember } from '@shared/types';
interface MemberCardProps { interface MemberCardProps {
member: ResolvedTeamMember; member: ResolvedTeamMember;
memberColor: string;
taskCounts?: TaskStatusCounts | null;
isTeamAlive?: boolean; isTeamAlive?: boolean;
onClick?: () => void; onClick?: () => void;
onSendMessage?: () => void; onSendMessage?: () => void;
@ -15,6 +19,8 @@ interface MemberCardProps {
export const MemberCard = ({ export const MemberCard = ({
member, member,
memberColor,
taskCounts,
isTeamAlive, isTeamAlive,
onClick, onClick,
onSendMessage, onSendMessage,
@ -22,81 +28,108 @@ export const MemberCard = ({
}: MemberCardProps): React.JSX.Element => { }: MemberCardProps): React.JSX.Element => {
const dotClass = getMemberDotClass(member, isTeamAlive); const dotClass = getMemberDotClass(member, isTeamAlive);
const presenceLabel = getPresenceLabel(member, isTeamAlive); const presenceLabel = getPresenceLabel(member, isTeamAlive);
const colors = getTeamColorSet(memberColor);
const pending = taskCounts?.pending ?? 0;
const inProgress = taskCounts?.inProgress ?? 0;
const completed = taskCounts?.completed ?? 0;
const totalTasks = pending + inProgress + completed;
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
const progressPercent = Math.round(completedRatio * 100);
return ( return (
<div <div className="rounded">
className="group flex cursor-pointer items-center gap-2.5 rounded px-2 py-1.5 hover:bg-[var(--color-surface-raised)]" <div
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined} className="group relative flex cursor-pointer items-center gap-2.5 rounded-t px-2 py-1.5"
role="button" style={{
tabIndex={0} borderLeft: `3px solid ${colors.border}`,
onClick={onClick} backgroundColor: colors.badge,
onKeyDown={(e) => { }}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
}}
>
<div className="relative shrink-0">
<img
src={agentAvatarUrl(member.name)}
alt={member.name}
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<span
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
aria-label={member.status}
/>
</div>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text)]">
{member.name}
</span>
{(() => {
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
return roleLabel ? (
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
{roleLabel}
</span>
) : null;
})()}
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined} title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
}}
> >
{presenceLabel} <div className="pointer-events-none absolute inset-0 rounded-t transition-colors group-hover:bg-white/5" />
</Badge> <div className="relative shrink-0">
<Badge <img
variant="secondary" src={agentAvatarUrl(member.name)}
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none" alt={member.name}
> className="size-7 rounded-full bg-[var(--color-surface-raised)]"
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'} loading="lazy"
</Badge> />
<div className="flex shrink-0 items-center gap-0.5"> <span
<button className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
type="button" aria-label={member.status}
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]" />
title="Send Message" </div>
onClick={(e) => { <span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text)]">
e.stopPropagation(); {member.name}
onSendMessage?.(); </span>
}} {(() => {
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
return roleLabel ? (
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
{roleLabel}
</span>
) : null;
})()}
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
> >
<MessageSquare size={13} /> {presenceLabel}
</button> </Badge>
<button <Badge
type="button" variant="secondary"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]" className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
title="Assign Task"
onClick={(e) => {
e.stopPropagation();
onAssignTask?.();
}}
> >
<ListPlus size={13} /> {member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
</button> </Badge>
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
title="Send Message"
onClick={(e) => {
e.stopPropagation();
onSendMessage?.();
}}
>
<MessageSquare size={13} />
</button>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
title="Assign Task"
onClick={(e) => {
e.stopPropagation();
onAssignTask?.();
}}
>
<ListPlus size={13} />
</button>
</div>
</div> </div>
<div
className="h-0.5 rounded-b bg-[var(--color-border)]"
role="progressbar"
aria-valuenow={completed}
aria-valuemin={0}
aria-valuemax={totalTasks}
aria-label={`Tasks ${completed}/${totalTasks} completed`}
title={`${completed}/${totalTasks} tasks`}
style={{
background: `linear-gradient(to right, #10b981 ${progressPercent}%, var(--color-border) ${progressPercent}%)`,
}}
/>
</div> </div>
); );
}; };

View file

@ -1,9 +1,13 @@
import { getMemberColor } from '@shared/constants/memberColors';
import { MemberCard } from './MemberCard'; import { MemberCard } from './MemberCard';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { ResolvedTeamMember } from '@shared/types'; import type { ResolvedTeamMember } from '@shared/types';
interface MemberListProps { interface MemberListProps {
members: ResolvedTeamMember[]; members: ResolvedTeamMember[];
memberTaskCounts?: Map<string, TaskStatusCounts>;
isTeamAlive?: boolean; isTeamAlive?: boolean;
onMemberClick?: (member: ResolvedTeamMember) => void; onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void; onSendMessage?: (member: ResolvedTeamMember) => void;
@ -12,6 +16,7 @@ interface MemberListProps {
export const MemberList = ({ export const MemberList = ({
members, members,
memberTaskCounts,
isTeamAlive, isTeamAlive,
onMemberClick, onMemberClick,
onSendMessage, onSendMessage,
@ -27,10 +32,12 @@ export const MemberList = ({
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{members.map((member) => ( {members.map((member, index) => (
<MemberCard <MemberCard
key={member.name} key={member.name}
member={member} member={member}
memberColor={member.color ?? getMemberColor(index)}
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
isTeamAlive={isTeamAlive} isTeamAlive={isTeamAlive}
onClick={() => onMemberClick?.(member)} onClick={() => onMemberClick?.(member)}
onSendMessage={() => onSendMessage?.(member)} onSendMessage={() => onSendMessage?.(member)}

View file

@ -18,10 +18,15 @@ import type { MemberLogSummary } from '@shared/types';
interface MemberLogsTabProps { interface MemberLogsTabProps {
teamName: string; teamName: string;
memberName: string; memberName?: string;
taskId?: string;
} }
export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): React.JSX.Element => { export const MemberLogsTab = ({
teamName,
memberName,
taskId,
}: MemberLogsTabProps): React.JSX.Element => {
const [logs, setLogs] = useState<MemberLogSummary[]>([]); const [logs, setLogs] = useState<MemberLogSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -36,7 +41,14 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea
void (async () => { void (async () => {
try { try {
const result = await api.teams.getMemberLogs(teamName, memberName); if (taskId == null && !memberName) {
if (!cancelled) setLogs([]);
return;
}
const result =
taskId != null
? await api.teams.getLogsForTask(teamName, taskId)
: await api.teams.getMemberLogs(teamName, memberName!);
if (!cancelled) { if (!cancelled) {
setLogs(result); setLogs(result);
} }
@ -54,7 +66,7 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [teamName, memberName]); }, [teamName, memberName, taskId]);
const handleExpand = useCallback( const handleExpand = useCallback(
async (log: MemberLogSummary) => { async (log: MemberLogSummary) => {
@ -112,7 +124,9 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea
<FileText size={20} className="mx-auto mb-2 opacity-40" /> <FileText size={20} className="mx-auto mb-2 opacity-40" />
No logs found No logs found
<p className="mt-1 text-[10px] opacity-60"> <p className="mt-1 text-[10px] opacity-60">
This member has no recorded session activity yet {taskId != null
? 'No session activity for this task yet'
: 'This member has no recorded session activity yet'}
</p> </p>
</div> </div>
); );

View file

@ -0,0 +1,173 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Filter } from 'lucide-react';
import type { InboxMessage } from '@shared/types';
export interface MessagesFilterState {
from: Set<string>;
to: Set<string>;
}
interface MessagesFilterPopoverProps {
filter: MessagesFilterState;
messages: InboxMessage[];
open: boolean;
onOpenChange: (open: boolean) => void;
onApply: (filter: MessagesFilterState) => void;
}
function collectFromOptions(messages: InboxMessage[]): string[] {
const set = new Set<string>();
for (const m of messages) {
if (m.from?.trim()) set.add(m.from.trim());
}
return Array.from(set).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
}
function collectToOptions(messages: InboxMessage[]): string[] {
const set = new Set<string>();
for (const m of messages) {
if (m.to?.trim()) set.add(m.to.trim());
}
return Array.from(set).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
}
export const MessagesFilterPopover = ({
filter,
messages,
open,
onOpenChange,
onApply,
}: MessagesFilterPopoverProps): React.JSX.Element => {
const [draft, setDraft] = useState<MessagesFilterState>({ from: new Set(), to: new Set() });
useEffect(() => {
if (open) {
const next = {
from: new Set(filter.from),
to: new Set(filter.to),
};
const schedule = (): void => setDraft(next);
queueMicrotask(schedule);
}
}, [open, filter.from, filter.to]);
const fromOptions = useMemo(() => collectFromOptions(messages), [messages]);
const toOptions = useMemo(() => collectToOptions(messages), [messages]);
const activeCount = (filter.from.size > 0 ? 1 : 0) + (filter.to.size > 0 ? 1 : 0);
const draftCount = (draft.from.size > 0 ? 1 : 0) + (draft.to.size > 0 ? 1 : 0);
const toggleFrom = (name: string): void => {
setDraft((prev) => {
const next = new Set(prev.from);
if (next.has(name)) next.delete(name);
else next.add(name);
return { ...prev, from: next };
});
};
const toggleTo = (name: string): void => {
setDraft((prev) => {
const next = new Set(prev.to);
if (next.has(name)) next.delete(name);
else next.add(name);
return { ...prev, to: next };
});
};
const handleSave = (): void => {
onApply(draft);
onOpenChange(false);
};
const handleReset = (): void => {
const empty = { from: new Set<string>(), to: new Set<string>() };
setDraft(empty);
onApply(empty);
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
aria-label="Filter messages"
title="Filter"
>
<Filter size={14} />
{activeCount > 0 && (
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
{activeCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-0">
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
From
</p>
<div className="max-h-40 space-y-1 overflow-y-auto">
{fromOptions.length === 0 ? (
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
) : (
fromOptions.map((name) => (
<label
key={name}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
>
<Checkbox
checked={draft.from.has(name)}
onCheckedChange={() => toggleFrom(name)}
/>
{name}
</label>
))
)}
</div>
</div>
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
To
</p>
<div className="max-h-40 space-y-1 overflow-y-auto">
{toOptions.length === 0 ? (
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
) : (
toOptions.map((name) => (
<label
key={name}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
>
<Checkbox checked={draft.to.has(name)} onCheckedChange={() => toggleTo(name)} />
{name}
</label>
))
)}
</div>
</div>
<div className="flex justify-between gap-2 p-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
disabled={draftCount === 0}
onClick={handleReset}
>
Reset
</Button>
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={handleSave}>
Save
</Button>
</div>
</PopoverContent>
</Popover>
);
};

View file

@ -168,6 +168,22 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
textareaRef: internalRef, textareaRef: internalRef,
}); });
// Sync backdrop font with textarea computed font to prevent caret drift.
// Chromium UA stylesheet may apply different font-family / letter-spacing
// to <textarea> vs <div>, and sub-pixel differences accumulate over text length.
React.useLayoutEffect(() => {
const textarea = internalRef.current;
const backdrop = backdropRef.current;
if (!textarea || !backdrop) return;
const cs = window.getComputedStyle(textarea);
backdrop.style.font = cs.font;
backdrop.style.letterSpacing = cs.letterSpacing;
backdrop.style.wordSpacing = cs.wordSpacing;
backdrop.style.textIndent = cs.textIndent;
backdrop.style.textTransform = cs.textTransform;
backdrop.style.tabSize = cs.tabSize;
}, [value]); // re-sync when value changes (textarea may reflow)
// --- Mention overlay --- // --- Mention overlay ---
const hasMentionOverlay = suggestions.length > 0; const hasMentionOverlay = suggestions.length > 0;

View file

@ -84,7 +84,7 @@ export const Combobox = ({
</div> </div>
<CommandPrimitive.List <CommandPrimitive.List
id={listboxId} id={listboxId}
className="max-h-72 overflow-y-auto overscroll-contain p-1" className="max-h-72 overflow-y-auto overscroll-contain px-2 py-1"
onWheel={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}
> >
<CommandPrimitive.Empty className="px-2 py-4 text-center text-xs text-[var(--color-text-muted)]"> <CommandPrimitive.Empty className="px-2 py-4 text-center text-xs text-[var(--color-text-muted)]">
@ -111,7 +111,7 @@ export const Combobox = ({
setOpen(false); setOpen(false);
setSearch(''); setSearch('');
}} }}
className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]" className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-0 pr-2 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
> >
{renderOption ? ( {renderOption ? (
renderOption(option, isSelected, search) renderOption(option, isSelected, search)

View file

@ -0,0 +1,32 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard Radix/shadcn pattern */
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@renderer/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipPortal = TooltipPrimitive.Portal;
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 max-w-[var(--radix-tooltip-content-available-width)] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger };
/* eslint-enable react/jsx-props-no-spreading -- Standard Radix/shadcn pattern */

View file

@ -28,16 +28,16 @@
--highlight-text-inactive: #fef08a; --highlight-text-inactive: #fef08a;
--highlight-ring: #facc15; --highlight-ring: #facc15;
/* User chat bubble — cool slate */ /* User chat bubble — orange (Claude Code) */
--chat-user-bg: #1c1d26; --chat-user-bg: #1c1d26;
--chat-user-text: #94a3b8; --chat-user-text: #94a3b8;
--chat-user-border: rgba(148, 163, 184, 0.1); --chat-user-border: rgba(249, 115, 22, 0.2);
--chat-user-shadow: 0 1px 0 0 rgba(99, 102, 241, 0.04); --chat-user-shadow: 0 1px 0 0 rgba(249, 115, 22, 0.06);
/* User bubble inline tags */ /* User bubble inline tags */
--chat-user-tag-bg: rgba(148, 163, 184, 0.08); --chat-user-tag-bg: rgba(249, 115, 22, 0.12);
--chat-user-tag-text: #e2e8f0; --chat-user-tag-text: #fb923c;
--chat-user-tag-border: rgba(148, 163, 184, 0.12); --chat-user-tag-border: rgba(249, 115, 22, 0.2);
/* Tool items */ /* Tool items */
--tool-item-name: #e2e8f0; --tool-item-name: #e2e8f0;
@ -218,16 +218,16 @@
--highlight-text-inactive: #422006; --highlight-text-inactive: #422006;
--highlight-ring: #ca8a04; --highlight-ring: #ca8a04;
/* User chat bubble - Warm neutral, clearly visible */ /* User chat bubble - orange (Claude Code) */
--chat-user-bg: #eae9e6; --chat-user-bg: #eae9e6;
--chat-user-text: #5a5955; --chat-user-text: #5a5955;
--chat-user-border: #d5d3cf; --chat-user-border: rgba(249, 115, 22, 0.35);
--chat-user-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.04); --chat-user-shadow: 0 1px 2px 0 rgba(249, 115, 22, 0.08);
/* User bubble inline tags - Warm neutral */ /* User bubble inline tags */
--chat-user-tag-bg: rgba(0, 0, 0, 0.05); --chat-user-tag-bg: rgba(249, 115, 22, 0.12);
--chat-user-tag-text: #3a3935; --chat-user-tag-text: #c2410c;
--chat-user-tag-border: rgba(0, 0, 0, 0.08); --chat-user-tag-border: rgba(249, 115, 22, 0.25);
/* Tool items - Warm high contrast */ /* Tool items - Warm high contrast */
--tool-item-name: #1c1b19; --tool-item-name: #1c1b19;

View file

@ -39,3 +39,18 @@ export function buildTaskCountsByTeam(tasks: GlobalTask[]): Map<string, TaskStat
} }
return map; return map;
} }
/** Build a map of owner name (lowercase) -> task status counts (ignores deleted). */
export function buildTaskCountsByOwner(
tasks: { owner?: string | null; status: string }[]
): Map<string, TaskStatusCounts> {
const map = new Map<string, TaskStatusCounts>();
for (const task of tasks) {
const owner = task.owner?.trim();
if (!owner || task.status === 'deleted') continue;
const key = owner.toLowerCase();
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
map.set(key, incrementStatus(counts, task.status));
}
return map;
}

View file

@ -8,3 +8,12 @@ export const MEMBER_COLOR_PALETTE = ['blue', 'green', 'yellow', 'cyan', 'magenta
export function getMemberColor(index: number): string { export function getMemberColor(index: number): string {
return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length]; return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length];
} }
/** Derive a stable fallback color from a member name (position-independent). */
export function getMemberColorByName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
}
return MEMBER_COLOR_PALETTE[Math.abs(hash) % MEMBER_COLOR_PALETTE.length];
}

View file

@ -361,6 +361,7 @@ export interface TeamsAPI {
aliveList: () => Promise<string[]>; aliveList: () => Promise<string[]>;
createConfig: (request: TeamCreateConfigRequest) => Promise<void>; createConfig: (request: TeamCreateConfigRequest) => Promise<void>;
getMemberLogs: (teamName: string, memberName: string) => Promise<MemberLogSummary[]>; getMemberLogs: (teamName: string, memberName: string) => Promise<MemberLogSummary[]>;
getLogsForTask: (teamName: string, taskId: string) => Promise<MemberLogSummary[]>;
getMemberStats: (teamName: string, memberName: string) => Promise<MemberFullStats>; getMemberStats: (teamName: string, memberName: string) => Promise<MemberFullStats>;
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>; launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
getAllTasks: () => Promise<GlobalTask[]>; getAllTasks: () => Promise<GlobalTask[]>;

View file

@ -60,6 +60,7 @@ export interface TeamTask {
description?: string; description?: string;
activeForm?: string; activeForm?: string;
owner?: string; owner?: string;
createdBy?: string;
status: TeamTaskStatus; status: TeamTaskStatus;
blocks?: string[]; blocks?: string[];
blockedBy?: string[]; blockedBy?: string[];

View file

@ -21,6 +21,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_PROCESS_ALIVE: 'team:processAlive', TEAM_PROCESS_ALIVE: 'team:processAlive',
TEAM_ALIVE_LIST: 'team:aliveList', TEAM_ALIVE_LIST: 'team:aliveList',
TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs', TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs',
TEAM_GET_LOGS_FOR_TASK: 'team:getLogsForTask',
TEAM_GET_MEMBER_STATS: 'team:getMemberStats', TEAM_GET_MEMBER_STATS: 'team:getMemberStats',
TEAM_UPDATE_CONFIG: 'team:updateConfig', TEAM_UPDATE_CONFIG: 'team:updateConfig',
TEAM_START_TASK: 'team:startTask', TEAM_START_TASK: 'team:startTask',
@ -45,6 +46,7 @@ import {
TEAM_REQUEST_REVIEW, TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE, TEAM_SEND_MESSAGE,
TEAM_GET_ALL_TASKS, TEAM_GET_ALL_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS, TEAM_GET_MEMBER_STATS,
TEAM_START_TASK, TEAM_START_TASK,
@ -135,6 +137,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(true); expect(handlers.has(TEAM_ALIVE_LIST)).toBe(true);
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(true); expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(true);
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(true); expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(true);
expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(true);
expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(true); expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(true);
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true); expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true);
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(true); expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(true);
@ -303,6 +306,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(false); expect(handlers.has(TEAM_ALIVE_LIST)).toBe(false);
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(false); expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(false);
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(false); expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(false);
expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(false);
expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(false); expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(false);
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false); expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false);
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false); expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false);

View file

@ -121,4 +121,46 @@ describe('TeamDataService', () => {
expect.objectContaining({ projectPath: '/Users/dev/my-project' }) expect.objectContaining({ projectPath: '/Users/dev/my-project' })
); );
}); });
it('creates task with status pending when startImmediately is false', async () => {
const createTaskMock = vi.fn(async () => undefined);
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
} as never,
{
getNextTaskId: vi.fn(async () => '2'),
getTasks: vi.fn(async () => []),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => []),
} as never,
{} as never,
{
createTask: createTaskMock,
addBlocksEntry: vi.fn(async () => undefined),
} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect: vi.fn(async () => undefined),
} as never
);
const result = await service.createTask('my-team', {
subject: 'Review main file',
owner: 'alice',
startImmediately: false,
});
expect(result.status).toBe('pending');
expect(createTaskMock).toHaveBeenCalledWith(
'my-team',
expect.objectContaining({ status: 'pending', owner: 'alice', createdBy: 'user' })
);
});
}); });