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 = {}) {
|
function requestReview(context, taskId, flags = {}) {
|
||||||
const task = tasks.getTask(context, taskId);
|
const task = tasks.getTask(context, taskId);
|
||||||
if (task.status !== 'completed') {
|
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';
|
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||||
const reviewer = getReviewer(context, flags);
|
const reviewer = getReviewer(context, flags);
|
||||||
const leadSessionId = resolveLeadSessionId(context, flags);
|
const leadSessionId = resolveLeadSessionId(context, flags);
|
||||||
|
const prevReviewState = getCurrentReviewState(task);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
kanban.setKanbanColumn(context, task.id, 'review');
|
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) {
|
if (!reviewer) {
|
||||||
return tasks.getTask(context, task.id);
|
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';
|
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 note = typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved';
|
||||||
const leadSessionId = resolveLeadSessionId(context, flags);
|
const leadSessionId = resolveLeadSessionId(context, flags);
|
||||||
|
const prevReviewState = getCurrentReviewState(task);
|
||||||
|
|
||||||
kanban.setKanbanColumn(context, task.id, 'approved');
|
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, {
|
tasks.addTaskComment(context, task.id, {
|
||||||
text: note,
|
text: note,
|
||||||
from,
|
from,
|
||||||
|
|
@ -119,6 +163,20 @@ function requestChanges(context, taskId, flags = {}) {
|
||||||
? flags.comment.trim()
|
? flags.comment.trim()
|
||||||
: 'Reviewer requested changes.';
|
: 'Reviewer requested changes.';
|
||||||
const leadSessionId = resolveLeadSessionId(context, flags);
|
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' });
|
kanban.clearKanban(context, task.id, { nextReviewState: 'needsFix' });
|
||||||
tasks.setTaskStatus(context, task.id, 'pending', from);
|
tasks.setTaskStatus(context, task.id, 'pending', from);
|
||||||
|
|
|
||||||
|
|
@ -145,8 +145,10 @@ function readTask(paths, taskRef, options = {}) {
|
||||||
return normalizeTask(rawTask, taskPath);
|
return normalizeTask(rawTask, taskPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStatusTransition(history, from, to, actor, timestamp) {
|
function appendHistoryEvent(events, event) {
|
||||||
return [...(Array.isArray(history) ? history : []), { from, to, timestamp, ...(actor ? { actor } : {}) }];
|
const list = Array.isArray(events) ? [...events] : [];
|
||||||
|
list.push({ id: crypto.randomUUID(), timestamp: nowIso(), ...event });
|
||||||
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatus(status) {
|
function normalizeStatus(status) {
|
||||||
|
|
@ -285,7 +287,12 @@ function createTask(paths, input = {}) {
|
||||||
: Array.isArray(input.workIntervals)
|
: Array.isArray(input.workIntervals)
|
||||||
? input.workIntervals
|
? input.workIntervals
|
||||||
: undefined,
|
: 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] : [],
|
blocks: Array.isArray(input.blocks) ? [...input.blocks] : [],
|
||||||
blockedBy: blockedByIds,
|
blockedBy: blockedByIds,
|
||||||
related: relatedIds.length > 0 ? relatedIds : undefined,
|
related: relatedIds.length > 0 ? relatedIds : undefined,
|
||||||
|
|
@ -364,7 +371,13 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
||||||
}
|
}
|
||||||
|
|
||||||
task.workIntervals = workIntervals.length > 0 ? workIntervals : undefined;
|
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;
|
task.status = status;
|
||||||
|
|
||||||
if (status === 'deleted') {
|
if (status === 'deleted') {
|
||||||
|
|
@ -601,6 +614,18 @@ function compareTasksByFreshness(a, b) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEffectiveReviewState(kanbanEntry, task) {
|
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') {
|
if (normalizeTaskReviewState(task.reviewState) !== 'none') {
|
||||||
return normalizeTaskReviewState(task.reviewState);
|
return normalizeTaskReviewState(task.reviewState);
|
||||||
}
|
}
|
||||||
|
|
@ -724,6 +749,7 @@ module.exports = {
|
||||||
addCommentAttachmentMeta,
|
addCommentAttachmentMeta,
|
||||||
addTaskAttachmentMeta,
|
addTaskAttachmentMeta,
|
||||||
addTaskComment,
|
addTaskComment,
|
||||||
|
appendHistoryEvent,
|
||||||
buildTaskReference,
|
buildTaskReference,
|
||||||
createTask,
|
createTask,
|
||||||
deriveDisplayId,
|
deriveDisplayId,
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,7 @@ async function taskBriefing(context, memberName) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
addTaskAttachmentMeta,
|
addTaskAttachmentMeta,
|
||||||
addTaskComment,
|
addTaskComment,
|
||||||
|
appendHistoryEvent: taskStore.appendHistoryEvent,
|
||||||
attachTaskFile,
|
attachTaskFile,
|
||||||
attachCommentFile,
|
attachCommentFile,
|
||||||
completeTask,
|
completeTask,
|
||||||
|
|
@ -227,5 +228,7 @@ module.exports = {
|
||||||
startTask,
|
startTask,
|
||||||
taskBriefing,
|
taskBriefing,
|
||||||
unlinkTask,
|
unlinkTask,
|
||||||
|
updateTask: (context, taskRef, updater) =>
|
||||||
|
taskStore.updateTask(context.paths, taskRef, updater),
|
||||||
updateTaskFields,
|
updateTaskFields,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -290,7 +290,7 @@ describe('agent-teams-controller API', () => {
|
||||||
const task = controller.tasks.createTask({ subject: 'Lifecycle task' });
|
const task = controller.tasks.createTask({ subject: 'Lifecycle task' });
|
||||||
|
|
||||||
expect(task.status).toBe('pending');
|
expect(task.status).toBe('pending');
|
||||||
expect(task.statusHistory).toHaveLength(1);
|
expect(task.historyEvents).toHaveLength(1);
|
||||||
expect(task.workIntervals).toBeUndefined();
|
expect(task.workIntervals).toBeUndefined();
|
||||||
|
|
||||||
const started = controller.tasks.startTask(task.id, 'bob');
|
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');
|
const restored = controller.tasks.restoreTask(task.id, 'bob');
|
||||||
|
|
||||||
expect(started.status).toBe('in_progress');
|
expect(started.status).toBe('in_progress');
|
||||||
expect(startedAgain.statusHistory).toHaveLength(2);
|
expect(startedAgain.historyEvents).toHaveLength(2);
|
||||||
expect(startedAgain.workIntervals).toHaveLength(1);
|
expect(startedAgain.workIntervals).toHaveLength(1);
|
||||||
expect(startedAgain.workIntervals[0].startedAt).toBeTruthy();
|
expect(startedAgain.workIntervals[0].startedAt).toBeTruthy();
|
||||||
|
|
||||||
expect(completed.status).toBe('completed');
|
expect(completed.status).toBe('completed');
|
||||||
expect(completedAgain.statusHistory).toHaveLength(3);
|
expect(completedAgain.historyEvents).toHaveLength(3);
|
||||||
expect(completedAgain.workIntervals).toHaveLength(1);
|
expect(completedAgain.workIntervals).toHaveLength(1);
|
||||||
expect(completedAgain.workIntervals[0].completedAt).toBeTruthy();
|
expect(completedAgain.workIntervals[0].completedAt).toBeTruthy();
|
||||||
|
|
||||||
|
|
@ -314,9 +314,23 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(deleted.deletedAt).toBeTruthy();
|
expect(deleted.deletedAt).toBeTruthy();
|
||||||
expect(restored.status).toBe('pending');
|
expect(restored.status).toBe('pending');
|
||||||
expect(restored.deletedAt).toBeUndefined();
|
expect(restored.deletedAt).toBeUndefined();
|
||||||
expect(restored.statusHistory).toHaveLength(5);
|
expect(restored.historyEvents).toHaveLength(5);
|
||||||
expect(restored.statusHistory.map((entry) => entry.to)).toEqual([
|
|
||||||
'pending',
|
// 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',
|
'in_progress',
|
||||||
'completed',
|
'completed',
|
||||||
'deleted',
|
'deleted',
|
||||||
|
|
|
||||||
|
|
@ -244,12 +244,13 @@ export class ChangeExtractorService {
|
||||||
|
|
||||||
const derivedIntervals = (() => {
|
const derivedIntervals = (() => {
|
||||||
if (Array.isArray(intervals) && intervals.length > 0) return intervals;
|
if (Array.isArray(intervals) && intervals.length > 0) return intervals;
|
||||||
const rawHistory = parsed.statusHistory;
|
const rawHistory = parsed.historyEvents;
|
||||||
if (!Array.isArray(rawHistory)) return undefined;
|
if (!Array.isArray(rawHistory)) return undefined;
|
||||||
|
|
||||||
const transitions = rawHistory
|
const transitions = rawHistory
|
||||||
.map((h) => (h && typeof h === 'object' ? (h as Record<string, unknown>) : null))
|
.map((h) => (h && typeof h === 'object' ? (h as Record<string, unknown>) : null))
|
||||||
.filter((h): h is Record<string, unknown> => h !== null)
|
.filter((h): h is Record<string, unknown> => h !== null)
|
||||||
|
.filter((h) => h.type === 'status_changed')
|
||||||
.map((h) => ({
|
.map((h) => ({
|
||||||
to: typeof h.to === 'string' ? h.to : null,
|
to: typeof h.to === 'string' ? h.to : null,
|
||||||
timestamp: typeof h.timestamp === 'string' ? h.timestamp : 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).
|
* 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.
|
* sends an inbox notification to the team lead.
|
||||||
*/
|
*/
|
||||||
async notifyLeadOnTeammateTaskStart(teamName: string, taskId: string): Promise<void> {
|
async notifyLeadOnTeammateTaskStart(teamName: string, taskId: string): Promise<void> {
|
||||||
|
|
@ -822,11 +822,11 @@ export class TeamDataService {
|
||||||
const task = tasks.find((t) => t.id === taskId);
|
const task = tasks.find((t) => t.id === taskId);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|
||||||
const history = task.statusHistory;
|
const events = task.historyEvents;
|
||||||
if (!Array.isArray(history) || history.length === 0) return;
|
if (!Array.isArray(events) || events.length === 0) return;
|
||||||
|
|
||||||
const last = history[history.length - 1];
|
const last = events[events.length - 1];
|
||||||
if (last.to !== 'in_progress') return;
|
if (last.type !== 'status_changed' || last.to !== 'in_progress') return;
|
||||||
if (!last.actor || last.actor === 'user') return;
|
if (!last.actor || last.actor === 'user') return;
|
||||||
|
|
||||||
// Dedup: only notify once per unique transition (keyed by team+task+timestamp).
|
// 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[];
|
let sessionIds: string[];
|
||||||
if (knownSessionIds.size > 0) {
|
if (knownSessionIds.size > 0) {
|
||||||
const verified: string[] = [];
|
const verified: string[] = [];
|
||||||
|
|
@ -423,9 +424,11 @@ export class TeamMemberLogsFinder {
|
||||||
// dir doesn't exist
|
// 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 {
|
} else {
|
||||||
sessionIds = await this.listSessionDirs(projectDir);
|
sessionIds = discoveredSessionIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownMembers = new Set<string>(
|
const knownMembers = new Set<string>(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { yieldToEventLoop } from '@main/utils/asyncYield';
|
||||||
import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||||
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
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 { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -10,9 +10,9 @@ import * as path from 'path';
|
||||||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
StatusTransition,
|
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
|
TaskHistoryEvent,
|
||||||
TaskWorkInterval,
|
TaskWorkInterval,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
|
|
@ -115,25 +115,17 @@ export class TeamTaskReader {
|
||||||
// `satisfies Record<keyof TeamTask, unknown>` ensures compile-time
|
// `satisfies Record<keyof TeamTask, unknown>` ensures compile-time
|
||||||
// safety: if a field is added to TeamTask but not mapped here,
|
// safety: if a field is added to TeamTask but not mapped here,
|
||||||
// TypeScript will error. This prevents silently dropping new fields.
|
// TypeScript will error. This prevents silently dropping new fields.
|
||||||
const statusHistory: StatusTransition[] | undefined = Array.isArray(parsed.statusHistory)
|
const historyEvents: TaskHistoryEvent[] | undefined = Array.isArray(parsed.historyEvents)
|
||||||
? (parsed.statusHistory as unknown[])
|
? (parsed.historyEvents as unknown[])
|
||||||
.filter(
|
.filter(
|
||||||
(e): e is { from: string | null; to: string; timestamp: string; actor?: string } =>
|
(e): e is Record<string, unknown> =>
|
||||||
Boolean(e) &&
|
Boolean(e) &&
|
||||||
typeof e === 'object' &&
|
typeof e === 'object' &&
|
||||||
((e as Record<string, unknown>).from === null ||
|
typeof (e as Record<string, unknown>).id === 'string' &&
|
||||||
typeof (e as Record<string, unknown>).from === 'string') &&
|
|
||||||
typeof (e as Record<string, unknown>).to === 'string' &&
|
|
||||||
typeof (e as Record<string, unknown>).timestamp === 'string' &&
|
typeof (e as Record<string, unknown>).timestamp === 'string' &&
|
||||||
((e as Record<string, unknown>).actor === undefined ||
|
typeof (e as Record<string, unknown>).type === 'string'
|
||||||
typeof (e as Record<string, unknown>).actor === 'string')
|
|
||||||
)
|
)
|
||||||
.map((e) => ({
|
.map((e) => e as unknown as TaskHistoryEvent)
|
||||||
from: e.from as TeamTaskStatus | null,
|
|
||||||
to: e.to as TeamTaskStatus,
|
|
||||||
timestamp: e.timestamp,
|
|
||||||
...(e.actor ? { actor: e.actor } : {}),
|
|
||||||
}))
|
|
||||||
: undefined;
|
: undefined;
|
||||||
const workIntervals: TaskWorkInterval[] | undefined = Array.isArray(parsed.workIntervals)
|
const workIntervals: TaskWorkInterval[] | undefined = Array.isArray(parsed.workIntervals)
|
||||||
? (parsed.workIntervals as unknown[])
|
? (parsed.workIntervals as unknown[])
|
||||||
|
|
@ -172,7 +164,7 @@ export class TeamTaskReader {
|
||||||
? (parsed.status as TeamTask['status'])
|
? (parsed.status as TeamTask['status'])
|
||||||
: 'pending',
|
: 'pending',
|
||||||
workIntervals,
|
workIntervals,
|
||||||
statusHistory,
|
historyEvents,
|
||||||
blocks: Array.isArray(parsed.blocks)
|
blocks: Array.isArray(parsed.blocks)
|
||||||
? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string')
|
? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string')
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
@ -262,7 +254,10 @@ export class TeamTaskReader {
|
||||||
addedAt: a.addedAt,
|
addedAt: a.addedAt,
|
||||||
}))
|
}))
|
||||||
: undefined,
|
: undefined,
|
||||||
reviewState: normalizeReviewState(parsed.reviewState),
|
reviewState: getReviewStateFromTask({
|
||||||
|
historyEvents,
|
||||||
|
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
||||||
|
}),
|
||||||
} satisfies Record<keyof TeamTask, unknown>;
|
} satisfies Record<keyof TeamTask, unknown>;
|
||||||
if (task.status === 'deleted') {
|
if (task.status === 'deleted') {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -359,7 +354,9 @@ export class TeamTaskReader {
|
||||||
status: 'deleted',
|
status: 'deleted',
|
||||||
deletedAt: typeof parsed.deletedAt === 'string' ? parsed.deletedAt : undefined,
|
deletedAt: typeof parsed.deletedAt === 'string' ? parsed.deletedAt : undefined,
|
||||||
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
|
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
|
||||||
reviewState: normalizeReviewState(parsed.reviewState),
|
reviewState: getReviewStateFromTask({
|
||||||
|
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
tasks.push(task);
|
tasks.push(task);
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@ import * as path from 'path';
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
StatusTransition,
|
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TaskCommentType,
|
TaskCommentType,
|
||||||
|
TaskHistoryEvent,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -34,16 +34,17 @@ async function withTaskLock<T>(taskPath: string, fn: () => Promise<T>): Promise<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendTransition(
|
function appendHistoryEvent(
|
||||||
history: StatusTransition[] | undefined,
|
events: TaskHistoryEvent[] | undefined,
|
||||||
from: TeamTaskStatus | null,
|
event: Omit<TaskHistoryEvent, 'id' | 'timestamp'>
|
||||||
to: TeamTaskStatus,
|
): TaskHistoryEvent[] {
|
||||||
timestamp: string,
|
const list = Array.isArray(events) ? [...events] : [];
|
||||||
actor?: string
|
list.push({
|
||||||
): StatusTransition[] {
|
id: randomUUID(),
|
||||||
const entry: StatusTransition = { from, to, timestamp };
|
timestamp: new Date().toISOString(),
|
||||||
if (actor) entry.actor = actor;
|
...event,
|
||||||
return [...(history ?? []), entry];
|
} as TaskHistoryEvent);
|
||||||
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TeamTaskWriter {
|
export class TeamTaskWriter {
|
||||||
|
|
@ -82,13 +83,11 @@ export class TeamTaskWriter {
|
||||||
: [{ startedAt: createdAt }]),
|
: [{ startedAt: createdAt }]),
|
||||||
]
|
]
|
||||||
: task.workIntervals,
|
: task.workIntervals,
|
||||||
statusHistory: appendTransition(
|
historyEvents: appendHistoryEvent(task.historyEvents, {
|
||||||
task.statusHistory,
|
type: 'task_created',
|
||||||
null,
|
status: task.status,
|
||||||
task.status,
|
...(task.createdBy ? { actor: task.createdBy } : {}),
|
||||||
createdAt,
|
} as Omit<TaskHistoryEvent, 'id' | 'timestamp'>),
|
||||||
task.createdBy
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2));
|
await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2));
|
||||||
|
|
@ -344,12 +343,14 @@ export class TeamTaskWriter {
|
||||||
}
|
}
|
||||||
|
|
||||||
task.workIntervals = intervals.length > 0 ? intervals : undefined;
|
task.workIntervals = intervals.length > 0 ? intervals : undefined;
|
||||||
task.statusHistory = appendTransition(
|
task.historyEvents = appendHistoryEvent(
|
||||||
Array.isArray(task.statusHistory) ? task.statusHistory : undefined,
|
Array.isArray(task.historyEvents) ? task.historyEvents : undefined,
|
||||||
prevStatus,
|
{
|
||||||
status,
|
type: 'status_changed',
|
||||||
nowIso,
|
from: prevStatus,
|
||||||
actor
|
to: status,
|
||||||
|
...(actor ? { actor } : {}),
|
||||||
|
} as Omit<TaskHistoryEvent, 'id' | 'timestamp'>
|
||||||
);
|
);
|
||||||
task.status = status;
|
task.status = status;
|
||||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||||
|
|
@ -416,12 +417,14 @@ export class TeamTaskWriter {
|
||||||
|
|
||||||
task.status = 'deleted';
|
task.status = 'deleted';
|
||||||
task.deletedAt = nowIso;
|
task.deletedAt = nowIso;
|
||||||
task.statusHistory = appendTransition(
|
task.historyEvents = appendHistoryEvent(
|
||||||
Array.isArray(task.statusHistory) ? task.statusHistory : undefined,
|
Array.isArray(task.historyEvents) ? task.historyEvents : undefined,
|
||||||
prevStatus,
|
{
|
||||||
'deleted',
|
type: 'status_changed',
|
||||||
nowIso,
|
from: prevStatus,
|
||||||
actor
|
to: 'deleted',
|
||||||
|
...(actor ? { actor } : {}),
|
||||||
|
} as Omit<TaskHistoryEvent, 'id' | 'timestamp'>
|
||||||
);
|
);
|
||||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||||
|
|
||||||
|
|
@ -449,13 +452,14 @@ export class TeamTaskWriter {
|
||||||
|
|
||||||
const task = JSON.parse(raw) as TeamTask;
|
const task = JSON.parse(raw) as TeamTask;
|
||||||
const prevStatus = task.status;
|
const prevStatus = task.status;
|
||||||
const nowIso = new Date().toISOString();
|
task.historyEvents = appendHistoryEvent(
|
||||||
task.statusHistory = appendTransition(
|
Array.isArray(task.historyEvents) ? task.historyEvents : undefined,
|
||||||
Array.isArray(task.statusHistory) ? task.statusHistory : undefined,
|
{
|
||||||
prevStatus,
|
type: 'status_changed',
|
||||||
'pending',
|
from: prevStatus,
|
||||||
nowIso,
|
to: 'pending',
|
||||||
actor ?? 'user'
|
actor: actor ?? 'user',
|
||||||
|
} as Omit<TaskHistoryEvent, 'id' | 'timestamp'>
|
||||||
);
|
);
|
||||||
task.status = 'pending';
|
task.status = 'pending';
|
||||||
delete task.deletedAt;
|
delete task.deletedAt;
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ interface ParsedTask {
|
||||||
reviewState?: unknown;
|
reviewState?: unknown;
|
||||||
metadata?: { _internal?: unknown };
|
metadata?: { _internal?: unknown };
|
||||||
workIntervals?: unknown;
|
workIntervals?: unknown;
|
||||||
statusHistory?: unknown;
|
historyEvents?: unknown;
|
||||||
attachments?: unknown;
|
attachments?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,11 +129,12 @@ interface RawWorkInterval {
|
||||||
completedAt?: unknown;
|
completedAt?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawStatusTransition {
|
interface RawHistoryEvent {
|
||||||
from?: unknown;
|
id?: unknown;
|
||||||
to?: unknown;
|
type?: unknown;
|
||||||
timestamp?: unknown;
|
timestamp?: unknown;
|
||||||
actor?: unknown;
|
actor?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawComment {
|
interface RawComment {
|
||||||
|
|
@ -477,26 +478,35 @@ function normalizeWorkIntervals(
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatusHistory(
|
function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefined {
|
||||||
parsed: ParsedTask
|
if (!Array.isArray(parsed.historyEvents)) return undefined;
|
||||||
): { from: string | null; to: string; timestamp: string; actor?: string }[] | undefined {
|
return (parsed.historyEvents as unknown[])
|
||||||
if (!Array.isArray(parsed.statusHistory)) return undefined;
|
|
||||||
return (parsed.statusHistory as unknown[])
|
|
||||||
.filter(
|
.filter(
|
||||||
(i): i is RawStatusTransition =>
|
(i): i is RawHistoryEvent =>
|
||||||
Boolean(i) &&
|
Boolean(i) &&
|
||||||
typeof i === 'object' &&
|
typeof i === 'object' &&
|
||||||
((i as RawStatusTransition).from === null ||
|
typeof (i as RawHistoryEvent).id === 'string' &&
|
||||||
typeof (i as RawStatusTransition).from === 'string') &&
|
typeof (i as RawHistoryEvent).timestamp === 'string' &&
|
||||||
typeof (i as RawStatusTransition).to === 'string' &&
|
typeof (i as RawHistoryEvent).type === 'string'
|
||||||
typeof (i as RawStatusTransition).timestamp === 'string'
|
|
||||||
)
|
)
|
||||||
.map((i) => ({
|
.map((i) => ({ ...i }));
|
||||||
from: i.from as string | null,
|
}
|
||||||
to: i.to as string,
|
|
||||||
timestamp: i.timestamp as string,
|
/** Derive review state from historyEvents (inline reducer for worker isolation). */
|
||||||
...(typeof i.actor === 'string' ? { actor: i.actor } : {}),
|
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 {
|
function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
|
||||||
|
|
@ -601,13 +611,8 @@ async function readTasksDirForTeam(
|
||||||
parsed.needsClarification === 'lead' || parsed.needsClarification === 'user'
|
parsed.needsClarification === 'lead' || parsed.needsClarification === 'user'
|
||||||
? (parsed.needsClarification as string)
|
? (parsed.needsClarification as string)
|
||||||
: undefined;
|
: undefined;
|
||||||
const reviewState =
|
const historyEvents = normalizeHistoryEvents(parsed);
|
||||||
parsed.reviewState === 'review' ||
|
const reviewState = deriveReviewStateFromEvents(historyEvents);
|
||||||
parsed.reviewState === 'needsFix' ||
|
|
||||||
parsed.reviewState === 'approved' ||
|
|
||||||
parsed.reviewState === 'none'
|
|
||||||
? parsed.reviewState
|
|
||||||
: 'none';
|
|
||||||
|
|
||||||
tasks.push({
|
tasks.push({
|
||||||
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||||
|
|
@ -632,7 +637,7 @@ async function readTasksDirForTeam(
|
||||||
? (parsed.status as string)
|
? (parsed.status as string)
|
||||||
: 'pending',
|
: 'pending',
|
||||||
workIntervals: normalizeWorkIntervals(parsed),
|
workIntervals: normalizeWorkIntervals(parsed),
|
||||||
statusHistory: normalizeStatusHistory(parsed),
|
historyEvents: normalizeHistoryEvents(parsed),
|
||||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,
|
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,
|
||||||
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as unknown[]) : undefined,
|
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as unknown[]) : undefined,
|
||||||
related: Array.isArray(parsed.related)
|
related: Array.isArray(parsed.related)
|
||||||
|
|
|
||||||
|
|
@ -104,9 +104,7 @@ export const DisplayItemList = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
order === 'newest-first'
|
order === 'newest-first' ? 'flex min-w-0 flex-col-reverse gap-2' : 'min-w-0 space-y-2'
|
||||||
? 'min-w-0 flex flex-col-reverse gap-2'
|
|
||||||
: 'min-w-0 space-y-2'
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
|
|
@ -132,6 +130,7 @@ export const DisplayItemList = ({
|
||||||
preview={truncateText(item.content, 150)}
|
preview={truncateText(item.content, 150)}
|
||||||
onClick={() => onItemClick(itemKey)}
|
onClick={() => onItemClick(itemKey)}
|
||||||
isExpanded={expandedItemIds.has(itemKey)}
|
isExpanded={expandedItemIds.has(itemKey)}
|
||||||
|
timestamp={item.timestamp}
|
||||||
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
||||||
searchQueryOverride={searchQueryOverride}
|
searchQueryOverride={searchQueryOverride}
|
||||||
/>
|
/>
|
||||||
|
|
@ -157,6 +156,7 @@ export const DisplayItemList = ({
|
||||||
preview={truncateText(item.content, 150)}
|
preview={truncateText(item.content, 150)}
|
||||||
onClick={() => onItemClick(itemKey)}
|
onClick={() => onItemClick(itemKey)}
|
||||||
isExpanded={expandedItemIds.has(itemKey)}
|
isExpanded={expandedItemIds.has(itemKey)}
|
||||||
|
timestamp={item.timestamp}
|
||||||
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
||||||
searchQueryOverride={searchQueryOverride}
|
searchQueryOverride={searchQueryOverride}
|
||||||
/>
|
/>
|
||||||
|
|
@ -171,6 +171,7 @@ export const DisplayItemList = ({
|
||||||
linkedTool={item.tool}
|
linkedTool={item.tool}
|
||||||
onClick={() => onItemClick(itemKey)}
|
onClick={() => onItemClick(itemKey)}
|
||||||
isExpanded={expandedItemIds.has(itemKey)}
|
isExpanded={expandedItemIds.has(itemKey)}
|
||||||
|
timestamp={item.tool.startTime}
|
||||||
searchQueryOverride={searchQueryOverride}
|
searchQueryOverride={searchQueryOverride}
|
||||||
isHighlighted={highlightToolUseId === item.tool.id}
|
isHighlighted={highlightToolUseId === item.tool.id}
|
||||||
highlightColor={highlightColor}
|
highlightColor={highlightColor}
|
||||||
|
|
@ -221,6 +222,7 @@ export const DisplayItemList = ({
|
||||||
slash={item.slash}
|
slash={item.slash}
|
||||||
onClick={() => onItemClick(itemKey)}
|
onClick={() => onItemClick(itemKey)}
|
||||||
isExpanded={expandedItemIds.has(itemKey)}
|
isExpanded={expandedItemIds.has(itemKey)}
|
||||||
|
timestamp={item.slash.timestamp}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
@ -249,6 +251,7 @@ export const DisplayItemList = ({
|
||||||
label="Input"
|
label="Input"
|
||||||
summary={truncateText(inputContent, 80)}
|
summary={truncateText(inputContent, 80)}
|
||||||
tokenCount={inputTokenCount}
|
tokenCount={inputTokenCount}
|
||||||
|
timestamp={item.timestamp}
|
||||||
onClick={() => onItemClick(itemKey)}
|
onClick={() => onItemClick(itemKey)}
|
||||||
isExpanded={expandedItemIds.has(itemKey)}
|
isExpanded={expandedItemIds.has(itemKey)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||||
|
|
||||||
import { TOOL_ITEM_MUTED } from '@renderer/constants/cssVariables';
|
import { TOOL_ITEM_MUTED } from '@renderer/constants/cssVariables';
|
||||||
import { getTriggerColorDef, type TriggerColor } from '@shared/constants/triggerColors';
|
import { getTriggerColorDef, type TriggerColor } from '@shared/constants/triggerColors';
|
||||||
|
import { format } from 'date-fns';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
import { formatDuration, formatTokens, getStatusDotColor } from './baseItemHelpers';
|
import { formatDuration, formatTokens, getStatusDotColor } from './baseItemHelpers';
|
||||||
|
|
@ -27,6 +28,8 @@ interface BaseItemProps {
|
||||||
status?: ItemStatus;
|
status?: ItemStatus;
|
||||||
/** Duration in milliseconds */
|
/** Duration in milliseconds */
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
|
/** Timestamp to display (compact HH:mm:ss) */
|
||||||
|
timestamp?: Date;
|
||||||
/** Click handler for toggling */
|
/** Click handler for toggling */
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
/** Whether the item is expanded */
|
/** Whether the item is expanded */
|
||||||
|
|
@ -80,6 +83,7 @@ export const BaseItem: React.FC<BaseItemProps> = ({
|
||||||
tokenLabel = 'tokens',
|
tokenLabel = 'tokens',
|
||||||
status,
|
status,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
timestamp,
|
||||||
onClick,
|
onClick,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
hasExpandableContent = true,
|
hasExpandableContent = true,
|
||||||
|
|
@ -169,6 +173,13 @@ export const BaseItem: React.FC<BaseItemProps> = ({
|
||||||
</span>
|
</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 */}
|
{/* Expand/collapse chevron */}
|
||||||
{hasExpandableContent && (
|
{hasExpandableContent && (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
|
||||||
preview={preview}
|
preview={preview}
|
||||||
onClick={() => handleItemClick(itemId)}
|
onClick={() => handleItemClick(itemId)}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
|
timestamp={item.timestamp}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -122,6 +123,7 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
|
||||||
preview={preview}
|
preview={preview}
|
||||||
onClick={() => handleItemClick(itemId)}
|
onClick={() => handleItemClick(itemId)}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
|
timestamp={item.timestamp}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -136,6 +138,7 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
|
||||||
linkedTool={item.tool}
|
linkedTool={item.tool}
|
||||||
onClick={() => handleItemClick(itemId)}
|
onClick={() => handleItemClick(itemId)}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
|
timestamp={item.tool.startTime}
|
||||||
isHighlighted={isHighlighted}
|
isHighlighted={isHighlighted}
|
||||||
highlightColor={highlightColor}
|
highlightColor={highlightColor}
|
||||||
notificationDotColor={notificationColorMap?.get(item.tool.id)}
|
notificationDotColor={notificationColorMap?.get(item.tool.id)}
|
||||||
|
|
@ -167,6 +170,7 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
|
||||||
label="Input"
|
label="Input"
|
||||||
summary={truncateText(item.content, 80)}
|
summary={truncateText(item.content, 80)}
|
||||||
tokenCount={item.tokenCount}
|
tokenCount={item.tokenCount}
|
||||||
|
timestamp={item.timestamp}
|
||||||
onClick={() => handleItemClick(itemId)}
|
onClick={() => handleItemClick(itemId)}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ interface LinkedToolItemProps {
|
||||||
linkedTool: LinkedToolItemType;
|
linkedTool: LinkedToolItemType;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
|
/** Timestamp for display */
|
||||||
|
timestamp?: Date;
|
||||||
/** Optional local search query override for inline highlighting */
|
/** Optional local search query override for inline highlighting */
|
||||||
searchQueryOverride?: string;
|
searchQueryOverride?: string;
|
||||||
/** Whether this item should be highlighted for error deep linking */
|
/** Whether this item should be highlighted for error deep linking */
|
||||||
|
|
@ -64,6 +66,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
||||||
linkedTool,
|
linkedTool,
|
||||||
onClick,
|
onClick,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
|
timestamp,
|
||||||
searchQueryOverride,
|
searchQueryOverride,
|
||||||
isHighlighted,
|
isHighlighted,
|
||||||
highlightColor,
|
highlightColor,
|
||||||
|
|
@ -177,6 +180,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
||||||
tokenCount={getToolContextTokens(linkedTool)}
|
tokenCount={getToolContextTokens(linkedTool)}
|
||||||
status={status}
|
status={status}
|
||||||
durationMs={linkedTool.durationMs}
|
durationMs={linkedTool.durationMs}
|
||||||
|
timestamp={timestamp}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
highlightClasses={highlightClasses}
|
highlightClasses={highlightClasses}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ interface SlashItemProps {
|
||||||
slash: SlashItemType;
|
slash: SlashItemType;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
|
/** Timestamp for display */
|
||||||
|
timestamp?: Date;
|
||||||
/** Additional classes for highlighting (e.g., error deep linking) */
|
/** Additional classes for highlighting (e.g., error deep linking) */
|
||||||
highlightClasses?: string;
|
highlightClasses?: string;
|
||||||
/** Inline styles for highlighting (used by custom hex colors) */
|
/** Inline styles for highlighting (used by custom hex colors) */
|
||||||
|
|
@ -34,6 +36,7 @@ export const SlashItem: React.FC<SlashItemProps> = ({
|
||||||
slash,
|
slash,
|
||||||
onClick,
|
onClick,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
|
timestamp,
|
||||||
highlightClasses,
|
highlightClasses,
|
||||||
highlightStyle,
|
highlightStyle,
|
||||||
notificationDotColor,
|
notificationDotColor,
|
||||||
|
|
@ -51,6 +54,7 @@ export const SlashItem: React.FC<SlashItemProps> = ({
|
||||||
tokenCount={slash.instructionsTokenCount}
|
tokenCount={slash.instructionsTokenCount}
|
||||||
tokenLabel="tokens"
|
tokenLabel="tokens"
|
||||||
status={hasInstructions ? 'ok' : undefined}
|
status={hasInstructions ? 'ok' : undefined}
|
||||||
|
timestamp={timestamp}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
hasExpandableContent={hasInstructions}
|
hasExpandableContent={hasInstructions}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers';
|
||||||
import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters';
|
import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters';
|
||||||
import { getHighlightProps, type TriggerColor } from '@shared/constants/triggerColors';
|
import { getHighlightProps, type TriggerColor } from '@shared/constants/triggerColors';
|
||||||
import { getModelColorClass, parseModelString } from '@shared/utils/modelParser';
|
import { getModelColorClass, parseModelString } from '@shared/utils/modelParser';
|
||||||
|
import { format } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Bot,
|
Bot,
|
||||||
|
|
@ -369,6 +370,14 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
||||||
>
|
>
|
||||||
{formatDuration(subagent.durationMs)}
|
{formatDuration(subagent.durationMs)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* ========== Level 1 Expanded: Dashboard Content ========== */}
|
{/* ========== Level 1 Expanded: Dashboard Content ========== */}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||||
|
import { format } from 'date-fns';
|
||||||
import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react';
|
import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '../viewers/MarkdownViewer';
|
import { MarkdownViewer } from '../viewers/MarkdownViewer';
|
||||||
|
|
@ -218,6 +219,14 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
||||||
~{formatTokensCompact(teammateMessage.tokenCount)} tokens
|
~{formatTokensCompact(teammateMessage.tokenCount)} tokens
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Expanded content */}
|
{/* Expanded content */}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ interface TextItemProps {
|
||||||
preview: string;
|
preview: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
|
/** Timestamp for display */
|
||||||
|
timestamp?: Date;
|
||||||
/** Optional local search query for inline highlighting */
|
/** Optional local search query for inline highlighting */
|
||||||
searchQueryOverride?: string;
|
searchQueryOverride?: string;
|
||||||
/** Optional stable item id for search highlighting */
|
/** Optional stable item id for search highlighting */
|
||||||
|
|
@ -33,6 +35,7 @@ export const TextItem: React.FC<TextItemProps> = ({
|
||||||
preview,
|
preview,
|
||||||
onClick,
|
onClick,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
|
timestamp,
|
||||||
searchQueryOverride,
|
searchQueryOverride,
|
||||||
markdownItemId,
|
markdownItemId,
|
||||||
highlightClasses,
|
highlightClasses,
|
||||||
|
|
@ -61,6 +64,7 @@ export const TextItem: React.FC<TextItemProps> = ({
|
||||||
label="Output"
|
label="Output"
|
||||||
summary={summary}
|
summary={summary}
|
||||||
tokenCount={tokenCount}
|
tokenCount={tokenCount}
|
||||||
|
timestamp={timestamp}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
highlightClasses={highlightClasses}
|
highlightClasses={highlightClasses}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ interface ThinkingItemProps {
|
||||||
preview: string;
|
preview: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
|
/** Timestamp for display */
|
||||||
|
timestamp?: Date;
|
||||||
/** Optional local search query for inline highlighting */
|
/** Optional local search query for inline highlighting */
|
||||||
searchQueryOverride?: string;
|
searchQueryOverride?: string;
|
||||||
/** Optional stable item id for search highlighting */
|
/** Optional stable item id for search highlighting */
|
||||||
|
|
@ -33,6 +35,7 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
|
||||||
preview,
|
preview,
|
||||||
onClick,
|
onClick,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
|
timestamp,
|
||||||
searchQueryOverride,
|
searchQueryOverride,
|
||||||
markdownItemId,
|
markdownItemId,
|
||||||
highlightClasses,
|
highlightClasses,
|
||||||
|
|
@ -61,6 +64,7 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
|
||||||
label="Thinking"
|
label="Thinking"
|
||||||
summary={summary}
|
summary={summary}
|
||||||
tokenCount={tokenCount}
|
tokenCount={tokenCount}
|
||||||
|
timestamp={timestamp}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
highlightClasses={highlightClasses}
|
highlightClasses={highlightClasses}
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,14 @@ export const UnreadCommentsBadge = ({
|
||||||
if (totalCount === 0) return null;
|
if (totalCount === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<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)]">
|
||||||
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'}
|
|
||||||
>
|
|
||||||
<MessageSquare size={10} />
|
<MessageSquare size={10} />
|
||||||
{totalCount}
|
{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>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,20 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
|
import { Send } from 'lucide-react';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
@ -55,14 +54,13 @@ export const ReviewDialog = ({
|
||||||
[members, colorMap]
|
[members, colorMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCancel = (): void => {
|
const trimmed = draft.value.trim();
|
||||||
onCancel();
|
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (): void => {
|
const handleSubmit = (): void => {
|
||||||
const trimmed = draft.value.trim() || undefined;
|
const comment = trimmed || undefined;
|
||||||
draft.clearDraft();
|
draft.clearDraft();
|
||||||
onSubmit(trimmed);
|
onSubmit(comment);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -70,44 +68,54 @@ export const ReviewDialog = ({
|
||||||
open={open && taskId !== null}
|
open={open && taskId !== null}
|
||||||
onOpenChange={(nextOpen) => {
|
onOpenChange={(nextOpen) => {
|
||||||
if (!nextOpen) {
|
if (!nextOpen) {
|
||||||
handleCancel();
|
onCancel();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-[420px]">
|
<DialogContent className="sm:max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Request Changes</DialogTitle>
|
<DialogTitle>Request Changes</DialogTitle>
|
||||||
<DialogDescription>Task #{taskId ? deriveTaskDisplayId(taskId) : ''}</DialogDescription>
|
<DialogDescription>Task #{taskId ? deriveTaskDisplayId(taskId) : ''}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-2 py-2">
|
<div className="grid gap-2 py-2">
|
||||||
<Label htmlFor="review-comment" className="label-optional">
|
|
||||||
Comment (optional)
|
|
||||||
</Label>
|
|
||||||
<MentionableTextarea
|
<MentionableTextarea
|
||||||
id="review-comment"
|
id="review-comment"
|
||||||
className="min-h-[110px] text-xs"
|
|
||||||
value={draft.value}
|
value={draft.value}
|
||||||
onValueChange={draft.setValue}
|
onValueChange={draft.setValue}
|
||||||
placeholder="Describe what needs to change..."
|
placeholder="Describe what needs to change... (Enter to submit)"
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
projectPath={projectPath}
|
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={
|
footerRight={
|
||||||
draft.isSaved ? (
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
{remaining < 200 ? (
|
||||||
) : undefined
|
<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>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" size="sm" onClick={handleCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" size="sm" onClick={handleSubmit}>
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,38 @@
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { TASK_STATUS_LABELS, TASK_STATUS_STYLES } from '@renderer/utils/memberHelpers';
|
import {
|
||||||
import { ArrowRight, Plus } from 'lucide-react';
|
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 {
|
interface WorkflowTimelineProps {
|
||||||
history: StatusTransition[];
|
events: TaskHistoryEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StatusHistoryTimeline = ({ history }: StatusHistoryTimelineProps) => {
|
export const WorkflowTimeline = ({ events }: WorkflowTimelineProps) => {
|
||||||
if (history.length === 0) {
|
if (events.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||||
No status history recorded
|
No workflow history recorded
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0 px-3 py-2">
|
<div className="space-y-0 px-3 py-2">
|
||||||
{history.map((transition, idx) => {
|
{events.map((event, idx) => {
|
||||||
const isLast = idx === history.length - 1;
|
const isLast = idx === events.length - 1;
|
||||||
const time = formatTime(transition.timestamp);
|
const time = formatTime(event.timestamp);
|
||||||
const isCreation = transition.from === null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${transition.timestamp}-${idx}`} className="flex">
|
<div key={event.id} className="flex">
|
||||||
{/* Timeline line + dot */}
|
{/* Timeline line + dot */}
|
||||||
<div className="flex w-5 shrink-0 flex-col items-center">
|
<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" />}
|
{!isLast && <div className="w-px flex-1 bg-zinc-700" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -40,28 +43,16 @@ export const StatusHistoryTimeline = ({ history }: StatusHistoryTimelineProps) =
|
||||||
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
|
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||||
{time}
|
{time}
|
||||||
</span>
|
</span>
|
||||||
{isCreation ? (
|
<EventContent event={event} />
|
||||||
<span className="flex items-center gap-1">
|
{event.actor ? (
|
||||||
<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 ? (
|
|
||||||
<span className="ml-auto shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
<span className="ml-auto shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||||
by {transition.actor}
|
by {event.actor}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
{new Date(transition.timestamp).toLocaleString()}
|
{new Date(event.timestamp).toLocaleString()}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</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 StatusBadge = ({ status }: { status: TeamTaskStatus }) => {
|
||||||
const style = TASK_STATUS_STYLES[status] ?? TASK_STATUS_STYLES.pending;
|
const style = TASK_STATUS_STYLES[status] ?? TASK_STATUS_STYLES.pending;
|
||||||
const label = TASK_STATUS_LABELS[status] ?? status;
|
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) {
|
switch (status) {
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return 'bg-zinc-500';
|
return 'bg-zinc-500';
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||||
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
|
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 { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
|
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -50,6 +52,7 @@ export const TaskCommentInput = ({
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||||
const [attachError, setAttachError] = useState<string | null>(null);
|
const [attachError, setAttachError] = useState<string | null>(null);
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
|
|
@ -210,16 +213,20 @@ export const TaskCommentInput = ({
|
||||||
{/* Pending attachment previews */}
|
{/* Pending attachment previews */}
|
||||||
{pendingAttachments.length > 0 ? (
|
{pendingAttachments.length > 0 ? (
|
||||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||||
{pendingAttachments.map((att) => (
|
{pendingAttachments.map((att, idx) => (
|
||||||
<div
|
<div
|
||||||
key={att.id}
|
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" />
|
<img src={att.previewUrl} alt={att.filename} className="size-full object-cover" />
|
||||||
<button
|
<button
|
||||||
type="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"
|
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} />
|
<Trash2 size={8} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -228,6 +235,20 @@ export const TaskCommentInput = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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}
|
{attachError ? <p className="mb-1 text-[10px] text-red-400">{attachError}</p> : null}
|
||||||
|
|
||||||
<div className="relative" onPaste={handlePaste}>
|
<div className="relative" onPaste={handlePaste}>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ import {
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { StatusHistoryTimeline } from './StatusHistoryTimeline';
|
import { WorkflowTimeline } from './StatusHistoryTimeline';
|
||||||
import { TaskAttachments } from './TaskAttachments';
|
import { TaskAttachments } from './TaskAttachments';
|
||||||
import { TaskCommentInput } from './TaskCommentInput';
|
import { TaskCommentInput } from './TaskCommentInput';
|
||||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||||
|
|
@ -894,18 +894,18 @@ export const TaskDetailDialog = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Status History */}
|
{/* Workflow History */}
|
||||||
{currentTask.statusHistory && currentTask.statusHistory.length > 0 ? (
|
{currentTask.historyEvents && currentTask.historyEvents.length > 0 ? (
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
title="Status History"
|
title="Workflow History"
|
||||||
icon={<History size={14} />}
|
icon={<History size={14} />}
|
||||||
badge={currentTask.statusHistory.length}
|
badge={currentTask.historyEvents.length}
|
||||||
contentClassName="pl-2.5"
|
contentClassName="pl-2.5"
|
||||||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||||
headerContentClassName="pl-6"
|
headerContentClassName="pl-6"
|
||||||
defaultOpen={false}
|
defaultOpen={false}
|
||||||
>
|
>
|
||||||
<StatusHistoryTimeline history={currentTask.statusHistory} />
|
<WorkflowTimeline events={currentTask.historyEvents} />
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -328,12 +328,12 @@
|
||||||
--diff-added-bg: rgba(34, 197, 94, 0.18);
|
--diff-added-bg: rgba(34, 197, 94, 0.18);
|
||||||
--diff-added-text: #14532d;
|
--diff-added-text: #14532d;
|
||||||
--diff-added-border: #16a34a;
|
--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-text: #7f1d1d;
|
||||||
--diff-removed-border: #dc2626;
|
--diff-removed-border: #dc2626;
|
||||||
/* CodeMirror diff backgrounds - Light mode */
|
/* CodeMirror diff backgrounds - Light mode */
|
||||||
--diff-cm-changed-bg: rgba(34, 197, 94, 0.14);
|
--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 */
|
/* CodeMirror merge buttons - Light mode */
|
||||||
--diff-merge-undo-hover-bg: rgba(0, 0, 0, 0.08);
|
--diff-merge-undo-hover-bg: rgba(0, 0, 0, 0.08);
|
||||||
--diff-merge-keep-color: #15803d;
|
--diff-merge-keep-color: #15803d;
|
||||||
|
|
|
||||||
|
|
@ -65,18 +65,56 @@ export interface TaskWorkInterval {
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Records a single status transition for audit/timeline display. */
|
// ---------------------------------------------------------------------------
|
||||||
export interface StatusTransition {
|
// Task History Events — unified workflow event log
|
||||||
/** Previous status (null for initial creation). */
|
// ---------------------------------------------------------------------------
|
||||||
from: TeamTaskStatus | null;
|
|
||||||
/** New status after the transition. */
|
interface TaskHistoryEventBase {
|
||||||
to: TeamTaskStatus;
|
id: string;
|
||||||
/** ISO timestamp when the transition occurred. */
|
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
/** Who triggered the change: member name, 'user', or undefined if unknown. */
|
|
||||||
actor?: string;
|
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 type TaskCommentType = 'regular' | 'review_request' | 'review_approved';
|
||||||
|
|
||||||
export interface TaskComment {
|
export interface TaskComment {
|
||||||
|
|
@ -107,11 +145,10 @@ export interface TeamTask {
|
||||||
*/
|
*/
|
||||||
workIntervals?: TaskWorkInterval[];
|
workIntervals?: TaskWorkInterval[];
|
||||||
/**
|
/**
|
||||||
* Chronological record of every status change.
|
* Unified workflow event log.
|
||||||
* Append-only — each transition records from, to, timestamp, actor.
|
* Append-only — records task creation, status changes, and review transitions.
|
||||||
* Optional for backwards compatibility with pre-existing tasks.
|
|
||||||
*/
|
*/
|
||||||
statusHistory?: StatusTransition[];
|
historyEvents?: TaskHistoryEvent[];
|
||||||
blocks?: string[];
|
blocks?: string[];
|
||||||
blockedBy?: string[];
|
blockedBy?: string[];
|
||||||
/**
|
/**
|
||||||
|
|
@ -130,7 +167,7 @@ export interface TeamTask {
|
||||||
deletedAt?: string;
|
deletedAt?: string;
|
||||||
/** Attachments associated with this task. Metadata only — actual files stored on disk. */
|
/** Attachments associated with this task. Metadata only — actual files stored on disk. */
|
||||||
attachments?: TaskAttachmentMeta[];
|
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;
|
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 {
|
interface ReviewStateLike {
|
||||||
reviewState?: TeamReviewState | null;
|
reviewState?: TeamReviewState | null;
|
||||||
|
historyEvents?: unknown[];
|
||||||
kanbanColumn?: 'review' | 'approved' | null;
|
kanbanColumn?: 'review' | 'approved' | null;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -11,6 +14,11 @@ export function normalizeReviewState(value: unknown): TeamReviewState {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReviewStateFromTask(task: ReviewStateLike): 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);
|
const explicit = normalizeReviewState(task.reviewState);
|
||||||
if (explicit !== 'none') {
|
if (explicit !== 'none') {
|
||||||
return explicit;
|
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', () => {
|
describe('historyEvents', () => {
|
||||||
it('createTask records initial statusHistory entry', async () => {
|
it('createTask records initial task_created event', async () => {
|
||||||
await writer.createTask('my-team', {
|
await writer.createTask('my-team', {
|
||||||
id: '10',
|
id: '10',
|
||||||
subject: 'New task',
|
subject: 'New task',
|
||||||
|
|
@ -167,16 +167,17 @@ describe('TeamTaskWriter', () => {
|
||||||
|
|
||||||
const writtenPath = '/mock/tasks/my-team/10.json';
|
const writtenPath = '/mock/tasks/my-team/10.json';
|
||||||
const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}');
|
const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}');
|
||||||
expect(persisted.statusHistory).toHaveLength(1);
|
expect(persisted.historyEvents).toHaveLength(1);
|
||||||
expect(persisted.statusHistory[0]).toMatchObject({
|
expect(persisted.historyEvents[0]).toMatchObject({
|
||||||
from: null,
|
type: 'task_created',
|
||||||
to: 'pending',
|
status: 'pending',
|
||||||
actor: 'alice',
|
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', {
|
await writer.createTask('my-team', {
|
||||||
id: '11',
|
id: '11',
|
||||||
subject: 'Start immediately',
|
subject: 'Start immediately',
|
||||||
|
|
@ -186,10 +187,10 @@ describe('TeamTaskWriter', () => {
|
||||||
|
|
||||||
const writtenPath = '/mock/tasks/my-team/11.json';
|
const writtenPath = '/mock/tasks/my-team/11.json';
|
||||||
const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}');
|
const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}');
|
||||||
expect(persisted.statusHistory).toHaveLength(1);
|
expect(persisted.historyEvents).toHaveLength(1);
|
||||||
expect(persisted.statusHistory[0]).toMatchObject({
|
expect(persisted.historyEvents[0]).toMatchObject({
|
||||||
from: null,
|
type: 'task_created',
|
||||||
to: 'in_progress',
|
status: 'in_progress',
|
||||||
actor: 'bob',
|
actor: 'bob',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -203,19 +204,20 @@ describe('TeamTaskWriter', () => {
|
||||||
|
|
||||||
const writtenPath = '/mock/tasks/my-team/13.json';
|
const writtenPath = '/mock/tasks/my-team/13.json';
|
||||||
const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}');
|
const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}');
|
||||||
expect(persisted.statusHistory).toHaveLength(1);
|
expect(persisted.historyEvents).toHaveLength(1);
|
||||||
expect(persisted.statusHistory[0].actor).toBeUndefined();
|
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(
|
hoisted.files.set(
|
||||||
taskPath,
|
taskPath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
id: '12',
|
id: '12',
|
||||||
subject: 'task',
|
subject: 'task',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
statusHistory: [
|
historyEvents: [
|
||||||
{ from: null, to: 'pending', timestamp: '2024-01-01T00:00:00.000Z', actor: 'user' },
|
{ 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');
|
await writer.updateStatus('my-team', '12', 'in_progress', 'alice');
|
||||||
|
|
||||||
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
||||||
expect(persisted.statusHistory).toHaveLength(2);
|
expect(persisted.historyEvents).toHaveLength(2);
|
||||||
expect(persisted.statusHistory[1]).toMatchObject({
|
expect(persisted.historyEvents[1]).toMatchObject({
|
||||||
|
type: 'status_changed',
|
||||||
from: 'pending',
|
from: 'pending',
|
||||||
to: 'in_progress',
|
to: 'in_progress',
|
||||||
actor: 'alice',
|
actor: 'alice',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updateStatus works on legacy task without statusHistory', async () => {
|
it('updateStatus works on task without historyEvents', async () => {
|
||||||
hoisted.files.set(
|
hoisted.files.set(
|
||||||
taskPath,
|
taskPath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|
@ -244,24 +247,25 @@ describe('TeamTaskWriter', () => {
|
||||||
await writer.updateStatus('my-team', '12', 'in_progress');
|
await writer.updateStatus('my-team', '12', 'in_progress');
|
||||||
|
|
||||||
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
||||||
expect(persisted.statusHistory).toHaveLength(1);
|
expect(persisted.historyEvents).toHaveLength(1);
|
||||||
expect(persisted.statusHistory[0]).toMatchObject({
|
expect(persisted.historyEvents[0]).toMatchObject({
|
||||||
|
type: 'status_changed',
|
||||||
from: 'pending',
|
from: 'pending',
|
||||||
to: 'in_progress',
|
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(
|
hoisted.files.set(
|
||||||
taskPath,
|
taskPath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
id: '12',
|
id: '12',
|
||||||
subject: 'task',
|
subject: 'task',
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
statusHistory: [
|
historyEvents: [
|
||||||
{ from: null, to: 'pending', timestamp: '2024-01-01T00:00:00.000Z' },
|
{ type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z' },
|
||||||
{ from: 'pending', to: 'in_progress', timestamp: '2024-01-01T00:01: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');
|
await writer.softDelete('my-team', '12', 'user');
|
||||||
|
|
||||||
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
||||||
expect(persisted.statusHistory).toHaveLength(3);
|
expect(persisted.historyEvents).toHaveLength(3);
|
||||||
expect(persisted.statusHistory[2]).toMatchObject({
|
expect(persisted.historyEvents[2]).toMatchObject({
|
||||||
|
type: 'status_changed',
|
||||||
from: 'in_progress',
|
from: 'in_progress',
|
||||||
to: 'deleted',
|
to: 'deleted',
|
||||||
actor: 'user',
|
actor: 'user',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('restoreTask appends pending transition', async () => {
|
it('restoreTask appends status_changed to pending', async () => {
|
||||||
hoisted.files.set(
|
hoisted.files.set(
|
||||||
taskPath,
|
taskPath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|
@ -285,9 +290,9 @@ describe('TeamTaskWriter', () => {
|
||||||
subject: 'task',
|
subject: 'task',
|
||||||
status: 'deleted',
|
status: 'deleted',
|
||||||
deletedAt: '2024-01-01T00:02:00.000Z',
|
deletedAt: '2024-01-01T00:02:00.000Z',
|
||||||
statusHistory: [
|
historyEvents: [
|
||||||
{ from: null, to: 'pending', timestamp: '2024-01-01T00:00:00.000Z' },
|
{ type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z' },
|
||||||
{ from: 'pending', to: 'deleted', timestamp: '2024-01-01T00:02: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) ?? '{}');
|
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
||||||
expect(persisted.status).toBe('pending');
|
expect(persisted.status).toBe('pending');
|
||||||
expect(persisted.statusHistory).toHaveLength(3);
|
expect(persisted.historyEvents).toHaveLength(3);
|
||||||
expect(persisted.statusHistory[2]).toMatchObject({
|
expect(persisted.historyEvents[2]).toMatchObject({
|
||||||
|
type: 'status_changed',
|
||||||
from: 'deleted',
|
from: 'deleted',
|
||||||
to: 'pending',
|
to: 'pending',
|
||||||
actor: 'user',
|
actor: 'user',
|
||||||
|
|
@ -318,8 +324,9 @@ describe('TeamTaskWriter', () => {
|
||||||
await writer.restoreTask('my-team', '12');
|
await writer.restoreTask('my-team', '12');
|
||||||
|
|
||||||
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
||||||
expect(persisted.statusHistory).toHaveLength(1);
|
expect(persisted.historyEvents).toHaveLength(1);
|
||||||
expect(persisted.statusHistory[0]).toMatchObject({
|
expect(persisted.historyEvents[0]).toMatchObject({
|
||||||
|
type: 'status_changed',
|
||||||
from: 'deleted',
|
from: 'deleted',
|
||||||
to: 'pending',
|
to: 'pending',
|
||||||
actor: 'user',
|
actor: 'user',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue