feat(FN-2408): merge fusion/fn-2408
This commit is contained in:
@@ -75,9 +75,8 @@ describe("DashboardApp smoke", () => {
|
||||
const controller = newController();
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
const frame = lastFrame() ?? "";
|
||||
// Block-letter "F" opens with this run on wide terminals; on narrow
|
||||
// terminals the compact layout shows plain "FUSION".
|
||||
expect(frame).toMatch(/███████╗|FUSION/);
|
||||
// Splash can render either the compact text mark or the expanded block-art logo.
|
||||
expect(frame).toMatch(/FUSION|███████╗/);
|
||||
expect(frame).toContain("AI coding agent dashboard");
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -75,9 +75,10 @@ function formatRelativeTime(iso: string): string {
|
||||
return `${h}h ago`;
|
||||
}
|
||||
|
||||
// All-blue vertical gradient — top: brightest white, bottom: deep blue.
|
||||
// Strictly white + blue shades, no cyan/purple.
|
||||
const LOGO_COLORS = ["whiteBright", "white", "blueBright", "blueBright", "blue", "blue", "blue", "blue"] as const;
|
||||
// All-blue vertical gradient — top: brightest white, fading through plain
|
||||
// blue. blueBright is avoided because some terminal themes render it with
|
||||
// a purple cast; we want the gradient to read as strictly white→blue.
|
||||
const LOGO_COLORS = ["whiteBright", "white", "white", "blue", "blue", "blue", "blue", "blue"] as const;
|
||||
type InkColor = typeof LOGO_COLORS[number];
|
||||
|
||||
function logoColor(index: number, total: number): InkColor {
|
||||
@@ -119,15 +120,15 @@ function SplashScreen({ loadingStatus }: { loadingStatus: string }) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
{compact ? (
|
||||
<Text bold color="blueBright">FUSION</Text>
|
||||
<Text bold color="blue">FUSION</Text>
|
||||
) : (
|
||||
<AnimatedFusionLogo lines={large ? FUSION_LOGO_LARGE_LINES : FUSION_LOGO_LINES} />
|
||||
)}
|
||||
<Text color="blueBright" dimColor>{FUSION_TAGLINE}</Text>
|
||||
<Text color="blue" dimColor>{FUSION_TAGLINE}</Text>
|
||||
<Box height={1} />
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color="blueBright"><Spinner type="dots" /></Text>
|
||||
<Text color="blueBright" dimColor>{loadingStatus}</Text>
|
||||
<Text color="blue"><Spinner type="dots" /></Text>
|
||||
<Text color="blue" dimColor>{loadingStatus}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -138,7 +139,7 @@ function SplashScreen({ loadingStatus }: { loadingStatus: string }) {
|
||||
function MiniLogo() {
|
||||
return (
|
||||
<Box flexDirection="row" gap={0}>
|
||||
<Text color="blueBright" bold>FUSION</Text>
|
||||
<Text color="blue" bold>FUSION</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -248,39 +249,159 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b
|
||||
|
||||
// ── Stats panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "—";
|
||||
const mb = bytes / (1024 * 1024);
|
||||
if (mb < 1024) return `${mb.toFixed(0)}MB`;
|
||||
return `${(mb / 1024).toFixed(2)}GB`;
|
||||
}
|
||||
|
||||
function heapColor(used: number, limit: number): "red" | "yellow" | "green" {
|
||||
if (limit <= 0) return "green";
|
||||
const pct = used / limit;
|
||||
if (pct >= 0.85) return "red";
|
||||
if (pct >= 0.65) return "yellow";
|
||||
return "green";
|
||||
}
|
||||
|
||||
function rssColor(rss: number, totalSystemMem: number): "red" | "yellow" | undefined {
|
||||
if (totalSystemMem <= 0) return undefined;
|
||||
const pct = rss / totalSystemMem;
|
||||
if (pct >= 0.5) return "red";
|
||||
if (pct >= 0.25) return "yellow";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sysMemColor(used: number, total: number): "red" | "yellow" | undefined {
|
||||
if (total <= 0) return undefined;
|
||||
const pct = used / total;
|
||||
if (pct >= 0.9) return "red";
|
||||
if (pct >= 0.75) return "yellow";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cpuColor(percent: number, cores: number): "red" | "yellow" | undefined {
|
||||
// Per-core normalized — >100% means oversubscribed.
|
||||
const norm = cores > 0 ? percent / cores : percent;
|
||||
if (norm >= 80) return "red";
|
||||
if (norm >= 50) return "yellow";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function StatRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
// Fixed-width label column produces a clean two-column layout.
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={0}>
|
||||
<Box width={11}>
|
||||
<Text dimColor>{label}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row" gap={1}>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsPanel({ state, isFocused }: { state: DashboardState; isFocused: boolean }) {
|
||||
const stats = state.taskStats;
|
||||
const sys = state.systemStats;
|
||||
return (
|
||||
<Panel title="Stats" isFocused={isFocused} flexGrow={1}>
|
||||
{!stats ? (
|
||||
<Text dimColor>Statistics not available.</Text>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text dimColor>Total:</Text>
|
||||
<Text>{stats.total}</Text>
|
||||
</Box>
|
||||
{Object.entries(stats.byColumn).map(([col, count]) => {
|
||||
const name = col.replace(/-/g, " ");
|
||||
const isActive = (col === "in-progress" || col === "in-review") && count > 0;
|
||||
return (
|
||||
<Box key={col} flexDirection="row" gap={1} marginLeft={1}>
|
||||
<Text dimColor>{name}:</Text>
|
||||
<Text color={isActive ? "green" : undefined}>{count}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box height={1} />
|
||||
<Text dimColor>Agents:</Text>
|
||||
<Box marginLeft={1} flexDirection="column">
|
||||
<Text dimColor>idle: <Text color="white">{stats.agents.idle}</Text></Text>
|
||||
<Text dimColor>active: <Text color="green">{stats.agents.active}</Text></Text>
|
||||
<Text color={stats.agents.error > 0 ? "red" : undefined} dimColor={stats.agents.error === 0}>
|
||||
error: {stats.agents.error}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexDirection="column">
|
||||
{sys && (
|
||||
<>
|
||||
<Text bold>Process</Text>
|
||||
<Box marginLeft={1} flexDirection="column" marginTop={0}>
|
||||
<StatRow label="RSS">
|
||||
<Text color={rssColor(sys.rss, sys.systemTotalMem)}>
|
||||
{formatBytes(sys.rss)}
|
||||
</Text>
|
||||
{sys.systemTotalMem > 0 && (
|
||||
<Text dimColor>
|
||||
({((sys.rss / sys.systemTotalMem) * 100).toFixed(1)}%)
|
||||
</Text>
|
||||
)}
|
||||
</StatRow>
|
||||
<StatRow label="Heap">
|
||||
<Text color={heapColor(sys.heapUsed, sys.heapLimit)}>
|
||||
{formatBytes(sys.heapUsed)}
|
||||
</Text>
|
||||
<Text dimColor>/ {formatBytes(sys.heapTotal)}</Text>
|
||||
<Text dimColor>· limit {formatBytes(sys.heapLimit)}</Text>
|
||||
</StatRow>
|
||||
<StatRow label="External">
|
||||
<Text>{formatBytes(sys.external)}</Text>
|
||||
<Text dimColor>· buffers {formatBytes(sys.arrayBuffers)}</Text>
|
||||
</StatRow>
|
||||
<StatRow label="CPU">
|
||||
<Text color={cpuColor(sys.cpuPercent, sys.cpuCount)}>
|
||||
{sys.cpuPercent.toFixed(1)}%
|
||||
</Text>
|
||||
<Text dimColor>· load {sys.loadAvg.map((n) => n.toFixed(2)).join(" ")}</Text>
|
||||
</StatRow>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
<Text bold>System</Text>
|
||||
<Box marginLeft={1} flexDirection="column">
|
||||
<StatRow label="Memory">
|
||||
<Text color={sysMemColor(sys.systemTotalMem - sys.systemFreeMem, sys.systemTotalMem)}>
|
||||
{formatBytes(sys.systemTotalMem - sys.systemFreeMem)}
|
||||
</Text>
|
||||
<Text dimColor>used ·</Text>
|
||||
<Text>{formatBytes(sys.systemFreeMem)}</Text>
|
||||
<Text dimColor>free</Text>
|
||||
</StatRow>
|
||||
<StatRow label="Total">
|
||||
<Text>{formatBytes(sys.systemTotalMem)}</Text>
|
||||
</StatRow>
|
||||
<StatRow label="Cores">
|
||||
<Text>{sys.cpuCount}</Text>
|
||||
</StatRow>
|
||||
<StatRow label="Platform">
|
||||
<Text>{sys.platform}</Text>
|
||||
</StatRow>
|
||||
<StatRow label="Node">
|
||||
<Text>{sys.nodeVersion}</Text>
|
||||
</StatRow>
|
||||
<StatRow label="PID">
|
||||
<Text>{sys.pid}</Text>
|
||||
</StatRow>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
</>
|
||||
)}
|
||||
{!stats ? (
|
||||
<Text dimColor>Tasks not available.</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text bold>Tasks</Text>
|
||||
<Box marginLeft={1} flexDirection="column">
|
||||
{Object.entries(stats.byColumn).map(([col, count]) => {
|
||||
const name = col.replace(/-/g, " ");
|
||||
const isActive = (col === "in-progress" || col === "in-review") && count > 0;
|
||||
return (
|
||||
<StatRow key={col} label={name}>
|
||||
<Text color={isActive ? "green" : undefined}>{count}</Text>
|
||||
</StatRow>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
<Text bold>Agents</Text>
|
||||
<Box marginLeft={1} flexDirection="column">
|
||||
<StatRow label="idle">
|
||||
<Text>{stats.agents.idle}</Text>
|
||||
</StatRow>
|
||||
<StatRow label="active">
|
||||
<Text color="green">{stats.agents.active}</Text>
|
||||
</StatRow>
|
||||
<StatRow label="error">
|
||||
<Text color={stats.agents.error > 0 ? "red" : undefined}>
|
||||
{stats.agents.error}
|
||||
</Text>
|
||||
</StatRow>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -570,7 +691,6 @@ function StatusModeGrid({
|
||||
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
<SystemPanel state={state} isFocused={focused === "system"} />
|
||||
<StatsPanel state={state} isFocused={focused === "stats"} />
|
||||
<SettingsPanel state={state} isFocused={focused === "settings"} />
|
||||
</Box>
|
||||
<Box flexDirection="column" flexGrow={2} overflow="hidden">
|
||||
<LogsPanel
|
||||
@@ -578,7 +698,14 @@ function StatusModeGrid({
|
||||
isFocused={focused === "logs"}
|
||||
availableRows={logsAvailableRows}
|
||||
/>
|
||||
<UtilitiesPanel isFocused={focused === "utilities"} />
|
||||
<Box flexDirection="row" overflow="hidden">
|
||||
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
<UtilitiesPanel isFocused={focused === "utilities"} />
|
||||
</Box>
|
||||
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
<SettingsPanel state={state} isFocused={focused === "settings"} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import os from "node:os";
|
||||
import v8 from "node:v8";
|
||||
import { LogRingBuffer } from "./log-ring-buffer.js";
|
||||
import type { LogEntry } from "./log-ring-buffer.js";
|
||||
import type {
|
||||
SystemInfo,
|
||||
SystemStats,
|
||||
TaskStats,
|
||||
SettingsValues,
|
||||
TUICallbacks,
|
||||
@@ -30,6 +33,7 @@ export class DashboardTUI {
|
||||
logBuffer: LogRingBuffer;
|
||||
systemInfo: SystemInfo | null = null;
|
||||
taskStats: TaskStats | null = null;
|
||||
systemStats: SystemStats | null = null;
|
||||
settings: SettingsValues | null = null;
|
||||
callbacks: TUICallbacks | null = null;
|
||||
isRunning = false;
|
||||
@@ -57,6 +61,10 @@ export class DashboardTUI {
|
||||
|
||||
// Uptime ticker to keep footer time live.
|
||||
private uptimeTimer: ReturnType<typeof setInterval> | null = null;
|
||||
// System stats sampler — process memory + CPU%.
|
||||
private systemStatsTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastCpuUsage: NodeJS.CpuUsage | null = null;
|
||||
private lastCpuSampleAt = 0;
|
||||
|
||||
constructor() {
|
||||
this.logBuffer = new LogRingBuffer();
|
||||
@@ -76,6 +84,7 @@ export class DashboardTUI {
|
||||
logEntries: this.logBuffer.getAll(),
|
||||
systemInfo: this.systemInfo,
|
||||
taskStats: this.taskStats,
|
||||
systemStats: this.systemStats,
|
||||
settings: this.settings,
|
||||
callbacks: this.callbacks,
|
||||
showHelp: this.showHelp,
|
||||
@@ -118,6 +127,49 @@ export class DashboardTUI {
|
||||
this.notify();
|
||||
}
|
||||
|
||||
setSystemStats(stats: SystemStats): void {
|
||||
this.systemStats = stats;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/** Sample process memory + CPU% in-place. Called from the sampler timer. */
|
||||
sampleSystemStats(): void {
|
||||
const mem = process.memoryUsage();
|
||||
const heapStats = v8.getHeapStatistics();
|
||||
const now = Date.now();
|
||||
const cpu = process.cpuUsage();
|
||||
let cpuPercent = 0;
|
||||
if (this.lastCpuUsage && this.lastCpuSampleAt > 0) {
|
||||
const elapsedMicros = (now - this.lastCpuSampleAt) * 1000;
|
||||
if (elapsedMicros > 0) {
|
||||
const usedMicros =
|
||||
(cpu.user - this.lastCpuUsage.user) +
|
||||
(cpu.system - this.lastCpuUsage.system);
|
||||
cpuPercent = (usedMicros / elapsedMicros) * 100;
|
||||
}
|
||||
}
|
||||
this.lastCpuUsage = cpu;
|
||||
this.lastCpuSampleAt = now;
|
||||
|
||||
const load = os.loadavg();
|
||||
this.setSystemStats({
|
||||
rss: mem.rss,
|
||||
heapUsed: mem.heapUsed,
|
||||
heapTotal: mem.heapTotal,
|
||||
heapLimit: heapStats.heap_size_limit,
|
||||
external: mem.external,
|
||||
arrayBuffers: mem.arrayBuffers,
|
||||
cpuPercent,
|
||||
loadAvg: [load[0] ?? 0, load[1] ?? 0, load[2] ?? 0],
|
||||
cpuCount: os.cpus().length,
|
||||
systemTotalMem: os.totalmem(),
|
||||
systemFreeMem: os.freemem(),
|
||||
pid: process.pid,
|
||||
nodeVersion: process.version,
|
||||
platform: `${process.platform}/${process.arch}`,
|
||||
});
|
||||
}
|
||||
|
||||
setSettings(settings: SettingsValues): void {
|
||||
this.settings = settings;
|
||||
this.notify();
|
||||
@@ -278,6 +330,14 @@ export class DashboardTUI {
|
||||
this.uptimeTimer = setInterval(() => {
|
||||
if (this.isRunning) this.notify();
|
||||
}, 5000);
|
||||
|
||||
// Prime CPU baseline, then sample every 2s.
|
||||
this.lastCpuUsage = process.cpuUsage();
|
||||
this.lastCpuSampleAt = Date.now();
|
||||
this.sampleSystemStats();
|
||||
this.systemStatsTimer = setInterval(() => {
|
||||
if (this.isRunning) this.sampleSystemStats();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
@@ -289,6 +349,11 @@ export class DashboardTUI {
|
||||
this.uptimeTimer = null;
|
||||
}
|
||||
|
||||
if (this.systemStatsTimer) {
|
||||
clearInterval(this.systemStatsTimer);
|
||||
this.systemStatsTimer = null;
|
||||
}
|
||||
|
||||
if (this.inkInstance) {
|
||||
this.inkInstance.unmount();
|
||||
this.inkInstance = null;
|
||||
|
||||
@@ -12,16 +12,18 @@ export const FUSION_LOGO_LINES = [
|
||||
"╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝",
|
||||
];
|
||||
|
||||
// Colossal font — 62 cols × 8 rows. Used when the terminal has room for it.
|
||||
// ANSI Shadow (extended) — ~70 cols × 10 rows. Same block-letter aesthetic
|
||||
// as the small variant, scaled up by extending vertical bodies and widening
|
||||
// letter cells. Used when the terminal has room for it.
|
||||
export const FUSION_LOGO_LARGE_LINES = [
|
||||
"8888888888 888 888 .d8888b. 8888888 .d88888b. 888b 888 ",
|
||||
"888 888 888 d88P Y88b 888 d88P\" \"Y88b 8888b 888 ",
|
||||
"888 888 888 Y88b. 888 888 888 88888b 888 ",
|
||||
"8888888 888 888 \"Y888b. 888 888 888 888Y88b 888 ",
|
||||
"888 888 888 \"Y88b. 888 888 888 888 Y88b888 ",
|
||||
"888 888 888 \"888 888 888 888 888 Y88888 ",
|
||||
"888 Y88b. .d88P Y88b d88P 888 Y88b. .d88P 888 Y8888 ",
|
||||
"888 \"Y88888P\" \"Y8888P\" 8888888 \"Y88888P\" 888 Y888 ",
|
||||
"███████╗ ██╗ ██╗ ███████╗ ██╗ ██████╗ ███╗ ██╗",
|
||||
"██╔════╝ ██║ ██║ ██╔════╝ ██║ ██╔═══██╗ ████╗ ██║",
|
||||
"██║ ██║ ██║ ██║ ██║ ██║ ██║ ██╔██╗ ██║",
|
||||
"█████╗ ██║ ██║ ███████╗ ██║ ██║ ██║ ██║╚██╗██║",
|
||||
"██╔══╝ ██║ ██║ ╚════██║ ██║ ██║ ██║ ██║ ╚████║",
|
||||
"██║ ██║ ██║ ██║ ██║ ██║ ██║ ██║ ╚███║",
|
||||
"██║ ╚██████╔═╝ ███████║ ██║ ╚██████╔╝ ██║ ╚██║",
|
||||
"╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝",
|
||||
];
|
||||
|
||||
export const FUSION_TAGLINE = "AI coding agent dashboard";
|
||||
|
||||
@@ -34,6 +34,23 @@ export interface TaskStats {
|
||||
};
|
||||
}
|
||||
|
||||
export interface SystemStats {
|
||||
rss: number;
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
heapLimit: number;
|
||||
external: number;
|
||||
arrayBuffers: number;
|
||||
cpuPercent: number;
|
||||
loadAvg: [number, number, number];
|
||||
cpuCount: number;
|
||||
systemTotalMem: number;
|
||||
systemFreeMem: number;
|
||||
pid: number;
|
||||
nodeVersion: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export interface SettingsValues {
|
||||
maxConcurrent: number;
|
||||
maxWorktrees: number;
|
||||
@@ -127,6 +144,7 @@ export interface DashboardState {
|
||||
logEntries: LogEntry[];
|
||||
systemInfo: SystemInfo | null;
|
||||
taskStats: TaskStats | null;
|
||||
systemStats: SystemStats | null;
|
||||
settings: SettingsValues | null;
|
||||
callbacks: TUICallbacks | null;
|
||||
showHelp: boolean;
|
||||
@@ -149,6 +167,7 @@ export function createInitialState(): DashboardState {
|
||||
logEntries: [],
|
||||
systemInfo: null,
|
||||
taskStats: null,
|
||||
systemStats: null,
|
||||
settings: null,
|
||||
callbacks: null,
|
||||
showHelp: false,
|
||||
|
||||
@@ -434,7 +434,7 @@ describe("tablet header controls", () => {
|
||||
expect(btn.textContent).toContain("Projects");
|
||||
});
|
||||
|
||||
it("renders compact project switch trigger beside logo on tablet", () => {
|
||||
it("does not render mobile project switch trigger on tablet", () => {
|
||||
const projects = [
|
||||
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
|
||||
@@ -444,7 +444,7 @@ describe("tablet header controls", () => {
|
||||
currentProject: projects[0],
|
||||
onSelectProject: vi.fn(),
|
||||
});
|
||||
expect(screen.getByTestId("mobile-project-switch-trigger")).toBeDefined();
|
||||
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
|
||||
expect(screen.queryByTestId("project-selector-trigger")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -143,27 +143,24 @@ describe("terminal mobile keyboard layout CSS contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop .terminal-modal base rule", () => {
|
||||
describe("desktop .modal.terminal-modal base rule", () => {
|
||||
/**
|
||||
* Extract the desktop .terminal-modal rule (top-level, not inside any
|
||||
* @media block). This is the first .terminal-modal { ... } in the file
|
||||
* that is not indented (i.e., not nested inside a media query).
|
||||
* Extract the desktop terminal modal rule (top-level, not inside any
|
||||
* @media block).
|
||||
*/
|
||||
function findDesktopTerminalModalRule(): string {
|
||||
// Match a top-level .terminal-modal { ... } (not indented)
|
||||
// Use multiline with ^ to match start-of-line
|
||||
const match = css.match(/^\.terminal-modal\s*\{([^}]*)\}/m);
|
||||
const match = css.match(/^\.modal\.terminal-modal\s*\{([^}]*)\}/m);
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
it("has min-height: 90vh on desktop", () => {
|
||||
it("has min-height: 80vh on desktop", () => {
|
||||
const ruleBody = findDesktopTerminalModalRule();
|
||||
expect(ruleBody).toContain("min-height: 90vh");
|
||||
expect(ruleBody).toContain("min-height: 80vh");
|
||||
});
|
||||
|
||||
it("has max-height: 90vh on desktop", () => {
|
||||
it("has max-height: 85vh on desktop", () => {
|
||||
const ruleBody = findDesktopTerminalModalRule();
|
||||
expect(ruleBody).toContain("max-height: 90vh");
|
||||
expect(ruleBody).toContain("max-height: 85vh");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -526,8 +526,8 @@ export function Header({
|
||||
<h1 className="logo">Fusion</h1>
|
||||
</div>
|
||||
|
||||
{/* Compact Project Switch - dropdown trigger next to logo when 2+ projects (mobile + tablet) */}
|
||||
{isCompact && projects.length > 1 && onSelectProject && (
|
||||
{/* Mobile Project Switch - dropdown trigger next to logo when at least one project exists (mobile only) */}
|
||||
{isMobile && projects.length >= 1 && onSelectProject && (
|
||||
<div className="mobile-project-switch" ref={mobileProjectSwitchRef}>
|
||||
<button
|
||||
className={`mobile-project-switch-trigger${isMobileProjectSwitchOpen ? " mobile-project-switch-trigger--open" : ""}`}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/* === Onboarding Resume Card === */
|
||||
|
||||
.onboarding-resume-card {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -11,127 +8,13 @@
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
animation: onboarding-resume-card-enter 180ms ease-out;
|
||||
animation: onboarding-resume-card-enter var(--transition-normal) ease-out;
|
||||
}
|
||||
|
||||
@keyframes onboarding-resume-card-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.onboarding-resume-card__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.onboarding-resume-card__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--glow-info, rgba(88, 166, 255, 0.15));
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.onboarding-resume-card__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.onboarding-resume-card__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.onboarding-resume-card__meta {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.onboarding-resume-card__meta strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.onboarding-resume-card__actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.onboarding-resume-card__continue {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.onboarding-resume-card__continue:hover {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.onboarding-resume-card__continue:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.onboarding-resume-card__continue:active {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.onboarding-resume-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.onboarding-resume-card__actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.onboarding-resume-card__continue {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Onboarding Resume Card */
|
||||
.onboarding-resume-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--surface));
|
||||
border-bottom: 1px solid var(--border);
|
||||
animation: onboarding-resume-card-enter 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes onboarding-resume-card-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transform: translateY(calc(var(--space-sm) * -1));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -151,11 +34,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--primary) 15%, var(--surface));
|
||||
color: var(--primary);
|
||||
width: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||
height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--todo) 16%, transparent);
|
||||
color: var(--todo);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -164,22 +47,21 @@
|
||||
}
|
||||
|
||||
.onboarding-resume-card__title {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
font-size: calc(var(--space-md) + (var(--space-xs) / 2));
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 2px 0;
|
||||
}
|
||||
|
||||
.onboarding-resume-card__description {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
font-size: calc(var(--space-md) + (var(--space-xs) / 4));
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.onboarding-resume-card__description strong {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.onboarding-resume-card__actions {
|
||||
@@ -187,32 +69,7 @@
|
||||
}
|
||||
|
||||
.onboarding-resume-card__resume-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
background: var(--primary);
|
||||
color: var(--bg);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.onboarding-resume-card__resume-btn:hover {
|
||||
background: color-mix(in srgb, var(--primary) 85%, var(--bg));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.onboarding-resume-card__resume-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.onboarding-resume-card__resume-btn:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function OnboardingResumeCard({ onResume }: OnboardingResumeCardProps) {
|
||||
</div>
|
||||
<div className="onboarding-resume-card__actions">
|
||||
<button
|
||||
className="onboarding-resume-card__resume-btn"
|
||||
className="onboarding-resume-card__resume-btn btn btn-primary btn-sm"
|
||||
onClick={() => {
|
||||
trackOnboardingEvent("onboarding:resumed", {
|
||||
source: "resume-card",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
.session-notification-banner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 900;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
|
||||
import type { Settings } from "@fusion/core";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
|
||||
@@ -1531,15 +1531,19 @@ describe("App GitHub import", () => {
|
||||
|
||||
// Open the modal
|
||||
fireEvent.click(screen.getByTitle("Import from GitHub"));
|
||||
expect(screen.getByText("Import from GitHub")).toBeTruthy();
|
||||
|
||||
// Close the modal - use getAllByRole since there might be multiple buttons
|
||||
const cancelButtons = screen.getAllByRole("button", { name: /Cancel/i });
|
||||
fireEvent.click(cancelButtons[cancelButtons.length - 1]);
|
||||
// Scope interactions to the GitHub import modal to avoid clicking Cancel
|
||||
// buttons from other overlays (e.g. onboarding wizard).
|
||||
const modalHeading = await screen.findByRole("heading", { name: "Import from GitHub" });
|
||||
const modalOverlay = modalHeading.closest(".modal-overlay");
|
||||
expect(modalOverlay).toBeTruthy();
|
||||
|
||||
// Modal should be closed - the Load button from the modal should be gone
|
||||
const cancelButton = within(modalOverlay as HTMLElement).getByRole("button", { name: /^Cancel$/i });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
// Modal heading should be gone after cancel closes the overlay.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /^Load$/i })).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "Import from GitHub" })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -972,7 +972,7 @@ describe("Header", () => {
|
||||
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders mobile project switch trigger on tablet with 2+ projects", () => {
|
||||
it("does not render mobile project switch trigger on tablet", () => {
|
||||
const projects = [
|
||||
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
|
||||
@@ -982,7 +982,7 @@ describe("Header", () => {
|
||||
currentProject: projects[0],
|
||||
onSelectProject: vi.fn(),
|
||||
}, "tablet");
|
||||
expect(screen.getByTestId("mobile-project-switch-trigger")).toBeDefined();
|
||||
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders mobile project switch trigger on mobile with 2+ projects", () => {
|
||||
@@ -998,7 +998,7 @@ describe("Header", () => {
|
||||
expect(screen.getByTestId("mobile-project-switch-trigger")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render mobile project switch trigger on mobile with single project", () => {
|
||||
it("renders mobile project switch trigger on mobile with single project", () => {
|
||||
const projects = [
|
||||
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
];
|
||||
@@ -1007,10 +1007,10 @@ describe("Header", () => {
|
||||
currentProject: projects[0],
|
||||
onSelectProject: vi.fn(),
|
||||
}, "mobile");
|
||||
expect(screen.queryByTestId("mobile-project-switch-trigger")).toBeNull();
|
||||
expect(screen.getByTestId("mobile-project-switch-trigger")).toBeDefined();
|
||||
});
|
||||
|
||||
it("closes compact project switch dropdown on Escape in tablet mode", async () => {
|
||||
it("closes compact project switch dropdown on Escape in mobile mode", async () => {
|
||||
const projects = [
|
||||
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
{ id: "2", name: "Project Two", path: "/path/two", status: "paused" as const },
|
||||
@@ -1019,7 +1019,7 @@ describe("Header", () => {
|
||||
projects,
|
||||
currentProject: projects[0],
|
||||
onSelectProject: vi.fn(),
|
||||
}, "tablet");
|
||||
}, "mobile");
|
||||
|
||||
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
|
||||
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
|
||||
@@ -1031,7 +1031,7 @@ describe("Header", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("closes compact project switch dropdown on outside click in tablet mode", async () => {
|
||||
it("closes compact project switch dropdown on outside click in mobile mode", async () => {
|
||||
const projects = [
|
||||
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
{ id: "2", name: "Project Two", path: "/path/two", status: "paused" as const },
|
||||
@@ -1040,7 +1040,7 @@ describe("Header", () => {
|
||||
projects,
|
||||
currentProject: projects[0],
|
||||
onSelectProject: vi.fn(),
|
||||
}, "tablet");
|
||||
}, "mobile");
|
||||
|
||||
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
|
||||
expect(screen.getByTestId("mobile-project-switch-dropdown")).toBeDefined();
|
||||
@@ -1052,7 +1052,7 @@ describe("Header", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("closes compact project switch dropdown after selecting a project in tablet mode", async () => {
|
||||
it("closes compact project switch dropdown after selecting a project in mobile mode", async () => {
|
||||
const projects = [
|
||||
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
|
||||
{ id: "2", name: "Project Two", path: "/path/two", status: "paused" as const },
|
||||
@@ -1062,7 +1062,7 @@ describe("Header", () => {
|
||||
projects,
|
||||
currentProject: projects[0],
|
||||
onSelectProject,
|
||||
}, "tablet");
|
||||
}, "mobile");
|
||||
|
||||
fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
|
||||
fireEvent.click(screen.getByTestId("mobile-project-switch-item-2"));
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("OnboardingResumeCard", () => {
|
||||
expect(screen.getByText("Continue onboarding")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has accessible button with proper role", () => {
|
||||
it("has accessible button with proper role and shared CTA classes", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue({
|
||||
currentStep: "ai-setup",
|
||||
label: "AI Setup",
|
||||
@@ -86,6 +86,7 @@ describe("OnboardingResumeCard", () => {
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
const button = screen.getByRole("button", { name: "Continue onboarding" });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass("btn", "btn-primary", "btn-sm", "onboarding-resume-card__resume-btn");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,7 +105,10 @@ describe("OnboardingResumeCard", () => {
|
||||
|
||||
expect(mockTrackOnboardingEvent).toHaveBeenCalledWith(
|
||||
"onboarding:resumed",
|
||||
expect.objectContaining({ source: "resume-card" }),
|
||||
expect.objectContaining({
|
||||
source: "resume-card",
|
||||
resumedFromStep: "ai-setup",
|
||||
}),
|
||||
);
|
||||
expect(onResume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -471,8 +471,11 @@ describe("RoutineEditor", () => {
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
// Button should be re-enabled
|
||||
expect(screen.getByText("Create Routine")).not.toBeDisabled();
|
||||
|
||||
// Button should return from the transient "Saving…" state and be enabled again.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Create Routine" })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("builds correct webhook trigger on submit", async () => {
|
||||
|
||||
@@ -1186,6 +1186,7 @@ body {
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
/* Must remain above sticky top banners (e.g. onboarding/session resume cards). */
|
||||
z-index: 100;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -239,10 +239,10 @@ describe("getAgentHealthStatus", () => {
|
||||
expect(status.stateDerived).toBe(false);
|
||||
});
|
||||
|
||||
it('returns "Unresponsive" when heartbeat exceeds the timeout with periodic heartbeat', () => {
|
||||
it('returns "Unresponsive" when heartbeat exceeds the freshness threshold with periodic heartbeat', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 12 * 60 * 1000 - 1).toISOString(), // just over 12 minutes ago
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 24 * 60 * 1000 - 1).toISOString(), // just over 24 minutes ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 6 * 60 * 1000 }, // 6 minute interval
|
||||
});
|
||||
const status = getAgentHealthStatus(agent);
|
||||
@@ -266,7 +266,7 @@ describe("getAgentHealthStatus", () => {
|
||||
// ── Agents without explicit heartbeatIntervalMs ───────────────────────────
|
||||
//
|
||||
// Agents that never had an interval persisted still get the server-side
|
||||
// default interval (1h), so they render Healthy within ~2h of the last
|
||||
// default interval (1h), so they render Healthy within ~4h of the last
|
||||
// heartbeat and tip into Unresponsive beyond that.
|
||||
|
||||
describe("agents without explicit heartbeatIntervalMs", () => {
|
||||
@@ -279,21 +279,21 @@ describe("getAgentHealthStatus", () => {
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Healthy");
|
||||
});
|
||||
|
||||
it('returns "Unresponsive" once elapsed exceeds 2× the default 1h interval', () => {
|
||||
it('returns "Unresponsive" once elapsed exceeds 4× the default 1h interval', () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 3 * 3_600_000).toISOString(), // 3h ago
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 5 * 3_600_000).toISOString(), // 5h ago
|
||||
runtimeConfig: {},
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
});
|
||||
|
||||
it("clamps invalid intervals (0/negative) to the dashboard minimum (5m)", () => {
|
||||
// 0 clamp to 300000ms (5m minimum) → threshold = max(300000 × 2, 60000) = 600000ms (10 minutes).
|
||||
// A heartbeat 11 minutes old is stale.
|
||||
// 0 clamp to 300000ms (5m minimum) → threshold = max(300000 × 4, 300000) = 1,200,000ms (20 minutes).
|
||||
// A heartbeat 21 minutes old is stale.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 660_000).toISOString(), // 11 minutes ago
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 1_260_000).toISOString(), // 21 minutes ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 0 },
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
@@ -302,11 +302,11 @@ describe("getAgentHealthStatus", () => {
|
||||
|
||||
// ── Staleness floor ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Short intervals get a 60s floor so the UI doesn't flicker between
|
||||
// Short intervals get a 5m floor so the UI doesn't flicker between
|
||||
// Healthy and Unresponsive every tick for second-level heartbeats.
|
||||
|
||||
describe("staleness floor", () => {
|
||||
it("holds Healthy below the 60s floor even for sub-minute intervals", () => {
|
||||
it("holds Healthy below the 5m floor even for sub-minute intervals", () => {
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString(),
|
||||
@@ -316,11 +316,11 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
|
||||
it("tips to Unresponsive past the floor", () => {
|
||||
// 6 minute interval → threshold = max(6 × 60s × 2, 60s floor) = max(12 min, 1 min) = 12 minutes.
|
||||
// A heartbeat 13 minutes old exceeds the 12-minute threshold.
|
||||
// 6 minute interval → threshold = max(6m × 4, 5m floor) = 24 minutes.
|
||||
// A heartbeat 25 minutes old exceeds the threshold.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 13 * 60 * 1000).toISOString(), // 13 minutes ago
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 25 * 60 * 1000).toISOString(), // 25 minutes ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 6 * 60 * 1000 }, // 6 minute interval
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
@@ -375,7 +375,7 @@ describe("getAgentHealthStatus", () => {
|
||||
name: "unresponsive",
|
||||
agent: makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 13 * 60 * 1000).toISOString(), // 13 minutes ago
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 25 * 60 * 1000).toISOString(), // 25 minutes ago
|
||||
runtimeConfig: { heartbeatIntervalMs: 6 * 60 * 1000 }, // 6 minute interval
|
||||
}),
|
||||
expectedLabel: "Unresponsive",
|
||||
@@ -436,11 +436,11 @@ describe("getAgentHealthStatus", () => {
|
||||
});
|
||||
|
||||
it("ignores runtimeConfig.enabled and uses interval-based staleness", () => {
|
||||
// 6 minute interval → 12 minute threshold. 13 minutes elapsed is stale regardless of any
|
||||
// 6 minute interval → 24 minute threshold. 25 minutes elapsed is stale regardless of any
|
||||
// legacy enabled flag or per-run timeout.
|
||||
const agent = makeAgent({
|
||||
state: "active",
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 13 * 60 * 1000).toISOString(), // 13 minutes ago
|
||||
lastHeartbeatAt: new Date(FIXED_NOW - 25 * 60 * 1000).toISOString(), // 25 minutes ago
|
||||
runtimeConfig: { enabled: true, heartbeatIntervalMs: 6 * 60 * 1000, heartbeatTimeoutMs: 120_000 },
|
||||
});
|
||||
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
|
||||
|
||||
@@ -468,7 +468,7 @@ describe("SelfHealingManager", () => {
|
||||
const result = await managerWithRecovery.recoverNoProgressNoTaskDoneFailures();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.listTasks).toHaveBeenCalledWith({ column: "in-progress" });
|
||||
expect(store.listTasks).toHaveBeenCalledWith({ column: "in-progress", slim: true });
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-1473", {
|
||||
status: "stuck-killed",
|
||||
worktree: null,
|
||||
@@ -766,7 +766,7 @@ describe("SelfHealingManager", () => {
|
||||
const result = await managerWithRecovery.recoverCompletedTasks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.listTasks).toHaveBeenCalledWith({ column: "in-progress" });
|
||||
expect(store.listTasks).toHaveBeenCalledWith({ column: "in-progress", slim: true });
|
||||
expect(recoverFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "FN-001" }),
|
||||
);
|
||||
@@ -1047,7 +1047,7 @@ describe("SelfHealingManager", () => {
|
||||
const result = await managerWithRecovery.recoverPartialProgressNoTaskDoneFailures();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.listTasks).toHaveBeenCalledWith({ column: "in-review" });
|
||||
expect(store.listTasks).toHaveBeenCalledWith({ column: "in-review", slim: true });
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-2164", {
|
||||
status: null,
|
||||
error: null,
|
||||
|
||||
Reference in New Issue
Block a user