diff --git a/packages/agent-graph/src/canvas/draw-particles.ts b/packages/agent-graph/src/canvas/draw-particles.ts index 3118af0e..ce222779 100644 --- a/packages/agent-graph/src/canvas/draw-particles.ts +++ b/packages/agent-graph/src/canvas/draw-particles.ts @@ -156,16 +156,7 @@ function drawParticleCore( ctx.drawImage(sprite, pos.x - glowR, pos.y - glowR); if (kind === 'task_comment') { - ctx.strokeStyle = color; - ctx.lineWidth = 1.8; - ctx.beginPath(); - ctx.arc(pos.x, pos.y, size * 1.1, 0, Math.PI * 2); - ctx.stroke(); - - ctx.fillStyle = '#ffffff'; - ctx.beginPath(); - ctx.arc(pos.x, pos.y, size * 0.35, 0, Math.PI * 2); - ctx.fill(); + drawCommentBubble(ctx, pos.x, pos.y, size, color); return; } @@ -181,3 +172,48 @@ function drawParticleCore( ctx.arc(pos.x, pos.y, size * PARTICLE_DRAW.coreHighlightScale, 0, Math.PI * 2); ctx.fill(); } + +/** + * Draw a speech-bubble icon for comment particles. + * Rounded rect body + small triangular tail at bottom-left. + */ +function drawCommentBubble( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + size: number, + color: string, +): void { + const w = size * 2.4; + const h = size * 1.8; + const r = size * 0.4; // corner radius + const x = cx - w / 2; + const y = cy - h / 2; + + // Bubble body + ctx.fillStyle = color; + ctx.beginPath(); + ctx.roundRect(x, y, w, h, r); + ctx.fill(); + + // Tail (small triangle at bottom-left) + const tailX = x + w * 0.25; + const tailY = y + h; + ctx.beginPath(); + ctx.moveTo(tailX, tailY - 1); + ctx.lineTo(tailX - size * 0.4, tailY + size * 0.5); + ctx.lineTo(tailX + size * 0.4, tailY - 1); + ctx.closePath(); + ctx.fill(); + + // Inner dots (three small dots to suggest text) + ctx.fillStyle = '#ffffff'; + const dotR = size * 0.18; + const dotY = cy - size * 0.05; + const gap = size * 0.5; + for (let i = -1; i <= 1; i++) { + ctx.beginPath(); + ctx.arc(cx + i * gap, dotY, dotR, 0, Math.PI * 2); + ctx.fill(); + } +} diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 6a29c300..c8b5f162 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -371,6 +371,7 @@ export class ChangeExtractorService { return derived.length > 0 ? derived : undefined; })(); return { + createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined, owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, status: typeof parsed.status === 'string' ? parsed.status : undefined, intervals: derivedIntervals, @@ -725,6 +726,7 @@ export class ChangeExtractorService { } const descriptor = buildTaskChangePresenceDescriptor({ + createdAt: taskMeta.createdAt, owner: effectiveOptions.owner ?? taskMeta.owner, status: effectiveOptions.status ?? taskMeta.status, intervals: effectiveOptions.intervals ?? taskMeta.intervals, diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index ab2393b3..92bdbf5e 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -234,6 +234,7 @@ export class TeamDataService { for (const task of tasks) { const descriptor = buildTaskChangePresenceDescriptor({ + createdAt: task.createdAt, owner: task.owner, status: task.status, intervals: task.workIntervals, diff --git a/src/main/services/team/taskChangePresenceUtils.ts b/src/main/services/team/taskChangePresenceUtils.ts index 58cc6c90..3ef3b3c8 100644 --- a/src/main/services/team/taskChangePresenceUtils.ts +++ b/src/main/services/team/taskChangePresenceUtils.ts @@ -2,6 +2,7 @@ import { getTaskChangeStateBucket, type TaskChangeStateBucket, } from '@shared/utils/taskChangeState'; +import { deriveTaskSince } from '@shared/utils/taskChangeSince'; import { createHash } from 'crypto'; export interface TaskChangePresenceInterval { @@ -13,6 +14,7 @@ export interface TaskChangePresenceDescriptorInput { owner?: string; status?: string; intervals?: TaskChangePresenceInterval[]; + createdAt?: string; since?: string; reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; historyEvents?: unknown[]; @@ -102,6 +104,15 @@ export function computeTaskChangePresenceProjectFingerprint( export function buildTaskChangePresenceDescriptor( input: TaskChangePresenceDescriptorInput ): TaskChangePresenceDescriptor { + const effectiveSince = + typeof input.since === 'string' + ? input.since + : deriveTaskSince({ + createdAt: input.createdAt, + workIntervals: input.intervals, + historyEvents: input.historyEvents as { timestamp?: string | null }[] | undefined, + }); + const effectiveIntervals = Array.isArray(input.intervals) && input.intervals.length > 0 ? input.intervals.map((interval) => ({ @@ -124,7 +135,7 @@ export function buildTaskChangePresenceDescriptor( owner: typeof input.owner === 'string' ? input.owner.trim() : '', status: typeof input.status === 'string' ? input.status.trim() : '', intervals: effectiveIntervals, - since: typeof input.since === 'string' ? input.since : '', + since: effectiveSince ?? '', }; return { diff --git a/src/main/services/team/taskChangeWorkerTypes.ts b/src/main/services/team/taskChangeWorkerTypes.ts index 5bc0f105..a1aae4fe 100644 --- a/src/main/services/team/taskChangeWorkerTypes.ts +++ b/src/main/services/team/taskChangeWorkerTypes.ts @@ -1,6 +1,7 @@ import type { TaskChangeSetV2 } from '@shared/types'; export interface TaskChangeTaskMeta { + createdAt?: string; owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 755e50f3..d6a84fd9 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -494,6 +494,9 @@ export class TeamGraphAdapter { if (this.#seenMessageIds.has(msgKey)) continue; this.#seenMessageIds.add(msgKey); + // Skip comment notifications — #buildCommentParticles handles them with real text + if (msg.summary?.startsWith('Comment on ')) continue; + const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges); if (!edgeId) continue; diff --git a/src/renderer/utils/taskChangeRequest.ts b/src/renderer/utils/taskChangeRequest.ts index e384deca..64960d9c 100644 --- a/src/renderer/utils/taskChangeRequest.ts +++ b/src/renderer/utils/taskChangeRequest.ts @@ -3,12 +3,11 @@ import { isTaskChangeSummaryCacheable, type TaskChangeStateBucket, } from '@shared/utils/taskChangeState'; +import { deriveTaskSince as deriveSharedTaskSince } from '@shared/utils/taskChangeSince'; import type { ReviewAPI } from '@shared/types/api'; import type { TeamTaskWithKanban } from '@shared/types/team'; -const TASK_SINCE_GRACE_MS = 2 * 60 * 1000; - export type TaskChangeRequestOptions = NonNullable[2]>; export interface TaskChangeContext { @@ -31,27 +30,7 @@ type TaskChangeTaskLike = Pick< >; export function deriveTaskSince(task: TaskChangeTaskLike | null): string | undefined { - if (!task) return undefined; - - const sources: string[] = []; - if (task.createdAt) sources.push(task.createdAt); - if (Array.isArray(task.workIntervals)) { - for (const interval of task.workIntervals) { - if (interval.startedAt) sources.push(interval.startedAt); - } - } - if (Array.isArray(task.historyEvents)) { - for (const event of task.historyEvents) { - if (event.timestamp) sources.push(event.timestamp); - } - } - if (sources.length === 0) return undefined; - - const [first, ...rest] = sources; - const earliest = rest.reduce((a, b) => (a < b ? a : b), first); - const date = new Date(earliest); - date.setTime(date.getTime() - TASK_SINCE_GRACE_MS); - return date.toISOString(); + return deriveSharedTaskSince(task); } export function buildTaskChangeRequestOptions( diff --git a/src/shared/utils/taskChangeSince.ts b/src/shared/utils/taskChangeSince.ts new file mode 100644 index 00000000..b21b92ac --- /dev/null +++ b/src/shared/utils/taskChangeSince.ts @@ -0,0 +1,51 @@ +const TASK_SINCE_GRACE_MS = 2 * 60 * 1000; + +type TaskChangeIntervalLike = { + startedAt?: string | null; +}; + +type TaskChangeHistoryEventLike = { + timestamp?: string | null; +}; + +export interface TaskChangeSinceLike< + TInterval extends TaskChangeIntervalLike = TaskChangeIntervalLike, + THistoryEvent extends TaskChangeHistoryEventLike = TaskChangeHistoryEventLike, +> { + createdAt?: string | null; + workIntervals?: TInterval[] | null; + historyEvents?: THistoryEvent[] | null; +} + +export function deriveTaskSince< + TInterval extends TaskChangeIntervalLike, + THistoryEvent extends TaskChangeHistoryEventLike, +>(task: TaskChangeSinceLike | null | undefined): string | undefined { + if (!task) return undefined; + + const sources: string[] = []; + if (typeof task.createdAt === 'string' && task.createdAt.length > 0) { + sources.push(task.createdAt); + } + if (Array.isArray(task.workIntervals)) { + for (const interval of task.workIntervals) { + if (typeof interval?.startedAt === 'string' && interval.startedAt.length > 0) { + sources.push(interval.startedAt); + } + } + } + if (Array.isArray(task.historyEvents)) { + for (const event of task.historyEvents) { + if (typeof event?.timestamp === 'string' && event.timestamp.length > 0) { + sources.push(event.timestamp); + } + } + } + if (sources.length === 0) return undefined; + + const [first, ...rest] = sources; + const earliest = rest.reduce((a, b) => (a < b ? a : b), first); + const date = new Date(earliest); + date.setTime(date.getTime() - TASK_SINCE_GRACE_MS); + return date.toISOString(); +} diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 8f2aa970..0e574462 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -1867,6 +1867,94 @@ describe('TeamDataService', () => { expect(mismatched.tasks[0]?.changePresence).toBe('unknown'); }); + it('preserves cached changePresence when persisted entry was recorded with derived since', async () => { + const task: TeamTask = { + id: 'task-1', + subject: 'Review API', + status: 'completed', + owner: 'alice', + createdAt: '2026-03-01T10:05:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:10:00.000Z' }], + historyEvents: [ + { + id: 'evt-1', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-03-01T10:00:00.000Z', + }, + ], + }; + + const persistedDescriptor = buildTaskChangePresenceDescriptor({ + createdAt: task.createdAt, + owner: task.owner, + status: task.status, + intervals: task.workIntervals, + since: '2026-03-01T09:58:00.000Z', + historyEvents: task.historyEvents, + reviewState: 'none', + }); + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })), + } as never, + { + getTasks: vi.fn(async () => [task]), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never + ); + + service.setTaskChangePresenceServices( + { + load: vi.fn(async () => ({ + version: 1, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: persistedDescriptor.taskSignature, + presence: 'has_changes', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + })), + upsertEntry: vi.fn(async () => undefined), + } as never, + { + getSnapshot: vi.fn(() => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + ensureTracking: vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + } as never + ); + + const data = await service.getTeamData('my-team'); + + expect(data.tasks[0]?.changePresence).toBe('has_changes'); + }); + it('returns lightweight task change presence without loading full team data', async () => { const task: TeamTask = { id: 'task-1',