feat(dashboard): polish modal widths, popover usage, onboarding contrast, TUI logo
- Resize the dashboard TUI splash logo to scale with the terminal: a Colossal-font variant kicks in on terminals ≥70 cols × 16 rows; gradient palette is now strictly white → blueBright → blue (no cyan/magenta) - Lower-clip TUI Kanban cards to a single-line title and constrain each column with overflow hidden so cards no longer bleed past the header row - Center the TerminalModal vertically by overriding overlay alignment via :has() and trimming the modal height - Match GitHub Import modal width to the file browser modal (90vw / 1600px max) - Render the Usage indicator as a top-right popover on desktop instead of a centered modal (mobile keeps the full-screen sheet) - Widen SetupWizard / Model Onboarding modals (540→880px, 560→880px) and replace the washed-out done step indicator background with a solid success swatch - Drop the duplicate mailbox icon button from the desktop header (view switch covers it; overflow menu still surfaces it on compact) - Loosen agent health detection: bump the heartbeat grace multiplier 2× → 4× and the staleness floor 60s → 5min so transient ticks don't flag agents Unresponsive Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,7 @@ import type {
|
||||
} from "./state.js";
|
||||
import { SECTION_ORDER } from "./state.js";
|
||||
import type { LogEntry } from "./log-ring-buffer.js";
|
||||
import { FUSION_LOGO_LINES, FUSION_TAGLINE } from "./logo.js";
|
||||
import { FUSION_LOGO_LINES, FUSION_LOGO_LARGE_LINES, FUSION_TAGLINE } from "./logo.js";
|
||||
import { useProjects, useTasks } from "./hooks/use-projects.js";
|
||||
|
||||
// ── Format helpers ────────────────────────────────────────────────────────────
|
||||
@@ -75,19 +75,26 @@ function formatRelativeTime(iso: string): string {
|
||||
return `${h}h ago`;
|
||||
}
|
||||
|
||||
// All-blue vertical gradient — top: brightest, bottom: shadow.
|
||||
const LOGO_COLORS = ["whiteBright", "cyanBright", "cyan", "blueBright", "blue", "blue"] as const;
|
||||
// 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;
|
||||
type InkColor = typeof LOGO_COLORS[number];
|
||||
|
||||
function logoColor(index: number): InkColor {
|
||||
return LOGO_COLORS[Math.min(index, LOGO_COLORS.length - 1)];
|
||||
function logoColor(index: number, total: number): InkColor {
|
||||
// Map this row's index proportionally across LOGO_COLORS so gradient
|
||||
// looks consistent regardless of how tall the chosen logo variant is.
|
||||
const slot = Math.min(
|
||||
LOGO_COLORS.length - 1,
|
||||
Math.floor((index / Math.max(1, total - 1)) * (LOGO_COLORS.length - 1)),
|
||||
);
|
||||
return LOGO_COLORS[slot];
|
||||
}
|
||||
|
||||
function AnimatedFusionLogo() {
|
||||
function AnimatedFusionLogo({ lines }: { lines: readonly string[] }) {
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
{FUSION_LOGO_LINES.map((line, i) => (
|
||||
<Text key={i} color={logoColor(i)} bold>{line}</Text>
|
||||
{lines.map((line, i) => (
|
||||
<Text key={i} color={logoColor(i, lines.length)} bold>{line}</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
@@ -95,31 +102,31 @@ function AnimatedFusionLogo() {
|
||||
|
||||
// ── Splash screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Smallest terminal that can fit the full block-letter splash centered.
|
||||
// Below either threshold the block-letter logo gets dropped for a plain
|
||||
// "FUSION" word so nothing wraps awkwardly on small terminals. The splash
|
||||
// always pins to the top-left — never centered — so it doesn't jump around
|
||||
// when the terminal resizes.
|
||||
// Logo width thresholds. Below SPLASH_MIN_COLS we fall back to the plain
|
||||
// "FUSION" word; otherwise we pick the largest variant that fits.
|
||||
const SPLASH_MIN_COLS = 56;
|
||||
const SPLASH_MIN_ROWS = 20;
|
||||
const SPLASH_MIN_ROWS = 12;
|
||||
const LARGE_LOGO_MIN_COLS = 70;
|
||||
const LARGE_LOGO_MIN_ROWS = 16;
|
||||
|
||||
function SplashScreen({ loadingStatus }: { loadingStatus: string }) {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const rows = stdout?.rows ?? 24;
|
||||
const compact = cols < SPLASH_MIN_COLS || rows < SPLASH_MIN_ROWS;
|
||||
const large = cols >= LARGE_LOGO_MIN_COLS && rows >= LARGE_LOGO_MIN_ROWS;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
{compact ? (
|
||||
<Text bold color="cyanBright">FUSION</Text>
|
||||
<Text bold color="blueBright">FUSION</Text>
|
||||
) : (
|
||||
<AnimatedFusionLogo />
|
||||
<AnimatedFusionLogo lines={large ? FUSION_LOGO_LARGE_LINES : FUSION_LOGO_LINES} />
|
||||
)}
|
||||
<Text color="blueBright" dimColor>{FUSION_TAGLINE}</Text>
|
||||
<Box height={1} />
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color="cyanBright"><Spinner type="dots" /></Text>
|
||||
<Text color="blueBright"><Spinner type="dots" /></Text>
|
||||
<Text color="blueBright" dimColor>{loadingStatus}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -131,7 +138,7 @@ function SplashScreen({ loadingStatus }: { loadingStatus: string }) {
|
||||
function MiniLogo() {
|
||||
return (
|
||||
<Box flexDirection="row" gap={0}>
|
||||
<Text color="cyan" bold>FUSION</Text>
|
||||
<Text color="blueBright" bold>FUSION</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -210,11 +217,6 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b
|
||||
<Text wrap="truncate" color="cyanBright">{info.tokenizedUrl}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text dimColor>Press</Text>
|
||||
<Text color="cyanBright" bold>[Enter]</Text>
|
||||
<Text dimColor>to open in browser</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -222,11 +224,6 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b
|
||||
<Text dimColor>Auth:</Text>
|
||||
<Text color="yellow">no auth</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text dimColor>Press</Text>
|
||||
<Text color="cyanBright" bold>[Enter]</Text>
|
||||
<Text dimColor>to open in browser</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box flexDirection="row" gap={1}>
|
||||
@@ -756,20 +753,31 @@ function KanbanColumnView({
|
||||
}) {
|
||||
const accent = COLUMN_COLORS[column];
|
||||
const headerColor = isFocused ? "whiteBright" : accent;
|
||||
const cardWidth = Math.max(12, width - 4);
|
||||
const cardWidth = Math.max(16, width - 2);
|
||||
const innerHeaderWidth = Math.max(8, width - 2);
|
||||
const label = `${columnLabel(column).toUpperCase()} (${tasks.length})`;
|
||||
const headerText = ` ${label} `.length > innerHeaderWidth
|
||||
? ` ${label} `.slice(0, innerHeaderWidth)
|
||||
: ` ${label} `.padEnd(innerHeaderWidth, " ");
|
||||
return (
|
||||
<Box flexDirection="column" width={width} flexShrink={0} paddingX={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
flexShrink={1}
|
||||
flexGrow={1}
|
||||
paddingX={1}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box width={innerHeaderWidth} flexShrink={0}>
|
||||
<Text bold color={headerColor} backgroundColor={isFocused ? accent : undefined}>
|
||||
{` ${columnLabel(column).toUpperCase()} `}
|
||||
{headerText}
|
||||
</Text>
|
||||
<Text dimColor>{tasks.length}</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
<Box height={1} flexShrink={0} />
|
||||
{tasks.length === 0 ? (
|
||||
<Text dimColor>—</Text>
|
||||
) : (
|
||||
<Box flexDirection="column" gap={0}>
|
||||
<Box flexDirection="column" gap={0} flexShrink={1} overflow="hidden">
|
||||
{tasks.map((task, i) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// FUSION block-letter logo using Unicode box-drawing + full blocks.
|
||||
// Font: ANSI Shadow (figlet-style). Hardcoded so we have no runtime dep.
|
||||
// The caller applies a cyan→whiteBright vertical gradient.
|
||||
// FUSION block-letter logos using Unicode box-drawing + full blocks.
|
||||
// The caller picks a size based on terminal dimensions and applies an
|
||||
// all-blue vertical gradient.
|
||||
|
||||
// ANSI Shadow font — 47 cols × 6 rows.
|
||||
export const FUSION_LOGO_LINES = [
|
||||
"███████╗██╗ ██╗███████╗██╗ ██████╗ ███╗ ██╗",
|
||||
"██╔════╝██║ ██║██╔════╝██║██╔═══██╗████╗ ██║",
|
||||
@@ -11,4 +12,16 @@ export const FUSION_LOGO_LINES = [
|
||||
"╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝",
|
||||
];
|
||||
|
||||
// Colossal font — 62 cols × 8 rows. 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";
|
||||
|
||||
@@ -173,8 +173,9 @@
|
||||
}
|
||||
|
||||
/* Wider modal for two-pane layout */
|
||||
.github-import-modal {
|
||||
width: min(1400px, calc(100vw - 32px));
|
||||
.modal.github-import-modal {
|
||||
width: 90vw;
|
||||
max-width: 1600px;
|
||||
max-height: min(900px, calc(100vh - 64px));
|
||||
}
|
||||
|
||||
|
||||
@@ -917,23 +917,6 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mailbox button - desktop only */}
|
||||
{!isCompact && onOpenMailbox && (
|
||||
<button
|
||||
className={`btn-icon${mailboxUnreadCount > 0 ? " btn-icon--has-indicator" : ""}`}
|
||||
onClick={onOpenMailbox}
|
||||
title={`Mailbox${mailboxUnreadCount > 0 ? ` (${mailboxUnreadCount} unread)` : ""}`}
|
||||
data-testid="header-mailbox-btn"
|
||||
>
|
||||
<Mail size={16} />
|
||||
{mailboxUnreadCount > 0 && (
|
||||
<span className="header-badge" data-testid="header-mailbox-badge">
|
||||
{mailboxUnreadCount > 9 ? "9+" : mailboxUnreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Desktop actions */}
|
||||
{!isCompact && !isElectron && (
|
||||
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
|
||||
|
||||
@@ -1634,3 +1634,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop: render as a top-right popover card instead of a centered modal */
|
||||
@media (min-width: 769px) {
|
||||
.modal-overlay:has(.usage-modal) {
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 60px var(--space-md) 0 0;
|
||||
}
|
||||
|
||||
.modal.usage-modal {
|
||||
width: 420px;
|
||||
max-height: 70vh;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
.setup-wizard-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 540px;
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
width: 90vw;
|
||||
max-height: min(720px, calc(100dvh - var(--overlay-padding-top, 10vh) - var(--space-lg)));
|
||||
animation: slideUp var(--transition-normal) ease-out;
|
||||
overflow: hidden;
|
||||
@@ -475,7 +475,7 @@
|
||||
/* ===== Model Onboarding Modal ===== */
|
||||
|
||||
.model-onboarding-modal {
|
||||
max-width: 560px;
|
||||
max-width: 880px;
|
||||
width: 90vw;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
@@ -565,9 +565,9 @@
|
||||
}
|
||||
|
||||
.model-onboarding-step-indicator.done .step-number {
|
||||
background: var(--status-done-bg-deep);
|
||||
border: 1px solid color-mix(in srgb, var(--done) 45%, transparent);
|
||||
color: var(--done);
|
||||
background: var(--color-success);
|
||||
border: 1px solid var(--color-success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.model-onboarding-step-indicator.done {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
/* === Terminal Modal === */
|
||||
/* Center vertically (override the overlay's default top alignment). */
|
||||
.modal-overlay:has(.terminal-modal) {
|
||||
align-items: center;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.modal.terminal-modal {
|
||||
width: 90vw;
|
||||
max-width: 1600px;
|
||||
min-height: 90vh;
|
||||
max-height: 90vh;
|
||||
min-height: 80vh;
|
||||
max-height: 85vh;
|
||||
background: var(--card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -11,17 +11,19 @@ import { resolveHeartbeatIntervalMs } from "./heartbeatIntervals";
|
||||
|
||||
/**
|
||||
* Grace multiplier applied to an agent's configured interval before flagging
|
||||
* it Unresponsive. A human reads "missed two scheduled ticks" as "something
|
||||
* is wrong", which is what 2× captures; this also tolerates timer jitter and
|
||||
* a paused engine restarting without causing a UI flicker.
|
||||
* it Unresponsive. We require several missed scheduled ticks before raising
|
||||
* the alarm — momentary timer jitter, an engine restart, or a single skipped
|
||||
* tick should never flip the UI to "Unresponsive".
|
||||
*/
|
||||
const HEARTBEAT_GRACE_MULTIPLIER = 2;
|
||||
const HEARTBEAT_GRACE_MULTIPLIER = 4;
|
||||
|
||||
/**
|
||||
* Staleness floor. Even on an agent configured for 1s heartbeats we don't
|
||||
* want the UI flickering between Healthy/Unresponsive on every tick.
|
||||
* want the UI flickering between Healthy/Unresponsive on every tick. Five
|
||||
* minutes is enough wall-clock buffer for any reasonable agent to recover
|
||||
* from an engine pause/resume cycle.
|
||||
*/
|
||||
const MIN_HEARTBEAT_STALENESS_MS = 60_000;
|
||||
const MIN_HEARTBEAT_STALENESS_MS = 5 * 60_000;
|
||||
|
||||
/** Shape of the health status returned by getAgentHealthStatus */
|
||||
export interface AgentHealthStatus {
|
||||
|
||||
Reference in New Issue
Block a user