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:
gsxdsm
2026-04-27 17:55:15 -07:00
parent dba2299137
commit 1fe59be960
16 changed files with 752 additions and 98 deletions

View File

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

View File

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

View File

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