From 55f7611b14578cb4d7a84c2a72b8ffff57887e79 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 11:37:31 +0200 Subject: [PATCH] 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. --- src/main/ipc/teams.ts | 21 +++ .../services/team/TeamAgentToolsInstaller.ts | 5 +- src/main/services/team/TeamDataService.ts | 1 + .../services/team/TeamMemberLogsFinder.ts | 145 ++++++++++++++---- .../services/team/TeamProvisioningService.ts | 33 +++- src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 4 + src/renderer/api/httpClient.ts | 3 + .../components/FlatInjectionList.tsx | 2 +- .../components/RankedInjectionList.tsx | 2 +- .../components/team/TeamDetailView.tsx | 2 + src/renderer/components/team/TeamListView.tsx | 41 +++-- .../components/team/activity/ActivityItem.tsx | 66 ++++++-- .../team/activity/ActivityTimeline.tsx | 18 ++- .../team/dialogs/CreateTeamDialog.tsx | 12 +- .../team/dialogs/TaskDetailDialog.tsx | 25 +-- .../components/team/members/MemberCard.tsx | 8 + .../components/team/members/MemberList.tsx | 5 +- .../components/team/members/MemberLogsTab.tsx | 20 ++- .../components/ui/MentionableTextarea.tsx | 16 ++ src/renderer/index.css | 26 ++-- src/shared/types/api.ts | 1 + src/shared/types/team.ts | 1 + .../services/team/TeamDataService.test.ts | 42 +++++ 24 files changed, 412 insertions(+), 90 deletions(-) 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..7e32207e 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 || undefined, 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..2563d8a9 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -244,6 +244,7 @@ export class TeamDataService { subject: request.subject, description, owner: request.owner, + createdBy: 'user', status: shouldStart ? 'in_progress' : 'pending', blocks: [], blockedBy, diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 06040dcc..f661f72a 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, 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 { + 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..f4cc4c0d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -263,16 +263,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 +296,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 +315,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 +348,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} `; 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/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
+
@@ -611,6 +612,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele { openCreateTaskDialog(subject, description); }} diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index c0e04678..310cd006 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -98,10 +98,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 +114,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 +152,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 +183,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 +196,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 +269,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); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index ca4e6d89..6ca8aa45 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,25 @@ export const ActivityItem = ({ ) : null} - {/* Recipient */} + {/* Recipient — clickable to open member popup */} {message.to && message.to !== message.from ? ( - - → {message.to} + + + {onMemberNameClick ? ( + + ) : ( + {message.to} + )} ) : null} diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 50cf6857..affd87cc 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -7,6 +7,7 @@ interface ActivityTimelineProps { members?: ResolvedTeamMember[]; onCreateTaskFromMessage?: (subject: string, description: string) => void; onReplyToMessage?: (message: InboxMessage) => void; + onMemberClick?: (member: ResolvedTeamMember) => void; } export const ActivityTimeline = ({ @@ -14,17 +15,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 +49,15 @@ 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; return ( diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 125d72ae..4d2be804 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, @@ -420,8 +423,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(); 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/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 37dd1841..369a8589 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,4 +1,5 @@ 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'; @@ -7,6 +8,7 @@ import type { ResolvedTeamMember } from '@shared/types'; interface MemberCardProps { member: ResolvedTeamMember; + memberColor: string; isTeamAlive?: boolean; onClick?: () => void; onSendMessage?: () => void; @@ -15,6 +17,7 @@ interface MemberCardProps { export const MemberCard = ({ member, + memberColor, isTeamAlive, onClick, onSendMessage, @@ -22,10 +25,15 @@ export const MemberCard = ({ }: MemberCardProps): React.JSX.Element => { const dotClass = getMemberDotClass(member, isTeamAlive); const presenceLabel = getPresenceLabel(member, isTeamAlive); + const colors = getTeamColorSet(memberColor); 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..dce232d7 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,10 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea void (async () => { 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) { setLogs(result); } @@ -54,7 +62,7 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea return () => { cancelled = true; }; - }, [teamName, memberName]); + }, [teamName, memberName, taskId]); const handleExpand = useCallback( async (log: MemberLogSummary) => { @@ -112,7 +120,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/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/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/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/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 736594cf..16ff76af 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' }) + ); + }); });