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