From 9678d790cd63dbd9db32f3e11bb8ee4ef1c2fae1 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 9 Mar 2026 14:52:38 +0200 Subject: [PATCH] feat: enhance task review process with new event tracking - Introduced a new function to determine the current review state based on task history events, improving the accuracy of review status tracking. - Updated the requestReview, approveReview, and requestChanges functions to append corresponding review events to the task history, ensuring comprehensive tracking of review actions. - Refactored task management logic to utilize the new historyEvents structure, replacing the previous statusHistory implementation for better clarity and maintainability. - Enhanced tests to validate the new review event handling and ensure correct behavior across various task states. --- agent-teams-controller/src/internal/review.js | 58 ++++++++ .../src/internal/taskStore.js | 34 ++++- agent-teams-controller/src/internal/tasks.js | 3 + .../test/controller.test.js | 26 +++- .../services/team/ChangeExtractorService.ts | 3 +- src/main/services/team/TeamDataService.ts | 10 +- .../services/team/TeamMemberLogsFinder.ts | 7 +- src/main/services/team/TeamTaskReader.ts | 35 +++-- src/main/services/team/TeamTaskWriter.ts | 78 +++++----- src/main/workers/team-fs-worker.ts | 61 ++++---- .../components/chat/DisplayItemList.tsx | 9 +- .../components/chat/items/BaseItem.tsx | 11 ++ .../components/chat/items/ExecutionTrace.tsx | 4 + .../components/chat/items/LinkedToolItem.tsx | 4 + .../components/chat/items/SlashItem.tsx | 4 + .../components/chat/items/SubagentItem.tsx | 9 ++ .../chat/items/TeammateMessageItem.tsx | 9 ++ .../components/chat/items/TextItem.tsx | 4 + .../components/chat/items/ThinkingItem.tsx | 4 + .../components/team/UnreadCommentsBadge.tsx | 14 +- .../components/team/dialogs/ReviewDialog.tsx | 62 ++++---- .../team/dialogs/StatusHistoryTimeline.tsx | 135 ++++++++++++++---- .../team/dialogs/TaskCommentInput.tsx | 27 +++- .../team/dialogs/TaskDetailDialog.tsx | 12 +- src/renderer/index.css | 4 +- src/shared/types/team.ts | 63 ++++++-- src/shared/utils/reviewState.ts | 10 +- src/shared/utils/taskHistory.ts | 50 +++++++ .../main/services/team/TeamTaskWriter.test.ts | 81 ++++++----- 29 files changed, 598 insertions(+), 233 deletions(-) create mode 100644 src/shared/utils/taskHistory.ts diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index 0ea22fd6..ec0e8040 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -32,6 +32,20 @@ function resolveLeadSessionId(context, flags) { } } +function getCurrentReviewState(task) { + const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i]; + if (e.type === 'review_requested' || e.type === 'review_changes_requested' || e.type === 'review_approved') { + return e.to; + } + if (e.type === 'status_changed' && e.to === 'in_progress') { + return 'none'; + } + } + return 'none'; +} + function requestReview(context, taskId, flags = {}) { const task = tasks.getTask(context, taskId); if (task.status !== 'completed') { @@ -42,9 +56,24 @@ function requestReview(context, taskId, flags = {}) { typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead'; const reviewer = getReviewer(context, flags); const leadSessionId = resolveLeadSessionId(context, flags); + const prevReviewState = getCurrentReviewState(task); try { kanban.setKanbanColumn(context, task.id, 'review'); + + // Append review_requested event + tasks.updateTask(context, task.id, (t) => { + t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { + type: 'review_requested', + from: prevReviewState, + to: 'review', + ...(reviewer ? { reviewer } : {}), + actor: from, + }); + t.reviewState = 'review'; + return t; + }); + if (!reviewer) { return tasks.getTask(context, task.id); } @@ -81,8 +110,23 @@ function approveReview(context, taskId, flags = {}) { typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead'; const note = typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved'; const leadSessionId = resolveLeadSessionId(context, flags); + const prevReviewState = getCurrentReviewState(task); kanban.setKanbanColumn(context, task.id, 'approved'); + + // Append review_approved event + tasks.updateTask(context, task.id, (t) => { + t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { + type: 'review_approved', + from: prevReviewState, + to: 'approved', + ...(note ? { note } : {}), + actor: from, + }); + t.reviewState = 'approved'; + return t; + }); + tasks.addTaskComment(context, task.id, { text: note, from, @@ -119,6 +163,20 @@ function requestChanges(context, taskId, flags = {}) { ? flags.comment.trim() : 'Reviewer requested changes.'; const leadSessionId = resolveLeadSessionId(context, flags); + const prevReviewState = getCurrentReviewState(task); + + // Append review_changes_requested event before status change + tasks.updateTask(context, task.id, (t) => { + t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, { + type: 'review_changes_requested', + from: prevReviewState, + to: 'needsFix', + ...(comment ? { note: comment } : {}), + actor: from, + }); + t.reviewState = 'needsFix'; + return t; + }); kanban.clearKanban(context, task.id, { nextReviewState: 'needsFix' }); tasks.setTaskStatus(context, task.id, 'pending', from); diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js index 7e437223..3d45b1af 100644 --- a/agent-teams-controller/src/internal/taskStore.js +++ b/agent-teams-controller/src/internal/taskStore.js @@ -145,8 +145,10 @@ function readTask(paths, taskRef, options = {}) { return normalizeTask(rawTask, taskPath); } -function createStatusTransition(history, from, to, actor, timestamp) { - return [...(Array.isArray(history) ? history : []), { from, to, timestamp, ...(actor ? { actor } : {}) }]; +function appendHistoryEvent(events, event) { + const list = Array.isArray(events) ? [...events] : []; + list.push({ id: crypto.randomUUID(), timestamp: nowIso(), ...event }); + return list; } function normalizeStatus(status) { @@ -285,7 +287,12 @@ function createTask(paths, input = {}) { : Array.isArray(input.workIntervals) ? input.workIntervals : undefined, - statusHistory: createStatusTransition(input.statusHistory, null, status, createdBy, createdAt), + historyEvents: appendHistoryEvent(undefined, { + type: 'task_created', + status, + ...(createdBy ? { actor: createdBy } : {}), + timestamp: createdAt, + }), blocks: Array.isArray(input.blocks) ? [...input.blocks] : [], blockedBy: blockedByIds, related: relatedIds.length > 0 ? relatedIds : undefined, @@ -364,7 +371,13 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) { } task.workIntervals = workIntervals.length > 0 ? workIntervals : undefined; - task.statusHistory = createStatusTransition(task.statusHistory, task.status, status, actor, timestamp); + task.historyEvents = appendHistoryEvent(task.historyEvents, { + type: 'status_changed', + from: task.status, + to: status, + ...(actor ? { actor } : {}), + timestamp, + }); task.status = status; if (status === 'deleted') { @@ -601,6 +614,18 @@ function compareTasksByFreshness(a, b) { } function getEffectiveReviewState(kanbanEntry, task) { + // Derive from historyEvents if available + const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i]; + if (e.type === 'review_requested' || e.type === 'review_changes_requested' || e.type === 'review_approved') { + return e.to; + } + if (e.type === 'status_changed' && e.to === 'in_progress') { + return 'none'; + } + } + // Fallback to persisted reviewState or kanban if (normalizeTaskReviewState(task.reviewState) !== 'none') { return normalizeTaskReviewState(task.reviewState); } @@ -724,6 +749,7 @@ module.exports = { addCommentAttachmentMeta, addTaskAttachmentMeta, addTaskComment, + appendHistoryEvent, buildTaskReference, createTask, deriveDisplayId, diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 3ea5c1a8..aed667fe 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -209,6 +209,7 @@ async function taskBriefing(context, memberName) { module.exports = { addTaskAttachmentMeta, addTaskComment, + appendHistoryEvent: taskStore.appendHistoryEvent, attachTaskFile, attachCommentFile, completeTask, @@ -227,5 +228,7 @@ module.exports = { startTask, taskBriefing, unlinkTask, + updateTask: (context, taskRef, updater) => + taskStore.updateTask(context.paths, taskRef, updater), updateTaskFields, }; diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index d6e5b1b4..c687835f 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -290,7 +290,7 @@ describe('agent-teams-controller API', () => { const task = controller.tasks.createTask({ subject: 'Lifecycle task' }); expect(task.status).toBe('pending'); - expect(task.statusHistory).toHaveLength(1); + expect(task.historyEvents).toHaveLength(1); expect(task.workIntervals).toBeUndefined(); const started = controller.tasks.startTask(task.id, 'bob'); @@ -301,12 +301,12 @@ describe('agent-teams-controller API', () => { const restored = controller.tasks.restoreTask(task.id, 'bob'); expect(started.status).toBe('in_progress'); - expect(startedAgain.statusHistory).toHaveLength(2); + expect(startedAgain.historyEvents).toHaveLength(2); expect(startedAgain.workIntervals).toHaveLength(1); expect(startedAgain.workIntervals[0].startedAt).toBeTruthy(); expect(completed.status).toBe('completed'); - expect(completedAgain.statusHistory).toHaveLength(3); + expect(completedAgain.historyEvents).toHaveLength(3); expect(completedAgain.workIntervals).toHaveLength(1); expect(completedAgain.workIntervals[0].completedAt).toBeTruthy(); @@ -314,9 +314,23 @@ describe('agent-teams-controller API', () => { expect(deleted.deletedAt).toBeTruthy(); expect(restored.status).toBe('pending'); expect(restored.deletedAt).toBeUndefined(); - expect(restored.statusHistory).toHaveLength(5); - expect(restored.statusHistory.map((entry) => entry.to)).toEqual([ - 'pending', + expect(restored.historyEvents).toHaveLength(5); + + // Verify the event sequence: task_created, then 4 status_changed events + const types = restored.historyEvents.map((e) => e.type); + expect(types).toEqual([ + 'task_created', + 'status_changed', + 'status_changed', + 'status_changed', + 'status_changed', + ]); + + // Verify the status flow: pending -> in_progress -> completed -> deleted -> pending + const firstEvent = restored.historyEvents[0]; + expect(firstEvent.status).toBe('pending'); + const statusChanges = restored.historyEvents.slice(1).map((e) => e.to); + expect(statusChanges).toEqual([ 'in_progress', 'completed', 'deleted', diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index e6b94118..4ba12644 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -244,12 +244,13 @@ export class ChangeExtractorService { const derivedIntervals = (() => { if (Array.isArray(intervals) && intervals.length > 0) return intervals; - const rawHistory = parsed.statusHistory; + const rawHistory = parsed.historyEvents; if (!Array.isArray(rawHistory)) return undefined; const transitions = rawHistory .map((h) => (h && typeof h === 'object' ? (h as Record) : null)) .filter((h): h is Record => h !== null) + .filter((h) => h.type === 'status_changed') .map((h) => ({ to: typeof h.to === 'string' ? h.to : null, timestamp: typeof h.timestamp === 'string' ? h.timestamp : null, diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 9d3b2b80..076c2321 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -813,7 +813,7 @@ export class TeamDataService { /** * Called when a task file changes on disk (e.g. teammate CLI wrote it). - * If the latest statusHistory entry shows a non-user actor started the task, + * If the latest historyEvents entry shows a non-user actor started the task, * sends an inbox notification to the team lead. */ async notifyLeadOnTeammateTaskStart(teamName: string, taskId: string): Promise { @@ -822,11 +822,11 @@ export class TeamDataService { const task = tasks.find((t) => t.id === taskId); if (!task) return; - const history = task.statusHistory; - if (!Array.isArray(history) || history.length === 0) return; + const events = task.historyEvents; + if (!Array.isArray(events) || events.length === 0) return; - const last = history[history.length - 1]; - if (last.to !== 'in_progress') return; + const last = events[events.length - 1]; + if (last.type !== 'status_changed' || last.to !== 'in_progress') return; if (!last.actor || last.actor === 'user') return; // Dedup: only notify once per unique transition (keyed by team+task+timestamp). diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 8c6f228e..4cdfd752 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -411,6 +411,7 @@ export class TeamMemberLogsFinder { } } + const discoveredSessionIds = await this.listSessionDirs(projectDir); let sessionIds: string[]; if (knownSessionIds.size > 0) { const verified: string[] = []; @@ -423,9 +424,11 @@ export class TeamMemberLogsFinder { // dir doesn't exist } } - sessionIds = verified.length > 0 ? verified : await this.listSessionDirs(projectDir); + // Prefer config-backed sessions first, but also include any live session dirs that have + // appeared on disk and are not yet reflected in config/sessionHistory. + sessionIds = Array.from(new Set([...verified, ...discoveredSessionIds])); } else { - sessionIds = await this.listSessionDirs(projectDir); + sessionIds = discoveredSessionIds; } const knownMembers = new Set( diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index ad74666c..8ff5eaf2 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -2,7 +2,7 @@ import { yieldToEventLoop } from '@main/utils/asyncYield'; import { readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTasksBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; -import { normalizeReviewState } from '@shared/utils/reviewState'; +import { getReviewStateFromTask } from '@shared/utils/reviewState'; import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; import * as fs from 'fs'; import * as path from 'path'; @@ -10,9 +10,9 @@ import * as path from 'path'; import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; import type { - StatusTransition, TaskAttachmentMeta, TaskComment, + TaskHistoryEvent, TaskWorkInterval, TeamTask, TeamTaskStatus, @@ -115,25 +115,17 @@ export class TeamTaskReader { // `satisfies Record` ensures compile-time // safety: if a field is added to TeamTask but not mapped here, // TypeScript will error. This prevents silently dropping new fields. - const statusHistory: StatusTransition[] | undefined = Array.isArray(parsed.statusHistory) - ? (parsed.statusHistory as unknown[]) + const historyEvents: TaskHistoryEvent[] | undefined = Array.isArray(parsed.historyEvents) + ? (parsed.historyEvents as unknown[]) .filter( - (e): e is { from: string | null; to: string; timestamp: string; actor?: string } => + (e): e is Record => Boolean(e) && typeof e === 'object' && - ((e as Record).from === null || - typeof (e as Record).from === 'string') && - typeof (e as Record).to === 'string' && + typeof (e as Record).id === 'string' && typeof (e as Record).timestamp === 'string' && - ((e as Record).actor === undefined || - typeof (e as Record).actor === 'string') + typeof (e as Record).type === 'string' ) - .map((e) => ({ - from: e.from as TeamTaskStatus | null, - to: e.to as TeamTaskStatus, - timestamp: e.timestamp, - ...(e.actor ? { actor: e.actor } : {}), - })) + .map((e) => e as unknown as TaskHistoryEvent) : undefined; const workIntervals: TaskWorkInterval[] | undefined = Array.isArray(parsed.workIntervals) ? (parsed.workIntervals as unknown[]) @@ -172,7 +164,7 @@ export class TeamTaskReader { ? (parsed.status as TeamTask['status']) : 'pending', workIntervals, - statusHistory, + historyEvents, blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string') : undefined, @@ -262,7 +254,10 @@ export class TeamTaskReader { addedAt: a.addedAt, })) : undefined, - reviewState: normalizeReviewState(parsed.reviewState), + reviewState: getReviewStateFromTask({ + historyEvents, + reviewState: parsed.reviewState as TeamTask['reviewState'], + }), } satisfies Record; if (task.status === 'deleted') { continue; @@ -359,7 +354,9 @@ export class TeamTaskReader { status: 'deleted', deletedAt: typeof parsed.deletedAt === 'string' ? parsed.deletedAt : undefined, createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined, - reviewState: normalizeReviewState(parsed.reviewState), + reviewState: getReviewStateFromTask({ + reviewState: parsed.reviewState as TeamTask['reviewState'], + }), }; tasks.push(task); diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index 207c6cff..233f6908 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -6,10 +6,10 @@ import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; import type { - StatusTransition, TaskAttachmentMeta, TaskComment, TaskCommentType, + TaskHistoryEvent, TeamTask, TeamTaskStatus, } from '@shared/types'; @@ -34,16 +34,17 @@ async function withTaskLock(taskPath: string, fn: () => Promise): Promise< } } -function appendTransition( - history: StatusTransition[] | undefined, - from: TeamTaskStatus | null, - to: TeamTaskStatus, - timestamp: string, - actor?: string -): StatusTransition[] { - const entry: StatusTransition = { from, to, timestamp }; - if (actor) entry.actor = actor; - return [...(history ?? []), entry]; +function appendHistoryEvent( + events: TaskHistoryEvent[] | undefined, + event: Omit +): TaskHistoryEvent[] { + const list = Array.isArray(events) ? [...events] : []; + list.push({ + id: randomUUID(), + timestamp: new Date().toISOString(), + ...event, + } as TaskHistoryEvent); + return list; } export class TeamTaskWriter { @@ -82,13 +83,11 @@ export class TeamTaskWriter { : [{ startedAt: createdAt }]), ] : task.workIntervals, - statusHistory: appendTransition( - task.statusHistory, - null, - task.status, - createdAt, - task.createdBy - ), + historyEvents: appendHistoryEvent(task.historyEvents, { + type: 'task_created', + status: task.status, + ...(task.createdBy ? { actor: task.createdBy } : {}), + } as Omit), }; await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2)); @@ -344,12 +343,14 @@ export class TeamTaskWriter { } task.workIntervals = intervals.length > 0 ? intervals : undefined; - task.statusHistory = appendTransition( - Array.isArray(task.statusHistory) ? task.statusHistory : undefined, - prevStatus, - status, - nowIso, - actor + task.historyEvents = appendHistoryEvent( + Array.isArray(task.historyEvents) ? task.historyEvents : undefined, + { + type: 'status_changed', + from: prevStatus, + to: status, + ...(actor ? { actor } : {}), + } as Omit ); task.status = status; await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); @@ -416,12 +417,14 @@ export class TeamTaskWriter { task.status = 'deleted'; task.deletedAt = nowIso; - task.statusHistory = appendTransition( - Array.isArray(task.statusHistory) ? task.statusHistory : undefined, - prevStatus, - 'deleted', - nowIso, - actor + task.historyEvents = appendHistoryEvent( + Array.isArray(task.historyEvents) ? task.historyEvents : undefined, + { + type: 'status_changed', + from: prevStatus, + to: 'deleted', + ...(actor ? { actor } : {}), + } as Omit ); await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); @@ -449,13 +452,14 @@ export class TeamTaskWriter { const task = JSON.parse(raw) as TeamTask; const prevStatus = task.status; - const nowIso = new Date().toISOString(); - task.statusHistory = appendTransition( - Array.isArray(task.statusHistory) ? task.statusHistory : undefined, - prevStatus, - 'pending', - nowIso, - actor ?? 'user' + task.historyEvents = appendHistoryEvent( + Array.isArray(task.historyEvents) ? task.historyEvents : undefined, + { + type: 'status_changed', + from: prevStatus, + to: 'pending', + actor: actor ?? 'user', + } as Omit ); task.status = 'pending'; delete task.deletedAt; diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index bd2e16a7..b67bfbd1 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -120,7 +120,7 @@ interface ParsedTask { reviewState?: unknown; metadata?: { _internal?: unknown }; workIntervals?: unknown; - statusHistory?: unknown; + historyEvents?: unknown; attachments?: unknown; } @@ -129,11 +129,12 @@ interface RawWorkInterval { completedAt?: unknown; } -interface RawStatusTransition { - from?: unknown; - to?: unknown; +interface RawHistoryEvent { + id?: unknown; + type?: unknown; timestamp?: unknown; actor?: unknown; + [key: string]: unknown; } interface RawComment { @@ -477,26 +478,35 @@ function normalizeWorkIntervals( })); } -function normalizeStatusHistory( - parsed: ParsedTask -): { from: string | null; to: string; timestamp: string; actor?: string }[] | undefined { - if (!Array.isArray(parsed.statusHistory)) return undefined; - return (parsed.statusHistory as unknown[]) +function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefined { + if (!Array.isArray(parsed.historyEvents)) return undefined; + return (parsed.historyEvents as unknown[]) .filter( - (i): i is RawStatusTransition => + (i): i is RawHistoryEvent => Boolean(i) && typeof i === 'object' && - ((i as RawStatusTransition).from === null || - typeof (i as RawStatusTransition).from === 'string') && - typeof (i as RawStatusTransition).to === 'string' && - typeof (i as RawStatusTransition).timestamp === 'string' + typeof (i as RawHistoryEvent).id === 'string' && + typeof (i as RawHistoryEvent).timestamp === 'string' && + typeof (i as RawHistoryEvent).type === 'string' ) - .map((i) => ({ - from: i.from as string | null, - to: i.to as string, - timestamp: i.timestamp as string, - ...(typeof i.actor === 'string' ? { actor: i.actor } : {}), - })); + .map((i) => ({ ...i })); +} + +/** Derive review state from historyEvents (inline reducer for worker isolation). */ +function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): string { + if (!Array.isArray(events) || events.length === 0) return 'none'; + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i]; + const t = e.type; + if (t === 'review_requested' || t === 'review_changes_requested' || t === 'review_approved') { + const to = typeof e.to === 'string' ? e.to : 'none'; + return to === 'review' || to === 'needsFix' || to === 'approved' ? to : 'none'; + } + if (t === 'status_changed' && e.to === 'in_progress') { + return 'none'; + } + } + return 'none'; } function normalizeComments(parsed: ParsedTask): unknown[] | undefined { @@ -601,13 +611,8 @@ async function readTasksDirForTeam( parsed.needsClarification === 'lead' || parsed.needsClarification === 'user' ? (parsed.needsClarification as string) : undefined; - const reviewState = - parsed.reviewState === 'review' || - parsed.reviewState === 'needsFix' || - parsed.reviewState === 'approved' || - parsed.reviewState === 'none' - ? parsed.reviewState - : 'none'; + const historyEvents = normalizeHistoryEvents(parsed); + const reviewState = deriveReviewStateFromEvents(historyEvents); tasks.push({ id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', @@ -632,7 +637,7 @@ async function readTasksDirForTeam( ? (parsed.status as string) : 'pending', workIntervals: normalizeWorkIntervals(parsed), - statusHistory: normalizeStatusHistory(parsed), + historyEvents: normalizeHistoryEvents(parsed), blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined, blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as unknown[]) : undefined, related: Array.isArray(parsed.related) diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index d6f90ad3..7bc6ee21 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -104,9 +104,7 @@ export const DisplayItemList = ({ return (
{items.map((item, index) => { @@ -132,6 +130,7 @@ export const DisplayItemList = ({ preview={truncateText(item.content, 150)} onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} + timestamp={item.timestamp} markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} searchQueryOverride={searchQueryOverride} /> @@ -157,6 +156,7 @@ export const DisplayItemList = ({ preview={truncateText(item.content, 150)} onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} + timestamp={item.timestamp} markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} searchQueryOverride={searchQueryOverride} /> @@ -171,6 +171,7 @@ export const DisplayItemList = ({ linkedTool={item.tool} onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} + timestamp={item.tool.startTime} searchQueryOverride={searchQueryOverride} isHighlighted={highlightToolUseId === item.tool.id} highlightColor={highlightColor} @@ -221,6 +222,7 @@ export const DisplayItemList = ({ slash={item.slash} onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} + timestamp={item.slash.timestamp} /> ); break; @@ -249,6 +251,7 @@ export const DisplayItemList = ({ label="Input" summary={truncateText(inputContent, 80)} tokenCount={inputTokenCount} + timestamp={item.timestamp} onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} > diff --git a/src/renderer/components/chat/items/BaseItem.tsx b/src/renderer/components/chat/items/BaseItem.tsx index e1a20ec0..72a0896e 100644 --- a/src/renderer/components/chat/items/BaseItem.tsx +++ b/src/renderer/components/chat/items/BaseItem.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { TOOL_ITEM_MUTED } from '@renderer/constants/cssVariables'; import { getTriggerColorDef, type TriggerColor } from '@shared/constants/triggerColors'; +import { format } from 'date-fns'; import { ChevronRight } from 'lucide-react'; import { formatDuration, formatTokens, getStatusDotColor } from './baseItemHelpers'; @@ -27,6 +28,8 @@ interface BaseItemProps { status?: ItemStatus; /** Duration in milliseconds */ durationMs?: number; + /** Timestamp to display (compact HH:mm:ss) */ + timestamp?: Date; /** Click handler for toggling */ onClick: () => void; /** Whether the item is expanded */ @@ -80,6 +83,7 @@ export const BaseItem: React.FC = ({ tokenLabel = 'tokens', status, durationMs, + timestamp, onClick, isExpanded, hasExpandableContent = true, @@ -169,6 +173,13 @@ export const BaseItem: React.FC = ({ )} + {/* Timestamp — rightmost info element */} + {timestamp && ( + + {format(timestamp, 'HH:mm:ss')} + + )} + {/* Expand/collapse chevron */} {hasExpandableContent && ( = ({ preview={preview} onClick={() => handleItemClick(itemId)} isExpanded={isExpanded} + timestamp={item.timestamp} /> ); } @@ -122,6 +123,7 @@ export const ExecutionTrace: React.FC = ({ preview={preview} onClick={() => handleItemClick(itemId)} isExpanded={isExpanded} + timestamp={item.timestamp} /> ); } @@ -136,6 +138,7 @@ export const ExecutionTrace: React.FC = ({ linkedTool={item.tool} onClick={() => handleItemClick(itemId)} isExpanded={isExpanded} + timestamp={item.tool.startTime} isHighlighted={isHighlighted} highlightColor={highlightColor} notificationDotColor={notificationColorMap?.get(item.tool.id)} @@ -167,6 +170,7 @@ export const ExecutionTrace: React.FC = ({ label="Input" summary={truncateText(item.content, 80)} tokenCount={item.tokenCount} + timestamp={item.timestamp} onClick={() => handleItemClick(itemId)} isExpanded={isExpanded} > diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index 2b7c4a51..590bde21 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -48,6 +48,8 @@ interface LinkedToolItemProps { linkedTool: LinkedToolItemType; onClick: () => void; isExpanded: boolean; + /** Timestamp for display */ + timestamp?: Date; /** Optional local search query override for inline highlighting */ searchQueryOverride?: string; /** Whether this item should be highlighted for error deep linking */ @@ -64,6 +66,7 @@ export const LinkedToolItem: React.FC = ({ linkedTool, onClick, isExpanded, + timestamp, searchQueryOverride, isHighlighted, highlightColor, @@ -177,6 +180,7 @@ export const LinkedToolItem: React.FC = ({ tokenCount={getToolContextTokens(linkedTool)} status={status} durationMs={linkedTool.durationMs} + timestamp={timestamp} onClick={onClick} isExpanded={isExpanded} highlightClasses={highlightClasses} diff --git a/src/renderer/components/chat/items/SlashItem.tsx b/src/renderer/components/chat/items/SlashItem.tsx index 146a35ec..a5741796 100644 --- a/src/renderer/components/chat/items/SlashItem.tsx +++ b/src/renderer/components/chat/items/SlashItem.tsx @@ -13,6 +13,8 @@ interface SlashItemProps { slash: SlashItemType; onClick: () => void; isExpanded: boolean; + /** Timestamp for display */ + timestamp?: Date; /** Additional classes for highlighting (e.g., error deep linking) */ highlightClasses?: string; /** Inline styles for highlighting (used by custom hex colors) */ @@ -34,6 +36,7 @@ export const SlashItem: React.FC = ({ slash, onClick, isExpanded, + timestamp, highlightClasses, highlightStyle, notificationDotColor, @@ -51,6 +54,7 @@ export const SlashItem: React.FC = ({ tokenCount={slash.instructionsTokenCount} tokenLabel="tokens" status={hasInstructions ? 'ok' : undefined} + timestamp={timestamp} onClick={onClick} isExpanded={isExpanded} hasExpandableContent={hasInstructions} diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx index 39b7482c..ef017160 100644 --- a/src/renderer/components/chat/items/SubagentItem.tsx +++ b/src/renderer/components/chat/items/SubagentItem.tsx @@ -25,6 +25,7 @@ import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers'; import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters'; import { getHighlightProps, type TriggerColor } from '@shared/constants/triggerColors'; import { getModelColorClass, parseModelString } from '@shared/utils/modelParser'; +import { format } from 'date-fns'; import { ArrowUpRight, Bot, @@ -369,6 +370,14 @@ export const SubagentItem: React.FC = ({ > {formatDuration(subagent.durationMs)} + + {/* Timestamp — rightmost info element */} + + {format(subagent.startTime, 'HH:mm:ss')} +
{/* ========== Level 1 Expanded: Dashboard Content ========== */} diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx index d1f9fafe..29c1fac9 100644 --- a/src/renderer/components/chat/items/TeammateMessageItem.tsx +++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx @@ -13,6 +13,7 @@ import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting'; import { formatTokensCompact } from '@renderer/utils/formatters'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; +import { format } from 'date-fns'; import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react'; import { MarkdownViewer } from '../viewers/MarkdownViewer'; @@ -218,6 +219,14 @@ export const TeammateMessageItem: React.FC = ({ ~{formatTokensCompact(teammateMessage.tokenCount)} tokens )} + + {/* Timestamp — rightmost info element */} + + {format(teammateMessage.timestamp, 'HH:mm:ss')} + {/* Expanded content */} diff --git a/src/renderer/components/chat/items/TextItem.tsx b/src/renderer/components/chat/items/TextItem.tsx index 2e0c88dd..43e823b7 100644 --- a/src/renderer/components/chat/items/TextItem.tsx +++ b/src/renderer/components/chat/items/TextItem.tsx @@ -16,6 +16,8 @@ interface TextItemProps { preview: string; onClick: () => void; isExpanded: boolean; + /** Timestamp for display */ + timestamp?: Date; /** Optional local search query for inline highlighting */ searchQueryOverride?: string; /** Optional stable item id for search highlighting */ @@ -33,6 +35,7 @@ export const TextItem: React.FC = ({ preview, onClick, isExpanded, + timestamp, searchQueryOverride, markdownItemId, highlightClasses, @@ -61,6 +64,7 @@ export const TextItem: React.FC = ({ label="Output" summary={summary} tokenCount={tokenCount} + timestamp={timestamp} onClick={onClick} isExpanded={isExpanded} highlightClasses={highlightClasses} diff --git a/src/renderer/components/chat/items/ThinkingItem.tsx b/src/renderer/components/chat/items/ThinkingItem.tsx index c74742ee..480747c0 100644 --- a/src/renderer/components/chat/items/ThinkingItem.tsx +++ b/src/renderer/components/chat/items/ThinkingItem.tsx @@ -16,6 +16,8 @@ interface ThinkingItemProps { preview: string; onClick: () => void; isExpanded: boolean; + /** Timestamp for display */ + timestamp?: Date; /** Optional local search query for inline highlighting */ searchQueryOverride?: string; /** Optional stable item id for search highlighting */ @@ -33,6 +35,7 @@ export const ThinkingItem: React.FC = ({ preview, onClick, isExpanded, + timestamp, searchQueryOverride, markdownItemId, highlightClasses, @@ -61,6 +64,7 @@ export const ThinkingItem: React.FC = ({ label="Thinking" summary={summary} tokenCount={tokenCount} + timestamp={timestamp} onClick={onClick} isExpanded={isExpanded} highlightClasses={highlightClasses} diff --git a/src/renderer/components/team/UnreadCommentsBadge.tsx b/src/renderer/components/team/UnreadCommentsBadge.tsx index 6e39115b..de26cf87 100644 --- a/src/renderer/components/team/UnreadCommentsBadge.tsx +++ b/src/renderer/components/team/UnreadCommentsBadge.tsx @@ -12,16 +12,14 @@ export const UnreadCommentsBadge = ({ if (totalCount === 0) return null; return ( - 0 - ? 'bg-blue-500/20 text-blue-600 dark:text-blue-400' - : 'bg-[var(--color-surface-raised)] text-[var(--color-text-muted)]' - }`} - title={unreadCount > 0 ? `${unreadCount} unread` : 'All read'} - > + {totalCount} + {unreadCount > 0 && ( + + {unreadCount} + + )} ); }; diff --git a/src/renderer/components/team/dialogs/ReviewDialog.tsx b/src/renderer/components/team/dialogs/ReviewDialog.tsx index 49efbe22..6f0c2f17 100644 --- a/src/renderer/components/team/dialogs/ReviewDialog.tsx +++ b/src/renderer/components/team/dialogs/ReviewDialog.tsx @@ -1,21 +1,20 @@ import { useMemo } from 'react'; -import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; -import { Label } from '@renderer/components/ui/label'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { MAX_TEXT_LENGTH } from '@shared/constants'; import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; +import { Send } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { ResolvedTeamMember } from '@shared/types'; @@ -55,14 +54,13 @@ export const ReviewDialog = ({ [members, colorMap] ); - const handleCancel = (): void => { - onCancel(); - }; + const trimmed = draft.value.trim(); + const remaining = MAX_TEXT_LENGTH - trimmed.length; const handleSubmit = (): void => { - const trimmed = draft.value.trim() || undefined; + const comment = trimmed || undefined; draft.clearDraft(); - onSubmit(trimmed); + onSubmit(comment); }; return ( @@ -70,44 +68,54 @@ export const ReviewDialog = ({ open={open && taskId !== null} onOpenChange={(nextOpen) => { if (!nextOpen) { - handleCancel(); + onCancel(); } }} > - + Request Changes Task #{taskId ? deriveTaskDisplayId(taskId) : ''}
- + + Submit + + } footerRight={ - draft.isSaved ? ( - Draft saved - ) : undefined +
+ {remaining < 200 ? ( + + {remaining} chars left + + ) : null} + {draft.isSaved ? ( + Draft saved + ) : null} +
} />
- - - - -
); diff --git a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx index 10632745..6dfbb974 100644 --- a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx +++ b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx @@ -1,35 +1,38 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; -import { TASK_STATUS_LABELS, TASK_STATUS_STYLES } from '@renderer/utils/memberHelpers'; -import { ArrowRight, Plus } from 'lucide-react'; +import { + REVIEW_STATE_DISPLAY, + TASK_STATUS_LABELS, + TASK_STATUS_STYLES, +} from '@renderer/utils/memberHelpers'; +import { ArrowRight, Eye, MessageSquareX, Play, Plus, ShieldCheck } from 'lucide-react'; -import type { StatusTransition, TeamTaskStatus } from '@shared/types'; +import type { TaskHistoryEvent, TeamReviewState, TeamTaskStatus } from '@shared/types'; -interface StatusHistoryTimelineProps { - history: StatusTransition[]; +interface WorkflowTimelineProps { + events: TaskHistoryEvent[]; } -export const StatusHistoryTimeline = ({ history }: StatusHistoryTimelineProps) => { - if (history.length === 0) { +export const WorkflowTimeline = ({ events }: WorkflowTimelineProps) => { + if (events.length === 0) { return (
- No status history recorded + No workflow history recorded
); } return (
- {history.map((transition, idx) => { - const isLast = idx === history.length - 1; - const time = formatTime(transition.timestamp); - const isCreation = transition.from === null; + {events.map((event, idx) => { + const isLast = idx === events.length - 1; + const time = formatTime(event.timestamp); return ( -
+
{/* Timeline line + dot */}
-
+
{!isLast &&
}
@@ -40,28 +43,16 @@ export const StatusHistoryTimeline = ({ history }: StatusHistoryTimelineProps) = {time} - {isCreation ? ( - - - Created as - - - ) : ( - - - - - - )} - {transition.actor ? ( + + {event.actor ? ( - by {transition.actor} + by {event.actor} ) : null}
- {new Date(transition.timestamp).toLocaleString()} + {new Date(event.timestamp).toLocaleString()}
@@ -71,6 +62,58 @@ export const StatusHistoryTimeline = ({ history }: StatusHistoryTimelineProps) = ); }; +/** Keep old name as re-export for backwards compatibility during migration. */ +export const StatusHistoryTimeline = WorkflowTimeline; + +const EventContent = ({ event }: { event: TaskHistoryEvent }) => { + switch (event.type) { + case 'task_created': + return ( + + + Created as + + + ); + case 'status_changed': + return ( + + + + + + ); + case 'review_requested': + return ( + + + Review requested + {event.reviewer ? ( + ({event.reviewer}) + ) : null} + + ); + case 'review_changes_requested': + return ( + + + Changes requested + + + ); + case 'review_approved': + return ( + + + Approved + + + ); + default: + return Unknown event; + } +}; + const StatusBadge = ({ status }: { status: TeamTaskStatus }) => { const style = TASK_STATUS_STYLES[status] ?? TASK_STATUS_STYLES.pending; const label = TASK_STATUS_LABELS[status] ?? status; @@ -83,7 +126,37 @@ const StatusBadge = ({ status }: { status: TeamTaskStatus }) => { ); }; -function dotColor(status: TeamTaskStatus): string { +const ReviewStateBadge = ({ state }: { state: TeamReviewState }) => { + if (state === 'none') return null; + const display = REVIEW_STATE_DISPLAY[state]; + if (!display) return null; + return ( + + {display.label} + + ); +}; + +function dotColor(event: TaskHistoryEvent): string { + switch (event.type) { + case 'task_created': + return dotColorForStatus(event.status); + case 'status_changed': + return dotColorForStatus(event.to); + case 'review_requested': + return 'bg-purple-400'; + case 'review_changes_requested': + return 'bg-amber-400'; + case 'review_approved': + return 'bg-emerald-400'; + default: + return 'bg-zinc-500'; + } +} + +function dotColorForStatus(status: TeamTaskStatus): string { switch (status) { case 'pending': return 'bg-zinc-500'; diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index 9fa24aff..b760a4bb 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -11,6 +11,8 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; +import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; + import type { MentionSuggestion } from '@renderer/types/mention'; import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types'; @@ -50,6 +52,7 @@ export const TaskCommentInput = ({ const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const [pendingAttachments, setPendingAttachments] = useState([]); const [attachError, setAttachError] = useState(null); + const [lightboxIndex, setLightboxIndex] = useState(null); const fileInputRef = useRef(null); const mentionSuggestions = useMemo( @@ -210,16 +213,20 @@ export const TaskCommentInput = ({ {/* Pending attachment previews */} {pendingAttachments.length > 0 ? (
- {pendingAttachments.map((att) => ( + {pendingAttachments.map((att, idx) => (
setLightboxIndex(idx)} > {att.filename} @@ -228,6 +235,20 @@ export const TaskCommentInput = ({
) : null} + {lightboxIndex !== null && pendingAttachments.length > 0 ? ( + setLightboxIndex(null)} + slides={pendingAttachments.map((att) => ({ + src: att.previewUrl, + alt: att.filename, + title: att.filename, + }))} + index={lightboxIndex} + showCounter={pendingAttachments.length > 1} + /> + ) : null} + {attachError ?

{attachError}

: null}
diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 97af61b2..720a81f4 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -56,7 +56,7 @@ import { X, } from 'lucide-react'; -import { StatusHistoryTimeline } from './StatusHistoryTimeline'; +import { WorkflowTimeline } from './StatusHistoryTimeline'; import { TaskAttachments } from './TaskAttachments'; import { TaskCommentInput } from './TaskCommentInput'; import { TaskCommentsSection } from './TaskCommentsSection'; @@ -894,18 +894,18 @@ export const TaskDetailDialog = ({
) : null} - {/* Status History */} - {currentTask.statusHistory && currentTask.statusHistory.length > 0 ? ( + {/* Workflow History */} + {currentTask.historyEvents && currentTask.historyEvents.length > 0 ? ( } - badge={currentTask.statusHistory.length} + badge={currentTask.historyEvents.length} contentClassName="pl-2.5" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen={false} > - + ) : null} diff --git a/src/renderer/index.css b/src/renderer/index.css index 3275c12c..49f7ff89 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -328,12 +328,12 @@ --diff-added-bg: rgba(34, 197, 94, 0.18); --diff-added-text: #14532d; --diff-added-border: #16a34a; - --diff-removed-bg: rgba(239, 68, 68, 0.18); + --diff-removed-bg: rgba(239, 68, 68, 0.09); --diff-removed-text: #7f1d1d; --diff-removed-border: #dc2626; /* CodeMirror diff backgrounds - Light mode */ --diff-cm-changed-bg: rgba(34, 197, 94, 0.14); - --diff-cm-deleted-bg: rgba(239, 68, 68, 0.14); + --diff-cm-deleted-bg: rgba(239, 68, 68, 0.07); /* CodeMirror merge buttons - Light mode */ --diff-merge-undo-hover-bg: rgba(0, 0, 0, 0.08); --diff-merge-keep-color: #15803d; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 45e6d9e3..bd7fa2c8 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -65,18 +65,56 @@ export interface TaskWorkInterval { completedAt?: string; } -/** Records a single status transition for audit/timeline display. */ -export interface StatusTransition { - /** Previous status (null for initial creation). */ - from: TeamTaskStatus | null; - /** New status after the transition. */ - to: TeamTaskStatus; - /** ISO timestamp when the transition occurred. */ +// --------------------------------------------------------------------------- +// Task History Events — unified workflow event log +// --------------------------------------------------------------------------- + +interface TaskHistoryEventBase { + id: string; timestamp: string; - /** Who triggered the change: member name, 'user', or undefined if unknown. */ actor?: string; } +export interface TaskCreatedEvent extends TaskHistoryEventBase { + type: 'task_created'; + status: TeamTaskStatus; +} + +export interface TaskStatusChangedEvent extends TaskHistoryEventBase { + type: 'status_changed'; + from: TeamTaskStatus; + to: TeamTaskStatus; +} + +export interface TaskReviewRequestedEvent extends TaskHistoryEventBase { + type: 'review_requested'; + from: TeamReviewState; + to: 'review'; + reviewer?: string; + note?: string; +} + +export interface TaskReviewChangesRequestedEvent extends TaskHistoryEventBase { + type: 'review_changes_requested'; + from: TeamReviewState; + to: 'needsFix'; + note?: string; +} + +export interface TaskReviewApprovedEvent extends TaskHistoryEventBase { + type: 'review_approved'; + from: TeamReviewState; + to: 'approved'; + note?: string; +} + +export type TaskHistoryEvent = + | TaskCreatedEvent + | TaskStatusChangedEvent + | TaskReviewRequestedEvent + | TaskReviewChangesRequestedEvent + | TaskReviewApprovedEvent; + export type TaskCommentType = 'regular' | 'review_request' | 'review_approved'; export interface TaskComment { @@ -107,11 +145,10 @@ export interface TeamTask { */ workIntervals?: TaskWorkInterval[]; /** - * Chronological record of every status change. - * Append-only — each transition records from, to, timestamp, actor. - * Optional for backwards compatibility with pre-existing tasks. + * Unified workflow event log. + * Append-only — records task creation, status changes, and review transitions. */ - statusHistory?: StatusTransition[]; + historyEvents?: TaskHistoryEvent[]; blocks?: string[]; blockedBy?: string[]; /** @@ -130,7 +167,7 @@ export interface TeamTask { deletedAt?: string; /** Attachments associated with this task. Metadata only — actual files stored on disk. */ attachments?: TaskAttachmentMeta[]; - /** Separate review lifecycle axis. Persisted on modern tasks, derived for legacy rows when needed. */ + /** Derived review state — computed from historyEvents, not persisted as authority. */ reviewState?: TeamReviewState; } diff --git a/src/shared/utils/reviewState.ts b/src/shared/utils/reviewState.ts index 5ed3241f..cf361183 100644 --- a/src/shared/utils/reviewState.ts +++ b/src/shared/utils/reviewState.ts @@ -1,7 +1,10 @@ -import type { TeamReviewState } from '@shared/types'; +import { getDerivedReviewState } from '@shared/utils/taskHistory'; + +import type { TaskHistoryEvent, TeamReviewState } from '@shared/types'; interface ReviewStateLike { reviewState?: TeamReviewState | null; + historyEvents?: unknown[]; kanbanColumn?: 'review' | 'approved' | null; status?: string | null; } @@ -11,6 +14,11 @@ export function normalizeReviewState(value: unknown): TeamReviewState { } export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState { + // Prefer derivation from historyEvents when available + if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) { + return getDerivedReviewState({ historyEvents: task.historyEvents as TaskHistoryEvent[] }); + } + const explicit = normalizeReviewState(task.reviewState); if (explicit !== 'none') { return explicit; diff --git a/src/shared/utils/taskHistory.ts b/src/shared/utils/taskHistory.ts new file mode 100644 index 00000000..a4bc4fa7 --- /dev/null +++ b/src/shared/utils/taskHistory.ts @@ -0,0 +1,50 @@ +import type { TaskHistoryEvent, TeamReviewState, TeamTask, TeamTaskStatus } from '@shared/types'; + +/** Extract historyEvents from a task, defaulting to empty array. */ +export function getTaskHistoryEvents(task: Pick): TaskHistoryEvent[] { + return Array.isArray(task.historyEvents) ? task.historyEvents : []; +} + +/** Derive the current task status from historyEvents. Falls back to task.status if no events. */ +export function getDerivedTaskStatus( + task: Pick +): TeamTaskStatus { + const events = getTaskHistoryEvents(task); + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + if (event.type === 'task_created') return event.status; + if (event.type === 'status_changed') return event.to; + } + return task.status; +} + +/** Derive the current review state from historyEvents. */ +export function getDerivedReviewState(task: Pick): TeamReviewState { + const events = getTaskHistoryEvents(task); + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + if ( + event.type === 'review_requested' || + event.type === 'review_changes_requested' || + event.type === 'review_approved' + ) { + return event.to; + } + // A status_changed to in_progress after a review event resets review state + if (event.type === 'status_changed' && event.to === 'in_progress') { + return 'none'; + } + } + return 'none'; +} + +/** Get a full workflow snapshot from historyEvents. */ +export function getTaskWorkflowSnapshot(task: Pick): { + status: TeamTaskStatus; + reviewState: TeamReviewState; +} { + return { + status: getDerivedTaskStatus(task), + reviewState: getDerivedReviewState(task), + }; +} diff --git a/test/main/services/team/TeamTaskWriter.test.ts b/test/main/services/team/TeamTaskWriter.test.ts index 7d0d8430..0b237706 100644 --- a/test/main/services/team/TeamTaskWriter.test.ts +++ b/test/main/services/team/TeamTaskWriter.test.ts @@ -156,8 +156,8 @@ describe('TeamTaskWriter', () => { ); }); - describe('statusHistory', () => { - it('createTask records initial statusHistory entry', async () => { + describe('historyEvents', () => { + it('createTask records initial task_created event', async () => { await writer.createTask('my-team', { id: '10', subject: 'New task', @@ -167,16 +167,17 @@ describe('TeamTaskWriter', () => { const writtenPath = '/mock/tasks/my-team/10.json'; const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}'); - expect(persisted.statusHistory).toHaveLength(1); - expect(persisted.statusHistory[0]).toMatchObject({ - from: null, - to: 'pending', + expect(persisted.historyEvents).toHaveLength(1); + expect(persisted.historyEvents[0]).toMatchObject({ + type: 'task_created', + status: 'pending', actor: 'alice', }); - expect(typeof persisted.statusHistory[0].timestamp).toBe('string'); + expect(typeof persisted.historyEvents[0].id).toBe('string'); + expect(typeof persisted.historyEvents[0].timestamp).toBe('string'); }); - it('createTask with in_progress records initial transition', async () => { + it('createTask with in_progress records initial task_created event', async () => { await writer.createTask('my-team', { id: '11', subject: 'Start immediately', @@ -186,10 +187,10 @@ describe('TeamTaskWriter', () => { const writtenPath = '/mock/tasks/my-team/11.json'; const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}'); - expect(persisted.statusHistory).toHaveLength(1); - expect(persisted.statusHistory[0]).toMatchObject({ - from: null, - to: 'in_progress', + expect(persisted.historyEvents).toHaveLength(1); + expect(persisted.historyEvents[0]).toMatchObject({ + type: 'task_created', + status: 'in_progress', actor: 'bob', }); }); @@ -203,19 +204,20 @@ describe('TeamTaskWriter', () => { const writtenPath = '/mock/tasks/my-team/13.json'; const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}'); - expect(persisted.statusHistory).toHaveLength(1); - expect(persisted.statusHistory[0].actor).toBeUndefined(); + expect(persisted.historyEvents).toHaveLength(1); + expect(persisted.historyEvents[0].type).toBe('task_created'); + expect(persisted.historyEvents[0].actor).toBeUndefined(); }); - it('updateStatus appends transition to statusHistory', async () => { + it('updateStatus appends status_changed event', async () => { hoisted.files.set( taskPath, JSON.stringify({ id: '12', subject: 'task', status: 'pending', - statusHistory: [ - { from: null, to: 'pending', timestamp: '2024-01-01T00:00:00.000Z', actor: 'user' }, + historyEvents: [ + { type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z', actor: 'user' }, ], }) ); @@ -223,15 +225,16 @@ describe('TeamTaskWriter', () => { await writer.updateStatus('my-team', '12', 'in_progress', 'alice'); const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}'); - expect(persisted.statusHistory).toHaveLength(2); - expect(persisted.statusHistory[1]).toMatchObject({ + expect(persisted.historyEvents).toHaveLength(2); + expect(persisted.historyEvents[1]).toMatchObject({ + type: 'status_changed', from: 'pending', to: 'in_progress', actor: 'alice', }); }); - it('updateStatus works on legacy task without statusHistory', async () => { + it('updateStatus works on task without historyEvents', async () => { hoisted.files.set( taskPath, JSON.stringify({ @@ -244,24 +247,25 @@ describe('TeamTaskWriter', () => { await writer.updateStatus('my-team', '12', 'in_progress'); const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}'); - expect(persisted.statusHistory).toHaveLength(1); - expect(persisted.statusHistory[0]).toMatchObject({ + expect(persisted.historyEvents).toHaveLength(1); + expect(persisted.historyEvents[0]).toMatchObject({ + type: 'status_changed', from: 'pending', to: 'in_progress', }); - expect(persisted.statusHistory[0].actor).toBeUndefined(); + expect(persisted.historyEvents[0].actor).toBeUndefined(); }); - it('softDelete appends deleted transition', async () => { + it('softDelete appends status_changed to deleted', async () => { hoisted.files.set( taskPath, JSON.stringify({ id: '12', subject: 'task', status: 'in_progress', - statusHistory: [ - { from: null, to: 'pending', timestamp: '2024-01-01T00:00:00.000Z' }, - { from: 'pending', to: 'in_progress', timestamp: '2024-01-01T00:01:00.000Z' }, + historyEvents: [ + { type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z' }, + { type: 'status_changed', id: 'ev2', from: 'pending', to: 'in_progress', timestamp: '2024-01-01T00:01:00.000Z' }, ], }) ); @@ -269,15 +273,16 @@ describe('TeamTaskWriter', () => { await writer.softDelete('my-team', '12', 'user'); const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}'); - expect(persisted.statusHistory).toHaveLength(3); - expect(persisted.statusHistory[2]).toMatchObject({ + expect(persisted.historyEvents).toHaveLength(3); + expect(persisted.historyEvents[2]).toMatchObject({ + type: 'status_changed', from: 'in_progress', to: 'deleted', actor: 'user', }); }); - it('restoreTask appends pending transition', async () => { + it('restoreTask appends status_changed to pending', async () => { hoisted.files.set( taskPath, JSON.stringify({ @@ -285,9 +290,9 @@ describe('TeamTaskWriter', () => { subject: 'task', status: 'deleted', deletedAt: '2024-01-01T00:02:00.000Z', - statusHistory: [ - { from: null, to: 'pending', timestamp: '2024-01-01T00:00:00.000Z' }, - { from: 'pending', to: 'deleted', timestamp: '2024-01-01T00:02:00.000Z' }, + historyEvents: [ + { type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z' }, + { type: 'status_changed', id: 'ev2', from: 'pending', to: 'deleted', timestamp: '2024-01-01T00:02:00.000Z' }, ], }) ); @@ -296,8 +301,9 @@ describe('TeamTaskWriter', () => { const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}'); expect(persisted.status).toBe('pending'); - expect(persisted.statusHistory).toHaveLength(3); - expect(persisted.statusHistory[2]).toMatchObject({ + expect(persisted.historyEvents).toHaveLength(3); + expect(persisted.historyEvents[2]).toMatchObject({ + type: 'status_changed', from: 'deleted', to: 'pending', actor: 'user', @@ -318,8 +324,9 @@ describe('TeamTaskWriter', () => { await writer.restoreTask('my-team', '12'); const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}'); - expect(persisted.statusHistory).toHaveLength(1); - expect(persisted.statusHistory[0]).toMatchObject({ + expect(persisted.historyEvents).toHaveLength(1); + expect(persisted.historyEvents[0]).toMatchObject({ + type: 'status_changed', from: 'deleted', to: 'pending', actor: 'user',