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:
parent
86a1abdefa
commit
9678d790cd
29 changed files with 598 additions and 233 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 ========== */}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
50
src/shared/utils/taskHistory.ts
Normal file
50
src/shared/utils/taskHistory.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue