blob: db064de2b301cf729584818cf6be84f06b8d6cd0 [file]
import { useMemo, type CSSProperties, type ReactNode } from 'react';
import { Collapsible, IconButton } from '@astryxdesign/core';
import type { Task, TaskStatus } from '@maka/core';
import {
AlertCircle,
Ban,
CheckCircle2,
CircleGauge,
Clock,
RefreshCcw,
X,
} from './icons.js';
import { useUiLocale } from './locale-context.js';
import { getSharedUiCopy, type SharedUiCopy } from './shared-ui-copy.js';
const STATUS_ICONS = {
pending: Clock,
in_progress: CircleGauge,
blocked: AlertCircle,
completed: CheckCircle2,
failed: X,
cancelled: Ban,
} satisfies Record<TaskStatus, typeof Clock>;
export interface TaskLedgerPanelProps {
tasks: readonly Task[];
loading?: boolean;
error?: string;
onRetry?: () => void;
}
export interface TaskLedgerPanelModel {
activeCount: number;
activeTree: Task[];
recentTerminalCount: number;
recentTerminalTree: Task[];
}
export function deriveTaskLedgerPanelModel(tasks: readonly Task[]): TaskLedgerPanelModel {
const activeSeeds = tasks.filter((task) => !isTerminal(task.status));
const recentTerminalSeeds = tasks
.filter((task) => isTerminal(task.status))
.sort((a, b) => (b.endedAt ?? b.updatedAt) - (a.endedAt ?? a.updatedAt))
.slice(0, 3);
return {
activeCount: activeSeeds.length,
activeTree: orderTaskTree(withAncestors(tasks, activeSeeds)),
recentTerminalCount: recentTerminalSeeds.length,
recentTerminalTree: orderTaskTree(withAncestors(tasks, recentTerminalSeeds)),
};
}
export function TaskLedgerPanel(props: TaskLedgerPanelProps) {
const copy = getSharedUiCopy(useUiLocale()).taskLedger;
const model = useMemo(() => deriveTaskLedgerPanelModel(props.tasks), [props.tasks]);
return (
<section className="maka-task-ledger-panel" aria-label={copy.ariaLabel}>
{props.error ? (
<div className="maka-task-ledger-message" role="alert">
<span>{props.error}</span>
{props.onRetry && (
<IconButton
variant="ghost"
size="sm"
className="maka-task-ledger-retry"
onClick={props.onRetry}
label={copy.retry}
tooltip={copy.retry}
icon={<RefreshCcw size={14} aria-hidden="true" />}
/>
)}
</div>
) : props.loading && props.tasks.length === 0 ? (
<div className="maka-task-ledger-message" role="status">{copy.loading}</div>
) : (
<>
{model.activeCount > 0 ? (
<div className="maka-task-ledger-tree" role="tree" aria-label={copy.activeAriaLabel}>
<TaskLedgerTree tasks={model.activeTree} copy={copy} />
</div>
) : (
<p className="maka-task-ledger-empty">{copy.empty}</p>
)}
{model.recentTerminalCount > 0 && (
<Collapsible
className="maka-task-ledger-terminal"
defaultIsOpen={false}
trigger={(
<span className="maka-task-ledger-terminal-trigger">
<span>{copy.recent}</span>
<span>{model.recentTerminalCount}</span>
</span>
)}
>
<div className="maka-task-ledger-tree" role="tree" aria-label={copy.recentAriaLabel}>
<TaskLedgerTree tasks={model.recentTerminalTree} copy={copy} />
</div>
</Collapsible>
)}
</>
)}
</section>
);
}
function TaskLedgerTree({ tasks, copy }: { tasks: readonly Task[]; copy: SharedUiCopy['taskLedger'] }) {
const included = new Set(tasks.map((task) => task.id));
const childrenByParent = new Map<string | undefined, Task[]>();
for (const task of tasks) {
const parent = task.parentId && included.has(task.parentId) ? task.parentId : undefined;
const siblings = childrenByParent.get(parent) ?? [];
siblings.push(task);
childrenByParent.set(parent, siblings);
}
const renderLevel = (parentId: string | undefined, level: number): ReactNode => {
const siblings = childrenByParent.get(parentId) ?? [];
return siblings.map((task, index) => (
<TaskLedgerRow
key={task.id}
task={task}
copy={copy}
level={level}
position={index + 1}
setSize={siblings.length}
>
{childrenByParent.has(task.id) ? (
<div role="group" className="maka-task-ledger-group">
{renderLevel(task.id, level + 1)}
</div>
) : null}
</TaskLedgerRow>
));
};
return renderLevel(undefined, 1);
}
function TaskLedgerRow({ task, copy, level, position, setSize, children }: {
task: Task;
copy: SharedUiCopy['taskLedger'];
level: number;
position: number;
setSize: number;
children?: ReactNode;
}) {
const StatusIcon = STATUS_ICONS[task.status];
const depth = level - 1;
const detail = task.blockedReason ?? task.failureReason ?? task.completionEvidence;
const owner = task.owner?.actor === 'child_agent'
? copy.childAgent(task.owner.agentId)
: task.owner?.actor === 'main_agent' ? copy.mainAgent : undefined;
return (
<div
className="maka-task-ledger-row"
role="treeitem"
aria-level={level}
aria-posinset={position}
aria-setsize={setSize}
data-status={task.status}
style={{ '--task-depth': Math.min(depth, 6) } as CSSProperties}
>
<StatusIcon size={14} aria-hidden="true" />
<code className="maka-task-ledger-key">{task.key}</code>
<span className="maka-task-ledger-subject" title={task.subject}>{task.subject}</span>
<span className="maka-task-ledger-meta">
<span>{copy.status[task.status]}</span>
{owner && <span title={owner}>{owner}</span>}
</span>
{detail && <span className="maka-task-ledger-detail" title={detail}>{detail}</span>}
{children}
</div>
);
}
function isTerminal(status: TaskStatus): boolean {
return status === 'completed' || status === 'failed' || status === 'cancelled';
}
function orderTaskTree(tasks: readonly Task[]): Task[] {
return [...tasks].sort((a, b) => compareKeys(a.key, b.key));
}
function withAncestors(allTasks: readonly Task[], seeds: readonly Task[]): Task[] {
const byId = new Map(allTasks.map((task) => [task.id, task]));
const selected = new Map<string, Task>();
for (const seed of seeds) {
let current: Task | undefined = seed;
const seen = new Set<string>();
while (current && !seen.has(current.id)) {
seen.add(current.id);
selected.set(current.id, current);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
}
return [...selected.values()];
}
function compareKeys(left: string, right: string): number {
const a = left.slice(1).split('.').map(Number);
const b = right.slice(1).split('.').map(Number);
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
if (a[index] === undefined) return -1;
if (b[index] === undefined) return 1;
if (a[index] !== b[index]) return a[index]! - b[index]!;
}
return 0;
}