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.
This commit is contained in:
parent
a5e9da278c
commit
7cf1789c6a
6 changed files with 130 additions and 13 deletions
|
|
@ -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<boolean> {
|
||||
const TASK_ID_PATTERN = /#(\d+)/g;
|
||||
let synced = false;
|
||||
|
||||
// Dedup broadcasts: same sender + same text → process only once
|
||||
const processedTexts = new Set<string>();
|
||||
|
||||
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<string>();
|
||||
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<InboxMessage[]> {
|
||||
if (!config.leadSessionId || !config.projectPath) {
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -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 <taskId> --text \\"<your reply>\\" --from \\"<your-name>\\"
|
||||
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 <taskId> --text \\"<summary of your finding or decision>\\" --from \\"<your-name>\\"
|
||||
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 #<taskId> in your SendMessage summary field for traceability.
|
||||
Failure to follow this protocol means the task board will show incorrect status.`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -117,19 +117,28 @@ export class TeamTaskWriter {
|
|||
});
|
||||
}
|
||||
|
||||
async addComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
|
||||
async addComment(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
options?: { id?: string; author?: string; createdAt?: string }
|
||||
): Promise<TaskComment> {
|
||||
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<string, unknown>;
|
||||
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));
|
||||
|
||||
|
|
|
|||
|
|
@ -277,15 +277,19 @@ export const ActivityItem = ({
|
|||
</span>
|
||||
) : 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 ? (
|
||||
<span className="text-[10px]">
|
||||
<span style={{ color: CARD_ICON_MUTED }}>→ </span>
|
||||
{onMemberNameClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-0.5 py-0 font-medium transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||
style={{ color: recipientColors?.text ?? CARD_ICON_MUTED }}
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||
style={{
|
||||
backgroundColor: recipientColors.badge,
|
||||
color: recipientColors.text,
|
||||
border: `1px solid ${recipientColors.border}40`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMemberNameClick(message.to!);
|
||||
|
|
@ -294,7 +298,35 @@ export const ActivityItem = ({
|
|||
{message.to}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ color: recipientColors?.text ?? CARD_ICON_MUTED }}>{message.to}</span>
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
style={{
|
||||
backgroundColor: recipientColors.badge,
|
||||
color: recipientColors.text,
|
||||
border: `1px solid ${recipientColors.border}40`,
|
||||
}}
|
||||
>
|
||||
{message.to}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : message.to && message.to !== message.from ? (
|
||||
<span className="text-[10px]">
|
||||
<span style={{ color: CARD_ICON_MUTED }}>→ </span>
|
||||
{onMemberNameClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-0.5 py-0 font-medium transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMemberNameClick(message.to!);
|
||||
}}
|
||||
>
|
||||
{message.to}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ color: CARD_ICON_MUTED }}>{message.to}</span>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<ActivityItem
|
||||
key={`${message.messageId ?? index}-${message.timestamp}-${message.from}`}
|
||||
message={message}
|
||||
memberRole={info?.role}
|
||||
memberColor={info?.color}
|
||||
recipientColor={recipientInfo?.color}
|
||||
recipientColor={recipientColor}
|
||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||
onCreateTask={onCreateTaskFromMessage}
|
||||
onReply={onReplyToMessage}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
|
||||
import { MemberCard } from './MemberCard';
|
||||
|
||||
|
|
@ -29,11 +29,11 @@ export const MemberList = ({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{members.map((member) => (
|
||||
{members.map((member, index) => (
|
||||
<MemberCard
|
||||
key={member.name}
|
||||
member={member}
|
||||
memberColor={member.color ?? getMemberColorByName(member.name)}
|
||||
memberColor={member.color ?? getMemberColor(index)}
|
||||
isTeamAlive={isTeamAlive}
|
||||
onClick={() => onMemberClick?.(member)}
|
||||
onSendMessage={() => onSendMessage?.(member)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue