diff --git a/package.json b/package.json index 1f94361b..8a25762f 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-virtual": "^3.10.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16fdc299..3e40b1b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@radix-ui/react-tabs': 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) + '@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': specifier: ^3.10.8 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': 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': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -6176,6 +6192,26 @@ snapshots: '@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)': dependencies: react: 18.3.1 diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index ed873fd5..0d6212c4 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -8,6 +8,7 @@ import { TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_DATA, + TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_LAUNCH, @@ -101,6 +102,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList); ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig); 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_UPDATE_CONFIG, handleUpdateConfig); ipcMain.handle(TEAM_START_TASK, handleStartTask); @@ -128,6 +130,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_ALIVE_LIST); ipcMain.removeHandler(TEAM_CREATE_CONFIG); ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS); + ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK); ipcMain.removeHandler(TEAM_GET_MEMBER_STATS); ipcMain.removeHandler(TEAM_UPDATE_CONFIG); ipcMain.removeHandler(TEAM_START_TASK); @@ -763,6 +766,24 @@ async function handleGetMemberLogs( ); } +async function handleGetLogsForTask( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown +): Promise> { + 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 { if (!memberStatsComputer) { throw new Error('Member stats computer is not initialized'); diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index b85f6ba9..018540fe 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -274,12 +274,15 @@ function createTask(paths, flags) { const taskPath = path.join(paths.tasksDir, String(nextId) + '.json'); 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 = { id: nextId, subject, description: String(description || subject), activeForm: activeForm ? String(activeForm) : undefined, owner, + createdBy: from, status, blocks: [], blockedBy: [], @@ -419,7 +422,7 @@ function printHelp() { ' node teamctl.js task set-status [--team ]', ' node teamctl.js task complete [--team ]', ' node teamctl.js task start [--team ]', - ' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--notify --from "member"] [--team ]', + ' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team ]', ' node teamctl.js task comment --text "..." [--from "member"] [--team ]', ' node teamctl.js kanban set-column [--team ]', ' node teamctl.js kanban clear [--team ]', diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 3006d6c3..adc55287 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -203,6 +203,20 @@ export class TeamDataService { tasks, 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 { teamName, config, @@ -244,6 +258,7 @@ export class TeamDataService { subject: request.subject, description, owner: request.owner, + createdBy: 'user', status: shouldStart ? 'in_progress' : 'pending', blocks: [], 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 { + const TASK_ID_PATTERN = /#(\d+)/g; + let synced = false; + + // Dedup broadcasts: same sender + same text → process only once + const processedTexts = new Set(); + + 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(); + 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 { if (!config.leadSessionId || !config.projectPath) { return []; diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 06040dcc..bb5ecf15 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -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 { + 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. * Uses the same discovery logic as findMemberLogs but collects file paths. @@ -149,16 +211,12 @@ export class TeamMemberLogsFinder { return paths; } - private async discoverMemberFiles( - teamName: string, - memberName: string - ): Promise<{ + private async discoverProjectSessions(teamName: string): Promise<{ projectDir: string; projectId: string; config: NonNullable>>; sessionIds: string[]; knownMembers: Set; - isLeadMember: boolean; } | null> { const config = await this.configReader.getConfig(teamName); if (!config?.projectPath) { @@ -171,11 +229,6 @@ export class TeamMemberLogsFinder { const baseDir = extractBaseDir(projectId); 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(); if (config.leadSessionId) { knownSessionIds.add(config.leadSessionId); @@ -190,17 +243,14 @@ export class TeamMemberLogsFinder { let sessionIds: string[]; if (knownSessionIds.size > 0) { - // Verify each known session dir exists, fall back to full scan if none exist const verified: string[] = []; for (const sid of knownSessionIds) { const sidDir = path.join(projectDir, sid); try { const stat = await fs.stat(sidDir); - if (stat.isDirectory()) { - verified.push(sid); - } + if (stat.isDirectory()) verified.push(sid); } catch { - // dir doesn't exist, skip + // dir doesn't exist } } sessionIds = verified.length > 0 ? verified : await this.listSessionDirs(projectDir); @@ -217,26 +267,69 @@ export class TeamMemberLogsFinder { const metaMembers = await this.membersMetaStore.getMembers(teamName); for (const member of metaMembers) { const normalized = member.name.trim().toLowerCase(); - if (normalized.length > 0) { - knownMembers.add(normalized); - } + if (normalized.length > 0) knownMembers.add(normalized); } } catch { - // Best-effort enrichment. + // best-effort } try { const inboxMembers = await this.inboxReader.listInboxNames(teamName); - for (const memberNameFromInbox of inboxMembers) { - const normalized = memberNameFromInbox.trim().toLowerCase(); - if (normalized.length > 0) { - knownMembers.add(normalized); - } + for (const name of inboxMembers) { + const normalized = name.trim().toLowerCase(); + if (normalized.length > 0) knownMembers.add(normalized); } } 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>>; + sessionIds: string[]; + knownMembers: Set; + 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 { + 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 { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 278133bc..15d66ea6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -251,6 +251,10 @@ function buildTaskStatusProtocol(teamName: string): string { 5. NEVER skip status updates. A task is NOT done until completed status is written. 6. To reply to a comment on a task: node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task comment --text \\"\\" --from \\"\\" +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 --text \\"\\" --from \\"\\" + 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 # in your SendMessage summary field for traceability. 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` : ''; + 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. +You are "${leadName}", the team lead. Goal: Provision a Claude Code agent team with live teammates. - +${userPromptBlock} Constraints: - Do NOT call TeamDelete under any circumstances. - Do NOT use TodoWrite — use TaskCreate for tasks. - Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). - Do NOT shut down, terminate, or clean up the team or its members. - Keep assistant text minimal. +- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. + +Task board operations — use teamctl.js via Bash: +- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "" --notify --from "${leadName}" +- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task set-status Steps (execute in this exact order): @@ -287,8 +300,10 @@ Steps (execute in this exact order): ${taskProtocol}" -3) After spawning all members, output a short summary. -${userPromptBlock} +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. + +4) After all steps, output a short summary. + Members: ${members} `; @@ -304,16 +319,24 @@ function buildLaunchPrompt( : ''; 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. +You are "${leadName}", the team lead. Goal: Reconnect with existing team "${request.teamName}". - +${userPromptBlock} Constraints: - Do NOT call TeamDelete under any circumstances. - Do NOT use TodoWrite — use TaskCreate for tasks. - Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). - Do NOT shut down, terminate, or clean up the team or its members. - Keep assistant text minimal. +- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. + +Task board operations — use teamctl.js via Bash: +- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "" --notify --from "${leadName}" +- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task set-status Steps (execute in this exact order): @@ -329,8 +352,10 @@ Steps (execute in this exact order): ${taskProtocol}" -4) After spawning all members, output a short summary. -${userPromptBlock} +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. + +5) After all steps, output a short summary. + Members: ${membersBlock} `; @@ -619,6 +644,7 @@ export class TeamProvisioningService { 'user,project,local', '--disallowedTools', 'TeamDelete,TodoWrite', + '--dangerously-skip-permissions', ], { cwd: request.cwd, @@ -888,6 +914,7 @@ export class TeamProvisioningService { 'user,project,local', '--disallowedTools', 'TeamDelete,TodoWrite', + '--dangerously-skip-permissions', ]; if (previousSessionId) { launchArgs.push('--resume', previousSessionId); diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index be10af5d..e8b5a94c 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -117,19 +117,28 @@ export class TeamTaskWriter { }); } - async addComment(teamName: string, taskId: string, text: string): Promise { + async addComment( + teamName: string, + taskId: string, + text: string, + options?: { id?: string; author?: string; createdAt?: string } + ): Promise { const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); const comment: TaskComment = { - id: randomUUID(), - author: 'user', + id: options?.id ?? randomUUID(), + author: options?.author ?? 'user', text, - createdAt: new Date().toISOString(), + createdAt: options?.createdAt ?? new Date().toISOString(), }; await withTaskLock(taskPath, async () => { const raw = await fs.promises.readFile(taskPath, 'utf8'); const task = JSON.parse(raw) as Record; 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]; await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 1d0b6fcc..71914682 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -242,6 +242,9 @@ export const TEAM_CREATE_CONFIG = 'team:createConfig'; /** Get member subagent logs */ 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) */ export const TEAM_UPDATE_CONFIG = 'team:updateConfig'; diff --git a/src/preload/index.ts b/src/preload/index.ts index de79a6b3..ae217b67 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -28,6 +28,7 @@ import { TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_DATA, + TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_LAUNCH, @@ -572,6 +573,9 @@ const electronAPI: ElectronAPI = { getMemberLogs: async (teamName: string, memberName: string) => { return invokeIpcWithResult(TEAM_GET_MEMBER_LOGS, teamName, memberName); }, + getLogsForTask: async (teamName: string, taskId: string) => { + return invokeIpcWithResult(TEAM_GET_LOGS_FOR_TASK, teamName, taskId); + }, getMemberStats: async (teamName: string, memberName: string) => { return invokeIpcWithResult(TEAM_GET_MEMBER_STATS, teamName, memberName); }, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 1f307d94..764da802 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,7 @@ import React, { useEffect } from 'react'; +import { TooltipProvider } from '@renderer/components/ui/tooltip'; + import { ConfirmDialog } from './components/common/ConfirmDialog'; import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay'; import { ErrorBoundary } from './components/common/ErrorBoundary'; @@ -43,9 +45,11 @@ export const App = (): React.JSX.Element => { return ( - - - + + + + + ); }; diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 15655266..6115fa22 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -692,6 +692,9 @@ export class HttpAPIClient implements ElectronAPI { console.warn('[HttpAPIClient] getMemberLogs is not available in browser mode'); return []; }, + getLogsForTask: async () => { + return []; + }, getMemberStats: async () => { console.warn('[HttpAPIClient] getMemberStats is not available in browser mode'); return { diff --git a/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx b/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx index 5b5f35d4..107c9c3b 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx @@ -24,7 +24,7 @@ const CATEGORY_COLORS: Record({ + from: new Set(), + to: new Set(), + }); + const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); useEffect(() => { if (!teamName) { @@ -251,12 +260,31 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const filteredMessages = useMemo(() => { if (!data) return []; - if (!timeWindow) return data.messages; - return data.messages.filter((m) => { - const ts = new Date(m.timestamp).getTime(); - return ts >= timeWindow.start && ts < timeWindow.end; - }); - }, [data, timeWindow]); + let list = data.messages; + if (timeWindow) { + list = list.filter((m) => { + const ts = new Date(m.timestamp).getTime(); + return ts >= timeWindow.start && ts < timeWindow.end; + }); + } + 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 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 memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]); + const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => { setCreateTaskDialog({ open: true, @@ -351,6 +381,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele return (
+
@@ -459,6 +490,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele { @@ -592,25 +624,47 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele badge={filteredMessages.length} defaultOpen action={ - +
+
+ + setMessagesSearchQuery(e.target.value)} + 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" + /> +
+ + +
} > { openCreateTaskDialog(subject, description); }} diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index c0e04678..95495353 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -4,11 +4,17 @@ import { api, isElectronMode } from '@renderer/api'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Input } from '@renderer/components/ui/input'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; 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 { CreateTeamDialog } from './dialogs/CreateTeamDialog'; @@ -98,10 +104,14 @@ export const TeamListView = (): React.JSX.Element => { fetchTeams, openTeamTab, deleteTeam, - selectedProjectId, projects, globalTasks, fetchAllTasks, + viewMode, + repositoryGroups, + selectedRepositoryId, + selectedWorktreeId, + activeProjectId, } = useStore( useShallow((s) => ({ teams: s.teams, @@ -110,10 +120,14 @@ export const TeamListView = (): React.JSX.Element => { fetchTeams: s.fetchTeams, openTeamTab: s.openTeamTab, deleteTeam: s.deleteTeam, - selectedProjectId: s.selectedProjectId, projects: s.projects, globalTasks: s.globalTasks, fetchAllTasks: s.fetchAllTasks, + viewMode: s.viewMode, + repositoryGroups: s.repositoryGroups, + selectedRepositoryId: s.selectedRepositoryId, + selectedWorktreeId: s.selectedWorktreeId, + activeProjectId: s.activeProjectId, })) ); const { connectionMode, createTeam, provisioningError, provisioningRuns } = useStore( @@ -144,11 +158,23 @@ export const TeamListView = (): React.JSX.Element => { }; }, [electronMode, teams]); - const selectedProjectPath = useMemo(() => { - if (!selectedProjectId) return null; - const project = projects.find((p) => p.id === selectedProjectId); + const currentProjectPath = useMemo(() => { + if (viewMode === 'grouped') { + 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; - }, [selectedProjectId, projects]); + }, [ + viewMode, + repositoryGroups, + selectedRepositoryId, + selectedWorktreeId, + projects, + activeProjectId, + ]); const filteredTeams = useMemo(() => { let result = teams; @@ -163,10 +189,10 @@ export const TeamListView = (): React.JSX.Element => { ); } - if (selectedProjectPath) { + if (currentProjectPath) { const matches = (t: TeamSummary): boolean => { - if (t.projectPath && normalizePath(t.projectPath) === selectedProjectPath) return true; - return t.projectPathHistory?.some((p) => normalizePath(p) === selectedProjectPath) ?? false; + if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true; + return t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false; }; result = [...result].sort((a, b) => { const aMatch = matches(a) ? 0 : 1; @@ -176,7 +202,7 @@ export const TeamListView = (): React.JSX.Element => { } return result; - }, [teams, searchQuery, selectedProjectPath]); + }, [teams, searchQuery, currentProjectPath]); const handleDeleteTeam = useCallback( (teamName: string, e: React.MouseEvent) => { @@ -249,6 +275,7 @@ export const TeamListView = (): React.JSX.Element => { provisioningError={provisioningError} existingTeamNames={teams.map((t) => t.teamName)} initialData={copyData ?? undefined} + defaultProjectPath={currentProjectPath} onClose={() => { setShowCreateDialog(false); setCopyData(null); @@ -356,164 +383,197 @@ export const TeamListView = (): React.JSX.Element => { } return ( -
- {renderHeader()} + +
+ {renderHeader()} - {filteredTeams.length === 0 && searchQuery.trim() ? ( -
- No teams matching "{searchQuery.trim()}" -
- ) : ( -
- {filteredTeams.map((team) => { - const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns); - const teamColorSet = team.color ? getTeamColorSet(team.color) : null; - return ( -
openTeamTab(team.teamName, team.projectPath)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - openTeamTab(team.teamName, team.projectPath); + {filteredTeams.length === 0 && searchQuery.trim() ? ( +
+ No teams matching "{searchQuery.trim()}" +
+ ) : ( +
+ {filteredTeams.map((team) => { + const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns); + const teamColorSet = team.color ? getTeamColorSet(team.color) : null; + return ( +
- {teamColorSet ? ( -
- ) : null} -
-
-
-

- {team.displayName} -

- -
-
- - -
-
-

- {team.description || 'No description'} -

-
- {team.members && team.members.length > 0 ? ( - team.members.map((m) => { - const memberColor = m.color ? getTeamColorSet(m.color) : null; - return ( - - openTeamTab(team.teamName, team.projectPath)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openTeamTab(team.teamName, team.projectPath); + } + }} + > + {teamColorSet ? ( +
+ ) : null} +
+
+
+

+ {team.displayName} +

+ +
+
+ + + + + Copy team + + + + + + Delete team + +
+
+

+ {team.description || 'No description'} +

+
+ {team.members && team.members.length > 0 ? ( + team.members.map((m) => { + const memberColor = m.color ? getTeamColorSet(m.color) : null; + return ( + + + {m.name} - ) : null} - - ); - }) - ) : ( - - Members: {team.memberCount} - - )} - {(() => { - const tc = taskCountsByTeam.get(team.teamName); - if (!tc || (tc.pending === 0 && tc.inProgress === 0 && tc.completed === 0)) { + {m.role ? ( + + {m.role} + + ) : null} + + ); + }) + ) : ( + + Members: {team.memberCount} + + )} + {(() => { + 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 ( - - Tasks: 0 - +
+
+
+
+
+ + {completed}/{totalTasks} + +
+ {totalTasks > 0 && ( +
+ {inProgress > 0 && ( + + + {inProgress} in_progress + + )} + {pending > 0 && ( + + + {pending} pending + + )} + {completed > 0 && ( + + + {completed} completed + + )} +
+ )} +
); - } + })()} +
+ {(() => { + const recentPaths = getRecentProjects(team); + if (recentPaths.length === 0) return null; return ( - <> - {tc.inProgress > 0 && ( - - {tc.inProgress} active - - )} - {tc.pending > 0 && ( - - {tc.pending} pending - - )} - {tc.completed > 0 && ( - - {tc.completed} done - - )} - +
+ + + {recentPaths.map((p, i) => ( + + {i === 0 && status === 'running' ? ( + {folderName(p)} + ) : ( + folderName(p) + )} + {i < recentPaths.length - 1 ? ', ' : ''} + + ))} + +
); })()}
- {(() => { - const recentPaths = getRecentProjects(team); - if (recentPaths.length === 0) return null; - return ( -
- - - {recentPaths.map((p, i) => ( - - {i === 0 && status === 'running' ? ( - {folderName(p)} - ) : ( - folderName(p) - )} - {i < recentPaths.length - 1 ? ', ' : ''} - - ))} - -
- ); - })()}
-
- ); - })} -
- )} - {createDialogElement} -
+ ); + })} +
+ )} + {createDialogElement} +
+ ); }; diff --git a/src/renderer/components/team/TeamSessionsSection.tsx b/src/renderer/components/team/TeamSessionsSection.tsx index ba0966ba..cff780c7 100644 --- a/src/renderer/components/team/TeamSessionsSection.tsx +++ b/src/renderer/components/team/TeamSessionsSection.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo } from 'react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { formatDistanceToNowStrict } from 'date-fns'; import { @@ -205,29 +206,40 @@ const SessionRow = ({
- - + + + + + + {isSelected ? 'Remove filter' : 'Filter by this session'} + + + + + + + Open session +
); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index ca4e6d89..c8f3c8a9 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -29,6 +29,8 @@ interface ActivityItemProps { message: InboxMessage; memberRole?: string; memberColor?: string; + recipientColor?: string; + onMemberNameClick?: (memberName: string) => void; onCreateTask?: (subject: string, description: string) => void; onReply?: (message: InboxMessage) => void; } @@ -124,10 +126,13 @@ export const ActivityItem = ({ message, memberRole, memberColor, + recipientColor, + onMemberNameClick, onCreateTask, onReply, }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); + const recipientColors = message.to && recipientColor ? getTeamColorSet(recipientColor) : null; const formattedRole = formatAgentRole(memberRole); const timestamp = Number.isNaN(Date.parse(message.timestamp)) @@ -217,17 +222,35 @@ export const ActivityItem = ({ )} - {/* Name badge */} - - {message.from} - + {/* Name badge — clickable to open member popup */} + {onMemberNameClick ? ( + + ) : ( + + {message.from} + + )} {/* Role */} {formattedRole ? ( @@ -254,10 +277,57 @@ export const ActivityItem = ({ ) : null} - {/* Recipient */} - {message.to && message.to !== message.from ? ( - - → {message.to} + {/* Recipient — badge like sender, clickable to open member popup */} + {message.to && message.to !== message.from && recipientColors ? ( + + + {onMemberNameClick ? ( + + ) : ( + + {message.to} + + )} + + ) : message.to && message.to !== message.from ? ( + + + {onMemberNameClick ? ( + + ) : ( + {message.to} + )} ) : null} diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 50cf6857..5cccaa04 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -1,3 +1,5 @@ +import { getMemberColorByName } from '@shared/constants/memberColors'; + import { ActivityItem } from './ActivityItem'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; @@ -7,6 +9,7 @@ interface ActivityTimelineProps { members?: ResolvedTeamMember[]; onCreateTaskFromMessage?: (subject: string, description: string) => void; onReplyToMessage?: (message: InboxMessage) => void; + onMemberClick?: (member: ResolvedTeamMember) => void; } export const ActivityTimeline = ({ @@ -14,17 +17,27 @@ export const ActivityTimeline = ({ members, onCreateTaskFromMessage, onReplyToMessage, + onMemberClick, }: ActivityTimelineProps): React.JSX.Element => { const memberInfo = new Map(); if (members) { for (const m of members) { - memberInfo.set(m.name, { + const info = { role: m.role ?? (m.agentType !== 'general-purpose' ? m.agentType : undefined), 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) { return (
@@ -38,12 +51,17 @@ export const ActivityTimeline = ({
{messages.slice(0, 200).map((message, index) => { 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 ( diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 125d72ae..7557bb41 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -26,6 +26,7 @@ import { import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { cn } from '@renderer/lib/utils'; +import { normalizePath } from '@renderer/utils/pathNormalize'; import { getMemberColor } from '@shared/constants/memberColors'; import { Check, CheckCircle2, Loader2 } from 'lucide-react'; @@ -61,6 +62,7 @@ interface CreateTeamDialogProps { provisioningError: string | null; existingTeamNames: string[]; initialData?: TeamCopyData; + defaultProjectPath?: string | null; onClose: () => void; onCreate: (request: TeamCreateRequest) => Promise; onOpenTeam: (teamName: string, projectPath?: string) => void; @@ -230,6 +232,7 @@ export const CreateTeamDialog = ({ provisioningError, existingTeamNames, initialData, + defaultProjectPath, onClose, onCreate, onOpenTeam, @@ -259,6 +262,15 @@ export const CreateTeamDialog = ({ const [launchTeam, setLaunchTeam] = useState(true); const [teamColor, setTeamColor] = useState(''); + const resetUIState = (): void => { + setLocalError(null); + setFieldErrors({}); + setIsSubmitting(false); + setPrepareState('idle'); + setPrepareMessage(null); + setPrepareWarnings([]); + }; + const resetFormState = (): void => { setTeamName(''); descriptionDraft.clearDraft(); @@ -268,13 +280,8 @@ export const CreateTeamDialog = ({ setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); - setLocalError(null); - setFieldErrors({}); - setIsSubmitting(false); - setPrepareState('idle'); - setPrepareMessage(null); - setPrepareWarnings([]); setLaunchTeam(true); + resetUIState(); }; useEffect(() => { @@ -420,8 +427,15 @@ export const CreateTeamDialog = ({ if (selectedProjectPath || projects.length === 0) { return; } + if (defaultProjectPath) { + const match = projects.find((p) => normalizePath(p.path) === defaultProjectPath); + if (match) { + setSelectedProjectPath(match.path); + return; + } + } setSelectedProjectPath(projects[0].path); - }, [cwdMode, projects, selectedProjectPath]); + }, [cwdMode, projects, selectedProjectPath, defaultProjectPath]); const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); @@ -552,7 +566,7 @@ export const CreateTeamDialog = ({ open={open} onOpenChange={(nextOpen) => { if (!nextOpen) { - resetFormState(); + resetUIState(); onClose(); } }} diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index cd827969..120bc8b4 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -19,6 +19,7 @@ import { SelectTrigger, SelectValue, } from '@renderer/components/ui/select'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; @@ -180,13 +181,18 @@ export const SendMessageDialog = ({ {quote ? (
- + + + + + Remove quote + Replying to @{quote.from} diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 51467b44..9cc7de3d 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead'; import { useStore } from '@renderer/store'; @@ -156,13 +157,18 @@ export const TaskCommentsSection = ({ {replyTo.text}
- + + + + + Cancel reply +
) : null} diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 07c893ea..df48f257 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -12,7 +12,14 @@ import { } from '@renderer/components/ui/dialog'; import { TASK_STATUS_LABELS, TASK_STATUS_STYLES } from '@renderer/utils/memberHelpers'; 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'; @@ -92,6 +99,12 @@ export const TaskDetailDialog = ({ {currentTask.owner ?? '\u2014'}
+ {currentTask.createdBy ? ( +
+ + {currentTask.createdBy} +
+ ) : null} {currentTask.createdAt ? (() => { const date = new Date(currentTask.createdAt); @@ -204,18 +217,12 @@ export const TaskDetailDialog = ({ {/* Separator */}
- {/* Session Logs */} + {/* Session Logs — sessions that reference this task */}

Execution Logs

- {currentTask.owner ? ( - - ) : ( -

- Assign a member to see execution logs -

- )} +
diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 44289e58..11adde9f 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; import { Columns3, LayoutGrid } from 'lucide-react'; @@ -138,36 +139,44 @@ export const KanbanBoard = ({ onFilterChange={onFilterChange} />
- - + + + + + Grid view + + + + + + Columns view +
diff --git a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx index 024c0945..cfd8a163 100644 --- a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx +++ b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx @@ -3,6 +3,7 @@ import { useMemo } 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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Crown, Filter } from 'lucide-react'; import type { Session } from '@renderer/types/data'; @@ -57,22 +58,26 @@ export const KanbanFilterPopover = ({ return ( - - - + + + + + + + Filter tasks + {/* Session section */}
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 37dd1841..4291cf7e 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,12 +1,16 @@ import { Badge } from '@renderer/components/ui/badge'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { ListPlus, MessageSquare } from 'lucide-react'; +import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { ResolvedTeamMember } from '@shared/types'; interface MemberCardProps { member: ResolvedTeamMember; + memberColor: string; + taskCounts?: TaskStatusCounts | null; isTeamAlive?: boolean; onClick?: () => void; onSendMessage?: () => void; @@ -15,6 +19,8 @@ interface MemberCardProps { export const MemberCard = ({ member, + memberColor, + taskCounts, isTeamAlive, onClick, onSendMessage, @@ -22,81 +28,108 @@ export const MemberCard = ({ }: MemberCardProps): React.JSX.Element => { const dotClass = getMemberDotClass(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 ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick?.(); - } - }} - > -
- {member.name} - -
- - {member.name} - - {(() => { - const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); - return roleLabel ? ( - - {roleLabel} - - ) : null; - })()} - +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(); + } + }} > - {presenceLabel} - - - {member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'} - -
- - + {member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'} + +
+ + +
+
); }; diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 168ce382..75737a6a 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -1,9 +1,13 @@ +import { getMemberColor } from '@shared/constants/memberColors'; + import { MemberCard } from './MemberCard'; +import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { ResolvedTeamMember } from '@shared/types'; interface MemberListProps { members: ResolvedTeamMember[]; + memberTaskCounts?: Map; isTeamAlive?: boolean; onMemberClick?: (member: ResolvedTeamMember) => void; onSendMessage?: (member: ResolvedTeamMember) => void; @@ -12,6 +16,7 @@ interface MemberListProps { export const MemberList = ({ members, + memberTaskCounts, isTeamAlive, onMemberClick, onSendMessage, @@ -27,10 +32,12 @@ export const MemberList = ({ return (
- {members.map((member) => ( + {members.map((member, index) => ( onMemberClick?.(member)} onSendMessage={() => onSendMessage?.(member)} diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 391a1ce2..fd889a45 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -18,10 +18,15 @@ import type { MemberLogSummary } from '@shared/types'; interface MemberLogsTabProps { 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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -36,7 +41,14 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea void (async () => { 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) { setLogs(result); } @@ -54,7 +66,7 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea return () => { cancelled = true; }; - }, [teamName, memberName]); + }, [teamName, memberName, taskId]); const handleExpand = useCallback( async (log: MemberLogSummary) => { @@ -112,7 +124,9 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea No logs found

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

); diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx new file mode 100644 index 00000000..eb26d984 --- /dev/null +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -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; + to: Set; +} + +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(); + 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(); + 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({ 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(), to: new Set() }; + setDraft(empty); + onApply(empty); + }; + + return ( + + + + + +
+

+ From +

+
+ {fromOptions.length === 0 ? ( +

No data

+ ) : ( + fromOptions.map((name) => ( + + )) + )} +
+
+
+

+ To +

+
+ {toOptions.length === 0 ? ( +

No data

+ ) : ( + toOptions.map((name) => ( + + )) + )} +
+
+
+ + +
+
+
+ ); +}; diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index 8f4a5e9c..5414bb56 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -168,6 +168,22 @@ export const MentionableTextarea = React.forwardRef vs
, 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 --- const hasMentionOverlay = suggestions.length > 0; diff --git a/src/renderer/components/ui/combobox.tsx b/src/renderer/components/ui/combobox.tsx index ceef2029..326ee245 100644 --- a/src/renderer/components/ui/combobox.tsx +++ b/src/renderer/components/ui/combobox.tsx @@ -84,7 +84,7 @@ export const Combobox = ({
e.stopPropagation()} > @@ -111,7 +111,7 @@ export const Combobox = ({ setOpen(false); 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(option, isSelected, search) diff --git a/src/renderer/components/ui/tooltip.tsx b/src/renderer/components/ui/tooltip.tsx new file mode 100644 index 00000000..745ddd4a --- /dev/null +++ b/src/renderer/components/ui/tooltip.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger }; +/* eslint-enable react/jsx-props-no-spreading -- Standard Radix/shadcn pattern */ diff --git a/src/renderer/index.css b/src/renderer/index.css index e47b9ac1..d4cd8e0f 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -28,16 +28,16 @@ --highlight-text-inactive: #fef08a; --highlight-ring: #facc15; - /* User chat bubble — cool slate */ + /* User chat bubble — orange (Claude Code) */ --chat-user-bg: #1c1d26; --chat-user-text: #94a3b8; - --chat-user-border: rgba(148, 163, 184, 0.1); - --chat-user-shadow: 0 1px 0 0 rgba(99, 102, 241, 0.04); + --chat-user-border: rgba(249, 115, 22, 0.2); + --chat-user-shadow: 0 1px 0 0 rgba(249, 115, 22, 0.06); /* User bubble inline tags */ - --chat-user-tag-bg: rgba(148, 163, 184, 0.08); - --chat-user-tag-text: #e2e8f0; - --chat-user-tag-border: rgba(148, 163, 184, 0.12); + --chat-user-tag-bg: rgba(249, 115, 22, 0.12); + --chat-user-tag-text: #fb923c; + --chat-user-tag-border: rgba(249, 115, 22, 0.2); /* Tool items */ --tool-item-name: #e2e8f0; @@ -218,16 +218,16 @@ --highlight-text-inactive: #422006; --highlight-ring: #ca8a04; - /* User chat bubble - Warm neutral, clearly visible */ + /* User chat bubble - orange (Claude Code) */ --chat-user-bg: #eae9e6; --chat-user-text: #5a5955; - --chat-user-border: #d5d3cf; - --chat-user-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.04); + --chat-user-border: rgba(249, 115, 22, 0.35); + --chat-user-shadow: 0 1px 2px 0 rgba(249, 115, 22, 0.08); - /* User bubble inline tags - Warm neutral */ - --chat-user-tag-bg: rgba(0, 0, 0, 0.05); - --chat-user-tag-text: #3a3935; - --chat-user-tag-border: rgba(0, 0, 0, 0.08); + /* User bubble inline tags */ + --chat-user-tag-bg: rgba(249, 115, 22, 0.12); + --chat-user-tag-text: #c2410c; + --chat-user-tag-border: rgba(249, 115, 22, 0.25); /* Tool items - Warm high contrast */ --tool-item-name: #1c1b19; diff --git a/src/renderer/utils/pathNormalize.ts b/src/renderer/utils/pathNormalize.ts index 5acaf56a..8c62a4c3 100644 --- a/src/renderer/utils/pathNormalize.ts +++ b/src/renderer/utils/pathNormalize.ts @@ -39,3 +39,18 @@ export function buildTaskCountsByTeam(tasks: GlobalTask[]): Map task status counts (ignores deleted). */ +export function buildTaskCountsByOwner( + tasks: { owner?: string | null; status: string }[] +): Map { + const map = new Map(); + 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; +} diff --git a/src/shared/constants/memberColors.ts b/src/shared/constants/memberColors.ts index c86684a4..901672f0 100644 --- a/src/shared/constants/memberColors.ts +++ b/src/shared/constants/memberColors.ts @@ -8,3 +8,12 @@ export const MEMBER_COLOR_PALETTE = ['blue', 'green', 'yellow', 'cyan', 'magenta export function getMemberColor(index: number): string { 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]; +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index a3366140..1d9a7c02 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -361,6 +361,7 @@ export interface TeamsAPI { aliveList: () => Promise; createConfig: (request: TeamCreateConfigRequest) => Promise; getMemberLogs: (teamName: string, memberName: string) => Promise; + getLogsForTask: (teamName: string, taskId: string) => Promise; getMemberStats: (teamName: string, memberName: string) => Promise; launchTeam: (request: TeamLaunchRequest) => Promise; getAllTasks: () => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 93a13db8..fd041a77 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -60,6 +60,7 @@ export interface TeamTask { description?: string; activeForm?: string; owner?: string; + createdBy?: string; status: TeamTaskStatus; blocks?: string[]; blockedBy?: string[]; diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 2e2a5c25..6e9856b3 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -21,6 +21,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({ TEAM_PROCESS_ALIVE: 'team:processAlive', TEAM_ALIVE_LIST: 'team:aliveList', TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs', + TEAM_GET_LOGS_FOR_TASK: 'team:getLogsForTask', TEAM_GET_MEMBER_STATS: 'team:getMemberStats', TEAM_UPDATE_CONFIG: 'team:updateConfig', TEAM_START_TASK: 'team:startTask', @@ -45,6 +46,7 @@ import { TEAM_REQUEST_REVIEW, TEAM_SEND_MESSAGE, TEAM_GET_ALL_TASKS, + TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_START_TASK, @@ -135,6 +137,7 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_ALIVE_LIST)).toBe(true); expect(handlers.has(TEAM_CREATE_CONFIG)).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_UPDATE_CONFIG)).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_CREATE_CONFIG)).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_UPDATE_CONFIG)).toBe(false); expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 736594cf..daf62c0e 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -121,4 +121,46 @@ describe('TeamDataService', () => { 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' }) + ); + }); });