feat: enhance task status management with history tracking
- Introduced a new `statusHistory` feature to record every status transition of tasks, including the previous status, new status, timestamp, and actor responsible for the change. - Updated task creation and status update methods to append transitions to the status history, ensuring a comprehensive audit trail. - Enhanced UI components to display the status history, providing better visibility into task progress and changes over time. - Refactored related services to support the new status history functionality, improving overall task management practices.
This commit is contained in:
parent
f654c5f356
commit
887c7406d1
16 changed files with 709 additions and 179 deletions
|
|
@ -2084,7 +2084,8 @@
|
|||
"cache_read_input_token_cost": 3.3e-7,
|
||||
"litellm_provider": "deepinfra",
|
||||
"mode": "chat",
|
||||
"supports_tool_choice": true
|
||||
"supports_tool_choice": true,
|
||||
"supports_function_calling": true
|
||||
},
|
||||
"deepinfra/anthropic/claude-4-opus": {
|
||||
"max_tokens": 200000,
|
||||
|
|
@ -2094,7 +2095,8 @@
|
|||
"output_cost_per_token": 0.0000825,
|
||||
"litellm_provider": "deepinfra",
|
||||
"mode": "chat",
|
||||
"supports_tool_choice": true
|
||||
"supports_tool_choice": true,
|
||||
"supports_function_calling": true
|
||||
},
|
||||
"deepinfra/anthropic/claude-4-sonnet": {
|
||||
"max_tokens": 200000,
|
||||
|
|
@ -2104,7 +2106,8 @@
|
|||
"output_cost_per_token": 0.0000165,
|
||||
"litellm_provider": "deepinfra",
|
||||
"mode": "chat",
|
||||
"supports_tool_choice": true
|
||||
"supports_tool_choice": true,
|
||||
"supports_function_calling": true
|
||||
},
|
||||
"eu.anthropic.claude-3-5-haiku-20241022-v1:0": {
|
||||
"input_cost_per_token": 2.5e-7,
|
||||
|
|
|
|||
|
|
@ -440,7 +440,9 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
|
||||
// Auto-relay direct messages to live team lead process (no UI dependency).
|
||||
if (teamProvisioningService.isTeamAlive(teamName)) {
|
||||
void teamProvisioningService.relayLeadInboxMessages(teamName).catch(() => undefined);
|
||||
void teamProvisioningService
|
||||
.relayLeadInboxMessages(teamName)
|
||||
.catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`));
|
||||
}
|
||||
|
||||
// Show native OS notification for new inbox messages (debounced per inbox).
|
||||
|
|
|
|||
|
|
@ -371,7 +371,9 @@ async function handleGetData(
|
|||
const isAlive = provisioning.isTeamAlive(tn);
|
||||
|
||||
if (isAlive) {
|
||||
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
|
||||
void provisioning
|
||||
.relayLeadInboxMessages(tn)
|
||||
.catch((e: unknown) => logger.warn(`Relay failed for ${tn}: ${e}`));
|
||||
}
|
||||
|
||||
const displayName = data.config.name || tn;
|
||||
|
|
@ -891,9 +893,18 @@ async function handleSendMessage(
|
|||
if (isLeadRecipient && isAlive) {
|
||||
// Separate try blocks: stdin delivery vs persistence
|
||||
// If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate)
|
||||
// Wrap with instructions so lead responds with visible text (not just agent-only blocks)
|
||||
const wrappedText = [
|
||||
`You received a direct message from the user.`,
|
||||
`IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`,
|
||||
``,
|
||||
`Message from user:`,
|
||||
payload.text!,
|
||||
].join('\n');
|
||||
|
||||
let stdinSent = false;
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, payload.text!, validatedAttachments);
|
||||
await provisioning.sendMessageToTeam(tn, wrappedText, validatedAttachments);
|
||||
stdinSent = true;
|
||||
} catch (stdinError: unknown) {
|
||||
// Stdin failed (process died between check and write)
|
||||
|
|
@ -963,7 +974,9 @@ async function handleSendMessage(
|
|||
|
||||
// Best-effort relay for lead via inbox
|
||||
if (isLeadRecipient && isAlive) {
|
||||
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
|
||||
void provisioning
|
||||
.relayLeadInboxMessages(tn)
|
||||
.catch((e: unknown) => logger.warn(`Relay after sendMessage failed for ${tn}: ${e}`));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -243,13 +243,22 @@ function applyWorkIntervalsForStatusTransition(task, prevStatus, nextStatus, now
|
|||
else delete task.workIntervals;
|
||||
}
|
||||
|
||||
function setTaskStatus(paths, taskId, status) {
|
||||
function appendStatusTransition(task, fromStatus, toStatus, timestamp, actor) {
|
||||
var entry = { from: fromStatus, to: toStatus, timestamp: timestamp };
|
||||
if (actor) entry.actor = actor;
|
||||
var history = Array.isArray(task.statusHistory) ? task.statusHistory.slice() : [];
|
||||
history.push(entry);
|
||||
task.statusHistory = history;
|
||||
}
|
||||
|
||||
function setTaskStatus(paths, taskId, status, actor) {
|
||||
const normalized = normalizeStatus(status);
|
||||
if (!normalized) die('Invalid status: ' + String(status));
|
||||
const { taskPath, task } = readTask(paths, taskId);
|
||||
var prev = task.status;
|
||||
var now = nowIso();
|
||||
applyWorkIntervalsForStatusTransition(task, prev, normalized, now);
|
||||
appendStatusTransition(task, prev, normalized, now, actor);
|
||||
task.status = normalized;
|
||||
writeTask(taskPath, task);
|
||||
}
|
||||
|
|
@ -503,6 +512,7 @@ function createTask(paths, flags) {
|
|||
status,
|
||||
createdAt: createdAt,
|
||||
workIntervals: status === 'in_progress' ? [{ startedAt: createdAt }] : undefined,
|
||||
statusHistory: [{ from: null, to: status, timestamp: createdAt, actor: from }],
|
||||
blocks: [],
|
||||
blockedBy: blockedByIds,
|
||||
related: relatedIds.length > 0 ? relatedIds : undefined,
|
||||
|
|
@ -664,7 +674,9 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
|||
|
||||
clearKanban(paths, teamName, taskId);
|
||||
var now = nowIso();
|
||||
applyWorkIntervalsForStatusTransition(task, task.status, 'in_progress', now);
|
||||
var prevStatus = task.status;
|
||||
applyWorkIntervalsForStatusTransition(task, prevStatus, 'in_progress', now);
|
||||
appendStatusTransition(task, prevStatus, 'in_progress', now, from);
|
||||
task.status = 'in_progress';
|
||||
|
||||
// Record review comment in task.comments
|
||||
|
|
@ -957,27 +969,30 @@ async function main() {
|
|||
|
||||
const teamName = getTeamName(args.flags);
|
||||
const paths = getPaths(args.flags, teamName);
|
||||
var actor = typeof args.flags.from === 'string' && args.flags.from.trim()
|
||||
? args.flags.from.trim()
|
||||
: inferLeadName(paths);
|
||||
|
||||
if (domain === 'task') {
|
||||
if (action === 'set-status') {
|
||||
const id = rest[0] || args.flags.id;
|
||||
const status = rest[1] || args.flags.status;
|
||||
if (!id || !status) die('Usage: task set-status <id> <status>');
|
||||
setTaskStatus(paths, String(id), status);
|
||||
setTaskStatus(paths, String(id), status, actor);
|
||||
process.stdout.write('OK task #' + String(id) + ' status=' + String(status) + '\n');
|
||||
return;
|
||||
}
|
||||
if (action === 'complete' || action === 'done') {
|
||||
const id = rest[0] || args.flags.id;
|
||||
if (!id) die('Usage: task complete <id>');
|
||||
setTaskStatus(paths, String(id), 'completed');
|
||||
setTaskStatus(paths, String(id), 'completed', actor);
|
||||
process.stdout.write('OK task #' + String(id) + ' status=completed\n');
|
||||
return;
|
||||
}
|
||||
if (action === 'start') {
|
||||
const id = rest[0] || args.flags.id;
|
||||
if (!id) die('Usage: task start <id>');
|
||||
setTaskStatus(paths, String(id), 'in_progress');
|
||||
setTaskStatus(paths, String(id), 'in_progress', actor);
|
||||
process.stdout.write('OK task #' + String(id) + ' status=in_progress\n');
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -826,7 +826,7 @@ export class TeamDataService {
|
|||
throw new Error(`Task #${taskId} is not pending (current: ${task.status})`);
|
||||
}
|
||||
|
||||
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress');
|
||||
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress', 'user');
|
||||
|
||||
if (task.owner) {
|
||||
try {
|
||||
|
|
@ -856,16 +856,21 @@ export class TeamDataService {
|
|||
return { notifiedOwner: !!task.owner };
|
||||
}
|
||||
|
||||
async updateTaskStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {
|
||||
await this.taskWriter.updateStatus(teamName, taskId, status);
|
||||
async updateTaskStatus(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
status: TeamTaskStatus,
|
||||
actor?: string
|
||||
): Promise<void> {
|
||||
await this.taskWriter.updateStatus(teamName, taskId, status, actor);
|
||||
}
|
||||
|
||||
async softDeleteTask(teamName: string, taskId: string): Promise<void> {
|
||||
await this.taskWriter.softDelete(teamName, taskId);
|
||||
await this.taskWriter.softDelete(teamName, taskId, 'user');
|
||||
}
|
||||
|
||||
async restoreTask(teamName: string, taskId: string): Promise<void> {
|
||||
await this.taskWriter.restoreTask(teamName, taskId);
|
||||
await this.taskWriter.restoreTask(teamName, taskId, 'user');
|
||||
}
|
||||
|
||||
async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
|
||||
|
|
@ -929,8 +934,7 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
if (task?.owner && !this.isLeadOwner(task.owner, leadName)) {
|
||||
// UX: don't echo a user comment as an inbox notification "from the lead" when the
|
||||
// task is already owned by the lead. This creates confusing self-notifications.
|
||||
// Notify non-lead task owner via inbox (lead → member message)
|
||||
const parts = [
|
||||
`Comment on task #${taskId} "${task.subject}":\n\n${text}`,
|
||||
`\n${AGENT_BLOCK_OPEN}`,
|
||||
|
|
@ -944,6 +948,22 @@ export class TeamDataService {
|
|||
text: parts.join('\n'),
|
||||
summary: `Comment on #${taskId}`,
|
||||
});
|
||||
} else if (task?.owner && this.isLeadOwner(task.owner, leadName)) {
|
||||
// Notify lead about user's comment on their own task.
|
||||
// Write to lead's inbox — relay delivers to stdin when process is alive.
|
||||
const parts = [
|
||||
`New comment from user on your task #${taskId} "${task.subject}":\n\n${text}`,
|
||||
`\n${AGENT_BLOCK_OPEN}`,
|
||||
`Reply to this comment using:`,
|
||||
`node "${toolPath}" --team ${teamName} task comment ${taskId} --text "<your reply>" --from "${leadName}"`,
|
||||
AGENT_BLOCK_CLOSE,
|
||||
];
|
||||
await this.sendMessage(teamName, {
|
||||
member: leadName,
|
||||
from: 'user',
|
||||
text: parts.join('\n'),
|
||||
summary: `Comment on #${taskId}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Notification is best-effort — don't fail comment save
|
||||
|
|
@ -1269,7 +1289,7 @@ export class TeamDataService {
|
|||
await this.kanbanManager.updateTask(teamName, taskId, { op: 'remove' });
|
||||
|
||||
try {
|
||||
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress');
|
||||
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress', 'reviewer');
|
||||
const leadName = await this.resolveLeadName(teamName);
|
||||
await this.sendMessage(teamName, {
|
||||
member: task.owner,
|
||||
|
|
@ -1281,7 +1301,9 @@ export class TeamDataService {
|
|||
summary: `Fix request for #${taskId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.taskWriter.updateStatus(teamName, taskId, previousStatus).catch(() => undefined);
|
||||
await this.taskWriter
|
||||
.updateStatus(teamName, taskId, previousStatus, 'system')
|
||||
.catch(() => undefined);
|
||||
if (previousKanbanEntry) {
|
||||
await this.kanbanManager
|
||||
.updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column })
|
||||
|
|
|
|||
|
|
@ -1959,7 +1959,13 @@ export class TeamProvisioningService {
|
|||
content: contentBlocks,
|
||||
},
|
||||
});
|
||||
run.child.stdin.write(payload + '\n');
|
||||
const stdin = run.child.stdin;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stdin.write(payload + '\n', (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
this.setLeadActivity(run, 'active');
|
||||
}
|
||||
|
||||
|
|
@ -2145,7 +2151,11 @@ export class TeamProvisioningService {
|
|||
};
|
||||
this.pushLiveLeadProcessMessage(teamName, relayMsg);
|
||||
// Persist to disk so relayed replies survive app restart and trigger FileWatcher
|
||||
void this.sentMessagesStore.appendMessage(teamName, relayMsg).catch(() => undefined);
|
||||
void this.sentMessagesStore
|
||||
.appendMessage(teamName, relayMsg)
|
||||
.catch((e: unknown) =>
|
||||
logger.warn(`[${teamName}] sentMessagesStore persist failed: ${e}`)
|
||||
);
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'inbox',
|
||||
teamName,
|
||||
|
|
@ -2457,7 +2467,32 @@ export class TeamProvisioningService {
|
|||
// Persist to disk so replies survive app restart
|
||||
void this.sentMessagesStore
|
||||
.appendMessage(run.teamName, replyMsg)
|
||||
.catch(() => undefined);
|
||||
.catch((e: unknown) =>
|
||||
logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`)
|
||||
);
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'inbox',
|
||||
teamName: run.teamName,
|
||||
detail: 'lead-direct-reply',
|
||||
});
|
||||
} else if (rawReply.length > 0) {
|
||||
// Lead responded but only with agent-only content — send generic acknowledgment
|
||||
const fallbackMsg: InboxMessage = {
|
||||
from: leadName,
|
||||
to: 'user',
|
||||
text: '(Message received and processed)',
|
||||
timestamp: nowIso(),
|
||||
read: true,
|
||||
summary: 'Message processed',
|
||||
messageId: `lead-direct-${run.runId}-${Date.now()}`,
|
||||
source: 'lead_process',
|
||||
};
|
||||
this.pushLiveLeadProcessMessage(run.teamName, fallbackMsg);
|
||||
void this.sentMessagesStore
|
||||
.appendMessage(run.teamName, fallbackMsg)
|
||||
.catch((e: unknown) =>
|
||||
logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`)
|
||||
);
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'inbox',
|
||||
teamName: run.teamName,
|
||||
|
|
@ -2572,7 +2607,9 @@ export class TeamProvisioningService {
|
|||
logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`);
|
||||
|
||||
// Pick up any direct messages that arrived before/while reconnecting.
|
||||
void this.relayLeadInboxMessages(run.teamName).catch(() => undefined);
|
||||
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
|
||||
logger.warn(`[${run.teamName}] post-reconnect relay failed: ${e}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2613,7 +2650,9 @@ export class TeamProvisioningService {
|
|||
logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`);
|
||||
|
||||
// Pick up any direct messages that arrived during provisioning.
|
||||
void this.relayLeadInboxMessages(run.teamName).catch(() => undefined);
|
||||
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
|
||||
logger.warn(`[${run.teamName}] post-provisioning relay failed: ${e}`)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ import * as path from 'path';
|
|||
|
||||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||
|
||||
import type { TaskComment, TaskWorkInterval, TeamTask } from '@shared/types';
|
||||
import type {
|
||||
StatusTransition,
|
||||
TaskComment,
|
||||
TaskWorkInterval,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
} from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamTaskReader');
|
||||
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
|
@ -107,6 +113,26 @@ 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[])
|
||||
.filter(
|
||||
(e): e is { from: string | null; to: string; timestamp: string; actor?: string } =>
|
||||
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>).timestamp === 'string' &&
|
||||
((e as Record<string, unknown>).actor === undefined ||
|
||||
typeof (e as Record<string, unknown>).actor === 'string')
|
||||
)
|
||||
.map((e) => ({
|
||||
from: e.from as TeamTaskStatus | null,
|
||||
to: e.to as TeamTaskStatus,
|
||||
timestamp: e.timestamp,
|
||||
...(e.actor ? { actor: e.actor } : {}),
|
||||
}))
|
||||
: undefined;
|
||||
const workIntervals: TaskWorkInterval[] | undefined = Array.isArray(parsed.workIntervals)
|
||||
? (parsed.workIntervals as unknown[])
|
||||
.filter(
|
||||
|
|
@ -136,6 +162,7 @@ export class TeamTaskReader {
|
|||
? (parsed.status as TeamTask['status'])
|
||||
: 'pending',
|
||||
workIntervals,
|
||||
statusHistory,
|
||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined,
|
||||
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined,
|
||||
related: Array.isArray(parsed.related)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@ import * as path from 'path';
|
|||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
import type { TaskComment, TaskCommentType, TeamTask, TeamTaskStatus } from '@shared/types';
|
||||
import type {
|
||||
StatusTransition,
|
||||
TaskComment,
|
||||
TaskCommentType,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
} from '@shared/types';
|
||||
|
||||
const taskWriteLocks = new Map<string, Promise<void>>();
|
||||
|
||||
|
|
@ -27,6 +33,18 @@ 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];
|
||||
}
|
||||
|
||||
export class TeamTaskWriter {
|
||||
async createTask(teamName: string, task: TeamTask): Promise<void> {
|
||||
const tasksDir = path.join(getTasksBasePath(), teamName);
|
||||
|
|
@ -63,6 +81,13 @@ export class TeamTaskWriter {
|
|||
: [{ startedAt: createdAt }]),
|
||||
]
|
||||
: task.workIntervals,
|
||||
statusHistory: appendTransition(
|
||||
task.statusHistory,
|
||||
null,
|
||||
task.status,
|
||||
createdAt,
|
||||
task.createdBy
|
||||
),
|
||||
};
|
||||
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2));
|
||||
|
|
@ -272,7 +297,12 @@ export class TeamTaskWriter {
|
|||
}
|
||||
}
|
||||
|
||||
async updateStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {
|
||||
async updateStatus(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
status: TeamTaskStatus,
|
||||
actor?: string
|
||||
): Promise<void> {
|
||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||
|
||||
await withTaskLock(taskPath, async () => {
|
||||
|
|
@ -310,6 +340,13 @@ 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.status = status;
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
|
||||
|
|
@ -345,7 +382,7 @@ export class TeamTaskWriter {
|
|||
});
|
||||
}
|
||||
|
||||
async softDelete(teamName: string, taskId: string): Promise<void> {
|
||||
async softDelete(teamName: string, taskId: string, actor?: string): Promise<void> {
|
||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||
|
||||
await withTaskLock(taskPath, async () => {
|
||||
|
|
@ -360,6 +397,7 @@ export class TeamTaskWriter {
|
|||
}
|
||||
|
||||
const task = JSON.parse(raw) as TeamTask;
|
||||
const prevStatus = task.status;
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
// Ensure any open in_progress interval is closed on delete.
|
||||
|
|
@ -374,6 +412,13 @@ export class TeamTaskWriter {
|
|||
|
||||
task.status = 'deleted';
|
||||
task.deletedAt = nowIso;
|
||||
task.statusHistory = appendTransition(
|
||||
Array.isArray(task.statusHistory) ? task.statusHistory : undefined,
|
||||
prevStatus,
|
||||
'deleted',
|
||||
nowIso,
|
||||
actor
|
||||
);
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
|
||||
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
|
||||
|
|
@ -384,7 +429,7 @@ export class TeamTaskWriter {
|
|||
});
|
||||
}
|
||||
|
||||
async restoreTask(teamName: string, taskId: string): Promise<void> {
|
||||
async restoreTask(teamName: string, taskId: string, actor?: string): Promise<void> {
|
||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||
|
||||
await withTaskLock(taskPath, async () => {
|
||||
|
|
@ -399,6 +444,15 @@ 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.status = 'pending';
|
||||
delete task.deletedAt;
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ interface ParsedTask {
|
|||
needsClarification?: unknown;
|
||||
metadata?: { _internal?: unknown };
|
||||
workIntervals?: unknown;
|
||||
statusHistory?: unknown;
|
||||
}
|
||||
|
||||
interface RawWorkInterval {
|
||||
|
|
@ -116,6 +117,13 @@ interface RawWorkInterval {
|
|||
completedAt?: unknown;
|
||||
}
|
||||
|
||||
interface RawStatusTransition {
|
||||
from?: unknown;
|
||||
to?: unknown;
|
||||
timestamp?: unknown;
|
||||
actor?: unknown;
|
||||
}
|
||||
|
||||
interface RawComment {
|
||||
id?: unknown;
|
||||
author?: unknown;
|
||||
|
|
@ -436,6 +444,28 @@ 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[])
|
||||
.filter(
|
||||
(i): i is RawStatusTransition =>
|
||||
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'
|
||||
)
|
||||
.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 } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
|
||||
if (!Array.isArray(parsed.comments)) return undefined;
|
||||
return (parsed.comments as unknown[])
|
||||
|
|
@ -554,6 +584,7 @@ async function readTasksDirForTeam(
|
|||
? (parsed.status as string)
|
||||
: 'pending',
|
||||
workIntervals: normalizeWorkIntervals(parsed),
|
||||
statusHistory: normalizeStatusHistory(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)
|
||||
|
|
|
|||
|
|
@ -57,15 +57,11 @@ export const CollapsibleTeamSection = ({
|
|||
}, [handleNavigate]);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
data-section-id={sectionId}
|
||||
className="min-w-0 overflow-hidden border-b border-[var(--color-border)] pb-3 last:border-b-0"
|
||||
>
|
||||
<div className="relative -mx-4 flex min-h-10 w-full items-stretch py-3">
|
||||
<section ref={sectionRef} data-section-id={sectionId} className="min-w-0">
|
||||
<div className="relative -mx-4 flex min-h-9 w-[calc(100%+2rem)] items-stretch py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-0 cursor-pointer rounded-md transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
className={`absolute inset-0 z-0 cursor-pointer transition-colors ${isOpen ? 'rounded-t-md bg-white/[0.07] hover:bg-white/[0.1]' : 'rounded-md bg-white/[0.04] hover:bg-white/[0.08]'}`}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
aria-label={isOpen ? 'Collapse section' : 'Expand section'}
|
||||
/>
|
||||
|
|
@ -97,7 +93,7 @@ export const CollapsibleTeamSection = ({
|
|||
</div>
|
||||
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
|
||||
</div>
|
||||
{isOpen && <div className="mt-2 min-w-0 overflow-hidden">{children}</div>}
|
||||
{isOpen && <div className="mt-1.5 min-w-0 overflow-x-hidden pb-2 pl-2.5">{children}</div>}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
114
src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx
Normal file
114
src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
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 type { StatusTransition, TeamTaskStatus } from '@shared/types';
|
||||
|
||||
interface StatusHistoryTimelineProps {
|
||||
history: StatusTransition[];
|
||||
}
|
||||
|
||||
export const StatusHistoryTimeline = ({ history }: StatusHistoryTimelineProps) => {
|
||||
if (history.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
No status 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;
|
||||
|
||||
return (
|
||||
<div key={`${transition.timestamp}-${idx}`} 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))} />
|
||||
{!isLast && <div className="w-px flex-1 bg-zinc-700" />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mb-1.5 flex w-full items-center gap-2 rounded px-1.5 py-1 text-xs text-[var(--color-text-secondary)]">
|
||||
<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 ? (
|
||||
<span className="ml-auto shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
by {transition.actor}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{new Date(transition.timestamp).toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StatusBadge = ({ status }: { status: TeamTaskStatus }) => {
|
||||
const style = TASK_STATUS_STYLES[status] ?? TASK_STATUS_STYLES.pending;
|
||||
const label = TASK_STATUS_LABELS[status] ?? status;
|
||||
return (
|
||||
<span
|
||||
className={cn('rounded-full px-1.5 py-0.5 text-[10px] font-medium', style.bg, style.text)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
function dotColor(status: TeamTaskStatus): string {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-zinc-500';
|
||||
case 'in_progress':
|
||||
return 'bg-blue-400';
|
||||
case 'completed':
|
||||
return 'bg-emerald-400';
|
||||
case 'deleted':
|
||||
return 'bg-red-400';
|
||||
default:
|
||||
return 'bg-zinc-500';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return '??:??';
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return '??:??';
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ import {
|
|||
FileCode,
|
||||
FileDiff,
|
||||
HelpCircle,
|
||||
History,
|
||||
Link2,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
|
|
@ -54,6 +55,7 @@ import {
|
|||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { StatusHistoryTimeline } from './StatusHistoryTimeline';
|
||||
import { TaskCommentInput } from './TaskCommentInput';
|
||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||
|
||||
|
|
@ -337,7 +339,7 @@ export const TaskDetailDialog = ({
|
|||
</DialogHeader>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{canReassign ? (
|
||||
<Select
|
||||
|
|
@ -405,6 +407,20 @@ export const TaskDetailDialog = ({
|
|||
);
|
||||
})()
|
||||
: null}
|
||||
{onDeleteTask && currentTask ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-6 gap-1 text-xs text-[var(--color-text-muted)] hover:text-red-400"
|
||||
onClick={() => {
|
||||
onDeleteTask(currentTask.id);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Clarification banner */}
|
||||
|
|
@ -615,128 +631,146 @@ export const TaskDetailDialog = ({
|
|||
</CollapsibleTeamSection>
|
||||
) : null}
|
||||
|
||||
<div className="mb-3 space-y-2">
|
||||
{/* Dependencies */}
|
||||
{blockedByIds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-yellow-300">
|
||||
<ArrowLeftFromLine size={12} />
|
||||
Blocked by
|
||||
</span>
|
||||
{blockedByIds.map((id) => {
|
||||
const depTask = taskMap.get(id);
|
||||
const isCompleted = depTask?.status === 'completed';
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
||||
isCompleted
|
||||
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
|
||||
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
|
||||
} cursor-pointer`}
|
||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||
onClick={() => handleDependencyClick(id)}
|
||||
>
|
||||
#{id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{blocksIds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-blue-400">
|
||||
<ArrowRightFromLine size={12} />
|
||||
Blocks
|
||||
</span>
|
||||
{blocksIds.map((id) => {
|
||||
const depTask = taskMap.get(id);
|
||||
const isCompleted = depTask?.status === 'completed';
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
||||
isCompleted
|
||||
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
|
||||
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
|
||||
} cursor-pointer`}
|
||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||
onClick={() => handleDependencyClick(id)}
|
||||
>
|
||||
#{id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Related tasks (explicit) */}
|
||||
{relatedIds.length > 0 || relatedByIds.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
|
||||
<Link2 size={12} />
|
||||
Related tasks
|
||||
</div>
|
||||
|
||||
{relatedIds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs text-[var(--color-text-muted)]">Links</span>
|
||||
{relatedIds.map((id) => {
|
||||
const depTask = taskMap.get(id);
|
||||
return (
|
||||
<button
|
||||
key={`related:${currentTask.id}:${id}`}
|
||||
type="button"
|
||||
className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25"
|
||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||
onClick={() => handleDependencyClick(id)}
|
||||
>
|
||||
#{id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{relatedByIds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs text-[var(--color-text-muted)]">Linked from</span>
|
||||
{relatedByIds.map((id) => {
|
||||
const depTask = taskMap.get(id);
|
||||
return (
|
||||
<button
|
||||
key={`related-by:${currentTask.id}:${id}`}
|
||||
type="button"
|
||||
className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25"
|
||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||
onClick={() => handleDependencyClick(id)}
|
||||
>
|
||||
#{id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Review info */}
|
||||
{kanbanTaskState ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{kanbanTaskState.reviewer ? (
|
||||
<span className="text-xs text-[var(--color-text-secondary)]">
|
||||
Reviewer: {kanbanTaskState.reviewer}
|
||||
{blockedByIds.length > 0 ||
|
||||
blocksIds.length > 0 ||
|
||||
relatedIds.length > 0 ||
|
||||
relatedByIds.length > 0 ||
|
||||
kanbanTaskState ? (
|
||||
<div className="space-y-2">
|
||||
{/* Dependencies */}
|
||||
{blockedByIds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-yellow-300">
|
||||
<ArrowLeftFromLine size={12} />
|
||||
Blocked by
|
||||
</span>
|
||||
) : null}
|
||||
{kanbanTaskState.errorDescription ? (
|
||||
<span className="text-xs text-red-400">{kanbanTaskState.errorDescription}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{blockedByIds.map((id) => {
|
||||
const depTask = taskMap.get(id);
|
||||
const isCompleted = depTask?.status === 'completed';
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
||||
isCompleted
|
||||
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
|
||||
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
|
||||
} cursor-pointer`}
|
||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||
onClick={() => handleDependencyClick(id)}
|
||||
>
|
||||
#{id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{blocksIds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-blue-400">
|
||||
<ArrowRightFromLine size={12} />
|
||||
Blocks
|
||||
</span>
|
||||
{blocksIds.map((id) => {
|
||||
const depTask = taskMap.get(id);
|
||||
const isCompleted = depTask?.status === 'completed';
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
||||
isCompleted
|
||||
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
|
||||
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
|
||||
} cursor-pointer`}
|
||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||
onClick={() => handleDependencyClick(id)}
|
||||
>
|
||||
#{id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Related tasks (explicit) */}
|
||||
{relatedIds.length > 0 || relatedByIds.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
|
||||
<Link2 size={12} />
|
||||
Related tasks
|
||||
</div>
|
||||
|
||||
{relatedIds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs text-[var(--color-text-muted)]">Links</span>
|
||||
{relatedIds.map((id) => {
|
||||
const depTask = taskMap.get(id);
|
||||
return (
|
||||
<button
|
||||
key={`related:${currentTask.id}:${id}`}
|
||||
type="button"
|
||||
className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25"
|
||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||
onClick={() => handleDependencyClick(id)}
|
||||
>
|
||||
#{id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{relatedByIds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs text-[var(--color-text-muted)]">Linked from</span>
|
||||
{relatedByIds.map((id) => {
|
||||
const depTask = taskMap.get(id);
|
||||
return (
|
||||
<button
|
||||
key={`related-by:${currentTask.id}:${id}`}
|
||||
type="button"
|
||||
className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25"
|
||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||
onClick={() => handleDependencyClick(id)}
|
||||
>
|
||||
#{id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Review info */}
|
||||
{kanbanTaskState ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{kanbanTaskState.reviewer ? (
|
||||
<span className="text-xs text-[var(--color-text-secondary)]">
|
||||
Reviewer: {kanbanTaskState.reviewer}
|
||||
</span>
|
||||
) : null}
|
||||
{kanbanTaskState.errorDescription ? (
|
||||
<span className="text-xs text-red-400">{kanbanTaskState.errorDescription}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Status History */}
|
||||
{currentTask.statusHistory && currentTask.statusHistory.length > 0 ? (
|
||||
<CollapsibleTeamSection
|
||||
title="Status History"
|
||||
icon={<History size={14} />}
|
||||
badge={currentTask.statusHistory.length}
|
||||
defaultOpen={false}
|
||||
>
|
||||
<StatusHistoryTimeline history={currentTask.statusHistory} />
|
||||
</CollapsibleTeamSection>
|
||||
) : null}
|
||||
|
||||
{/* Comments */}
|
||||
<CollapsibleTeamSection
|
||||
|
|
@ -767,22 +801,7 @@ export const TaskDetailDialog = ({
|
|||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
||||
{onDeleteTask && currentTask ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onDeleteTask(currentTask.id);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<DialogFooter className="flex items-center justify-end sm:justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,18 @@ 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. */
|
||||
timestamp: string;
|
||||
/** Who triggered the change: member name, 'user', or undefined if unknown. */
|
||||
actor?: string;
|
||||
}
|
||||
|
||||
export type TaskCommentType = 'regular' | 'review_request' | 'review_approved';
|
||||
|
||||
export interface TaskComment {
|
||||
|
|
@ -89,6 +101,12 @@ export interface TeamTask {
|
|||
* We persist intervals for reliable log attribution without relying on heuristics.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
statusHistory?: StatusTransition[];
|
||||
blocks?: string[];
|
||||
blockedBy?: string[];
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
|||
import { spawnCli } from '@main/utils/childProcess';
|
||||
|
||||
function createFakeChild() {
|
||||
const writeSpy = vi.fn();
|
||||
const writeSpy = vi.fn((_data: unknown, cb?: (err?: Error | null) => void) => {
|
||||
if (typeof cb === 'function') cb(null);
|
||||
return true;
|
||||
});
|
||||
const endSpy = vi.fn();
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 12345,
|
||||
|
|
|
|||
|
|
@ -94,7 +94,10 @@ function attachAliveRun(
|
|||
opts?: { writable?: boolean }
|
||||
): { writeSpy: ReturnType<typeof vi.fn> } {
|
||||
const runId = 'run-1';
|
||||
const writeSpy = vi.fn();
|
||||
const writeSpy = vi.fn((_data: unknown, cb?: (err?: Error | null) => void) => {
|
||||
if (typeof cb === 'function') cb(null);
|
||||
return true;
|
||||
});
|
||||
const writable = opts?.writable ?? true;
|
||||
|
||||
(service as unknown as { activeByTeam: Map<string, string> }).activeByTeam.set(teamName, runId);
|
||||
|
|
|
|||
|
|
@ -155,4 +155,175 @@ describe('TeamTaskWriter', () => {
|
|||
'Task status update verification failed: 12'
|
||||
);
|
||||
});
|
||||
|
||||
describe('statusHistory', () => {
|
||||
it('createTask records initial statusHistory entry', async () => {
|
||||
await writer.createTask('my-team', {
|
||||
id: '10',
|
||||
subject: 'New task',
|
||||
status: 'pending',
|
||||
createdBy: 'alice',
|
||||
});
|
||||
|
||||
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',
|
||||
actor: 'alice',
|
||||
});
|
||||
expect(typeof persisted.statusHistory[0].timestamp).toBe('string');
|
||||
});
|
||||
|
||||
it('createTask with in_progress records initial transition', async () => {
|
||||
await writer.createTask('my-team', {
|
||||
id: '11',
|
||||
subject: 'Start immediately',
|
||||
status: 'in_progress',
|
||||
createdBy: 'bob',
|
||||
});
|
||||
|
||||
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',
|
||||
actor: 'bob',
|
||||
});
|
||||
});
|
||||
|
||||
it('createTask without createdBy omits actor', async () => {
|
||||
await writer.createTask('my-team', {
|
||||
id: '13',
|
||||
subject: 'No author',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('updateStatus appends transition to statusHistory', 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' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
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({
|
||||
from: 'pending',
|
||||
to: 'in_progress',
|
||||
actor: 'alice',
|
||||
});
|
||||
});
|
||||
|
||||
it('updateStatus works on legacy task without statusHistory', async () => {
|
||||
hoisted.files.set(
|
||||
taskPath,
|
||||
JSON.stringify({
|
||||
id: '12',
|
||||
subject: 'legacy task',
|
||||
status: 'pending',
|
||||
})
|
||||
);
|
||||
|
||||
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({
|
||||
from: 'pending',
|
||||
to: 'in_progress',
|
||||
});
|
||||
expect(persisted.statusHistory[0].actor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('softDelete appends deleted transition', 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' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
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({
|
||||
from: 'in_progress',
|
||||
to: 'deleted',
|
||||
actor: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('restoreTask appends pending transition', async () => {
|
||||
hoisted.files.set(
|
||||
taskPath,
|
||||
JSON.stringify({
|
||||
id: '12',
|
||||
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' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await writer.restoreTask('my-team', '12', 'user');
|
||||
|
||||
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
||||
expect(persisted.status).toBe('pending');
|
||||
expect(persisted.statusHistory).toHaveLength(3);
|
||||
expect(persisted.statusHistory[2]).toMatchObject({
|
||||
from: 'deleted',
|
||||
to: 'pending',
|
||||
actor: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('restoreTask defaults actor to user when not provided', async () => {
|
||||
hoisted.files.set(
|
||||
taskPath,
|
||||
JSON.stringify({
|
||||
id: '12',
|
||||
subject: 'task',
|
||||
status: 'deleted',
|
||||
deletedAt: '2024-01-01T00:02:00.000Z',
|
||||
})
|
||||
);
|
||||
|
||||
await writer.restoreTask('my-team', '12');
|
||||
|
||||
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
||||
expect(persisted.statusHistory).toHaveLength(1);
|
||||
expect(persisted.statusHistory[0]).toMatchObject({
|
||||
from: 'deleted',
|
||||
to: 'pending',
|
||||
actor: 'user',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue