diff --git a/apps/web/.impeccable/surfaces/src-app-tsx.md b/apps/web/.impeccable/surfaces/src-app-tsx.md index 3b8127401..5accf59dd 100644 --- a/apps/web/.impeccable/surfaces/src-app-tsx.md +++ b/apps/web/.impeccable/surfaces/src-app-tsx.md @@ -7,7 +7,7 @@ related_targets: ["src/ConsoleApp.tsx"] # Administrator console (operate) -Scope: the signed-in console shell and every page behind it, plus the first-run step. Visitor mode: Operate. +Scope: the signed-in console shell and every page behind it, including Getting started on the Overview and the optional console tour. Visitor mode: Operate. Audience: the administrator of one Parsar Core deployment. Task: judge health, capacity, usage and failures; inspect and delete or copy project assets; manage projects, keys and nodes. Constraints: Web API only (`/core/v1`); missing data stays visibly missing; no small print, explanations live in help tips; API terms stay English in Chinese copy; zh-CN and English, light and dark. diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 52246723f..fe0aec7dd 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -368,9 +368,9 @@ heading row sits 12px above its content. The recurring shapes in the body are the KPI strip (auto-fit columns, min 158px; three per row below 1180px), chart grids (two equal columns, single below 1180px), full-width table cards, and a fact row on detail pages. Overview has its own -arrangement: four metric tiles, Session activity beside the fleet topology (Core -in the middle, nodes left and right, solid lines online and dashed offline; Core -and each node open an anchored popover with a two-column glance and links to +arrangement: Getting started while a step is to do, four metric tiles, Session +activity beside the fleet topology (Core in the middle, nodes left and right, +solid lines online and dashed offline; Core and each node open an anchored popover with a two-column glance and links to their pages), then the attention table and usage by project, each on its own card with a 16px gap. Popovers are the overlay card (14px radius, overlay shadow, 16px padding): a 14px title, 12px labels over 13px values, links at a ruled foot. Nodes itself is a plain list with a detail page. @@ -511,7 +511,7 @@ items are 30px rows with a 15px outline icon and Sidebar Ink text. Hover takes t ink wash; the active item sits on a white chip (the page panel's surface, ringed) with Ledger Ink at 500, and the chip glides to the next item on navigation. The Platform group sits below a hairline. A secondary page (one Session) highlights its -parent. The footer holds sign-out and the language/theme menu. A detail page's back arrow returns to the page it was opened +parent. The footer holds Show Getting started, then sign-out and the language/theme menu. A detail page's back arrow returns to the page it was opened from (a Skill opened from a template goes back to the template); opened directly, it goes to its list. The arrow is labelled plainly "Back". @@ -572,7 +572,7 @@ with a help tip. Safety notices (a key shown once, a destructive consequence) st visible in body text. ### Onboarding -Signing in and first-run setup share one frame: a dark stage on the left (always +Signing in and the console tour share one frame: a dark stage on the left (always dark, whatever the theme) and the task panel on the right, which follows the theme. The stage is the product's one authored moment: a flickering indigo dot grid under slow light rays (Magic UI's flickering grid and light rays), Core as @@ -583,16 +583,30 @@ ink; it is a paragraph, not a heading, because the panel's title names the task. Signing in asks for one thing, the deployment's Core key, in a single password field; where the key is kept and what it can do sit behind a help tip beside the label, and a refused key, too many attempts or an unavailable console is an error -beside the field. When no project exists yet, first run follows on the same stage -in two steps with numbered progress in the panel header: the first project and its -project API key (shown once; the example request types itself into a terminal), -and a three-chapter tour — Monitor, -Resources, Platform — whose stage shows a real dark screenshot of those pages, -tilted towards the panel. Steps change inside a View Transition: the stage holds -still while the panel slides and blurs across; opening the console dissolves -the onboarding forward and reveals the console in a circle growing from the -pressed button. With reduced motion the orbits hold their places, the grid is a -still frame, the request is a plain block and no transition runs. +beside the field. Signing in opens the console on the Overview. The optional tour +has three chapters — Monitor, Resources, Platform — whose stage shows a real dark +screenshot of those pages, tilted towards the panel; it takes the place of the +console until its last button, Skip or Escape, and then returns the focus to the +control that opened it. Entering the console or the tour, and leaving the tour, +happen inside a View Transition: the old page dissolves forward and the new one +is revealed in a circle growing from the pressed button. With reduced motion the +orbits hold their places, the grid is a still frame and no transition runs. + +### Getting started +The first card on the Overview while any step is to do: a card header ("Getting +started", "n of 3 done", a help tip, then a ghost Take the tour button and an icon +button that hides it) over three rows split by Faint Rules. Each row has a 22px +numbered ring (a check on the tile wash when done), a 13px/600 title over one +12.5px Graphite line, a status dot (Done in green, To do in Idle Gray, Checking +pending, Unknown for a failed read) and one outline action while the step is to +do: Set up sandboxes, Add node, Open Nodes or Open sandbox backend; Create project +(which continues to the new project's first key) or Issue key; Projects and keys. +Add node, Create project and Issue key open their page with the dialog already +open. Every step done turns it into one line, "You're set", with Take the tour and +Dismiss; it stays, through the tour, until dismissed, and the checklist does not +come back on its own. The choice is kept per installation in the browser, also +while the deployment cannot be read; Show Getting started, a quiet row above the +sidebar's account controls, opens it again at any time. ### Sandbox setup Setting up hosted sandboxes, and changing the provider or resources in maintenance, diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 05a1efc37..ec979796d 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -92,11 +92,16 @@ workbench. many sleep (Core's retained minus active) on the Nodes list, a node's page, Sandbox metrics and Overview; a node's allocations show how long each has been suspended and about when Core reclaims it. Docker never suspends and shows none of it. -- **First run**: after signing in with the Core key while no project exists, - full-screen steps outside the shell create the first project (default name - `Default`) and its first project API key, show the plaintext once with an example request, - then give a three-chapter tour of the console (Monitor, Resources, Platform) - before opening it. Signing in uses the same stage. +- **Getting started**: signing in opens the console on the Overview; nothing is + forced first. While a step is to do, a Getting started checklist on the Overview + shows three steps, in any order, each with its state and one action: sandboxes + ready (a saved deployment and a node online and ready, or a saved E2B deployment + whose template build is not reported as not ready), + a project with an active key, and a first Session. Completion comes from reads the + console already makes. It can be hidden; Show Getting started in the sidebar + opens it again, and it ends with a brief "You're set". The optional + three-chapter tour of the console (Monitor, Resources, Platform) opens from it, + on the sign-in stage. - Terminology: API terms stay in English in the Chinese UI (Agent, Session, Turn, Skill, Vault, Credential, API key). The sign-in credential is the Core key ("Core Key"); keys issued in a project for applications are project API keys diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index 7c704978b..208cffa44 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Page } from "@playwright/test"; -import { expectManagementBoundary, FIXTURE_CORE_KEY, resetFixture } from "./console"; +import { expectManagementBoundary, FIXTURE_CORE_KEY, openConsole, resetFixture } from "./console"; test.afterEach(async ({ request }) => expectManagementBoundary(request)); @@ -38,23 +38,97 @@ test("signs in with the Core key, keeps it out of the browser, and signs out and expect(await browserStorage(page)).not.toContain(FIXTURE_CORE_KEY); }); -test("sets up a fresh install: Core key sign-in, first project, a key shown once, the tour, then the console", async ({ page, request }) => { +test("opens a fresh install on the Overview's Getting started: a project and its key shown once, then the step is done", async ({ page, request }) => { await resetFixture(request, "login", { fresh: true }); await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); await page.goto("/"); await signIn(page, FIXTURE_CORE_KEY); - await expect(page.getByRole("heading", { name: "Create your first project" })).toBeVisible(); - await page.locator("input[name=key-name]").fill("my-app"); - await page.getByRole("button", { name: "Create project and key" }).click(); - await expect(page.getByLabel("New key my-app")).toHaveValue(/fixture-secret/); - await page.getByRole("button", { name: "I've saved it, continue" }).click(); + await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible(); + const step = (name: string) => page.getByRole("region", { name: "Getting started" }).getByRole("listitem").filter({ hasText: name }); + // The fixture deployment already has a ready node. + await expect(step("Get sandboxes ready")).toContainText("Done"); + await expect(step("Create a project and issue a key")).toContainText("To do"); + await expect(step("Run the first Session")).toContainText("To do"); - await expect(page.getByLabel("New key my-app")).toHaveCount(0); + // The tour stays optional. + await page.getByRole("button", { name: "Take the tour" }).click(); await expect(page.getByRole("heading", { name: "Is it healthy, and where does it fail?" })).toBeVisible(); await page.getByRole("button", { name: "Skip" }).click(); - await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible(); + + await step("Create a project and issue a key").getByRole("button", { name: "Create project" }).click(); + await page.getByRole("dialog").getByLabel("Name").fill("My app"); + // Core's project list answers late: the new project's key dialog must not wait for it. + const projectList = /\/core\/v1\/projects(\?.*)?$/; + await page.route(projectList, async (route) => { + if (route.request().method() === "GET") await new Promise((resolve) => setTimeout(resolve, 5_000)); + await route.fallback(); + }); + await page.getByRole("dialog").getByRole("button", { name: "Create" }).click(); + const issue = page.getByRole("dialog", { name: "Issue a key for My app" }); + await expect(issue).toBeVisible({ timeout: 3_000 }); + await page.unroute(projectList); + await issue.getByLabel("Key name").fill("my-app"); + await issue.getByRole("button", { name: "Issue key" }).click(); + const issued = page.getByRole("dialog", { name: "Key issued" }); + await expect(issued.getByLabel("New key my-app")).toHaveValue(/fixture-secret/); + await issued.getByRole("button", { name: "I've saved this key" }).click(); + await expect(page.getByLabel("New key my-app")).toHaveCount(0); + + await page.getByRole("button", { name: "Overview", exact: true }).click(); + await expect(step("Create a project and issue a key")).toContainText("Done"); const stored = await browserStorage(page); expect(stored).not.toContain("fixture-secret"); expect(stored).not.toContain(FIXTURE_CORE_KEY); }); + +test("shows every page empty on a fresh install, and Getting started hides, comes back and leads to sandbox setup", async ({ page, request }) => { + await openConsole(page, request, "overview", { fresh: true, sandbox: "none", nodes: "none" }); + // What each page shows once its reads are done: an empty state, Core's own figures, or sandbox setup. + const loaded = (view: string) => view === "core-metrics" ? page.locator(".kpi-strip").first() + : view === "nodes" ? page.getByRole("heading", { name: "Where should sandboxes run?" }) + : page.locator(".console-empty").first(); + for (const view of ["core-metrics", "agent-metrics", "sandbox-metrics", "sessions", "agents", "templates", "skills", "files", "vaults", "projects", "nodes", "system", "overview"]) { + await page.goto(`/#${view}`); + await expect(loaded(view)).toBeVisible(); + await expect(page.locator('[aria-busy="true"]')).toHaveCount(0); + await expect(page.getByText(/Loading|Connecting/)).toHaveCount(0); + // No failed read: neither an error on the page nor an error toast. + await expect(page.getByRole("alert")).toHaveCount(0); + await expect(page.locator(".toast-region-assertive")).toBeEmpty(); + } + + const checklist = page.getByRole("region", { name: "Getting started" }); + await checklist.getByRole("button", { name: "Hide Getting started" }).click(); + await expect(checklist).toHaveCount(0); + await page.reload(); + await expect(page.locator(".console-empty").first()).toBeVisible(); + await expect(checklist).toHaveCount(0); + await page.getByRole("button", { name: "Show Getting started" }).click(); + const step = checklist.getByRole("listitem").filter({ hasText: "Get sandboxes ready" }); + await expect(step).toContainText("To do"); + await step.getByRole("button", { name: "Set up sandboxes" }).click(); + await expect(page.getByRole("heading", { name: "Where should sandboxes run?" })).toBeVisible(); +}); + +test("opens Add node from Getting started when no node has joined", async ({ page, request }) => { + await openConsole(page, request, "overview", { fresh: true, nodes: "none" }); + const step = page.getByRole("region", { name: "Getting started" }).getByRole("listitem").filter({ hasText: "Get sandboxes ready" }); + await expect(step).toContainText("To do"); + await step.getByRole("button", { name: "Add node" }).click(); + await expect(page.getByRole("heading", { name: "Nodes", level: 1 })).toBeVisible(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); +}); + +test("reopens a finished Getting started from the sidebar and keeps You're set through the tour", async ({ page, request }) => { + await openConsole(page, request); + await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible(); + await page.getByRole("button", { name: "Show Getting started" }).click(); + const done = page.getByRole("region", { name: "You're set" }); + await expect(done).toBeVisible(); + await done.getByRole("button", { name: "Take the tour" }).click(); + await page.getByRole("button", { name: "Skip" }).click(); + await expect(done.getByRole("button", { name: "Take the tour" })).toBeFocused(); + await done.getByRole("button", { name: "Dismiss" }).click(); + await expect(done).toHaveCount(0); +}); diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index f16bb9789..0c3c9c5b7 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -6,13 +6,19 @@ const web = `http://127.0.0.1:${process.env.AGENTS_WEB_PORT ?? 4174}`; /** The fixture deployment's Core key; the same value as CORE_KEY in fixture-console.mjs. */ export const FIXTURE_CORE_KEY = "fixture-core-key-3f9a2c71"; +/** + * Fixture state options: `fresh` is a new install (no project, Session or Runtime), + * `sandbox` the sandbox deployment, and `nodes: "none"` a deployment no node has joined. + */ +export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none" } + /** Fresh fixture state: signed out ("login") or already signed in ("authenticated"). */ -export async function resetFixture(request: APIRequestContext, auth: "login" | "authenticated" = "authenticated", options: { fresh?: boolean; sandbox?: "configured" | "none" | "e2b" } = {}) { - await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}`); +export async function resetFixture(request: APIRequestContext, auth: "login" | "authenticated" = "authenticated", options: FixtureOptions = {}) { + await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}${options.nodes ? `&nodes=${options.nodes}` : ""}`); } /** Opens the console already signed in, in English. */ -export async function openConsole(page: Page, request: APIRequestContext, hash = "overview", options: { sandbox?: "configured" | "none" | "e2b" } = {}) { +export async function openConsole(page: Page, request: APIRequestContext, hash = "overview", options: FixtureOptions = {}) { await resetFixture(request, "authenticated", options); await page.context().addCookies([{ name: "core_console", value: "fixture-session", url: web }]); await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index acd280724..95ab9cc32 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -36,13 +36,15 @@ function e2bDeployment() { return { ...configuredDeployment(), provider: "e2b", mode: "direct", resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "parsar-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", credential_configured: true, template_build: templateBuild } }; } -function reset(mode = "login", fresh = false, sandbox = "configured") { +function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo") { const base = buildDemo(); const now = Math.floor(Date.now() / 1000); const resources = buildResources(now, base.agents, base.sessions); const admin = buildAdmin(now, base, resources); - // A fresh install: no project yet, so the console starts first-run setup. - if (fresh) admin.projects.splice(0); + // A fresh install: no project, Session or Runtime yet; Getting started leads. + if (fresh) for (const list of [admin.projects, base.sessions, base.observations, base.allocations]) list.splice(0); + // "none": no node has enrolled yet. + if (nodes === "none") base.nodes.splice(0); state = { ...base, resources, admin, // "authenticated": the console holds a session for the fixture cookie; "login": it holds none. @@ -339,7 +341,7 @@ function registeredNode(nodeId) { async function fixtureRoute(request, response, url) { if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true }); if (url.pathname === "/__fixture/reset" && request.method === "POST") { - reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured"); + reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo"); return send(response, 200, { ok: true }); } if (url.pathname === "/__fixture/fail-next" && request.method === "POST") { diff --git a/apps/web/src/ConsoleApp.tsx b/apps/web/src/ConsoleApp.tsx index ec05a8503..b68812b63 100644 --- a/apps/web/src/ConsoleApp.tsx +++ b/apps/web/src/ConsoleApp.tsx @@ -1,8 +1,7 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ConsoleSidebar } from "./components/ConsoleSidebar"; -import { FirstProjectSetup } from "./features/api-keys/FirstProjectSetup"; import { ProjectsPage } from "./features/api-keys/ProjectsPage"; import { AgentsPage } from "./features/agents/AgentsPage"; import { TemplatesPage } from "./features/environment-templates/TemplatesPage"; @@ -10,6 +9,8 @@ import { FilesPage } from "./features/files/FilesPage"; import { AgentMetricsPage } from "./features/metrics/AgentMetricsPage"; import { CoreMetricsPage } from "./features/metrics/CoreMetricsPage"; import { SandboxMetricsPage } from "./features/metrics/SandboxMetricsPage"; +import { ConsoleTourContext, ConsoleTourScreen } from "./features/onboarding/ConsoleTour"; +import { withTransition } from "./features/onboarding/view-transition"; import { OverviewPage } from "./features/overview/OverviewPage"; import { SandboxManagerView } from "./features/sandbox/SandboxManagerView"; import { SessionLogPage } from "./features/sessions/SessionLogPage"; @@ -17,7 +18,7 @@ import { SessionPage } from "./features/sessions/SessionPage"; import { SkillsPage } from "./features/skills/SkillsPage"; import { SystemPage } from "./features/system/SystemPage"; import { VaultsPage } from "./features/vaults/VaultsPage"; -import { consoleDepth, ConsoleNavigationContext, hashWithParams, routeParamsFromHash, type RouteParams } from "./lib/console-navigation"; +import { consoleDepth, ConsoleNavigationContext, hashWithParams, routeParamsFromHash, type ConsoleIntent, type RouteParams } from "./lib/console-navigation"; import { consoleHashForView, consoleNavParent, consoleViewFromHash, type ConsoleView } from "./lib/console-routes"; import { ProjectsProvider, useProjects } from "./lib/projects"; import { collectionQuery, collections, filesCollection, queryClient, type CollectionSpec } from "./lib/queries"; @@ -33,9 +34,9 @@ const prefetchable: Partial>> = { sessions: collections.sessions, }; -function readLocation(): { view: ConsoleView; params: RouteParams } { +function readLocation(): { view: ConsoleView; params: RouteParams; intent: ConsoleIntent | null } { const hash = typeof window === "undefined" ? "" : window.location.hash; - return { view: consoleViewFromHash(hash), params: routeParamsFromHash(hash) }; + return { view: consoleViewFromHash(hash), params: routeParamsFromHash(hash), intent: null }; } function ConsolePage({ view }: { view: ConsoleView }) { @@ -61,13 +62,20 @@ function ConsoleShell() { const { t } = useTranslation("navigation"); const { state } = useProjects(); const [location, setLocation] = useState(readLocation); - const [setupDone, setSetupDone] = useState(false); - // Once first-run setup has started it stays until it finishes: a background - // re-read of the projects (which now include the new one) must not replace it - // while the first key is on screen. - const needsSetup = state.status === "ready" && state.projects.length === 0; - const [setupStarted, setSetupStarted] = useState(false); - useEffect(() => { if (needsSetup) setSetupStarted(true); }, [needsSetup]); + // The console tour takes the place of the shell until it ends; then the + // control that opened it, or else the page, takes the focus back. + const [touring, setTouring] = useState(false); + const tourEnded = useRef(false); + const openTour = useCallback((from: HTMLElement | null) => withTransition("enter", () => setTouring(true), from), []); + const endTour = useCallback((from: HTMLElement | null) => withTransition("enter", () => { + tourEnded.current = true; + setTouring(false); + }, from), []); + useEffect(() => { + if (touring || !tourEnded.current) return; + tourEnded.current = false; + (document.querySelector("[data-tour-opener]") ?? document.getElementById("main-content"))?.focus(); + }, [touring]); useEffect(() => { const sync = () => setLocation(readLocation()); @@ -79,22 +87,23 @@ function ConsoleShell() { }; }, []); - const navigate = useCallback((view: ConsoleView, params: RouteParams = {}) => { + const navigate = useCallback((view: ConsoleView, params: RouteParams = {}, intent: ConsoleIntent | null = null) => { const hash = hashWithParams(consoleHashForView(view), params); if (window.location.hash !== hash) { // Each entry records how many console pages lie behind it, so `back` knows it can return. window.history.pushState({ consoleDepth: consoleDepth() + 1 }, "", hash || window.location.pathname + window.location.search); } - setLocation({ view, params }); + setLocation({ view, params, intent }); document.getElementById("main-content")?.focus({ preventScroll: true }); }, []); + const clearIntent = useCallback(() => setLocation((current) => (current.intent ? { ...current, intent: null } : current)), []); const back = useCallback((view: ConsoleView, params: RouteParams = {}) => { if (consoleDepth() > 0) window.history.back(); else navigate(view, params); }, [navigate]); - const navigation = useMemo(() => ({ ...location, navigate, back }), [location, navigate, back]); + const navigation = useMemo(() => ({ ...location, navigate, back, clearIntent }), [location, navigate, back, clearIntent]); const prefetch = useCallback((view: ConsoleView) => { const spec = prefetchable[view]; @@ -102,23 +111,21 @@ function ConsoleShell() { for (const project of state.projects) void queryClient.prefetchQuery(collectionQuery(spec, project.id)); }, [state.projects]); - // First run: with no project yet, the administrator creates the first one and - // its API key. Signing in reads the projects first, so this opens at once. - if ((needsSetup || setupStarted) && !setupDone) { - return setSetupDone(true)} />; - } + if (touring) return ; return ( -
- {t("skipToContent")} - navigate(view)} onIntent={prefetch} /> -
-
- -
-
-
+ +
+ {t("skipToContent")} + navigate(view)} onIntent={prefetch} onGettingStarted={() => navigate("overview", {}, "getting-started")} /> +
+
+ +
+
+
+
); } diff --git a/apps/web/src/components/AppearanceMenu.tsx b/apps/web/src/components/AppearanceMenu.tsx index 33ead4c1b..f2862f253 100644 --- a/apps/web/src/components/AppearanceMenu.tsx +++ b/apps/web/src/components/AppearanceMenu.tsx @@ -36,7 +36,9 @@ export function AppearanceMenu() { }; const onKeyDown = (event: KeyboardEvent) => { + // Escape belongs to the menu only while it is open; otherwise the page may use it. if (event.key === "Escape") { + if (!open) return; event.preventDefault(); close(); return; diff --git a/apps/web/src/components/ConsoleSidebar.tsx b/apps/web/src/components/ConsoleSidebar.tsx index 130f46c39..2cf5531f6 100644 --- a/apps/web/src/components/ConsoleSidebar.tsx +++ b/apps/web/src/components/ConsoleSidebar.tsx @@ -7,6 +7,7 @@ import { FolderKanban, Layers3, LayoutDashboard, + ListChecks, ListTree, Network, Puzzle, @@ -44,11 +45,14 @@ export function ConsoleSidebar({ active, onSelect, onIntent, + onGettingStarted, }: { active: ConsoleView | null; onSelect: (view: ConsoleView) => void; /** Hover or focus on an item: read its page's data before the click. */ onIntent?: (view: ConsoleView) => void; + /** Opens the Overview's Getting started, even after it was hidden. */ + onGettingStarted: () => void; }) { const { t } = useTranslation("navigation"); const provider = useSandboxProvider(); @@ -97,6 +101,12 @@ export function ConsoleSidebar({
+
+ +
diff --git a/apps/web/src/components/charts/TimeSeriesChart.tsx b/apps/web/src/components/charts/TimeSeriesChart.tsx index d7d7d1cd2..b760c927c 100644 --- a/apps/web/src/components/charts/TimeSeriesChart.tsx +++ b/apps/web/src/components/charts/TimeSeriesChart.tsx @@ -28,6 +28,8 @@ export interface TimeSeriesChartProps { height?: number; /** Columns only: include this series in the tooltip without drawing it. */ tooltipOnly?: readonly TimeSeries[]; + /** The values are counts, so the axis ticks stay whole numbers. */ + counts?: boolean; } /** `left` is a floor: the axis gutter grows to the widest tick label. */ @@ -75,6 +77,7 @@ export function TimeSeriesChart({ formatAxis = formatValue, height = 168, tooltipOnly = [], + counts = false, }: TimeSeriesChartProps) { const { t, i18n } = useTranslation(); const locale = i18n.resolvedLanguage; @@ -109,7 +112,7 @@ export function TimeSeriesChart({ } return max; }, [count, kind, series, stacked]); - const ticks = niceTicks(maximum); + const ticks = niceTicks(maximum, 4, counts); const top = ticks[ticks.length - 1] || 1; // Tick labels start at the card's content edge, under the title and legend; // the plot begins after the widest label, so every chart lines up the same way. diff --git a/apps/web/src/components/charts/chart-scale.test.ts b/apps/web/src/components/charts/chart-scale.test.ts new file mode 100644 index 000000000..431a71382 --- /dev/null +++ b/apps/web/src/components/charts/chart-scale.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { niceTicks } from "./chart-scale"; + +describe("chart ticks", () => { + it("keeps count axes on whole numbers", () => { + expect(niceTicks(1, 4, true)).toEqual([0, 1]); + expect(niceTicks(3, 4, true)).toEqual([0, 1, 2, 3]); + expect(niceTicks(9, 4, true)).toEqual([0, 5, 10]); + expect(niceTicks(10, 4, true)).toEqual([0, 5, 10]); + expect(niceTicks(10)).toEqual([0, 2.5, 5, 7.5, 10]); + }); +}); diff --git a/apps/web/src/components/charts/chart-scale.ts b/apps/web/src/components/charts/chart-scale.ts index 9210fca69..0e021f9e1 100644 --- a/apps/web/src/components/charts/chart-scale.ts +++ b/apps/web/src/components/charts/chart-scale.ts @@ -1,12 +1,16 @@ /** Pure scale helpers shared by the console charts. */ -/** Round a positive maximum up to a clean axis limit and return evenly spaced ticks from zero. */ -export function niceTicks(maximum: number, targetCount = 4): number[] { +/** + * Round a positive maximum up to a clean axis limit and return evenly spaced ticks from zero. + * With `integer`, the step is a whole number (1, 2, 5, 10, …), so counts never get fractional ticks. + */ +export function niceTicks(maximum: number, targetCount = 4, integer = false): number[] { if (!Number.isFinite(maximum) || maximum <= 0) return [0, 1]; const rough = maximum / targetCount; const magnitude = 10 ** Math.floor(Math.log10(rough)); const residual = rough / magnitude; - const step = (residual <= 1 ? 1 : residual <= 2 ? 2 : residual <= 2.5 ? 2.5 : residual <= 5 ? 5 : 10) * magnitude; + const nice = (residual <= 1 ? 1 : residual <= 2 ? 2 : residual <= 2.5 && !integer ? 2.5 : residual <= 5 ? 5 : 10) * magnitude; + const step = integer ? Math.max(1, Math.ceil(nice)) : nice; const count = Math.max(1, Math.ceil(maximum / step - 1e-9)); return Array.from({ length: count + 1 }, (_, index) => Number((index * step).toPrecision(12))); } diff --git a/apps/web/src/components/magicui/terminal.tsx b/apps/web/src/components/magicui/terminal.tsx deleted file mode 100644 index df2f3f20d..000000000 --- a/apps/web/src/components/magicui/terminal.tsx +++ /dev/null @@ -1,300 +0,0 @@ -"use client" - -import { - Children, - createContext, - useContext, - useEffect, - useMemo, - useRef, - useState, - type ComponentType, - type RefAttributes, -} from "react" -import * as m from "motion/react-m" -import { - useInView, - type DOMMotionComponents, - type HTMLMotionProps, - type MotionProps, -} from "motion/react" - -import { cn } from "@/lib/utils" - -interface SequenceContextValue { - completeItem: (index: number) => void - activeIndex: number - sequenceStarted: boolean -} - -const SequenceContext = createContext(null) - -const useSequence = () => useContext(SequenceContext) - -const ItemIndexContext = createContext(null) -const useItemIndex = () => useContext(ItemIndexContext) - -const motionElements = { - article: m.article, - div: m.div, - h1: m.h1, - h2: m.h2, - h3: m.h3, - h4: m.h4, - h5: m.h5, - h6: m.h6, - li: m.li, - p: m.p, - section: m.section, - span: m.span, -} as const - -type MotionElementType = Extract< - keyof DOMMotionComponents, - keyof typeof motionElements -> -type TerminalTypingMotionComponent = ComponentType< - Omit, "ref"> & RefAttributes -> - -interface AnimatedSpanProps extends MotionProps { - children: React.ReactNode - delay?: number - className?: string - startOnView?: boolean -} - -export const AnimatedSpan = ({ - children, - delay = 0, - className, - startOnView = false, - ...props -}: AnimatedSpanProps) => { - const elementRef = useRef(null) - const isInView = useInView(elementRef as React.RefObject, { - amount: 0.3, - once: true, - }) - - const sequence = useSequence() - const itemIndex = useItemIndex() - const [hasStarted, setHasStarted] = useState(false) - useEffect(() => { - if (!sequence || itemIndex === null) return - if (!sequence.sequenceStarted) return - if (hasStarted) return - if (sequence.activeIndex === itemIndex) { - setHasStarted(true) - } - }, [sequence, hasStarted, itemIndex]) - - const shouldAnimate = sequence ? hasStarted : startOnView ? isInView : true - - return ( - { - if (!sequence) return - if (itemIndex === null) return - sequence.completeItem(itemIndex) - }} - {...props} - > - {children} - - ) -} - -interface TypingAnimationProps extends Omit { - children: string - className?: string - duration?: number - delay?: number - as?: MotionElementType - startOnView?: boolean -} - -export const TypingAnimation = ({ - children, - className, - duration = 60, - delay = 0, - as: Component = "span", - startOnView = true, - ...props -}: TypingAnimationProps) => { - if (typeof children !== "string") { - throw new Error("TypingAnimation: children must be a string. Received:") - } - - const MotionComponent = motionElements[ - Component - ] as TerminalTypingMotionComponent - - const [displayedText, setDisplayedText] = useState("") - const [started, setStarted] = useState(false) - const elementRef = useRef(null) - const isInView = useInView(elementRef as React.RefObject, { - amount: 0.3, - once: true, - }) - - const sequence = useSequence() - const itemIndex = useItemIndex() - const hasSequence = sequence !== null - const sequenceStarted = sequence?.sequenceStarted ?? false - const sequenceActiveIndex = sequence?.activeIndex ?? null - const sequenceCompleteItemRef = useRef< - SequenceContextValue["completeItem"] | null - >(null) - const sequenceItemIndexRef = useRef(null) - - useEffect(() => { - sequenceCompleteItemRef.current = sequence?.completeItem ?? null - sequenceItemIndexRef.current = itemIndex - }, [sequence?.completeItem, itemIndex]) - - useEffect(() => { - let startTimeout: ReturnType | null = null - - if (hasSequence && itemIndex !== null) { - if (sequenceStarted && !started && sequenceActiveIndex === itemIndex) { - setStarted(true) - } - } else if (!startOnView || isInView) { - startTimeout = setTimeout(() => setStarted(true), delay) - } - - return () => { - if (startTimeout !== null) { - clearTimeout(startTimeout) - } - } - }, [ - delay, - startOnView, - isInView, - started, - hasSequence, - sequenceActiveIndex, - sequenceStarted, - itemIndex, - ]) - - useEffect(() => { - let typingEffect: ReturnType | null = null - - if (started) { - let i = 0 - typingEffect = setInterval(() => { - if (i < children.length) { - setDisplayedText(children.substring(0, i + 1)) - i++ - } else { - if (typingEffect !== null) { - clearInterval(typingEffect) - } - const completeItem = sequenceCompleteItemRef.current - const currentItemIndex = sequenceItemIndexRef.current - if (completeItem && currentItemIndex !== null) { - completeItem(currentItemIndex) - } - } - }, duration) - } - - return () => { - if (typingEffect !== null) { - clearInterval(typingEffect) - } - } - }, [children, duration, started]) - - return ( - - {displayedText} - - ) -} - -interface TerminalProps { - children: React.ReactNode - className?: string - sequence?: boolean - startOnView?: boolean -} - -export const Terminal = ({ - children, - className, - sequence = true, - startOnView = true, -}: TerminalProps) => { - const containerRef = useRef(null) - const isInView = useInView(containerRef as React.RefObject, { - amount: 0.3, - once: true, - }) - - const [activeIndex, setActiveIndex] = useState(0) - const sequenceHasStarted = sequence ? !startOnView || isInView : false - - const contextValue = useMemo(() => { - if (!sequence) return null - return { - completeItem: (index: number) => { - setActiveIndex((current) => (index === current ? current + 1 : current)) - }, - activeIndex, - sequenceStarted: sequenceHasStarted, - } - }, [sequence, activeIndex, sequenceHasStarted]) - - const wrappedChildren = useMemo(() => { - if (!sequence) return children - const array = Children.toArray(children) - return array.map((child, index) => ( - - {child as React.ReactNode} - - )) - }, [children, sequence]) - - const content = ( -
-
-
-
-
-
-
-
-
-        {wrappedChildren}
-      
-
- ) - - if (!sequence) return content - - return ( - - {content} - - ) -} diff --git a/apps/web/src/features/api-keys/FirstProjectSetup.tsx b/apps/web/src/features/api-keys/FirstProjectSetup.tsx deleted file mode 100644 index b2f0f4207..000000000 --- a/apps/web/src/features/api-keys/FirstProjectSetup.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { SandboxAdminClient } from "@agents-core-web/agents-client"; -import { ArrowRight } from "lucide-react"; -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { AppearanceMenu } from "../../components/AppearanceMenu"; -import { HelpTip } from "../../components/console-ui"; -import { useProjects } from "../../lib/projects"; -import { ConsoleAccountMenu } from "../first-run/ConsoleAccess"; -import { curlExample, isUsableName, keyNameProblem, normalizeName, projectNameProblem, type KeyFlow } from "./key-flows"; -import { FlowErrorMessage, KeyNameField, NameField } from "./KeyFlowDialogs"; -import { PlaintextKey } from "./IssuedKey"; -import { ConsoleTour, TOUR_CHAPTERS } from "../onboarding/ConsoleTour"; -import { OnboardingLayout } from "../onboarding/OnboardingLayout"; -import { RequestTerminal } from "../onboarding/RequestTerminal"; -import { withTransition } from "../onboarding/view-transition"; -import { useKeyFlow } from "./use-key-flow"; -import "./api-keys.css"; - -/** The Core origin callers use, when the sandbox deployment reports one. */ -function useCoreOrigin(): string | null { - const [origin, setOrigin] = useState(null); - useEffect(() => { - const controller = new AbortController(); - new SandboxAdminClient({ baseUrl: "/core/v1/sandbox" }).retrieveDeployment({ signal: controller.signal }).then( - (deployment) => setOrigin(deployment.core_url || null), - () => undefined, - ); - return () => controller.abort(); - }, []); - return origin; -} - -function canSubmit(flow: Extract): boolean { - if (flow.busy || !isUsableName(flow.name, keyNameProblem(flow.name))) return false; - return flow.project !== null || isUsableName(flow.projectName, projectNameProblem(flow.projectName)); -} - -/** - * First run: the administrator creates the first project and its first named - * key, then sees the plaintext once with a request the caller can run (the - * console never sends it), then a short tour of the console. The key is - * discarded when the operator confirms it was saved; the shell leaves these - * screens only when the tour ends. - */ -export function FirstProjectSetup({ onDone }: { onDone: () => void }) { - const { t } = useTranslation("keys"); - const { refresh } = useProjects(); - // The project list is refreshed only on "Done": refreshing earlier would - // take the shell past this screen while the key is still on it. - const controls = useKeyFlow(); - const { flow, dispatch } = controls; - const coreOrigin = useCoreOrigin(); - - const [phase, setPhase] = useState<"setup" | "tour">("setup"); - const [chapter, setChapter] = useState(0); - - useEffect(() => { - if (phase === "setup" && flow.step === "idle") dispatch({ type: "openFirstRun" }); - }, [dispatch, flow.step, phase]); - - // Confirming the key discards its plaintext and moves on to the tour. - const toTour = () => withTransition("step", () => { - dispatch({ type: "saved" }); - setPhase("tour"); - }); - const enter = (from: HTMLElement | null) => withTransition("enter", () => { - refresh(); - onDone(); - }, from); - - let content = null; - if (phase === "tour") { - content = ; - } else if (flow.step === "issued") { - content = ( - <> -

{t("firstRun.readyTitle")}

-
-

{t("issued.onceNotice")}

- -
-
-
- {t("firstRun.tryIt")} - {t("firstRun.tryItHelp")} -
- -
-
- -
- - ); - } else if (flow.step === "issue") { - const projectProblem = flow.project ? null : projectNameProblem(flow.projectName); - content = ( - <> -

{t("firstRun.title")}

-
{ event.preventDefault(); if (canSubmit(flow)) void controls.submit(); }}> - dispatch({ type: "setProjectName", name })} - problem={projectProblem} - problemText={projectProblem ? t(`createDialog.problems.${projectProblem}`) : ""} - // Once created, the project is kept and a retry only issues its key. - disabled={flow.busy || flow.project !== null} - /> - - {flow.error?.kind === "uncertain" - // This screen has no list to refresh: check in the console instead of issuing a second key unseen. - ?

