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:
iliya 2026-03-04 13:47:53 +02:00
parent f654c5f356
commit 887c7406d1
16 changed files with 709 additions and 179 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}`)
);
}
/**

View file

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

View file

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

View file

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

View file

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

View 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 '??:??';
}
}

View file

@ -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,7 +631,12 @@ export const TaskDetailDialog = ({
</CollapsibleTeamSection>
) : null}
<div className="mb-3 space-y-2">
{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">
@ -737,6 +758,19 @@ export const TaskDetailDialog = ({
</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>

View file

@ -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[];
/**

View file

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

View file

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

View file

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