diff --git a/devlog/_plan/260904_dashboard_minimal/assets/071_startup_after.png b/devlog/_plan/260904_dashboard_minimal/assets/071_startup_after.png new file mode 100644 index 00000000000..e041ce91f06 Binary files /dev/null and b/devlog/_plan/260904_dashboard_minimal/assets/071_startup_after.png differ diff --git a/gui/src/pages/Startup.tsx b/gui/src/pages/Startup.tsx index 55c35c1e659..c5c531f5bce 100644 --- a/gui/src/pages/Startup.tsx +++ b/gui/src/pages/Startup.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IconRefresh } from "../icons"; import { type TFn, useI18n } from "../i18n/shared"; -import { navigateHash } from "../hash-routing"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { Notice } from "../ui"; import { useDataSurface } from "../data-surface"; @@ -319,14 +318,9 @@ export default function Startup({ apiBase, machineApiBase = apiBase, connected = return ( <>
-
-

{t("startup.title")}

-

{t("startup.subtitle")}

-
+

{t("startup.title")}

+ {/* The back button duplicated the sidebar; the explanatory sentence moved into the hero. */}
- diff --git a/gui/src/pages/startup-sections.tsx b/gui/src/pages/startup-sections.tsx index db4522c82df..dc88adeb734 100644 --- a/gui/src/pages/startup-sections.tsx +++ b/gui/src/pages/startup-sections.tsx @@ -53,23 +53,17 @@ export function StartupHeroSection({ : data.status === "at-risk" ? t(startupRiskDetailKey(data)) : t("startup.safeDetail")}

+ {/* + The three stat cards that used to restate this answer (routing, protection, + preference) are one line now; the page subtitle rides underneath as a visible + sentence rather than a title attribute. + */} +

+ {t(routingKey)} · {t(PROTECTION_KEYS[data.protection])} · {t(data.autostartEnabled ? "startup.enabled" : "startup.disabled")} +

+

{t("startup.subtitle")}

- -
-
-
{t("startup.routing")}
-
{t(routingKey)}
-
-
-
{t("startup.restartProtection")}
-
{t(PROTECTION_KEYS[data.protection])}
-
-
-
{t("startup.preference")}
-
{t(data.autostartEnabled ? "startup.enabled" : "startup.disabled")}
-
-
); } @@ -259,7 +253,12 @@ export function StartupRecoverySection({

{t("startup.recovery")}

-

{t("startup.recoveryHint")}

+ {/* + The one-click install/repair buttons above are the primary path; the copyable + commands are the fallback. Open by default only while protection is missing. + */} +
+ {t("startup.recoveryHint")}
{data.serviceSupported && (
@@ -296,6 +295,7 @@ export function StartupRecoverySection({ {t("startup.recommended", { cmd: data.recommendedCommand ?? data.commands.installService })}
)} +
); } diff --git a/gui/src/styles.css b/gui/src/styles.css index 2ba0bc7b4c5..90141379dbe 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1949,7 +1949,6 @@ dialog.modal-overlay::backdrop { .codex-account-priority-label { font-size: var(--text-label); color: var(--muted); font-weight: var(--weight-medium); white-space: nowrap; } .codex-account-priority .select-trigger { max-width: 100%; padding: 4px 9px; font-size: var(--text-label); } -.startup-page-sub { margin-bottom: 0; } .startup-page-head-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .startup-runtime-notice-slot { margin-bottom: 12px; @@ -2001,8 +2000,12 @@ dialog.modal-overlay::backdrop { .startup-hero-icon svg { width: 21px; height: 21px; } .startup-hero-copy h3 { margin: 10px 0 4px; font-size: var(--text-title); } .startup-hero-copy p { margin: 0; color: var(--muted); line-height: var(--leading-body); max-width: var(--prose-measure); } -.startup-state-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; } -.startup-state-grid .value { font-size: var(--text-subtitle); } +.startup-state-line { margin: 8px 0 0; font-size: var(--text-control); } +.startup-recovery-details > summary { cursor: pointer; list-style: none; display: inline-flex; align-items: center; gap: 6px; } +.startup-recovery-details > summary::-webkit-details-marker { display: none; } +.startup-recovery-details > summary::before { content: ""; width: 0; height: 0; border-left: 5px solid var(--muted); border-top: 4px solid transparent; border-bottom: 4px solid transparent; transition: transform var(--motion-fast); } +.startup-recovery-details[open] > summary::before { transform: rotate(90deg); } +.startup-recovery-details[open] > summary { margin-bottom: 8px; } .startup-details, .startup-actions { margin-bottom: 16px; } .startup-actions .panel-head > svg { width: 18px; height: 18px; flex: 0 0 auto; } .startup-detail-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; border-top: 1px solid var(--border-soft); } @@ -2020,7 +2023,6 @@ dialog.modal-overlay::backdrop { .startup-command-row code { color: var(--muted); font-size: var(--text-label); overflow-wrap: anywhere; } .startup-action-notice { margin: 14px 0 0; } @media (max-width: 700px) { - .startup-state-grid { grid-template-columns: 1fr; } .startup-command-row { align-items: flex-start; } .startup-detail-row { align-items: flex-start; } .startup-detail-row > .startup-detail-actions { flex-direction: column; align-items: flex-end; } diff --git a/gui/tests/startup-minimal.test.tsx b/gui/tests/startup-minimal.test.tsx new file mode 100644 index 00000000000..d96d2dd6450 --- /dev/null +++ b/gui/tests/startup-minimal.test.tsx @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import Startup from "../src/pages/Startup"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; + +/** + * devlog/_plan/260904_dashboard_minimal/070_startup.md: the hero answers the page's + * question and carries the one-line state + the explanatory sentence (visible, not a + * title); the three stat cards and the back button are gone; the copyable recovery + * commands sit behind a details that is open only while protection is missing. + */ +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; + +function health(status: "protected" | "at-risk") { + const safe = status === "protected"; + return { + status, routingKind: "opencodex-local", routingInjected: true, localRoutingDependency: true, + autostartEnabled: safe, rebootSafe: safe, protection: safe ? "service" : "none", + serviceInstalled: safe, serviceViable: safe, serviceEnabled: safe, serviceRunning: safe, + serviceStale: false, serviceConflict: false, serviceSupported: true, + shimInstalled: safe, shimHealthy: safe, shimCoverage: safe ? "full" : "none", platform: "darwin", + recommendedCommand: "ocx service install", diagnosticStale: false, + commands: { installService: "ocx service install", repairService: "ocx service repair", installShim: "ocx shim install", restoreNative: "ocx restore" }, + }; +} + +function response(body: unknown): Response { + return { ok: true, status: 200, text: async () => JSON.stringify(body), json: async () => body } as unknown as Response; +} + +let status: "protected" | "at-risk" = "protected"; + +beforeEach(() => { + clearClientResourceStoresForTests(); + previousGlobals = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#startup" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string) => { + const path = new URL(String(url), "http://localhost/").pathname; + if (path === "/api/startup-health") return response(health(status)); + if (path === "/api/settings") return response({ codexAutoStart: true, codexRuntime: { version: "x" } }); + return response({}); + }, + }); + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); +}); + +afterEach(async () => { + if (root) { const current = root; await act(async () => { current.unmount(); }); root = null; } + testWindow.close(); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + clearClientResourceStoresForTests(); +}); + +async function mount() { + root = createRoot(container); + await act(async () => { root!.render(); }); + await act(async () => { await new Promise(r => testWindow.setTimeout(r, 30)); }); +} + +test("protected: hero carries the state line and the sentence; no stat grid, no back button; recovery details closed", async () => { + status = "protected"; + await mount(); + expect(container.querySelector(".startup-state-grid")).toBeNull(); + expect([...container.querySelectorAll("button")].map(b => b.textContent?.trim())).not.toContain("Back to Dashboard"); + const hero = container.querySelector(".startup-hero")!; + expect(hero.querySelector(".startup-state-line")?.textContent).toContain("·"); + // The old subtitle is a visible sentence inside the hero, not a title attribute. + expect(hero.textContent).toContain("Verify that Codex can reach opencodex"); + expect(container.querySelector('[title*="Verify that Codex"]')).toBeNull(); + const details = container.querySelector("details.startup-recovery-details")!; + expect(details).not.toBeNull(); + expect(details.open).toBe(false); + // Commands are still there, one click away. + expect(details.textContent).toContain("ocx shim install"); +}); + +test("at-risk: recovery details open by default", async () => { + status = "at-risk"; + await mount(); + const details = container.querySelector("details.startup-recovery-details")!; + expect(details.open).toBe(true); +});