feat(dashboard,cli): update-check frequency, GitHub star button, more resizable modals
Update-check frequency (end-to-end) - Add `updateCheckFrequency: "manual" | "on-startup" | "daily" | "weekly"` to GlobalSettings (default: "daily"). - Backend `performUpdateCheck` honors the frequency: TTL = day/week, `manual` returns cache or empty without hitting npm, `on-startup` refreshes once per process lifetime then serves cache. `/refresh` route forces network regardless. - Settings → Updates surfaces a working `<select>` for the cadence, disabled when auto-checks are off entirely. - Tests: 4 new cases covering ttlForFrequency mapping, weekly window, manual semantics, on-startup once-per-process behavior. TUI update notice - Read cached update result synchronously on TUI startup. When an update is available, render a yellow notice line on the splash and a colored ● next to the version in the status bar. Settings modal header polish - Add "Star on GitHub" pill (icon + Star + cached star count from the GitHub API, 1h localStorage TTL) and "Help" button (opens project Discussions). Both link to the Runfusion/Fusion repo. - Star button auto-hides after the user clicks it (intent = star), tracked in localStorage `fusion:github-star-clicked`. - Settings → General gets a "Show Star on GitHub button" checkbox so users can hide it preemptively. New global setting `showGitHubStarButton: boolean` (default true) gates rendering. More resizable modals - Task Detail modal: 85vh default, resize: both, persisted via useModalResizePersist (key `fusion:task-detail-modal-size`). - Quick Chat FAB: full 8-direction resize (4 corners + 4 edges) via pointer-event handlers; persisted to `fusion:quick-chat-size-<projectId>`. Each handle has the right cursor + role="separator" for accessibility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,7 @@ import type {
|
||||
FileReadResult,
|
||||
TaskDetailData,
|
||||
TaskEvent,
|
||||
UpdateStatus,
|
||||
} from "./state.js";
|
||||
import { SECTION_ORDER } from "./state.js";
|
||||
import type { LogEntry } from "./log-ring-buffer.js";
|
||||
@@ -137,7 +138,7 @@ const SPLASH_MIN_ROWS = 12;
|
||||
const LARGE_LOGO_MIN_COLS = 70;
|
||||
const LARGE_LOGO_MIN_ROWS = 16;
|
||||
|
||||
function SplashScreen({ loadingStatus }: { loadingStatus: string }) {
|
||||
function SplashScreen({ loadingStatus, updateStatus }: { loadingStatus: string; updateStatus: UpdateStatus | null }) {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const rows = stdout?.rows ?? 24;
|
||||
@@ -154,6 +155,9 @@ function SplashScreen({ loadingStatus }: { loadingStatus: string }) {
|
||||
<Text color="cyanBright" dimColor>{FUSION_TAGLINE}</Text>
|
||||
<Text color="cyanBright" dimColor>{FUSION_URL}</Text>
|
||||
<Text color="cyanBright" dimColor>{`v${FUSION_VERSION}`}</Text>
|
||||
{updateStatus?.updateAvailable && (
|
||||
<Text color="yellow" dimColor>{`Update available: v${updateStatus.currentVersion} → v${updateStatus.latestVersion}. Run \`npm install -g @runfusion/fusion\`.`}</Text>
|
||||
)}
|
||||
<Box height={1} />
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color="cyanBright"><Spinner type="dots" /></Text>
|
||||
@@ -819,11 +823,11 @@ function StatusBar({ state, controller: _controller }: { state: DashboardState;
|
||||
hotkeys.push("Tab cycle panel", "1-5 jump");
|
||||
}
|
||||
|
||||
const statusParts: string[] = [];
|
||||
if (systemInfo) {
|
||||
statusParts.push(`${systemInfo.baseUrl} v${FUSION_VERSION}`);
|
||||
statusParts.push(formatUptime(Date.now() - systemInfo.startTimeMs));
|
||||
}
|
||||
const { updateStatus } = state;
|
||||
const hasUpdate = updateStatus?.updateAvailable === true;
|
||||
|
||||
const uptimePart = systemInfo ? formatUptime(Date.now() - systemInfo.startTimeMs) : null;
|
||||
const versionPart = systemInfo ? `${systemInfo.baseUrl} v${FUSION_VERSION}` : null;
|
||||
|
||||
// Truncate both halves so the StatusBar always fits in a single row.
|
||||
// Without this, default wrap="wrap" lets long hotkey strings or URLs
|
||||
@@ -832,8 +836,13 @@ function StatusBar({ state, controller: _controller }: { state: DashboardState;
|
||||
return (
|
||||
<Box height={1} justifyContent="space-between" paddingX={1} flexShrink={0} overflow="hidden">
|
||||
<Text dimColor wrap="truncate-end">{hotkeys.join(" · ")}</Text>
|
||||
{statusParts.length > 0 && (
|
||||
<Text dimColor wrap="truncate-end">{statusParts.join(" | ")}</Text>
|
||||
{versionPart && (
|
||||
<Box flexDirection="row" gap={1} flexShrink={0}>
|
||||
{uptimePart && <Text dimColor wrap="truncate-end">{uptimePart}</Text>}
|
||||
{uptimePart && <Text dimColor wrap="truncate-end">|</Text>}
|
||||
<Text dimColor wrap="truncate-end">{versionPart}</Text>
|
||||
{hasUpdate && <Text color="yellow" wrap="truncate-end">●</Text>}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
@@ -4108,7 +4117,7 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
if (!state.systemInfo) {
|
||||
return (
|
||||
<Box key={layoutKey} flexDirection="column" height={rows} width={cols} overflow="hidden">
|
||||
<SplashScreen loadingStatus={state.loadingStatus} />
|
||||
<SplashScreen loadingStatus={state.loadingStatus} updateStatus={state.updateStatus} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import os from "node:os";
|
||||
import v8 from "node:v8";
|
||||
import { execSync } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { getCachedUpdateStatus } from "../../update-cache.js";
|
||||
|
||||
const TUI_DEBUG_LOG = process.env.FUSION_TUI_DEBUG_LOG;
|
||||
function tuiDebug(tag: string, data: Record<string, unknown>): void {
|
||||
@@ -25,6 +26,7 @@ import type {
|
||||
DashboardState,
|
||||
InteractiveData,
|
||||
InteractiveView,
|
||||
UpdateStatus,
|
||||
} from "./state.js";
|
||||
import { SECTION_ORDER } from "./state.js";
|
||||
|
||||
@@ -74,6 +76,7 @@ export class DashboardTUI {
|
||||
interactiveData: InteractiveData | null = null;
|
||||
interactiveView: InteractiveView = "board";
|
||||
interactiveInputLocked = false;
|
||||
updateStatus: UpdateStatus | null = null;
|
||||
|
||||
// Subscribers registered by the Ink App component.
|
||||
private subscribers: Set<() => void> = new Set();
|
||||
@@ -112,6 +115,17 @@ export class DashboardTUI {
|
||||
|
||||
constructor() {
|
||||
this.logBuffer = new LogRingBuffer();
|
||||
// Read the update-check cache synchronously at construction time so the
|
||||
// splash screen and status bar can render the notice immediately, without
|
||||
// any network access.
|
||||
const cached = getCachedUpdateStatus();
|
||||
if (cached) {
|
||||
this.updateStatus = {
|
||||
updateAvailable: cached.updateAvailable,
|
||||
currentVersion: cached.currentVersion,
|
||||
latestVersion: cached.latestVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subscription API (for Ink App) ────────────────────────────────────────
|
||||
@@ -144,6 +158,7 @@ export class DashboardTUI {
|
||||
interactiveInputLocked: this.interactiveInputLocked,
|
||||
autoKillVitestOnPressure: this.autoKillVitestOnPressure,
|
||||
vitestKillThreshold: this.vitestKillThreshold,
|
||||
updateStatus: this.updateStatus,
|
||||
};
|
||||
return this.cachedSnapshot;
|
||||
}
|
||||
@@ -353,6 +368,11 @@ export class DashboardTUI {
|
||||
this.notify();
|
||||
}
|
||||
|
||||
setUpdateStatus(status: UpdateStatus | null): void {
|
||||
this.updateStatus = status;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
addLog(entry: Omit<LogEntry, "timestamp">): void {
|
||||
// If the cursor was sitting on the most recent entry (or there were no
|
||||
// entries yet), keep it pinned to the new tail so live logs follow the
|
||||
|
||||
@@ -318,6 +318,14 @@ export interface InteractiveData {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Update check status (surfaced in the TUI header/splash) ──────────────────
|
||||
|
||||
export interface UpdateStatus {
|
||||
updateAvailable: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
}
|
||||
|
||||
// ── Dashboard state (mutable, shared between controller and App) ───────────────
|
||||
|
||||
export interface DashboardState {
|
||||
@@ -341,6 +349,7 @@ export interface DashboardState {
|
||||
interactiveInputLocked: boolean;
|
||||
autoKillVitestOnPressure: boolean;
|
||||
vitestKillThreshold: number;
|
||||
updateStatus: UpdateStatus | null;
|
||||
}
|
||||
|
||||
export const SECTION_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "settings"];
|
||||
@@ -367,5 +376,6 @@ export function createInitialState(): DashboardState {
|
||||
interactiveInputLocked: false,
|
||||
autoKillVitestOnPressure: true,
|
||||
vitestKillThreshold: 0.9,
|
||||
updateStatus: null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user