feat: add task logging functionality and enhance team management
- Introduced TEAM_GET_LOGS_FOR_TASK IPC channel to retrieve session logs related to specific tasks. - Implemented handleGetLogsForTask function to validate inputs and fetch logs for a given task. - Updated TeamMemberLogsFinder to include findLogsForTask method for session log retrieval based on task ID. - Enhanced UI components to support displaying logs for tasks, improving task management and visibility. - Updated related services and components to accommodate the new logging functionality. These changes aim to enhance task tracking and improve collaboration within teams by providing access to relevant session logs.
This commit is contained in:
parent
6bcc525ace
commit
55f7611b14
24 changed files with 412 additions and 90 deletions
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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 || undefined,
|
||||||
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>]',
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,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,
|
||||||
|
|
|
||||||
|
|
@ -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, knownMembers } = 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[]> {
|
||||||
|
|
|
||||||
|
|
@ -263,16 +263,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 +296,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 +315,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 +348,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}
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,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)]" />
|
||||||
|
|
@ -611,6 +612,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
<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);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,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 +114,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 +152,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 +183,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 +196,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 +269,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);
|
||||||
|
|
|
||||||
|
|
@ -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,25 @@ export const ActivityItem = ({
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Recipient */}
|
{/* Recipient — clickable to open member popup */}
|
||||||
{message.to && message.to !== message.from ? (
|
{message.to && message.to !== message.from ? (
|
||||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
<span className="text-[10px]">
|
||||||
→ {message.to}
|
<span style={{ color: CARD_ICON_MUTED }}>→ </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: recipientColors?.text ?? CARD_ICON_MUTED }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMemberNameClick(message.to!);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.to}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: recipientColors?.text ?? CARD_ICON_MUTED }}>{message.to}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,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 +15,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 +49,15 @@ 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;
|
||||||
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={recipientInfo?.color}
|
||||||
|
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||||
onCreateTask={onCreateTaskFromMessage}
|
onCreateTask={onCreateTaskFromMessage}
|
||||||
onReply={onReplyToMessage}
|
onReply={onReplyToMessage}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -420,8 +423,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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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';
|
||||||
|
|
@ -7,6 +8,7 @@ import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
interface MemberCardProps {
|
interface MemberCardProps {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
|
memberColor: string;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onSendMessage?: () => void;
|
onSendMessage?: () => void;
|
||||||
|
|
@ -15,6 +17,7 @@ interface MemberCardProps {
|
||||||
|
|
||||||
export const MemberCard = ({
|
export const MemberCard = ({
|
||||||
member,
|
member,
|
||||||
|
memberColor,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
onClick,
|
onClick,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
|
|
@ -22,10 +25,15 @@ 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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group flex cursor-pointer items-center gap-2.5 rounded px-2 py-1.5 hover:bg-[var(--color-surface-raised)]"
|
className="group flex cursor-pointer items-center gap-2.5 rounded px-2 py-1.5 hover:bg-[var(--color-surface-raised)]"
|
||||||
|
style={{
|
||||||
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
|
backgroundColor: colors.badge,
|
||||||
|
}}
|
||||||
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
|
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
|
|
||||||
import { MemberCard } from './MemberCard';
|
import { MemberCard } from './MemberCard';
|
||||||
|
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
@ -27,10 +29,11 @@ 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)}
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
onClick={() => onMemberClick?.(member)}
|
onClick={() => onMemberClick?.(member)}
|
||||||
onSendMessage={() => onSendMessage?.(member)}
|
onSendMessage={() => onSendMessage?.(member)}
|
||||||
|
|
|
||||||
|
|
@ -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,10 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const result = await api.teams.getMemberLogs(teamName, memberName);
|
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 +62,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 +120,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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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[]>;
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue