diff --git a/resources/pricing.json b/resources/pricing.json index 61373380..78856312 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -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, diff --git a/src/main/index.ts b/src/main/index.ts index 68edb1d0..69a4f8d3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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). diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 9fab522a..13be0714 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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; diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 72e491a1..76dc58c0 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -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 '); - 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 '); - 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 '); - 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; } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index e900e050..fea162c5 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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 { - await this.taskWriter.updateStatus(teamName, taskId, status); + async updateTaskStatus( + teamName: string, + taskId: string, + status: TeamTaskStatus, + actor?: string + ): Promise { + await this.taskWriter.updateStatus(teamName, taskId, status, actor); } async softDeleteTask(teamName: string, taskId: string): Promise { - await this.taskWriter.softDelete(teamName, taskId); + await this.taskWriter.softDelete(teamName, taskId, 'user'); } async restoreTask(teamName: string, taskId: string): Promise { - await this.taskWriter.restoreTask(teamName, taskId); + await this.taskWriter.restoreTask(teamName, taskId, 'user'); } async getDeletedTasks(teamName: string): Promise { @@ -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 "" --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 }) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 896e5aa9..c2f8d23d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1959,7 +1959,13 @@ export class TeamProvisioningService { content: contentBlocks, }, }); - run.child.stdin.write(payload + '\n'); + const stdin = run.child.stdin; + await new Promise((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}`) + ); } /** diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index f463885d..9cf4eee9 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -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` 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).from === null || + typeof (e as Record).from === 'string') && + typeof (e as Record).to === 'string' && + typeof (e as Record).timestamp === 'string' && + ((e as Record).actor === undefined || + typeof (e as Record).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) diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index 8dee48d6..757471fa 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -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>(); @@ -27,6 +33,18 @@ async function withTaskLock(taskPath: string, fn: () => Promise): 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 { 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 { + async updateStatus( + teamName: string, + taskId: string, + status: TeamTaskStatus, + actor?: string + ): Promise { 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 { + async softDelete(teamName: string, taskId: string, actor?: string): Promise { 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 { + async restoreTask(teamName: string, taskId: string, actor?: string): Promise { 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)); diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index 57ec1c67..1853b1ab 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -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) diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index f036ad29..2ac57f73 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -57,15 +57,11 @@ export const CollapsibleTeamSection = ({ }, [handleNavigate]); return ( -
-
+
+
{action &&
{action}
}
- {isOpen &&
{children}
} + {isOpen &&
{children}
}
); }; diff --git a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx new file mode 100644 index 00000000..10632745 --- /dev/null +++ b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx @@ -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 ( +
+ No status history recorded +
+ ); + } + + return ( +
+ {history.map((transition, idx) => { + const isLast = idx === history.length - 1; + const time = formatTime(transition.timestamp); + const isCreation = transition.from === null; + + return ( +
+ {/* Timeline line + dot */} +
+
+ {!isLast &&
} +
+ + {/* Content */} + + +
+ + {time} + + {isCreation ? ( + + + Created as + + + ) : ( + + + + + + )} + {transition.actor ? ( + + by {transition.actor} + + ) : null} +
+
+ + {new Date(transition.timestamp).toLocaleString()} + +
+
+ ); + })} +
+ ); +}; + +const StatusBadge = ({ status }: { status: TeamTaskStatus }) => { + const style = TASK_STATUS_STYLES[status] ?? TASK_STATUS_STYLES.pending; + const label = TASK_STATUS_LABELS[status] ?? status; + return ( + + {label} + + ); +}; + +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 '??:??'; + } +} diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index ee9a9599..4c7c7e32 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -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 = ({ {/* Metadata */} -
+
{canReassign ? (