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.
This commit is contained in:
iliya 2026-03-09 14:52:38 +02:00
parent 86a1abdefa
commit 9678d790cd
29 changed files with 598 additions and 233 deletions

View file

@ -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);

View file

@ -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,

View file

@ -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,
};

View file

@ -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',

View file

@ -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<string, unknown>) : null))
.filter((h): h is Record<string, unknown> => 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,

View file

@ -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<void> {
@ -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).

View file

@ -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<string>(

View file

@ -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<keyof TeamTask, unknown>` 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<string, unknown> =>
Boolean(e) &&
typeof e === 'object' &&
((e as Record<string, unknown>).from === null ||
typeof (e as Record<string, unknown>).from === 'string') &&
typeof (e as Record<string, unknown>).to === 'string' &&
typeof (e as Record<string, unknown>).id === 'string' &&
typeof (e as Record<string, unknown>).timestamp === 'string' &&
((e as Record<string, unknown>).actor === undefined ||
typeof (e as Record<string, unknown>).actor === 'string')
typeof (e as Record<string, unknown>).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<keyof TeamTask, unknown>;
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);

View file

@ -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<T>(taskPath: string, fn: () => Promise<T>): 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, 'id' | 'timestamp'>
): 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<TaskHistoryEvent, 'id' | 'timestamp'>),
};
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<TaskHistoryEvent, 'id' | 'timestamp'>
);
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<TaskHistoryEvent, 'id' | 'timestamp'>
);
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<TaskHistoryEvent, 'id' | 'timestamp'>
);
task.status = 'pending';
delete task.deletedAt;

View file

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

View file

@ -104,9 +104,7 @@ export const DisplayItemList = ({
return (
<div
className={
order === 'newest-first'
? 'min-w-0 flex flex-col-reverse gap-2'
: 'min-w-0 space-y-2'
order === 'newest-first' ? 'flex min-w-0 flex-col-reverse gap-2' : 'min-w-0 space-y-2'
}
>
{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)}
>

View file

@ -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<BaseItemProps> = ({
tokenLabel = 'tokens',
status,
durationMs,
timestamp,
onClick,
isExpanded,
hasExpandableContent = true,
@ -169,6 +173,13 @@ export const BaseItem: React.FC<BaseItemProps> = ({
</span>
)}
{/* Timestamp — rightmost info element */}
{timestamp && (
<span className="shrink-0 text-[11px] tabular-nums" style={{ color: TOOL_ITEM_MUTED }}>
{format(timestamp, 'HH:mm:ss')}
</span>
)}
{/* Expand/collapse chevron */}
{hasExpandableContent && (
<ChevronRight

View file

@ -97,6 +97,7 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
preview={preview}
onClick={() => handleItemClick(itemId)}
isExpanded={isExpanded}
timestamp={item.timestamp}
/>
);
}
@ -122,6 +123,7 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
preview={preview}
onClick={() => handleItemClick(itemId)}
isExpanded={isExpanded}
timestamp={item.timestamp}
/>
);
}
@ -136,6 +138,7 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
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<ExecutionTraceProps> = ({
label="Input"
summary={truncateText(item.content, 80)}
tokenCount={item.tokenCount}
timestamp={item.timestamp}
onClick={() => handleItemClick(itemId)}
isExpanded={isExpanded}
>

View file

@ -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<LinkedToolItemProps> = ({
linkedTool,
onClick,
isExpanded,
timestamp,
searchQueryOverride,
isHighlighted,
highlightColor,
@ -177,6 +180,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
tokenCount={getToolContextTokens(linkedTool)}
status={status}
durationMs={linkedTool.durationMs}
timestamp={timestamp}
onClick={onClick}
isExpanded={isExpanded}
highlightClasses={highlightClasses}

View file

@ -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<SlashItemProps> = ({
slash,
onClick,
isExpanded,
timestamp,
highlightClasses,
highlightStyle,
notificationDotColor,
@ -51,6 +54,7 @@ export const SlashItem: React.FC<SlashItemProps> = ({
tokenCount={slash.instructionsTokenCount}
tokenLabel="tokens"
status={hasInstructions ? 'ok' : undefined}
timestamp={timestamp}
onClick={onClick}
isExpanded={isExpanded}
hasExpandableContent={hasInstructions}

View file

@ -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<SubagentItemProps> = ({
>
{formatDuration(subagent.durationMs)}
</span>
{/* Timestamp — rightmost info element */}
<span
className="shrink-0 font-mono text-[11px] tabular-nums"
style={{ color: CARD_ICON_MUTED }}
>
{format(subagent.startTime, 'HH:mm:ss')}
</span>
</div>
{/* ========== Level 1 Expanded: Dashboard Content ========== */}

View file

@ -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<TeammateMessageItemProps> = ({
~{formatTokensCompact(teammateMessage.tokenCount)} tokens
</span>
)}
{/* Timestamp — rightmost info element */}
<span
className="shrink-0 font-mono text-[11px] tabular-nums"
style={{ color: CARD_ICON_MUTED }}
>
{format(teammateMessage.timestamp, 'HH:mm:ss')}
</span>
</div>
{/* Expanded content */}

View file

@ -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<TextItemProps> = ({
preview,
onClick,
isExpanded,
timestamp,
searchQueryOverride,
markdownItemId,
highlightClasses,
@ -61,6 +64,7 @@ export const TextItem: React.FC<TextItemProps> = ({
label="Output"
summary={summary}
tokenCount={tokenCount}
timestamp={timestamp}
onClick={onClick}
isExpanded={isExpanded}
highlightClasses={highlightClasses}

View file

@ -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<ThinkingItemProps> = ({
preview,
onClick,
isExpanded,
timestamp,
searchQueryOverride,
markdownItemId,
highlightClasses,
@ -61,6 +64,7 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
label="Thinking"
summary={summary}
tokenCount={tokenCount}
timestamp={timestamp}
onClick={onClick}
isExpanded={isExpanded}
highlightClasses={highlightClasses}

View file

@ -12,16 +12,14 @@ export const UnreadCommentsBadge = ({
if (totalCount === 0) return null;
return (
<span
className={`inline-flex items-center gap-0.5 rounded-full px-1.5 py-0 text-[10px] font-medium ${
unreadCount > 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'}
>
<span className="relative inline-flex items-center gap-0.5 rounded-full bg-[var(--color-surface-raised)] px-1.5 py-0 text-[10px] font-medium text-[var(--color-text-muted)]">
<MessageSquare size={10} />
{totalCount}
{unreadCount > 0 && (
<span className="absolute -right-2.5 -top-1.5 flex size-3.5 items-center justify-center rounded-full bg-blue-500 text-[8px] font-bold leading-none text-white">
{unreadCount}
</span>
)}
</span>
);
};

View file

@ -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();
}
}}
>
<DialogContent className="sm:max-w-[420px]">
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Request Changes</DialogTitle>
<DialogDescription>Task #{taskId ? deriveTaskDisplayId(taskId) : ''}</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-2">
<Label htmlFor="review-comment" className="label-optional">
Comment (optional)
</Label>
<MentionableTextarea
id="review-comment"
className="min-h-[110px] text-xs"
value={draft.value}
onValueChange={draft.setValue}
placeholder="Describe what needs to change..."
placeholder="Describe what needs to change... (Enter to submit)"
suggestions={mentionSuggestions}
projectPath={projectPath}
onModEnter={handleSubmit}
minRows={4}
maxRows={12}
maxLength={MAX_TEXT_LENGTH}
cornerAction={
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-red-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-red-500 disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleSubmit}
>
<Send size={12} />
Submit
</button>
}
footerRight={
draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : undefined
<div className="flex items-center gap-2">
{remaining < 200 ? (
<span
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
>
{remaining} chars left
</span>
) : null}
{draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
}
/>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={handleSubmit}>
Submit
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View file

@ -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 (
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
No status history recorded
No workflow history recorded
</div>
);
}
return (
<div className="space-y-0 px-3 py-2">
{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 (
<div key={`${transition.timestamp}-${idx}`} className="flex">
<div key={event.id} className="flex">
{/* Timeline line + dot */}
<div className="flex w-5 shrink-0 flex-col items-center">
<div className={cn('mt-1.5 size-2 shrink-0 rounded-full', dotColor(transition.to))} />
<div className={cn('mt-1.5 size-2 shrink-0 rounded-full', dotColor(event))} />
{!isLast && <div className="w-px flex-1 bg-zinc-700" />}
</div>
@ -40,28 +43,16 @@ export const StatusHistoryTimeline = ({ history }: StatusHistoryTimelineProps) =
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
{time}
</span>
{isCreation ? (
<span className="flex items-center gap-1">
<Plus size={10} />
Created as
<StatusBadge status={transition.to} />
</span>
) : (
<span className="flex items-center gap-1">
<StatusBadge status={transition.from!} />
<ArrowRight size={10} className="text-[var(--color-text-muted)]" />
<StatusBadge status={transition.to} />
</span>
)}
{transition.actor ? (
<EventContent event={event} />
{event.actor ? (
<span className="ml-auto shrink-0 text-[10px] text-[var(--color-text-muted)]">
by {transition.actor}
by {event.actor}
</span>
) : null}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{new Date(transition.timestamp).toLocaleString()}
{new Date(event.timestamp).toLocaleString()}
</TooltipContent>
</Tooltip>
</div>
@ -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 (
<span className="flex items-center gap-1">
<Plus size={10} />
Created as
<StatusBadge status={event.status} />
</span>
);
case 'status_changed':
return (
<span className="flex items-center gap-1">
<StatusBadge status={event.from} />
<ArrowRight size={10} className="text-[var(--color-text-muted)]" />
<StatusBadge status={event.to} />
</span>
);
case 'review_requested':
return (
<span className="flex items-center gap-1">
<Eye size={10} className="text-purple-400" />
Review requested
{event.reviewer ? (
<span className="text-[10px] text-[var(--color-text-muted)]">({event.reviewer})</span>
) : null}
</span>
);
case 'review_changes_requested':
return (
<span className="flex items-center gap-1">
<MessageSquareX size={10} className="text-amber-400" />
Changes requested
<ReviewStateBadge state="needsFix" />
</span>
);
case 'review_approved':
return (
<span className="flex items-center gap-1">
<ShieldCheck size={10} className="text-emerald-400" />
Approved
<ReviewStateBadge state="approved" />
</span>
);
default:
return <span>Unknown event</span>;
}
};
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 (
<span
className={cn('rounded-full px-1.5 py-0.5 text-[10px] font-medium', display.bg, display.text)}
>
{display.label}
</span>
);
};
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';

View file

@ -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<PendingAttachment[]>([]);
const [attachError, setAttachError] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const mentionSuggestions = useMemo<MentionSuggestion[]>(
@ -210,16 +213,20 @@ export const TaskCommentInput = ({
{/* Pending attachment previews */}
{pendingAttachments.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-1.5">
{pendingAttachments.map((att) => (
{pendingAttachments.map((att, idx) => (
<div
key={att.id}
className="group relative size-14 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)]"
className="group relative size-14 cursor-pointer overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] transition-colors hover:border-[var(--color-border-emphasis)]"
onClick={() => setLightboxIndex(idx)}
>
<img src={att.previewUrl} alt={att.filename} className="size-full object-cover" />
<button
type="button"
className="absolute right-0.5 top-0.5 rounded bg-black/60 p-0.5 text-white opacity-0 transition-opacity hover:bg-red-600 group-hover:opacity-100"
onClick={() => removeAttachment(att.id)}
onClick={(e) => {
e.stopPropagation();
removeAttachment(att.id);
}}
>
<Trash2 size={8} />
</button>
@ -228,6 +235,20 @@ export const TaskCommentInput = ({
</div>
) : null}
{lightboxIndex !== null && pendingAttachments.length > 0 ? (
<ImageLightbox
open
onClose={() => setLightboxIndex(null)}
slides={pendingAttachments.map((att) => ({
src: att.previewUrl,
alt: att.filename,
title: att.filename,
}))}
index={lightboxIndex}
showCounter={pendingAttachments.length > 1}
/>
) : null}
{attachError ? <p className="mb-1 text-[10px] text-red-400">{attachError}</p> : null}
<div className="relative" onPaste={handlePaste}>

View file

@ -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 = ({
</div>
) : null}
{/* Status History */}
{currentTask.statusHistory && currentTask.statusHistory.length > 0 ? (
{/* Workflow History */}
{currentTask.historyEvents && currentTask.historyEvents.length > 0 ? (
<CollapsibleTeamSection
title="Status History"
title="Workflow History"
icon={<History size={14} />}
badge={currentTask.statusHistory.length}
badge={currentTask.historyEvents.length}
contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6"
defaultOpen={false}
>
<StatusHistoryTimeline history={currentTask.statusHistory} />
<WorkflowTimeline events={currentTask.historyEvents} />
</CollapsibleTeamSection>
) : null}

View file

@ -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;

View file

@ -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;
}

View file

@ -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;

View file

@ -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<TeamTask, 'historyEvents'>): 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<TeamTask, 'historyEvents' | 'status'>
): 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<TeamTask, 'historyEvents'>): 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<TeamTask, 'historyEvents' | 'status'>): {
status: TeamTaskStatus;
reviewState: TeamReviewState;
} {
return {
status: getDerivedTaskStatus(task),
reviewState: getDerivedReviewState(task),
};
}

View file

@ -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',