From 55f7611b14578cb4d7a84c2a72b8ffff57887e79 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 11:37:31 +0200 Subject: [PATCH 01/12] 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' }) + ); + }); }); From 1fde2f805ac8bc049bdff5f3d4424a05e3e07031 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 11:38:53 +0200 Subject: [PATCH 02/12] style: adjust padding in Combobox component for improved UI consistency - Updated padding classes in the Combobox component to enhance spacing and alignment. - Adjusted the `CommandPrimitive.List` and option item styles for better visual consistency. These changes aim to improve the overall user experience by refining the layout of the Combobox component. --- src/renderer/components/ui/combobox.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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) From dc2464a128c9c2c7972be1f664b825b9326bdf5c Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 11:44:41 +0200 Subject: [PATCH 03/12] fix: add missing TEAM_GET_LOGS_FOR_TASK to test mock and remove unused variable - Add TEAM_GET_LOGS_FOR_TASK to ipcChannels mock in teams.test.ts - Add handler registration/removal assertions for the new channel - Remove unused knownMembers destructuring in TeamMemberLogsFinder --- src/main/services/team/TeamMemberLogsFinder.ts | 2 +- test/main/ipc/teams.test.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index f661f72a..bb5ecf15 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -298,7 +298,7 @@ export class TeamMemberLogsFinder { } | null> { const discovery = await this.discoverProjectSessions(teamName); if (!discovery) return null; - const { config, knownMembers } = discovery; + const { config } = discovery; const leadMemberName = config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead'; const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase(); 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); From f1df2be208bc44b6102046b2c19d9c2be658f626 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 12:04:13 +0200 Subject: [PATCH 04/12] fix: address CodeRabbit review feedback - MemberCard: add hover overlay to fix inline backgroundColor killing Tailwind hover effect (UX regression for role="button" element) - MemberLogsTab: guard against missing taskId and memberName to prevent unnecessary IPC call with empty string - MemberList: use hash-based getMemberColorByName() for stable fallback colors independent of array position - TeamAgentToolsInstaller: remove redundant `from || undefined` - TeamDataService test: assert createdBy: 'user' field --- src/main/services/team/TeamAgentToolsInstaller.ts | 2 +- src/renderer/components/team/members/MemberCard.tsx | 3 ++- src/renderer/components/team/members/MemberList.tsx | 6 +++--- src/renderer/components/team/members/MemberLogsTab.tsx | 6 +++++- src/shared/constants/memberColors.ts | 9 +++++++++ test/main/services/team/TeamDataService.test.ts | 2 +- 6 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 7e32207e..018540fe 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -282,7 +282,7 @@ function createTask(paths, flags) { description: String(description || subject), activeForm: activeForm ? String(activeForm) : undefined, owner, - createdBy: from || undefined, + createdBy: from, status, blocks: [], blockedBy: [], diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 369a8589..963461bd 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -29,7 +29,7 @@ export const MemberCard = ({ return (
+
- {members.map((member, index) => ( + {members.map((member) => ( onMemberClick?.(member)} onSendMessage={() => onSendMessage?.(member)} diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index dce232d7..fd889a45 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -41,10 +41,14 @@ export const MemberLogsTab = ({ void (async () => { try { + if (taskId == null && !memberName) { + if (!cancelled) setLogs([]); + return; + } const result = taskId != null ? await api.teams.getLogsForTask(teamName, taskId) - : await api.teams.getMemberLogs(teamName, memberName ?? ''); + : await api.teams.getMemberLogs(teamName, memberName!); if (!cancelled) { setLogs(result); } 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/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 16ff76af..daf62c0e 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -160,7 +160,7 @@ describe('TeamDataService', () => { expect(result.status).toBe('pending'); expect(createTaskMock).toHaveBeenCalledWith( 'my-team', - expect.objectContaining({ status: 'pending', owner: 'alice' }) + expect.objectContaining({ status: 'pending', owner: 'alice', createdBy: 'user' }) ); }); }); From a5e9da278c66a59cae49381f875926e6d278cd2d Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 12:09:23 +0200 Subject: [PATCH 05/12] feat: integrate tooltip functionality across various components - Added TooltipProvider and Tooltip components to enhance user experience with contextual information. - Wrapped existing components such as TeamListView, TeamDetailView, and SendMessageDialog with tooltips for better interaction feedback. - Implemented MessagesFilterPopover to allow filtering messages by sender and recipient, improving message management. - Updated KanbanBoard and KanbanFilterPopover to include tooltips for view mode buttons, enhancing usability. These changes aim to provide users with clearer guidance and improve overall interaction within the application. --- src/renderer/App.tsx | 9 +- .../components/team/TeamDetailView.tsx | 88 +++-- src/renderer/components/team/TeamListView.tsx | 315 ++++++++++-------- .../components/team/TeamSessionsSection.tsx | 58 ++-- .../team/dialogs/SendMessageDialog.tsx | 20 +- .../team/dialogs/TaskCommentsSection.tsx | 20 +- .../components/team/kanban/KanbanBoard.tsx | 69 ++-- .../team/kanban/KanbanFilterPopover.tsx | 37 +- .../team/messages/MessagesFilterPopover.tsx | 171 ++++++++++ src/renderer/components/ui/tooltip.tsx | 32 ++ 10 files changed, 565 insertions(+), 254 deletions(-) create mode 100644 src/renderer/components/team/messages/MessagesFilterPopover.tsx create mode 100644 src/renderer/components/ui/tooltip.tsx diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 1f307d94..3f881a11 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4,6 +4,7 @@ import { ConfirmDialog } from './components/common/ConfirmDialog'; import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay'; import { ErrorBoundary } from './components/common/ErrorBoundary'; import { TabbedLayout } from './components/layout/TabbedLayout'; +import { TooltipProvider } from './components/ui/tooltip'; import { useTheme } from './hooks/useTheme'; import { api } from './api'; import { initializeNotificationListeners, useStore } from './store'; @@ -43,9 +44,11 @@ export const App = (): React.JSX.Element => { return ( - - - + + + + + ); }; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 258df23e..7a496cf1 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -19,11 +19,13 @@ import { KanbanBoard } from './kanban/KanbanBoard'; import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover'; import { MemberDetailDialog } from './members/MemberDetailDialog'; import { MemberList } from './members/MemberList'; +import { MessagesFilterPopover } from './messages/MessagesFilterPopover'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; +import type { MessagesFilterState } from './messages/MessagesFilterPopover'; import type { Session } from '@renderer/types/data'; import type { ResolvedTeamMember, TeamTask } from '@shared/types'; @@ -134,6 +136,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele ); const [kanbanSearch, setKanbanSearch] = useState(''); + const [messagesSearchQuery, setMessagesSearchQuery] = useState(''); + const [messagesFilter, setMessagesFilter] = useState({ + from: new Set(), + to: new Set(), + }); + const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); useEffect(() => { if (!teamName) { @@ -251,12 +259,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(); @@ -593,20 +620,41 @@ 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" + /> +
+ + +
} > { } 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); + if ( + !tc || + (tc.pending === 0 && tc.inProgress === 0 && tc.completed === 0) + ) { + return ( + + Tasks: 0 + + ); + } return ( - - Tasks: 0 - + <> + {tc.inProgress > 0 && ( + + {tc.inProgress} active + + )} + {tc.pending > 0 && ( + + {tc.pending} pending + + )} + {tc.completed > 0 && ( + + {tc.completed} done + + )} + ); - } + })()} +
+ {(() => { + 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/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/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/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx new file mode 100644 index 00000000..7037aafc --- /dev/null +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -0,0 +1,171 @@ +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(); +} + +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(); +} + +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) { + setDraft({ + from: new Set(filter.from), + to: new Set(filter.to), + }); + } + }, [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 ( + + + + + +
+

+ Кто писал +

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

Нет данных

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

+ Кому писали +

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

Нет данных

+ ) : ( + toOptions.map((name) => ( + + )) + )} +
+
+
+ + +
+
+
+ ); +}; diff --git a/src/renderer/components/ui/tooltip.tsx b/src/renderer/components/ui/tooltip.tsx new file mode 100644 index 00000000..5513e36e --- /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 */ From 7cf1789c6a9b319eb7ef40cedd33da1eac18fb09 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 12:18:34 +0200 Subject: [PATCH 06/12] feat: implement auto-sync for task-related comments from inbox messages - Added functionality in TeamDataService to automatically create comments from task-related inbox messages, enhancing task management. - Introduced syncLinkedComments method to process messages and link them to corresponding tasks, ensuring relevant discussions are captured. - Updated TeamTaskWriter to support deduplication of comments based on ID, preventing duplicate entries. - Enhanced ActivityItem and ActivityTimeline components to improve recipient color handling for better visual feedback. These changes aim to streamline communication around tasks and improve overall user experience in task tracking. --- src/main/services/team/TeamDataService.ts | 68 +++++++++++++++++++ .../services/team/TeamProvisioningService.ts | 4 ++ src/main/services/team/TeamTaskWriter.ts | 17 +++-- .../components/team/activity/ActivityItem.tsx | 42 ++++++++++-- .../team/activity/ActivityTimeline.tsx | 6 +- .../components/team/members/MemberList.tsx | 6 +- 6 files changed, 130 insertions(+), 13 deletions(-) diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 2563d8a9..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, @@ -437,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/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f4cc4c0d..a705cb19 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.`; } 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/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 6ca8aa45..c8f3c8a9 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -277,15 +277,19 @@ export const ActivityItem = ({ ) : null} - {/* Recipient — clickable to open member popup */} - {message.to && message.to !== message.from ? ( + {/* Recipient — badge like sender, clickable to open member popup */} + {message.to && message.to !== message.from && recipientColors ? ( {onMemberNameClick ? ( ) : ( - {message.to} + + {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 affd87cc..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'; @@ -50,13 +52,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; + const recipientColor = + recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined); return ( - {members.map((member) => ( + {members.map((member, index) => ( onMemberClick?.(member)} onSendMessage={() => onSendMessage?.(member)} From cb1bce2656da66330b25c687ffda3f72c8c40050 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 12:21:07 +0200 Subject: [PATCH 07/12] fix: preserve form data when closing Create Team dialog Previously resetFormState() was called on dialog close, wiping all user input. Now only UI state (errors, loading) is reset on close. Form data (team name, members, description, prompt) is preserved and only cleared after successful team creation. --- .../team/dialogs/CreateTeamDialog.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 4d2be804..7557bb41 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -262,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(); @@ -271,13 +280,8 @@ export const CreateTeamDialog = ({ setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); - setLocalError(null); - setFieldErrors({}); - setIsSubmitting(false); - setPrepareState('idle'); - setPrepareMessage(null); - setPrepareWarnings([]); setLaunchTeam(true); + resetUIState(); }; useEffect(() => { @@ -562,7 +566,7 @@ export const CreateTeamDialog = ({ open={open} onOpenChange={(nextOpen) => { if (!nextOpen) { - resetFormState(); + resetUIState(); onClose(); } }} From 56368f73f6dfcfaf28f382f63313fc7092ecca6a Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 12:27:54 +0200 Subject: [PATCH 08/12] fix: add description to eslint-enable comment in tooltip.tsx --- src/renderer/components/team/TeamListView.tsx | 72 ++++++++++++------- src/renderer/components/ui/tooltip.tsx | 2 +- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 373efa87..95495353 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -14,7 +14,7 @@ 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'; @@ -494,34 +494,54 @@ export const TeamListView = (): React.JSX.Element => { )} {(() => { const tc = taskCountsByTeam.get(team.teamName); - if ( - !tc || - (tc.pending === 0 && tc.inProgress === 0 && tc.completed === 0) - ) { - return ( - - Tasks: 0 - - ); - } + 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 ( - <> - {tc.inProgress > 0 && ( - - {tc.inProgress} active +
+
+
+
+
+ + {completed}/{totalTasks} +
+ {totalTasks > 0 && ( +
+ {inProgress > 0 && ( + + + {inProgress} in_progress + + )} + {pending > 0 && ( + + + {pending} pending + + )} + {completed > 0 && ( + + + {completed} completed + + )} +
)} - {tc.pending > 0 && ( - - {tc.pending} pending - - )} - {tc.completed > 0 && ( - - {tc.completed} done - - )} - +
); })()}
diff --git a/src/renderer/components/ui/tooltip.tsx b/src/renderer/components/ui/tooltip.tsx index 5513e36e..745ddd4a 100644 --- a/src/renderer/components/ui/tooltip.tsx +++ b/src/renderer/components/ui/tooltip.tsx @@ -29,4 +29,4 @@ const TooltipContent = React.forwardRef< TooltipContent.displayName = TooltipPrimitive.Content.displayName; export { Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger }; -/* eslint-enable react/jsx-props-no-spreading */ +/* eslint-enable react/jsx-props-no-spreading -- Standard Radix/shadcn pattern */ From 4a48f312c66462f7199f0175a661db2ffaac43b8 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 12:33:01 +0200 Subject: [PATCH 09/12] feat: enhance team member task tracking and UI improvements - Added functionality to build task counts by owner, integrating it into TeamDetailView and MemberCard components for better task management visibility. - Updated MemberList to pass task counts to MemberCard, allowing for detailed task status display per member. - Improved UI elements in MemberCard for better interaction and visual feedback, including task completion progress. - Translated various UI text elements from Russian to English for consistency and accessibility. --- src/renderer/App.tsx | 3 +- .../components/team/TeamDetailView.tsx | 6 +- .../components/team/members/MemberCard.tsx | 176 ++++++++++-------- .../components/team/members/MemberList.tsx | 4 + .../team/messages/MessagesFilterPopover.tsx | 12 +- src/renderer/utils/pathNormalize.ts | 15 ++ 6 files changed, 135 insertions(+), 81 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3f881a11..764da802 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,10 +1,11 @@ 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'; import { TabbedLayout } from './components/layout/TabbedLayout'; -import { TooltipProvider } from './components/ui/tooltip'; import { useTheme } from './hooks/useTheme'; import { api } from './api'; import { initializeNotificationListeners, useStore } from './store'; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 7a496cf1..acb2da32 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -5,6 +5,7 @@ import { Button } from '@renderer/components/ui/button'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; +import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize'; import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -293,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, @@ -487,6 +490,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele { @@ -625,7 +629,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele setMessagesSearchQuery(e.target.value)} onPointerDown={(e) => e.stopPropagation()} diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 963461bd..2ec8e7cf 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -4,11 +4,13 @@ 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; @@ -18,6 +20,7 @@ interface MemberCardProps { export const MemberCard = ({ member, memberColor, + taskCounts, isTeamAlive, onClick, onSendMessage, @@ -26,85 +29,112 @@ export const MemberCard = ({ 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; 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'} + +
+ + +
+
+
+
+
+
+
+ + {completed}/{totalTasks} + +
); diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index baa23290..75737a6a 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -2,10 +2,12 @@ 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; @@ -14,6 +16,7 @@ interface MemberListProps { export const MemberList = ({ members, + memberTaskCounts, isTeamAlive, onMemberClick, onSendMessage, @@ -34,6 +37,7 @@ export const MemberList = ({ key={member.name} member={member} memberColor={member.color ?? getMemberColor(index)} + taskCounts={memberTaskCounts?.get(member.name.toLowerCase())} isTeamAlive={isTeamAlive} onClick={() => onMemberClick?.(member)} onSendMessage={() => onSendMessage?.(member)} diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx index 7037aafc..4e14430e 100644 --- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -110,11 +110,11 @@ export const MessagesFilterPopover = ({

- Кто писал + From

{fromOptions.length === 0 ? ( -

Нет данных

+

No data

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