{t("firstRun.uncertain")}

- : } - {flow.project && flow.error?.kind === "rejected" ?

{t("firstRun.projectCreated", { project: flow.project.name })}

: null} -
- {flow.error?.kind === "uncertain" ? ( - - ) : null} - -
- - - ); - } - - return ( - } - > -
{content}
-
- ); -} diff --git a/apps/web/src/features/api-keys/IssuedKey.tsx b/apps/web/src/features/api-keys/IssuedKey.tsx index 1127c1f74..e028cbec7 100644 --- a/apps/web/src/features/api-keys/IssuedKey.tsx +++ b/apps/web/src/features/api-keys/IssuedKey.tsx @@ -59,18 +59,3 @@ export function PlaintextKey({ value, label, caption }: { value: string; label:
); } - -/** A short command with a copy button; the console never runs it. */ -export function CommandBlock({ value, label }: { value: string; label: string }) { - const { t } = useTranslation("keys"); - const { state, copy } = useCopy(value); - return ( -
-
{value}
- - {state === "failed" ?

{t("issued.copyCommandFailed")}

: null} -
- ); -} diff --git a/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx b/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx index 85e6af410..fe5cc8fb6 100644 --- a/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx +++ b/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx @@ -31,7 +31,7 @@ describe("one-time key display", () => { }); it("names the project and blocks a key name already used by an active key", () => { - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup(); expect(html).toContain("Issue a key for Production"); expect(html).toContain("An active key of this project already has this name."); expect(html).toMatch(/
; } -/** True when the deployment has no project yet, so first-run setup follows signing in. */ -async function needsFirstProject(): Promise { - try { - return (await queryClient.fetchQuery(projectsQuery)).length === 0; - } catch { - return false; // The console reports the failed read itself. - } -} - export function ConsoleAccess({ children }: { children: ReactNode }) { const { t } = useTranslation("firstRun"); const [status, setStatus] = useState(null); @@ -82,9 +72,9 @@ export function ConsoleAccess({ children }: { children: ReactNode }) { } }}>{children}; return }> - {status && !failed ? { - // Without a project, first-run setup continues on the same stage; otherwise the console opens. - withTransition(firstRun ? "step" : "enter", () => { + {status && !failed ? { + // The console opens on the Overview, revealed from the pressed button. + withTransition("enter", () => { generation.current++; setStatus(next); }, from); @@ -95,7 +85,7 @@ export function ConsoleAccess({ children }: { children: ReactNode }) { } function CoreKeyForm({ onAuthenticated }: { - onAuthenticated: (status: ConsoleAuth, firstRun: boolean, from: HTMLElement | null) => void; + onAuthenticated: (status: ConsoleAuth, from: HTMLElement | null) => void; }) { const { t } = useTranslation("firstRun"); const id = useId(); @@ -127,8 +117,7 @@ function CoreKeyForm({ onAuthenticated }: { const request = new AbortController(); controller.current = request; try { const next = await changeConsoleAuth({ action: "login", coreKey }, request.signal); - const firstRun = await needsFirstProject(); - if (!request.signal.aborted) onAuthenticated(next, firstRun, submitter); + if (!request.signal.aborted) onAuthenticated(next, submitter); } catch (cause) { if (request.signal.aborted) return; setError(errorMessage(cause)); diff --git a/apps/web/src/features/metrics/AgentMetricsPage.tsx b/apps/web/src/features/metrics/AgentMetricsPage.tsx index 246ec9a71..f7fd54f64 100644 --- a/apps/web/src/features/metrics/AgentMetricsPage.tsx +++ b/apps/web/src/features/metrics/AgentMetricsPage.tsx @@ -264,6 +264,7 @@ function AgentMetricsContent({ ]} tooltipOnly={[{ id: "total", label: t("agent.requests"), color: "var(--fg-muted)", values: series.requests }]} formatValue={integer} + counts formatAxis={compact} /> @@ -297,6 +298,7 @@ function AgentMetricsContent({ bucketSeconds={window.bucketSeconds} series={named(series.tokensByModel, modelLabel, modelColor, totalsOf(tokenItems, compact))} formatValue={integer} + counts formatAxis={compact} /> @@ -310,6 +312,7 @@ function AgentMetricsContent({ bucketSeconds={window.bucketSeconds} series={named(series.requestsByModel, modelLabel, modelColor, totalsOf(requestItems, integer))} formatValue={integer} + counts formatAxis={compact} /> @@ -330,6 +333,7 @@ function AgentMetricsContent({ bucketSeconds={window.bucketSeconds} series={named(series.callsByTool, (id) => toolLabel(id, toolIndex.get(id)), toolColor, totalsOf(toolItems, integer))} formatValue={integer} + counts formatAxis={compact} /> diff --git a/apps/web/src/features/metrics/CoreMetricsPage.tsx b/apps/web/src/features/metrics/CoreMetricsPage.tsx index df71953c6..277d73001 100644 --- a/apps/web/src/features/metrics/CoreMetricsPage.tsx +++ b/apps/web/src/features/metrics/CoreMetricsPage.tsx @@ -189,6 +189,7 @@ function CoreMetricsBody({ metrics }: { metrics: CoreMetrics }) { { id: "queued", label: t("core.execution.queued"), color: "var(--series-3)", values: execution.series.map((entry) => entry.queued) }, ]} formatValue={integer} + counts />
@@ -241,6 +242,7 @@ function CoreMetricsBody({ metrics }: { metrics: CoreMetrics }) { { id: "max", label: t("core.database.max"), color: "var(--ink-3)", values: database.series.map((entry) => (entry.pool_in_use === null ? null : database.pool.max)) }, ]} formatValue={integer} + counts />
diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index abb9eb625..09e2d832c 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -173,12 +173,21 @@ export function SandboxMetricsPage() { ) : ( - navigate("nodes")}>{t("sandbox.addNode")}} - /> + // Before sandbox setup there is nothing to add a node to; setup comes first. + fleet?.deployment.provider ? ( + navigate("nodes", {}, "add-node")}>{t("sandbox.addNode")}} + /> + ) : ( + navigate("nodes")}>{t("sandbox.setUp")}} + /> + ) ) : fleetState.status === "checking" || fleetState.status === "loading" ? :

{message}

} diff --git a/apps/web/src/features/onboarding/ConsoleTour.tsx b/apps/web/src/features/onboarding/ConsoleTour.tsx index 35056543d..dc697daef 100644 --- a/apps/web/src/features/onboarding/ConsoleTour.tsx +++ b/apps/web/src/features/onboarding/ConsoleTour.tsx @@ -1,9 +1,12 @@ import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; import { Activity, ArrowLeft, ArrowRight, Bot, FolderKanban, LayoutDashboard, ListTree, Server, Settings2, Trash2, Vault, type LucideIcon } from "lucide-react"; -import { useEffect, useRef } from "react"; +import { createContext, useContext, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { AppearanceMenu } from "../../components/AppearanceMenu"; +import { ConsoleAccountMenu } from "../first-run/ConsoleAccess"; +import { OnboardingLayout } from "./OnboardingLayout"; import type { TourChapter } from "./OnboardingStage"; export const TOUR_CHAPTERS: readonly TourChapter[] = ["monitor", "resources", "platform"]; @@ -17,15 +20,33 @@ const icons: Record const EASE = [0.16, 1, 0.3, 1] as const; /** - * The last first-run step: three short chapters, one per navigation group, - * each beside a screenshot of those pages on the stage. The last button - * opens the console. + * Opens the console tour from the console; receives the pressed button for the + * reveal's origin. A button marked `data-tour-opener` gets the focus back when + * the tour ends. */ -export function ConsoleTour({ chapter, onChapter, onEnter }: { +export const ConsoleTourContext = createContext<(from: HTMLElement | null) => void>(() => undefined); +export const useConsoleTour = () => useContext(ConsoleTourContext); + +/** The tour on the onboarding stage, in place of the console until it ends. */ +export function ConsoleTourScreen({ onDone }: { onDone: (from: HTMLElement | null) => void }) { + const [chapter, setChapter] = useState(0); + return ( + }> + + + ); +} + +/** + * An optional tour of the console: three short chapters, one per navigation + * group, each beside a screenshot of those pages on the stage. The last + * button, Skip and Escape return to the console. + */ +export function ConsoleTour({ chapter, onChapter, onDone }: { chapter: number; onChapter: (next: number) => void; - /** Opens the console; receives the pressed button for the reveal's origin. */ - onEnter: (from: HTMLElement | null) => void; + /** Returns to the console; receives the pressed button for the reveal's origin. */ + onDone: (from: HTMLElement | null) => void; }) { const { t } = useTranslation("onboarding"); const id = TOUR_CHAPTERS[chapter] ?? "monitor"; @@ -35,21 +56,27 @@ export function ConsoleTour({ chapter, onChapter, onEnter }: { const [First, Second, Third] = icons[id]; const pointIcons = [First, Second, Third]; + // The tour replaces the page, so focus starts on its way forward. + useEffect(() => { primary.current?.focus(); }, []); + useEffect(() => { const onKey = (event: KeyboardEvent) => { + // A control that already handled the key, such as Escape closing a menu, keeps it. + if (event.defaultPrevented) return; if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return; if (event.key === "ArrowRight" && !last) onChapter(chapter + 1); if (event.key === "ArrowLeft" && chapter > 0) onChapter(chapter - 1); + if (event.key === "Escape") onDone(null); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [chapter, last, onChapter]); + }, [chapter, last, onChapter, onDone]); return (
{t("tour.eyebrow", { n: chapter + 1, total: TOUR_CHAPTERS.length })} - {!last ? : null} + {!last ? : null}
-
+
{TOUR_CHAPTERS.map((entry, index) => (
diff --git a/apps/web/src/features/onboarding/OnboardingLayout.tsx b/apps/web/src/features/onboarding/OnboardingLayout.tsx index 50f7c5229..d33f82b52 100644 --- a/apps/web/src/features/onboarding/OnboardingLayout.tsx +++ b/apps/web/src/features/onboarding/OnboardingLayout.tsx @@ -1,24 +1,16 @@ -import * as m from "motion/react-m"; import type { ReactNode } from "react"; -import { useTranslation } from "react-i18next"; import { OnboardingStage, type StageScene, type TourChapter } from "./OnboardingStage"; import "./onboarding.css"; -export type OnboardingStep = "project" | "tour"; -const STEPS: readonly OnboardingStep[] = ["project", "tour"]; - /** - * Every onboarding screen: the dark stage on the left, the step's panel on - * the right with setup progress and the page controls above it. Signing in - * (no `step`) uses the same frame without the progress. + * Signing in and the console tour: the dark stage on the left, the panel on + * the right with the page controls above it. */ -export function OnboardingLayout({ scene, chapter, step, controls, children }: { +export function OnboardingLayout({ scene, chapter, controls, children }: { /** What the stage shows; null while the console is still checking the sign-in. */ - scene: StageScene | "tour" | null; + scene: StageScene | null; chapter?: TourChapter; - /** The first-run step this screen is; omitted when signing in. */ - step?: OnboardingStep; /** Theme, language and sign-out controls. */ controls: ReactNode; children: ReactNode; @@ -28,7 +20,6 @@ export function OnboardingLayout({ scene, chapter, step, controls, children }: {
- {step ? : }
{controls}
{children}
@@ -36,21 +27,3 @@ export function OnboardingLayout({ scene, chapter, step, controls, children }: {
); } - -function Progress({ step }: { step: OnboardingStep }) { - const { t } = useTranslation("onboarding"); - const current = STEPS.indexOf(step); - return ( -
    - {STEPS.map((entry, index) => ( -
  1. - - {index === current ? : null} - {index + 1} - - {t(`steps.${entry}`)} -
  2. - ))} -
- ); -} diff --git a/apps/web/src/features/onboarding/OnboardingStage.tsx b/apps/web/src/features/onboarding/OnboardingStage.tsx index 45d9b6117..db449733b 100644 --- a/apps/web/src/features/onboarding/OnboardingStage.tsx +++ b/apps/web/src/features/onboarding/OnboardingStage.tsx @@ -1,6 +1,6 @@ import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; -import { Bot, FileText, KeyRound, Layers3, MessagesSquare, Puzzle, Server, Vault } from "lucide-react"; +import { Bot, FileText, Layers3, MessagesSquare, Puzzle, Server, Vault } from "lucide-react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; @@ -9,18 +9,18 @@ import { FlickeringGrid } from "@/components/magicui/flickering-grid"; import { LightRays } from "@/components/magicui/light-rays"; import { OrbitingCircles } from "@/components/magicui/orbiting-circles"; -export type StageScene = "login" | "project"; +export type StageScene = "login" | "tour"; export type TourChapter = "monitor" | "resources" | "platform"; const EASE = [0.16, 1, 0.3, 1] as const; /** - * The dark stage beside every onboarding step. Its backdrop (a flickering + * The dark stage beside signing in and the tour. Its backdrop (a flickering * indigo grid under slow light rays) stays the same throughout; in front of * it, Core — the Parsar mark — holds its orbits of Agents, Sessions and the * rest, and during the tour a screenshot of the console takes its place. */ -export function OnboardingStage({ scene, chapter }: { scene: StageScene | "tour" | null; chapter?: TourChapter }) { +export function OnboardingStage({ scene, chapter }: { scene: StageScene | null; chapter?: TourChapter }) { return ( ); } -function Constellation({ scene }: { scene: StageScene | null }) { +function Constellation({ copy }: { copy: boolean }) { const { t } = useTranslation("onboarding"); const chip = (icon: ReactNode, key: string) => {icon}; return ( @@ -46,7 +46,7 @@ function Constellation({ scene }: { scene: StageScene | null }) { {chip(, "agent")} {chip(, "session")} - {chip(scene === "project" ? : , "inner")} + {chip(, "skill")} {chip(, "vault")} @@ -64,25 +64,23 @@ function Constellation({ scene }: { scene: StageScene | null }) { - {scene ? : null} + {copy ? : null} ); } -function StageCopy({ scene }: { scene: StageScene }) { +function StageCopy() { const { t } = useTranslation("onboarding"); - // Keyed, not exit-animated: a new scene simply blurs in over the old one. return (
{/* Brand copy, not a heading: the panel's title names the task. */} -

{t(`stage.${scene}.title`)}

-

{t(`stage.${scene}.body`)}

+

{t("stage.login.title")}

+

{t("stage.login.body")}

); diff --git a/apps/web/src/features/onboarding/RequestTerminal.tsx b/apps/web/src/features/onboarding/RequestTerminal.tsx deleted file mode 100644 index 6ef81fb5b..000000000 --- a/apps/web/src/features/onboarding/RequestTerminal.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useReducedMotion } from "motion/react"; -import { Check, Copy } from "lucide-react"; -import { useTranslation } from "react-i18next"; - -import { Terminal, TypingAnimation } from "@/components/magicui/terminal"; -import { CommandBlock, useCopy } from "../api-keys/IssuedKey"; - -/** - * The example request, typed line by line into a terminal. With reduced - * motion it is the plain command block. Either way it can be copied. - */ -export function RequestTerminal({ value, label }: { value: string; label: string }) { - const { t } = useTranslation("keys"); - const reduced = useReducedMotion(); - const { state, copy } = useCopy(value); - if (reduced) return ; - const copied = state === "copied"; - return ( -
- - {value.split("\n").map((line, index) => ( - {line} - ))} - - -
- ); -} diff --git a/apps/web/src/features/onboarding/onboarding.css b/apps/web/src/features/onboarding/onboarding.css index 2682878e5..56afe17ff 100644 --- a/apps/web/src/features/onboarding/onboarding.css +++ b/apps/web/src/features/onboarding/onboarding.css @@ -1,6 +1,5 @@ /* - * Onboarding: signing in with the Core key, the first project and its API key, - * and the tour. + * Onboarding: signing in with the Core key, and the optional console tour. * A dark stage (always dark, whatever the theme) holds the brand moment — Core * as the Parsar mark with its orbits, or a screenshot of the console during * the tour; the panel beside it follows the console theme and holds the task. @@ -179,7 +178,7 @@ .onboarding-panel-head { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-end; gap: 16px; padding: 22px 32px; } @@ -196,13 +195,6 @@ white-space: nowrap; } -/* Below 1180px wide the step names give way to their numbers. */ -@media (max-width: 1180px) { - .onboarding-progress li:not(.current) .onboarding-progress-label { - display: none; - } -} - .onboarding-panel-body { display: grid; flex: 1; @@ -216,82 +208,13 @@ max-width: 420px; } -/* Setup progress: numbered steps; the current one fills with indigo and the fill glides between steps. */ -.onboarding-progress { - display: flex; - min-width: 0; - align-items: center; - gap: 12px; - margin: 0; - padding: 0; - list-style: none; -} - -.onboarding-progress li { - display: flex; - align-items: center; - gap: 8px; - white-space: nowrap; - color: var(--ink-3); - font-size: 12.5px; - font-weight: 500; -} - -.onboarding-progress li + li::before { - content: ""; - width: 14px; - height: 1px; - margin-right: 4px; - background: var(--line); -} - -.onboarding-progress li.current, -.onboarding-progress li.done { - color: var(--ink); -} - -.onboarding-progress-mark { - position: relative; - display: grid; - width: 22px; - height: 22px; - place-items: center; - border-radius: 999px; - box-shadow: inset 0 0 0 1px var(--line-strong); -} - -.onboarding-progress li.done .onboarding-progress-mark { - color: var(--accent); - box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--accent) 50%, transparent); -} - -.onboarding-progress-fill { - position: absolute; - inset: 0; - background: var(--accent); - border-radius: 999px; - box-shadow: 0 0 0 4px color-mix(in oklch, var(--accent) 18%, transparent); -} - -.onboarding-progress-number { - position: relative; - font-size: 11px; - font-variant-numeric: tabular-nums; -} - -.onboarding-progress li.current .onboarding-progress-number { - color: #fff; -} - -/* The sign-in form and the first project screens inside the panel. */ -.onboarding-panel .console-auth-form, -.onboarding-panel .first-key-column { +/* The sign-in form inside the panel. */ +.onboarding-panel .console-auth-form { width: 100%; max-width: 420px; animation: none; } -.onboarding-panel .first-key-title, .onboarding-panel .console-auth-form h2 { font-size: 22px; font-weight: 600; @@ -299,34 +222,6 @@ letter-spacing: -0.02em; } -/* The example request types itself into a terminal. */ -.onboarding-terminal { - position: relative; -} - -.onboarding-terminal > div:first-child { - max-width: none; - width: 100%; - color: oklch(0.9 0.02 277); - background: oklch(0.15 0.02 277); - border-color: oklch(1 0 0 / 0.08); -} - -.onboarding-terminal pre, -.onboarding-terminal code { - color: inherit; - font-family: var(--font-mono); - font-size: 12px; - line-height: 1.7; - white-space: pre; -} - -.onboarding-terminal-copy { - position: absolute; - top: 8px; - right: 8px; -} - /* Tour -------------------------------------------------------------------- */ .onboarding-tour { @@ -474,37 +369,9 @@ } /* View transitions --------------------------------------------------------- - * `step`: the stage holds still while the panel slides; `enter`: onboarding - * dissolves forward and the console is revealed in a circle growing from the - * pressed button (--reveal-x/y). Reduced motion never starts a transition. */ - -html[data-transition="step"] .onboarding-stage { - view-transition-name: onboarding-stage; -} - -html[data-transition="step"] .onboarding-panel-body { - view-transition-name: onboarding-panel; -} - -html[data-transition="step"]::view-transition-group(onboarding-stage) { - animation-duration: 0s; -} - -html[data-transition="step"]::view-transition-old(onboarding-panel) { - animation: onboarding-panel-out 240ms cubic-bezier(0.4, 0, 1, 1) both; -} - -html[data-transition="step"]::view-transition-new(onboarding-panel) { - animation: onboarding-panel-in 520ms cubic-bezier(0.16, 1, 0.3, 1) 80ms both; -} - -@keyframes onboarding-panel-out { - to { opacity: 0; transform: translateX(-28px); filter: blur(6px); } -} - -@keyframes onboarding-panel-in { - from { opacity: 0; transform: translateX(36px); filter: blur(8px); } -} + * `enter`: the old page dissolves forward and the new one is revealed in a + * circle growing from the pressed button (--reveal-x/y). Reduced motion never + * starts a transition. */ html[data-transition="enter"]::view-transition-old(root) { animation: onboarding-dissolve 620ms cubic-bezier(0.4, 0, 0.2, 1) both; @@ -551,10 +418,6 @@ html[data-transition="enter"]::view-transition-new(root) { padding: 16px 20px; } - .onboarding-progress-label { - display: none; - } - .onboarding-panel-body { padding: 16px 20px 40px; } diff --git a/apps/web/src/features/onboarding/view-transition.ts b/apps/web/src/features/onboarding/view-transition.ts index c46d9b3b1..1e298f5fa 100644 --- a/apps/web/src/features/onboarding/view-transition.ts +++ b/apps/web/src/features/onboarding/view-transition.ts @@ -1,11 +1,11 @@ import { flushSync } from "react-dom"; /** - * How the page changes: `step` keeps the onboarding stage still and slides - * the panel; `enter` dissolves the onboarding and reveals the console in a - * circle growing from the button that was pressed. + * How the page changes: `enter` dissolves the old page and reveals the new + * one in a circle growing from the button that was pressed (signing in, and + * opening or leaving the tour). */ -export type OnboardingTransition = "step" | "enter"; +export type OnboardingTransition = "enter"; /** * Applies a state change inside a View Transition when the browser supports diff --git a/apps/web/src/features/overview/GettingStarted.tsx b/apps/web/src/features/overview/GettingStarted.tsx new file mode 100644 index 000000000..0fd18c6e8 --- /dev/null +++ b/apps/web/src/features/overview/GettingStarted.tsx @@ -0,0 +1,168 @@ +import { useQuery } from "@tanstack/react-query"; +import { Check, Compass, X } from "lucide-react"; +import { useCallback, useEffect, useMemo, useReducer, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { HelpTip, StatusDot } from "../../components/console-ui"; +import { useConsoleIntent, useConsoleNavigation } from "../../lib/console-navigation"; +import { projectsQuery } from "../../lib/queries"; +import { type FleetState } from "../fleet/use-sandbox-fleet"; +import { useConsoleTour } from "../onboarding/ConsoleTour"; +import { + checklistStorageKey, + checklistView, + gettingStartedSteps, + isCelebrating, + readChecklistMemory, + rememberInstallation, + setCelebrating, + writeChecklistMemory, + type ChecklistMemory, + type StepState, +} from "./getting-started"; + +/** + * Getting started: sandboxes, a project key and the first Session, each with + * its state and one action, in any order. It shows until every step is done + * or it is hidden; Show Getting started in the sidebar opens it again. The + * optional console tour opens from it. + */ +export function GettingStarted({ fleet, sessions }: { fleet: FleetState; sessions: number | "failed" | null }) { + const { t } = useTranslation("overview"); + const { navigate } = useConsoleNavigation(); + const openTour = useConsoleTour(); + const projects = useQuery(projectsQuery); + const steps = gettingStartedSteps({ fleet, projects: projects.data ?? (projects.isError ? "failed" : undefined), sessions }); + const states = [steps.sandboxes.state, steps.key.state, steps.session]; + const allDone = states.every((state) => state === "done"); + + // Remembered per installation; a choice made here overrides what was read. + const installationId = fleet.status === "ready" ? fleet.snapshot.deployment.installation_id : ""; + useEffect(() => { if (installationId) rememberInstallation(installationId); }, [installationId]); + const storageKey = checklistStorageKey(fleet); + const stored = useMemo(() => (storageKey === null ? null : readChecklistMemory(storageKey)), [storageKey]); + const [chosen, setChosen] = useState<{ key: string; memory: ChecklistMemory } | null>(null); + const memory = chosen && chosen.key === storageKey ? chosen.memory : stored; + const remember = useCallback((value: Exclude) => { + if (storageKey === null) return; + writeChecklistMemory(storageKey, value); + setChosen({ key: storageKey, memory: value }); + }, [storageKey]); + // "You're set" stays until it is dismissed, while the checklist is already closed; it outlasts the tour. + const [, rerender] = useReducer((count: number) => count + 1, 0); + const celebrating = storageKey !== null && isCelebrating(storageKey); + const celebrate = useCallback((on: boolean) => { + if (storageKey === null) return; + setCelebrating(storageKey, on); + rerender(); + }, [storageKey]); + const [focusPending, setFocusPending] = useState(false); + const view = storageKey === null ? "hidden" : checklistView(states, memory); + + // Shown with a step to do, the checklist opens; "You're set" closes it once + // shown, so a later regression never brings it back; a deployment first seen + // already set up is closed without either. + const next = view === "complete" ? "closed" : memory === null && storageKey !== null ? (view === "full" ? "open" : allDone ? "closed" : null) : null; + useEffect(() => { + if (!next) return; + if (view === "complete") celebrate(true); + remember(next); + }, [celebrate, next, remember, view]); + + // Show Getting started, from the sidebar. + useConsoleIntent("getting-started", storageKey === null ? "wait" : "ready", () => { + celebrate(false); + remember("open"); + setFocusPending(true); + }); + const showing = celebrating || view !== "hidden"; + useEffect(() => { + if (!focusPending || !showing) return; + setFocusPending(false); + document.getElementById("getting-started-heading")?.focus(); + }, [focusPending, showing]); + + const tourButton = ( + + ); + + if (celebrating || view === "complete") { + return ( +
+
+ +

{t("gettingStarted.complete.title")}

+ {t("gettingStarted.complete.body")} +
+
+ {tourButton} + +
+
+ ); + } + if (view === "hidden") return null; + + const done = states.filter((state) => state === "done").length; + const sandbox = steps.sandboxes; + const sandboxAction = sandbox.action === "setup" + ? { label: t("gettingStarted.sandboxes.setup"), run: () => navigate("nodes") } + : sandbox.action === "add-node" + ? { label: t("gettingStarted.sandboxes.addNode"), run: () => navigate("nodes", {}, "add-node") } + : { label: t(sandbox.cloud ? "gettingStarted.sandboxes.backend" : "gettingStarted.sandboxes.nodes"), run: () => navigate("nodes") }; + const keyProject = steps.key.project; + const keyAction = keyProject + ? { label: t("gettingStarted.key.issue"), run: () => navigate("projects", { id: keyProject.id }, "issue-key") } + : { label: t("gettingStarted.key.create"), run: () => navigate("projects", {}, "create-project") }; + + return ( +
+
+
+

{t("gettingStarted.title")}

+ {t("gettingStarted.progress", { done, total: states.length })} + {t("gettingStarted.help")} +
+
+ {tourButton} + +
+
+
    + + + navigate("projects") }} /> +
+
+ ); +} + +function Step({ index, state, title, body, action }: { + index: number; + state: StepState; + title: string; + body: ReactNode; + action: { label: string; run: () => void }; +}) { + const { t } = useTranslation("overview"); + const done = state === "done"; + return ( +
  • + +
    +

    {title}

    +

    {body}

    +
    + +
    + {done ? null : } +
    +
  • + ); +} diff --git a/apps/web/src/features/overview/OverviewPage.tsx b/apps/web/src/features/overview/OverviewPage.tsx index e62e5bac2..de2389244 100644 --- a/apps/web/src/features/overview/OverviewPage.tsx +++ b/apps/web/src/features/overview/OverviewPage.tsx @@ -25,6 +25,7 @@ import { capacitySummary, coreStatus, type CoreStatus } from "../fleet/fleet-mod import { fleetSnapshot, useSandboxFleet, type FleetSnapshot, type FleetState } from "../fleet/use-sandbox-fleet"; import { type InProject } from "../metrics/project-sessions"; import { FleetTopology, TOPOLOGY_LIMIT, type CloudHost } from "./FleetTopology"; +import { GettingStarted } from "./GettingStarted"; import { type OverviewData } from "./overview-loader"; import { overviewQuery } from "./overview-queries"; import { @@ -139,6 +140,10 @@ export function OverviewPage() { const updatedAt = data?.loadedAt ?? fleet?.loadedAt ?? null; const attentionTotal = totals ? attentionCount(totals.sessions) : null; const truncated = data?.sessions.truncated ?? []; + // Whether any Session exists: Core's summary counts them all; without it, any Session read counts. + const sessionCount = totals ? totals.sessions.total + : sessions?.length ? sessions.length + : summaryError !== null || (state.status === "failed" && data === null) ? "failed" : null; // Each failed read is its own toast, shown once while it lasts. useFailureToast(failure !== null, t("errors.load", { reason: failure ?? "" }), "overview-load"); @@ -157,6 +162,7 @@ export function OverviewPage() { actions={ { projectsState.refresh(); refresh(); refreshFleet(); }} />} /> +
    sum + value, 0), locale) }]} tooltipOnly={[{ id: "failed", label: t("activity.failed"), color: "var(--danger)", values: activity.failed }]} formatValue={(value) => formatInteger(value, locale)} + counts height={196} />
    @@ -311,7 +318,11 @@ function FleetCard({ fleetState, core }: { fleetState: FleetState; core: CoreSta {t("fleet.help")} {fleetState.status === "ready" ? ( - + !fleet?.deployment.provider + ? + : cloud || hosts.length + ? + : ) : null}
    @@ -327,16 +338,16 @@ function FleetCard({ fleetState, core }: { fleetState: FleetState; core: CoreSta onOpenCoreMetrics={() => navigate("core-metrics")} /> {hidden ? : null} - +
    ); } -function FleetFooter({ state, empty }: { state: FleetState; empty: boolean }) { +function FleetFooter({ state, empty, unset }: { state: FleetState; empty: boolean; unset: boolean }) { const { t } = useTranslation("overview"); if (state.status === "ready") { - return empty ?

    {t("fleet.noNodes")}

    : null; + return empty ?

    {t(unset ? "fleet.notSetUp" : "fleet.noNodes")}

    : null; } return (