feat(FN-2408): merge fusion/fn-2408

This commit is contained in:
Fusion
2026-04-24 22:40:53 -07:00
committed by gsxdsm
parent c0e58737c3
commit 1d1f1f0da5
18 changed files with 341 additions and 263 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" : ""}`}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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