feat(FN-2512): add elapsed timer chip to task cards
- Derive task elapsed time from columnMovedAt with updatedAt/createdAt fallbacks and guard against invalid or future timestamps - Render a clock-based timer chip on in-progress and done cards with accessible labeling and tooltip metadata - Add TaskCard styles for the timer row/chip using design tokens, including mobile-size adjustments - Expand TaskCard tests to cover visibility by column, invalid timestamp suppression, boundary label formatting, and 30s live refresh cadence
This commit is contained in:
5
.changeset/fix-dashboard-tui-agent-run-logs.md
Normal file
5
.changeset/fix-dashboard-tui-agent-run-logs.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix dashboard TUI agents view run history rendering to use readable status labels and allow opening selected run logs reliably.
|
||||||
@@ -115,6 +115,15 @@ afterEach(() => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function waitForFrameContains(lastFrame: () => string | undefined, text: string, timeoutMs = 1200) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
if ((lastFrame() ?? "").includes(text)) return;
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for frame to include: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
describe("DashboardApp smoke", () => {
|
describe("DashboardApp smoke", () => {
|
||||||
it("renders the splash logo and tagline before systemInfo arrives", () => {
|
it("renders the splash logo and tagline before systemInfo arrives", () => {
|
||||||
const controller = newController();
|
const controller = newController();
|
||||||
@@ -255,6 +264,45 @@ describe("Agents view", () => {
|
|||||||
expect(lastFrame() ?? "").toContain("Agent Detail");
|
expect(lastFrame() ?? "").toContain("Agent Detail");
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows run history with readable status labels and opens run logs for the selected run", async () => {
|
||||||
|
const controller = newController();
|
||||||
|
controller.setSystemInfo(makeSystemInfo());
|
||||||
|
|
||||||
|
const agents: AgentItem[] = [
|
||||||
|
{ id: "a1", name: "worker-1", state: "active", role: "executor" },
|
||||||
|
];
|
||||||
|
const detail: AgentDetailItem = {
|
||||||
|
id: "a1",
|
||||||
|
name: "worker-1",
|
||||||
|
state: "active",
|
||||||
|
role: "executor",
|
||||||
|
capabilities: ["executor"],
|
||||||
|
recentRuns: [
|
||||||
|
{
|
||||||
|
id: "run-1",
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
status: "completed",
|
||||||
|
stdoutExcerpt: "plan generated",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.setInteractiveData(makeInteractiveData({ agents, detail }));
|
||||||
|
controller.setMode("interactive");
|
||||||
|
controller.setInteractiveView("agents");
|
||||||
|
|
||||||
|
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||||
|
await waitForFrameContains(lastFrame, "Run history (latest first):");
|
||||||
|
await waitForFrameContains(lastFrame, "Completed");
|
||||||
|
|
||||||
|
stdin.write("\r");
|
||||||
|
await waitForFrameContains(lastFrame, "Run logs (1)");
|
||||||
|
await waitForFrameContains(lastFrame, "plan generated");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Settings view", () => {
|
describe("Settings view", () => {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import type {
|
|||||||
TaskItem,
|
TaskItem,
|
||||||
AgentItem,
|
AgentItem,
|
||||||
AgentDetailItem,
|
AgentDetailItem,
|
||||||
|
AgentRunItem,
|
||||||
ModelItem,
|
ModelItem,
|
||||||
SettingsValues,
|
SettingsValues,
|
||||||
InteractiveView,
|
InteractiveView,
|
||||||
@@ -371,7 +372,7 @@ function StatsPanel({ state, isFocused }: { state: DashboardState; isFocused: bo
|
|||||||
</StatRow>
|
</StatRow>
|
||||||
|
|
||||||
<SectionHeader title="System" />
|
<SectionHeader title="System" />
|
||||||
<StatRow label="Memory">
|
<StatRow label="MEM">
|
||||||
<Text color={sysMemColor(sys.systemTotalMem - sys.systemFreeMem, sys.systemTotalMem)}>
|
<Text color={sysMemColor(sys.systemTotalMem - sys.systemFreeMem, sys.systemTotalMem)}>
|
||||||
{formatBytes(sys.systemTotalMem - sys.systemFreeMem)}
|
{formatBytes(sys.systemTotalMem - sys.systemFreeMem)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1712,6 +1713,58 @@ function heartbeatFreshness(lastHeartbeatAt?: string): { fresh: boolean; label:
|
|||||||
|
|
||||||
type AgentSubView = "list" | "confirm-delete";
|
type AgentSubView = "list" | "confirm-delete";
|
||||||
|
|
||||||
|
function formatRunStatusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return "Completed";
|
||||||
|
case "failed":
|
||||||
|
return "Failed";
|
||||||
|
case "terminated":
|
||||||
|
return "Terminated";
|
||||||
|
case "active":
|
||||||
|
return "Active";
|
||||||
|
default:
|
||||||
|
return status.length > 0 ? `${status[0]!.toUpperCase()}${status.slice(1)}` : "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runStatusColor(status: string): "green" | "red" | "yellow" | "cyanBright" | "gray" {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return "green";
|
||||||
|
case "failed":
|
||||||
|
return "red";
|
||||||
|
case "terminated":
|
||||||
|
return "yellow";
|
||||||
|
case "active":
|
||||||
|
return "cyanBright";
|
||||||
|
default:
|
||||||
|
return "gray";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRunLogLines(run: AgentRunItem): string[] {
|
||||||
|
if (Array.isArray(run.logs) && run.logs.length > 0) return run.logs;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (run.triggerDetail) lines.push(`trigger: ${run.triggerDetail}`);
|
||||||
|
if (run.invocationSource) lines.push(`source: ${run.invocationSource}`);
|
||||||
|
if (run.stdoutExcerpt) {
|
||||||
|
lines.push("stdout:");
|
||||||
|
lines.push(...run.stdoutExcerpt.split(/\r?\n/).filter((line) => line.length > 0));
|
||||||
|
}
|
||||||
|
if (run.stderrExcerpt) {
|
||||||
|
lines.push("stderr:");
|
||||||
|
lines.push(...run.stderrExcerpt.split(/\r?\n/).filter((line) => line.length > 0));
|
||||||
|
}
|
||||||
|
if (run.resultJson) {
|
||||||
|
lines.push("result:");
|
||||||
|
lines.push(JSON.stringify(run.resultJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.length > 0 ? lines : ["No logs captured for this run."];
|
||||||
|
}
|
||||||
|
|
||||||
function AgentsView({ state }: { state: DashboardState }) {
|
function AgentsView({ state }: { state: DashboardState }) {
|
||||||
const { stdout } = useStdout();
|
const { stdout } = useStdout();
|
||||||
const cols = stdout?.columns ?? 80;
|
const cols = stdout?.columns ?? 80;
|
||||||
@@ -1724,6 +1777,8 @@ function AgentsView({ state }: { state: DashboardState }) {
|
|||||||
const [subView, setSubView] = useState<AgentSubView>("list");
|
const [subView, setSubView] = useState<AgentSubView>("list");
|
||||||
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
||||||
const [detailFocused, setDetailFocused] = useState(false);
|
const [detailFocused, setDetailFocused] = useState(false);
|
||||||
|
const [selectedRunIndex, setSelectedRunIndex] = useState(0);
|
||||||
|
const [showRunLogs, setShowRunLogs] = useState(false);
|
||||||
|
|
||||||
const data = state.interactiveData;
|
const data = state.interactiveData;
|
||||||
|
|
||||||
@@ -1733,6 +1788,8 @@ function AgentsView({ state }: { state: DashboardState }) {
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const selectedAgent = agents[selectedIndex] ?? null;
|
const selectedAgent = agents[selectedIndex] ?? null;
|
||||||
|
const recentRuns = detail?.recentRuns ?? [];
|
||||||
|
const selectedRun = recentRuns[selectedRunIndex] ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || !selectedAgent) {
|
if (!data || !selectedAgent) {
|
||||||
@@ -1749,6 +1806,15 @@ function AgentsView({ state }: { state: DashboardState }) {
|
|||||||
});
|
});
|
||||||
}, [data, selectedAgent?.id]);
|
}, [data, selectedAgent?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedRunIndex(0);
|
||||||
|
setShowRunLogs(false);
|
||||||
|
}, [selectedAgent?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedRunIndex((i) => Math.min(i, Math.max(0, recentRuns.length - 1)));
|
||||||
|
}, [recentRuns.length]);
|
||||||
|
|
||||||
function refreshDetail() {
|
function refreshDetail() {
|
||||||
if (!data || !selectedAgent) return;
|
if (!data || !selectedAgent) return;
|
||||||
setLoadingDetail(true);
|
setLoadingDetail(true);
|
||||||
@@ -1801,6 +1867,12 @@ function AgentsView({ state }: { state: DashboardState }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((key.return || input === "l") && selectedRun) {
|
||||||
|
setDetailFocused(true);
|
||||||
|
setShowRunLogs(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!detailFocused) {
|
if (!detailFocused) {
|
||||||
if (key.upArrow || input === "k") {
|
if (key.upArrow || input === "k") {
|
||||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||||
@@ -1810,6 +1882,19 @@ function AgentsView({ state }: { state: DashboardState }) {
|
|||||||
setSelectedIndex((i) => Math.min(agents.length - 1, i + 1));
|
setSelectedIndex((i) => Math.min(agents.length - 1, i + 1));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (showRunLogs && (key.escape || input === "q" || key.backspace)) {
|
||||||
|
setShowRunLogs(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!showRunLogs && (key.upArrow || input === "k")) {
|
||||||
|
setSelectedRunIndex((i) => Math.max(0, i - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!showRunLogs && (key.downArrow || input === "j")) {
|
||||||
|
setSelectedRunIndex((i) => Math.min(recentRuns.length - 1, i + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input === "s") {
|
if (input === "s") {
|
||||||
@@ -1964,19 +2049,36 @@ function AgentsView({ state }: { state: DashboardState }) {
|
|||||||
<Text>{detail.capabilities.join(", ")}</Text>
|
<Text>{detail.capabilities.join(", ")}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{detail.recentRuns.length > 0 && (
|
{recentRuns.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Box height={1} />
|
<Box height={1} />
|
||||||
<Text dimColor>Recent runs (latest first):</Text>
|
{showRunLogs && selectedRun ? (
|
||||||
{detail.recentRuns.slice(0, 5).map((run) => (
|
<>
|
||||||
<Box key={run.id} flexDirection="row" gap={1} marginLeft={1}>
|
<Text dimColor>Run logs ({selectedRunIndex + 1})</Text>
|
||||||
<Text color={run.status === "completed" ? "green" : run.status === "failed" ? "red" : "yellow"}>
|
<Text dimColor>ID: {selectedRun.id}</Text>
|
||||||
{run.status.slice(0, 4)}
|
<Box height={1} />
|
||||||
</Text>
|
{getRunLogLines(selectedRun).slice(0, 10).map((line, i) => (
|
||||||
<Text dimColor>{run.startedAt.slice(11, 19)}</Text>
|
<Text key={`${selectedRun.id}-log-${i}`} wrap="truncate-end">{line}</Text>
|
||||||
{run.triggerDetail && <Text dimColor>{run.triggerDetail}</Text>}
|
))}
|
||||||
</Box>
|
<Box height={1} />
|
||||||
))}
|
<Text dimColor>[Esc/q] back to runs</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text dimColor>Run history (latest first):</Text>
|
||||||
|
{recentRuns.slice(0, 5).map((run, i) => (
|
||||||
|
<Box key={run.id} flexDirection="row" gap={1} marginLeft={1}>
|
||||||
|
<Text color={detailFocused && i === selectedRunIndex ? "white" : "gray"}>
|
||||||
|
{detailFocused && i === selectedRunIndex ? "▶" : " "}
|
||||||
|
</Text>
|
||||||
|
<Text color={runStatusColor(run.status)}>{formatRunStatusLabel(run.status)}</Text>
|
||||||
|
<Text dimColor>{run.startedAt.slice(11, 19)}</Text>
|
||||||
|
{run.triggerDetail && <Text dimColor wrap="truncate-end">{run.triggerDetail}</Text>}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<Text dimColor>[Enter] open logs</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ export interface AgentRunItem {
|
|||||||
endedAt: string | null;
|
endedAt: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
triggerDetail?: string;
|
triggerDetail?: string;
|
||||||
|
invocationSource?: string;
|
||||||
|
stdoutExcerpt?: string;
|
||||||
|
stderrExcerpt?: string;
|
||||||
|
resultJson?: Record<string, unknown>;
|
||||||
|
// Optional synthetic log lines for tests / alternate data providers.
|
||||||
|
logs?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slim agent detail shape for Agents view detail panel
|
// Slim agent detail shape for Agents view detail panel
|
||||||
|
|||||||
@@ -1904,6 +1904,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
endedAt: r.endedAt,
|
endedAt: r.endedAt,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
triggerDetail: r.triggerDetail,
|
triggerDetail: r.triggerDetail,
|
||||||
|
invocationSource: r.invocationSource,
|
||||||
|
stdoutExcerpt: r.stdoutExcerpt,
|
||||||
|
stderrExcerpt: r.stderrExcerpt,
|
||||||
|
resultJson: r.resultJson,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -444,6 +444,28 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-time-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-time-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--text-muted) 30%, transparent);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.card-progress-bar {
|
.card-progress-bar {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
@@ -922,6 +944,15 @@
|
|||||||
padding: var(--space-xs) 0;
|
padding: var(--space-xs) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-time-row {
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-time-indicator {
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Card: smaller status badges for 280px width */
|
/* Card: smaller status badges for 280px width */
|
||||||
.card-status-badge {
|
.card-status-badge {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
|
|||||||
@@ -91,6 +91,40 @@ const COLUMN_PROGRESS_COLOR_MAP: Record<Column, string> = {
|
|||||||
archived: "var(--text-muted)",
|
archived: "var(--text-muted)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TIME_INDICATOR_COLUMNS = new Set<Column>(["in-progress", "done"]);
|
||||||
|
const LIVE_TIME_INDICATOR_POLL_MS = 30_000;
|
||||||
|
|
||||||
|
function parseTimestampToMs(value?: string): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeIndicatorStartMs(task: Task): number | null {
|
||||||
|
const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt;
|
||||||
|
const parsed = parseTimestampToMs(timestamp);
|
||||||
|
if (parsed == null) return null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (parsed > now) return null;
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsedDuration(elapsedMs: number): string {
|
||||||
|
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return "";
|
||||||
|
|
||||||
|
const elapsedMinutes = Math.floor(elapsedMs / 60_000);
|
||||||
|
if (elapsedMinutes < 1) return "<1m";
|
||||||
|
if (elapsedMinutes < 60) return `${elapsedMinutes}m`;
|
||||||
|
|
||||||
|
const elapsedHours = Math.floor(elapsedMinutes / 60);
|
||||||
|
if (elapsedHours < 24) return `${elapsedHours}h`;
|
||||||
|
|
||||||
|
const elapsedDays = Math.floor(elapsedHours / 24);
|
||||||
|
return `${elapsedDays}d`;
|
||||||
|
}
|
||||||
|
|
||||||
interface TaskCardProps {
|
interface TaskCardProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -318,6 +352,7 @@ function TaskCardComponent({
|
|||||||
const [missionTitle, setMissionTitle] = useState<string | null>(null);
|
const [missionTitle, setMissionTitle] = useState<string | null>(null);
|
||||||
const [agentName, setAgentName] = useState<string | null>(null);
|
const [agentName, setAgentName] = useState<string | null>(null);
|
||||||
const [showSendBackMenu, setShowSendBackMenu] = useState(false);
|
const [showSendBackMenu, setShowSendBackMenu] = useState(false);
|
||||||
|
const [timeIndicatorNowMs, setTimeIndicatorNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const touchOpenHandledRef = useRef(false);
|
const touchOpenHandledRef = useRef(false);
|
||||||
@@ -564,6 +599,46 @@ function TaskCardComponent({
|
|||||||
const showProgressSection =
|
const showProgressSection =
|
||||||
unifiedProgress.total > 0 && (task.status === "executing" || task.column === "in-progress");
|
unifiedProgress.total > 0 && (task.status === "executing" || task.column === "in-progress");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (task.column !== "in-progress") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMs = getTimeIndicatorStartMs(task);
|
||||||
|
if (startMs == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeIndicatorNowMs(Date.now());
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
setTimeIndicatorNowMs(Date.now());
|
||||||
|
}, LIVE_TIME_INDICATOR_POLL_MS);
|
||||||
|
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [task.column, task.columnMovedAt, task.updatedAt, task.createdAt]);
|
||||||
|
|
||||||
|
const timeIndicator = useMemo(() => {
|
||||||
|
if (!TIME_INDICATOR_COLUMNS.has(task.column)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMs = getTimeIndicatorStartMs(task);
|
||||||
|
if (startMs == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceNowMs = task.column === "in-progress" ? timeIndicatorNowMs : Date.now();
|
||||||
|
const elapsedLabel = formatElapsedDuration(referenceNowMs - startMs);
|
||||||
|
if (!elapsedLabel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: elapsedLabel,
|
||||||
|
title: `Since ${new Date(startMs).toLocaleString()}`,
|
||||||
|
};
|
||||||
|
}, [task.column, task.columnMovedAt, task.updatedAt, task.createdAt, timeIndicatorNowMs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasGitHubBadge || !isInViewport) {
|
if (!hasGitHubBadge || !isInViewport) {
|
||||||
unsubscribeFromBadge(task.id);
|
unsubscribeFromBadge(task.id);
|
||||||
@@ -1162,6 +1237,18 @@ function TaskCardComponent({
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})()}
|
})()}
|
||||||
|
{timeIndicator && (
|
||||||
|
<div className="card-time-row">
|
||||||
|
<span
|
||||||
|
className="card-time-indicator"
|
||||||
|
title={timeIndicator.title}
|
||||||
|
aria-label={`Elapsed time ${timeIndicator.label}. ${timeIndicator.title}`}
|
||||||
|
>
|
||||||
|
<Clock size={12} />
|
||||||
|
<span>{timeIndicator.label}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
|
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
|
||||||
<div className="card-meta">
|
<div className="card-meta">
|
||||||
{task.dependencies && task.dependencies.length > 0 && (
|
{task.dependencies && task.dependencies.length > 0 && (
|
||||||
|
|||||||
@@ -823,6 +823,7 @@ describe("AgentDetailView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows loading state while fetching chain of command", async () => {
|
it("shows loading state while fetching chain of command", async () => {
|
||||||
|
const resolvedChain = [{ id: "agent-001", name: "Test Agent" } as AgentDetail];
|
||||||
const resolveChainCalls: Array<(agents: AgentDetail[]) => void> = [];
|
const resolveChainCalls: Array<(agents: AgentDetail[]) => void> = [];
|
||||||
mockFetchChainOfCommand.mockImplementation(
|
mockFetchChainOfCommand.mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
@@ -843,9 +844,17 @@ describe("AgentDetailView", () => {
|
|||||||
expect(screen.getByText("Loading reporting chain...")).toBeInTheDocument();
|
expect(screen.getByText("Loading reporting chain...")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// React Strict Mode and concurrent rendering can trigger extra effect passes.
|
||||||
|
// Make late calls auto-resolve so the loading state can settle deterministically.
|
||||||
|
mockFetchChainOfCommand.mockResolvedValue(resolvedChain as any);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
for (const resolve of resolveChainCalls) {
|
// Allow any additional in-flight calls to register before resolving all pendings.
|
||||||
resolve([{ id: "agent-001", name: "Test Agent" } as AgentDetail]);
|
await Promise.resolve();
|
||||||
|
while (resolveChainCalls.length > 0) {
|
||||||
|
const resolve = resolveChainCalls.shift();
|
||||||
|
resolve?.(resolvedChain);
|
||||||
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||||
import { TaskCard } from "../TaskCard";
|
import { TaskCard } from "../TaskCard";
|
||||||
import type { Task } from "@fusion/core";
|
import type { Task } from "@fusion/core";
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ vi.mock("lucide-react", () => ({
|
|||||||
CircleDot: () => null,
|
CircleDot: () => null,
|
||||||
Target: () => null,
|
Target: () => null,
|
||||||
Bot: () => null,
|
Bot: () => null,
|
||||||
|
Trash2: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the api module
|
// Mock the api module
|
||||||
@@ -42,6 +43,10 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
|||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
describe("TaskCard", () => {
|
describe("TaskCard", () => {
|
||||||
it("renders the card ID text", () => {
|
it("renders the card ID text", () => {
|
||||||
render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
|
render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
|
||||||
@@ -437,6 +442,147 @@ describe("TaskCard", () => {
|
|||||||
expect(archiveBtn).not.toBeNull();
|
expect(archiveBtn).not.toBeNull();
|
||||||
expect(actionsContainer?.contains(archiveBtn)).toBe(true);
|
expect(actionsContainer?.contains(archiveBtn)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows timer chip for in-progress cards when timestamp fields exist", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-04-25T12:30:00.000Z"));
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({
|
||||||
|
column: "in-progress",
|
||||||
|
columnMovedAt: "2026-04-25T12:18:00.000Z",
|
||||||
|
updatedAt: "2026-04-25T12:10:00.000Z",
|
||||||
|
createdAt: "2026-04-25T12:00:00.000Z",
|
||||||
|
})}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const timer = container.querySelector(".card-time-indicator");
|
||||||
|
expect(timer).not.toBeNull();
|
||||||
|
expect(timer?.textContent).toContain("12m");
|
||||||
|
expect(timer?.getAttribute("title")).toContain("Since");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows timer chip for done cards when timestamp fields exist", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({
|
||||||
|
column: "done",
|
||||||
|
columnMovedAt: "2026-04-25T15:00:00.000Z",
|
||||||
|
updatedAt: "2026-04-25T14:00:00.000Z",
|
||||||
|
createdAt: "2026-04-25T13:00:00.000Z",
|
||||||
|
})}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const timer = container.querySelector(".card-time-indicator");
|
||||||
|
expect(timer).not.toBeNull();
|
||||||
|
expect(timer?.textContent).toContain("3h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["triage", "todo", "in-review", "archived"] as const)(
|
||||||
|
"does not render timer chip for %s cards",
|
||||||
|
(column) => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({
|
||||||
|
column,
|
||||||
|
columnMovedAt: "2026-04-25T15:00:00.000Z",
|
||||||
|
updatedAt: "2026-04-25T14:00:00.000Z",
|
||||||
|
createdAt: "2026-04-25T13:00:00.000Z",
|
||||||
|
})}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector(".card-time-indicator")).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("suppresses timer chip when all timestamp fallbacks are invalid or missing", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-04-25T18:00:00.000Z"));
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({
|
||||||
|
column: "in-progress",
|
||||||
|
columnMovedAt: "not-a-date",
|
||||||
|
updatedAt: "also-not-a-date",
|
||||||
|
createdAt: undefined as unknown as string,
|
||||||
|
})}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector(".card-time-indicator")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ elapsedMs: 59_000, expected: "<1m" },
|
||||||
|
{ elapsedMs: 60 * 60_000, expected: "1h" },
|
||||||
|
{ elapsedMs: 24 * 60 * 60_000, expected: "1d" },
|
||||||
|
])("formats elapsed timer label as $expected at boundary", ({ elapsedMs, expected }) => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const now = new Date("2026-04-25T20:00:00.000Z");
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({
|
||||||
|
column: "done",
|
||||||
|
columnMovedAt: new Date(now.getTime() - elapsedMs).toISOString(),
|
||||||
|
updatedAt: "2026-04-25T10:00:00.000Z",
|
||||||
|
createdAt: "2026-04-25T09:00:00.000Z",
|
||||||
|
})}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const timer = container.querySelector(".card-time-indicator");
|
||||||
|
expect(timer?.textContent).toContain(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes in-progress timer chip on 30s cadence", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-04-25T12:00:30.000Z"));
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({
|
||||||
|
column: "in-progress",
|
||||||
|
columnMovedAt: "2026-04-25T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-04-25T11:59:00.000Z",
|
||||||
|
createdAt: "2026-04-25T11:58:00.000Z",
|
||||||
|
})}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const timer = container.querySelector(".card-time-indicator");
|
||||||
|
expect(timer?.textContent).toContain("<1m");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("1m");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TaskCard mission badge", () => {
|
describe("TaskCard mission badge", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user