From 125ee5b0e282f235677726963154355bc4351ebb Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 17:44:18 +0000 Subject: [PATCH 01/14] Drop the unused Hosted Sandbox Manager string --- apps/web/src/lib/locale-strings.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 0297c558a..310b2cc5d 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -76,7 +76,6 @@ export const chinese = { "Check the log on the host:": "在主机上查看日志:", "Advanced network settings": "高级网络设置", - "Hosted Sandbox Manager": "托管沙箱管理", "System": "系统", "System navigation": "系统导航", "Deployment provider, runtime nodes and Session allocations.": "管理部署后端、运行节点和 Session 资源分配。", From 780846fd491691ab8fe9d31614ae64644549069c Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 17:57:45 +0000 Subject: [PATCH 02/14] Open the console on the Overview instead of forcing first-run setup Signing in no longer asks for a first project and key before the console opens. The console tour stays as an optional screen the console can open, on the same stage as sign-in. Remove what only the forced setup used: FirstProjectSetup, the typed request terminal and its command block, the combined create-project-and-key flow in the key-flow reducer, the setup progress and step transition, and their locale keys (keys.firstRun, onboarding.steps/stage.project/terminal, the unused navigation.gettingStarted and dead firstRun strings). --- apps/web/e2e/access.spec.ts | 16 +- apps/web/src/ConsoleApp.tsx | 39 +-- apps/web/src/components/magicui/terminal.tsx | 300 ------------------ .../features/api-keys/FirstProjectSetup.tsx | 143 --------- apps/web/src/features/api-keys/IssuedKey.tsx | 15 - .../features/api-keys/KeyFlowDialogs.test.tsx | 2 +- .../src/features/api-keys/KeyFlowDialogs.tsx | 2 +- .../src/features/api-keys/ProjectsPage.tsx | 4 +- apps/web/src/features/api-keys/api-keys.css | 105 ------ .../src/features/api-keys/key-flows.test.ts | 27 -- apps/web/src/features/api-keys/key-flows.ts | 35 +- .../web/src/features/api-keys/use-key-flow.ts | 18 +- .../src/features/first-run/ConsoleAccess.tsx | 21 +- .../src/features/onboarding/ConsoleTour.tsx | 42 ++- .../features/onboarding/OnboardingLayout.tsx | 35 +- .../features/onboarding/OnboardingStage.tsx | 24 +- .../features/onboarding/RequestTerminal.tsx | 36 --- .../src/features/onboarding/onboarding.css | 151 +-------- .../features/onboarding/view-transition.ts | 8 +- apps/web/src/i18n/locales/en/keys.ts | 18 -- apps/web/src/i18n/locales/en/navigation.ts | 1 - apps/web/src/i18n/locales/en/onboarding.ts | 10 +- apps/web/src/i18n/locales/zh-CN/keys.ts | 18 -- apps/web/src/i18n/locales/zh-CN/navigation.ts | 1 - apps/web/src/i18n/locales/zh-CN/onboarding.ts | 10 +- apps/web/src/lib/console-auth-strings.ts | 6 - 26 files changed, 96 insertions(+), 991 deletions(-) delete mode 100644 apps/web/src/components/magicui/terminal.tsx delete mode 100644 apps/web/src/features/api-keys/FirstProjectSetup.tsx delete mode 100644 apps/web/src/features/onboarding/RequestTerminal.tsx diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index 7c704978b..d837ffa49 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -38,23 +38,11 @@ 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 without asking for a project first", 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.getByLabel("New key my-app")).toHaveCount(0); - 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(); - const stored = await browserStorage(page); - expect(stored).not.toContain("fixture-secret"); - expect(stored).not.toContain(FIXTURE_CORE_KEY); + expect(await browserStorage(page)).not.toContain(FIXTURE_CORE_KEY); }); diff --git a/apps/web/src/ConsoleApp.tsx b/apps/web/src/ConsoleApp.tsx index ec05a8503..5a2480ff9 100644 --- a/apps/web/src/ConsoleApp.tsx +++ b/apps/web/src/ConsoleApp.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, 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"; @@ -61,13 +62,9 @@ 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. + const [touring, setTouring] = useState(false); + const openTour = useCallback((from: HTMLElement | null) => withTransition("enter", () => setTouring(true), from), []); useEffect(() => { const sync = () => setLocation(readLocation()); @@ -102,23 +99,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 withTransition("enter", () => setTouring(false), from)} />; return ( -
- {t("skipToContent")} - navigate(view)} onIntent={prefetch} /> -
-
- -
-
-
+ +
+ {t("skipToContent")} + navigate(view)} onIntent={prefetch} /> +
+
+ +
+
+
+
); } 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(/ : 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/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index a747002cc..7f847339c 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -86,8 +86,6 @@ export const keys = { copy: "Copy key", copied: "Copied", copyFailed: "Select the key and copy it manually.", - copyCommand: "Copy command", - copyCommandFailed: "Select the command and copy it manually.", saved: "I've saved this key", pending: "The new key {{name}} of {{project}} is shown only until you confirm it was saved.", }, @@ -192,20 +190,4 @@ export const keys = { artifact: "Artifact", }, }, - firstRun: { - title: "Create your first project", - lead: "A project holds Agents, Sessions and files. Applications reach it with a project API key.", - projectName: "Project name", - submit: "Create project and key", - submitting: "Creating…", - projectCreated: "Project {{project}} was created. Issue its key to continue.", - readyTitle: "Your project API key is ready", - keyCaption: "{{key}} · {{project}}", - tryIt: "Try it", - tryItHelp: "Run this in a terminal with PROJECT_API_KEY set to the project API key above. It lists the project's Agents; a new project has none yet. The console does not send this request.", - command: "Example request", - continue: "I've saved it, continue", - uncertain: "Core did not confirm the result. Open the console and check Projects and keys before trying again, so that no key is issued without you seeing it.", - check: "Open the console to check", - }, } as const; diff --git a/apps/web/src/i18n/locales/en/navigation.ts b/apps/web/src/i18n/locales/en/navigation.ts index 293e69070..520d1181a 100644 --- a/apps/web/src/i18n/locales/en/navigation.ts +++ b/apps/web/src/i18n/locales/en/navigation.ts @@ -23,7 +23,6 @@ export const navigation = { sandboxBackend: "Sandbox backend", system: "System", }, - gettingStarted: "Getting started", skipToContent: "Skip to main content", configureCore: "Configure Agent Core connection", coreApi: "Core API", diff --git a/apps/web/src/i18n/locales/en/onboarding.ts b/apps/web/src/i18n/locales/en/onboarding.ts index 551d75378..9524ee65d 100644 --- a/apps/web/src/i18n/locales/en/onboarding.ts +++ b/apps/web/src/i18n/locales/en/onboarding.ts @@ -1,21 +1,15 @@ export const onboarding = { - steps: { - label: "Setup progress", - project: "Project and API key", - tour: "Your console", - }, stage: { login: { title: "One Core. Many Agents.", body: "Connect your machines, run your Agents, and watch every Session from one console." }, - project: { title: "A project holds the work.", body: "Agents, Sessions, Skills, files and Vaults belong to a project. Applications reach it with the project's API keys." }, orbit: "Core and what it manages: Agents, Sessions, Skills, Vaults, files, templates and machines", }, - terminal: "Try it in a terminal", tour: { + label: "Your console", eyebrow: "Your console · {{n}} of {{total}}", skip: "Skip", back: "Back", next: "Next", - enter: "Open the console", + done: "Back to the console", shot: "The {{name}} pages of the console", chapters: { monitor: { diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 1cd5f7af2..2cbf33265 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -88,8 +88,6 @@ export const keys: TranslationShape = { copy: "复制 key", copied: "已复制", copyFailed: "请选中 key 手动复制。", - copyCommand: "复制命令", - copyCommandFailed: "请选中命令手动复制。", saved: "我已保存", pending: "{{project}} 的新 key {{name}} 只会显示到你确认已保存为止。", }, @@ -194,20 +192,4 @@ export const keys: TranslationShape = { artifact: "产物", }, }, - firstRun: { - title: "创建第一个项目", - lead: "项目存放 Agent、Session 和文件,应用用项目 API Key 访问它。", - projectName: "项目名称", - submit: "创建项目和 key", - submitting: "正在创建…", - projectCreated: "项目 {{project}} 已创建,签发它的 key 后继续。", - readyTitle: "项目 API Key 已就绪", - keyCaption: "{{key}} · {{project}}", - tryIt: "试一下", - tryItHelp: "在终端里把 PROJECT_API_KEY 设为上面的项目 API Key 后运行。它会列出项目里的 Agent,新项目还没有 Agent。控制台不会发送这个请求。", - command: "示例请求", - continue: "我已保存,继续", - uncertain: "Core 未确认结果。请先进入控制台,在“项目与 key”里确认,再决定是否重试,以免签发一个你看不到明文的 key。", - check: "进入控制台确认", - }, }; diff --git a/apps/web/src/i18n/locales/zh-CN/navigation.ts b/apps/web/src/i18n/locales/zh-CN/navigation.ts index b041b2da3..60cd16700 100644 --- a/apps/web/src/i18n/locales/zh-CN/navigation.ts +++ b/apps/web/src/i18n/locales/zh-CN/navigation.ts @@ -23,7 +23,6 @@ export const navigation = { sandboxBackend: "沙箱后端", system: "系统", }, - gettingStarted: "新手引导", skipToContent: "跳到主要内容", configureCore: "配置 Agent Core 连接", coreApi: "Core API", diff --git a/apps/web/src/i18n/locales/zh-CN/onboarding.ts b/apps/web/src/i18n/locales/zh-CN/onboarding.ts index 7a2bfdd8f..c8147d5c4 100644 --- a/apps/web/src/i18n/locales/zh-CN/onboarding.ts +++ b/apps/web/src/i18n/locales/zh-CN/onboarding.ts @@ -1,21 +1,15 @@ export const onboarding = { - steps: { - label: "设置进度", - project: "项目和 API Key", - tour: "认识控制台", - }, stage: { login: { title: "一个 Core,承载所有 Agent。", body: "连接你的机器,运行你的 Agent,在一个控制台里看清每一个 Session。" }, - project: { title: "项目承载所有工作。", body: "Agent、Session、Skills、文件和 Vault 都归属某个项目;应用用项目 API Key 访问它们。" }, orbit: "Core 和它管理的一切:Agent、Session、Skills、Vault、文件、环境模板和机器", }, - terminal: "在终端里试一下", tour: { + label: "认识控制台", eyebrow: "认识控制台 · {{n}} / {{total}}", skip: "跳过", back: "上一步", next: "下一步", - enter: "进入控制台", + done: "返回控制台", shot: "控制台的{{name}}页面", chapters: { monitor: { diff --git a/apps/web/src/lib/console-auth-strings.ts b/apps/web/src/lib/console-auth-strings.ts index 11ca907d8..4e565aa9f 100644 --- a/apps/web/src/lib/console-auth-strings.ts +++ b/apps/web/src/lib/console-auth-strings.ts @@ -1,8 +1,5 @@ export const consoleAuthChinese = { "Try again": "重试", - "Copied": "已复制", - "A place for your Agents to work.": "让你的 Agent 在这里工作。", - "Connect your machines. Create Agents. Watch work happen.": "连接自己的机器,创建 Agent,让工作在这里发生。", "Sign in to Parsar Core": "登录 Parsar Core", "Core key": "Core Key", "This deployment's Core key. The installer saved it in a private file: {{file}} in the installation directory, by default {{defaultPath}}. It is an administration credential: it cannot call the /v1 Agents API, and the console never keeps it in your browser.": @@ -20,7 +17,4 @@ export const consoleAuthChinese = { "Signing out…": "正在退出…", "Could not sign out. Try again.": "退出失败,请重试。", "Console language": "控制台语言", - "Getting started": "开始使用", - "Continue setup": "继续设置", - "Replay introduction": "再次查看引导", } as const; From a6fb8def2b2273e8271300dffc822c872ba13a1b Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 17:58:07 +0000 Subject: [PATCH 03/14] Add a Getting started checklist to the Overview Three steps, in any order, each with its state and one action, computed from reads the Overview already makes: sandboxes ready (a saved deployment with a node online and ready, or a ready E2B template build), a project with an active key, and any Session. The checklist shows while a step is to do, ends with "You're set" in a browser that saw it, and collapses to one line when hidden; the choice is kept in localStorage. Its header opens the optional console tour. Its actions open the right dialog on arrival through an in-memory navigation intent: Add node on the Nodes page, Create project (continuing to the new project's first key) or Issue key on Projects and keys. Browser acceptance: a fresh install signs in to the checklist, creates a project and a key shown once from it, and sees the step done; an empty install shows every page without errors and opens Add node from it. --- apps/web/e2e/access.spec.ts | 49 +++++- apps/web/e2e/console.ts | 12 +- apps/web/e2e/fixture-console.mjs | 10 +- apps/web/src/ConsoleApp.tsx | 13 +- .../src/features/api-keys/ProjectsPage.tsx | 13 +- .../src/features/overview/GettingStarted.tsx | 139 ++++++++++++++++++ .../src/features/overview/OverviewPage.tsx | 6 + .../features/overview/getting-started.test.ts | 52 +++++++ .../src/features/overview/getting-started.ts | 103 +++++++++++++ apps/web/src/features/overview/overview.css | 110 ++++++++++++++ .../features/sandbox/SandboxManagerView.tsx | 4 +- apps/web/src/i18n/locales/en/overview.ts | 39 +++++ apps/web/src/i18n/locales/zh-CN/overview.ts | 39 +++++ apps/web/src/lib/console-navigation.tsx | 29 +++- 14 files changed, 596 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/features/overview/GettingStarted.tsx create mode 100644 apps/web/src/features/overview/getting-started.test.ts create mode 100644 apps/web/src/features/overview/getting-started.ts diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index d837ffa49..7250fa23c 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,11 +38,54 @@ 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("opens a fresh install on the Overview without asking for a project first", 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: "Overview", level: 1 })).toBeVisible(); - expect(await browserStorage(page)).not.toContain(FIXTURE_CORE_KEY); + 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"); + + // 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 step("Create a project and issue a key").getByRole("button", { name: "Create project" }).click(); + await page.getByRole("dialog").getByLabel("Name").fill("My app"); + await page.getByRole("dialog").getByRole("button", { name: "Create" }).click(); + const issue = page.getByRole("dialog", { name: "Issue a key for My app" }); + 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 empty pages on a fresh install and opens Add node from Getting started", async ({ page, request }) => { + await openConsole(page, request, "overview", { fresh: true, nodes: "none" }); + 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(page.getByRole("heading", { level: 1 })).toBeVisible(); + // 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 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(); }); 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 5a2480ff9..f179fabed 100644 --- a/apps/web/src/ConsoleApp.tsx +++ b/apps/web/src/ConsoleApp.tsx @@ -18,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"; @@ -34,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 }) { @@ -76,22 +76,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]; diff --git a/apps/web/src/features/api-keys/ProjectsPage.tsx b/apps/web/src/features/api-keys/ProjectsPage.tsx index 0d78c0e60..c96fabd17 100644 --- a/apps/web/src/features/api-keys/ProjectsPage.tsx +++ b/apps/web/src/features/api-keys/ProjectsPage.tsx @@ -9,7 +9,7 @@ import { ErrorState } from "../../components/ErrorState"; import { ListToolbar, listSummary, NameCell, RowActions, SearchField } from "../../components/list-ui"; import { Modal } from "../../components/Modal"; import { useFailureToast } from "../../components/Toast"; -import { useConsoleNavigation } from "../../lib/console-navigation"; +import { useConsoleIntent, useConsoleNavigation } from "../../lib/console-navigation"; import { formatDateTime, formatInteger, formatRelative } from "../../lib/format"; import { admin, useProjects } from "../../lib/projects"; import { activeKeyNames, flowError, isAbort, isUsableName, matchesProject, normalizeName, prefixLabel, projectNameProblem, type FlowError } from "./key-flows"; @@ -23,7 +23,8 @@ import { type AdminKey, archiveProject, createProject, type Project, renameProje import { TableSkeleton } from "../../components/Skeleton"; type Dialog = - | { kind: "create"; name: string } + /** `thenIssue`: Getting started continues from the new project to its first key. */ + | { kind: "create"; name: string; thenIssue?: boolean } | { kind: "rename"; project: Project; name: string } | { kind: "archive"; project: Project } | { kind: "revoke"; project: Project; key: AdminKey; activeCount: number }; @@ -86,6 +87,11 @@ export function ProjectsPage() { }; const openDialog = (next: Dialog) => { setDialogError(null); setDialog(next); }; const closeDialog = () => { if (!dialogBusy) setDialog(null); }; + // Getting started opens a dialog on arrival: a new project (then its first key), or a key for an open project. + useConsoleIntent("create-project", true, () => openDialog({ kind: "create", name: "", thenIssue: true })); + useConsoleIntent("issue-key", Boolean(selected && manageable(selected)) && flow.step === "idle", () => { + if (selected) dispatch({ type: "openIssue", project: selected }); + }); const dialogNameProblem = dialog?.kind === "create" ? projectNameProblem(dialog.name, names) @@ -105,7 +111,8 @@ export function ProjectsPage() { if (current.kind === "create") { const project = await createProject(normalizeName(current.name)); setCreated(project); - open(project.id); + // The project page opens anew, so the key dialog follows as its intent. + navigate("projects", { id: project.id }, current.thenIssue ? "issue-key" : undefined); } else if (current.kind === "rename") { await renameProject(current.project.id, normalizeName(current.name)); } else if (current.kind === "archive") { diff --git a/apps/web/src/features/overview/GettingStarted.tsx b/apps/web/src/features/overview/GettingStarted.tsx new file mode 100644 index 000000000..fd6ecc646 --- /dev/null +++ b/apps/web/src/features/overview/GettingStarted.tsx @@ -0,0 +1,139 @@ +import { useQuery } from "@tanstack/react-query"; +import { Check, Compass, X } from "lucide-react"; +import { useEffect, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { HelpTip, StatusDot } from "../../components/console-ui"; +import { useConsoleNavigation } from "../../lib/console-navigation"; +import { projectsQuery } from "../../lib/queries"; +import { type FleetState } from "../fleet/use-sandbox-fleet"; +import { useConsoleTour } from "../onboarding/ConsoleTour"; +import { + checklistView, + gettingStartedSteps, + readChecklistMemory, + 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 dismissed; the optional console tour opens from its header. + */ +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 [memory, setMemory] = useState(readChecklistMemory); + const view = checklistView(states, memory); + const allDone = states.every((state) => state === "done"); + + const remember = (value: Exclude) => { + writeChecklistMemory(value); + setMemory(value); + }; + // Seen with a step to do, the checklist ends with "You're set"; a deployment + // first seen already set up never shows it. + const first = memory !== null ? null : view === "full" ? "open" : allDone ? "closed" : null; + useEffect(() => { + if (!first) return; + writeChecklistMemory(first); + setMemory(first); + }, [first]); + + if (view === "hidden") return null; + const done = states.filter((state) => state === "done").length; + const progress = t("gettingStarted.progress", { done, total: states.length }); + + if (view === "compact") { + return ( +
+
+

{t("gettingStarted.title")}

+ {progress} +
+ +
+ ); + } + + if (view === "complete") { + return ( +
+
+ +

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

+ {t("gettingStarted.complete.body")} +
+ +
+ ); + } + + 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")}

+ {progress} + {t("gettingStarted.help")} +
+
+ + +
+
+
    + + + 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..590983e5f 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(); }} />} /> +
    = {}): SandboxDeployment => ({ + installation_id: "i", provider: "docker", core_url: "http://core", maintenance: false, owner_epoch: 1, generation: 1, mode: "nodes", + resources: { allocations: 0, pending: 0 }, suspension: null, ...overrides, +}); +const fleet = (value: SandboxDeployment, nodes = [node("n1")]): FleetState => ({ status: "ready", snapshot: { deployment: value, nodes, allocations: [], loadedAt: 0 }, refreshing: false, error: null }); +const sandboxes = (state: FleetState) => gettingStartedSteps({ fleet: state, projects: [], sessions: 0 }).sandboxes; + +describe("Getting started steps", () => { + it("counts sandboxes ready only with a saved deployment and a ready node, or a ready E2B template build", () => { + expect(sandboxes(fleet(deployment({ provider: "", mode: "" }), []))).toMatchObject({ state: "todo", action: "setup" }); + expect(sandboxes(fleet(deployment(), []))).toMatchObject({ state: "todo", action: "add-node" }); + expect(sandboxes(fleet(deployment(), [node("n1", { provider_ready: false }), node("n2", { online: false })]))).toMatchObject({ state: "todo", action: "nodes" }); + expect(sandboxes(fleet(deployment()))).toMatchObject({ state: "done" }); + const e2b = (status: string | null) => deployment({ provider: "e2b", mode: "direct", e2b: { template: "t", credential_configured: true, template_build: { status, resources: { cpus: 2, memory_mib: 2048, root_disk_mib: null } } } }); + expect(sandboxes(fleet(e2b("building"), []))).toMatchObject({ state: "todo", cloud: true }); + expect(sandboxes(fleet(e2b("ready"), []))).toMatchObject({ state: "done", cloud: true }); + expect(sandboxes({ status: "loading" }).state).toBeNull(); + expect(sandboxes({ status: "failed", error: new Error("down") }).state).toBe("unknown"); + }); + + it("needs an active project with an active key, and any Session", () => { + const steps = (projects: Parameters[0]["projects"], sessions: number | "failed" | null = 0) => gettingStartedSteps({ fleet: { status: "loading" }, projects, sessions }); + expect(steps([]).key).toEqual({ state: "todo", project: null }); + const older = project("p1", { active_key_count: 0, created_at: 1 }); + const newer = project("p2", { active_key_count: 0, created_at: 2 }); + expect(steps([older, newer, project("p3", { status: "archived", active_key_count: 0, created_at: 3 })]).key).toEqual({ state: "todo", project: newer }); + expect(steps([older, project("p4")]).key.state).toBe("done"); + expect(steps(undefined).key.state).toBeNull(); + expect(steps("failed").key.state).toBe("unknown"); + expect([steps([], 0).session, steps([], 2).session, steps([], null).session, steps([], "failed").session]).toEqual(["todo", "done", null, "unknown"]); + }); +}); + +describe("Getting started visibility", () => { + it("shows while a step is to do, ends with You're set only where it was seen, and stays compact once dismissed", () => { + expect(checklistView(["done", "todo", null], null)).toBe("full"); + expect(checklistView([null, null, null], null)).toBe("hidden"); + expect(checklistView([null, null, null], "open")).toBe("full"); + expect(checklistView(["done", "done", "done"], null)).toBe("hidden"); + expect(checklistView(["done", "done", "done"], "open")).toBe("complete"); + expect(checklistView(["done", "todo", "todo"], "dismissed")).toBe("compact"); + expect(checklistView(["done", "done", "done"], "dismissed")).toBe("hidden"); + expect(checklistView(["todo", "todo", "todo"], "closed")).toBe("hidden"); + }); +}); diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts new file mode 100644 index 000000000..2ceeb5413 --- /dev/null +++ b/apps/web/src/features/overview/getting-started.ts @@ -0,0 +1,103 @@ +import { type Project } from "../../lib/admin-view"; +import { type FleetState } from "../fleet/use-sandbox-fleet"; + +/** + * Getting started on the Overview: three steps to a working deployment, + * computed only from reads the Overview already makes. A step whose read is + * pending is null, and "unknown" when that read failed; neither counts as done. + */ +export type StepState = "done" | "todo" | "unknown" | null; + +/** Where the sandbox step leads: the setup wizard, Add node, or the Nodes page. */ +export type SandboxAction = "setup" | "add-node" | "nodes"; + +export interface GettingStartedSteps { + sandboxes: { state: StepState; action: SandboxAction; cloud: boolean }; + /** `project` is the active project a key would be issued for; null means create one first. */ + key: { state: StepState; project: Project | null }; + session: StepState; +} + +export function gettingStartedSteps(input: { + fleet: FleetState; + /** Undefined until the project list is read. */ + projects: readonly Project[] | "failed" | undefined; + /** Sessions in every project, by Core's summary; null until it is read. */ + sessions: number | "failed" | null; +}): GettingStartedSteps { + const { sessions } = input; + return { + sandboxes: sandboxStep(input.fleet), + key: keyStep(input.projects), + session: sessions === null ? null : sessions === "failed" ? "unknown" : sessions > 0 ? "done" : "todo", + }; +} + +/** + * Own machines are ready once the deployment is saved and a node is online + * with its provider ready; E2B once the deployment is saved and its template + * build is ready. + */ +function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] { + if (fleet.status !== "ready") { + return { state: fleet.status === "failed" || fleet.status === "unconfigured" ? "unknown" : null, action: "nodes", cloud: false }; + } + const { deployment, nodes } = fleet.snapshot; + if (!deployment.provider) return { state: "todo", action: "setup", cloud: false }; + if (deployment.provider === "e2b") { + return { state: deployment.e2b?.template_build?.status === "ready" ? "done" : "todo", action: "nodes", cloud: true }; + } + if (nodes.some((node) => node.online && node.provider_ready)) return { state: "done", action: "nodes", cloud: false }; + return { state: "todo", action: nodes.length ? "nodes" : "add-node", cloud: false }; +} + +function keyStep(projects: readonly Project[] | "failed" | undefined): GettingStartedSteps["key"] { + if (!projects || projects === "failed") return { state: projects ? "unknown" : null, project: null }; + const active = projects.filter((project) => project.status === "active"); + if (active.some((project) => project.active_key_count > 0)) return { state: "done", project: null }; + // The newest active project, most likely the one just created. + const newest = active.reduce((best, project) => (!best || project.created_at > best.created_at ? project : best), null); + return { state: "todo", project: newest }; +} + +/** + * What this browser remembers: the checklist was shown with a step to do + * ("open"), hidden while steps remained ("dismissed"), or closed for good + * ("closed", after "You're set" or on a deployment that was already set up). + */ +export type ChecklistMemory = "open" | "dismissed" | "closed" | null; + +export type ChecklistView = "hidden" | "full" | "compact" | "complete"; + +/** + * The checklist shows while a step is to do. "You're set" follows only in a + * browser that saw a step to do, so a deployment set up before this console + * never shows it. A dismissed checklist stays one compact line until every + * step is done. + */ +export function checklistView(states: readonly StepState[], memory: ChecklistMemory): ChecklistView { + if (memory === "closed") return "hidden"; + if (states.every((state) => state === "done")) return memory === "open" ? "complete" : "hidden"; + const todo = states.includes("todo"); + if (memory === "dismissed") return todo ? "compact" : "hidden"; + return todo || memory === "open" ? "full" : "hidden"; +} + +const STORAGE_KEY = "agents-core-web.getting-started"; + +export function readChecklistMemory(): ChecklistMemory { + try { + const value = window.localStorage.getItem(STORAGE_KEY); + return value === "open" || value === "dismissed" || value === "closed" ? value : null; + } catch { + return null; + } +} + +export function writeChecklistMemory(value: Exclude): void { + try { + window.localStorage.setItem(STORAGE_KEY, value); + } catch { + // Storage can be unavailable; the choice then lasts until the page reloads. + } +} diff --git a/apps/web/src/features/overview/overview.css b/apps/web/src/features/overview/overview.css index 463036d77..135534b64 100644 --- a/apps/web/src/features/overview/overview.css +++ b/apps/web/src/features/overview/overview.css @@ -64,6 +64,115 @@ .metric-tile-dot-danger { background: var(--danger); } .metric-tile-dot-pending { background: var(--status-running); } +/* Getting started: one card with a row per step (mark, text, state, action); + a single line once dismissed or when every step is done. */ + +.getting-started { + animation: overview-rise 520ms var(--ease-settle) both; +} + +.getting-started-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.getting-started-steps { + display: grid; + margin: 0; + padding: 0 16px 4px; + list-style: none; +} + +.getting-started-step { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) 104px 176px; + align-items: center; + gap: 12px; + min-height: 60px; + padding: 10px 0; + border-top: 1px solid var(--line-muted); +} + +.getting-started-mark { + display: grid; + width: 22px; + height: 22px; + place-items: center; + color: var(--fg-muted); + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; + border-radius: 999px; + box-shadow: inset 0 0 0 1px var(--line-strong); +} + +.getting-started-mark.done { + color: var(--fg); + background: var(--tile); + box-shadow: none; +} + +.getting-started-text h3 { + margin: 0; + color: var(--fg); + font-size: 13px; + font-weight: 600; + line-height: 18px; +} + +.getting-started-text p { + margin: 2px 0 0; + color: var(--fg-muted); + font-size: 12.5px; + line-height: 18px; +} + +.getting-started-action { + display: flex; + justify-content: flex-end; +} + +.overview-card.getting-started-compact { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 52px; + padding: 10px 16px; +} + +.getting-started-compact .console-section-title { + min-width: 0; +} + +.getting-started-compact h2 { + margin: 0; + color: var(--fg); + font-size: 14px; + font-weight: 600; + line-height: 20px; +} + +.getting-started-compact .overview-card-meta { + overflow: hidden; + text-overflow: ellipsis; +} + +/* Narrow: the state and the action go under the step's text. */ +@media (max-width: 720px) { + .getting-started-step { + grid-template-columns: 22px minmax(0, 1fr); + row-gap: 8px; + } + + .getting-started-step > .status-dot, + .getting-started-action { + grid-column: 2; + justify-content: flex-start; + } +} + /* Cards ------------------------------------------------------------------ */ .overview-grid { @@ -407,6 +516,7 @@ button.fleet-core:disabled { } @media (prefers-reduced-motion: reduce) { + .getting-started, .metric-tile, .overview-activity, .overview-fleet, diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 9aed25f82..d213961b7 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -8,7 +8,7 @@ import { EmptyState, HelpTip, RefreshButton } from "../../components/console-ui" import { ErrorDialog } from "../../components/ErrorDialog"; import { ErrorState } from "../../components/ErrorState"; import { useFailureToast, useToast } from "../../components/Toast"; -import { useConsoleNavigation } from "../../lib/console-navigation"; +import { useConsoleIntent, useConsoleNavigation } from "../../lib/console-navigation"; import { sandboxRequestError } from "../../lib/sandbox-labels"; import type { SandboxConsoleConfig } from "./console-config"; import { sandboxAdmin, sandboxConsoleConfigQuery, sandboxDeploymentQuery, sandboxScope, sandboxSnapshotQuery, type SandboxSnapshot } from "./sandbox-queries"; @@ -173,6 +173,8 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig const nodes = snapshot?.nodes ?? []; const allocations = snapshot?.allocations ?? []; const hostedNodes = Boolean(snapshot?.deployment.provider && snapshot.deployment.provider !== "e2b"); + // Getting started asks for Add node on arrival. + useConsoleIntent("add-node", hostedNodes && fresh && !busy && !snapshot?.deployment.maintenance, () => setAdding(true)); const selected = params.id ? nodes.find((node) => node.id === params.id) : undefined; const refreshButton = ; const readFailure = error !== null ? sandboxRequestError(error, locale) : null; diff --git a/apps/web/src/i18n/locales/en/overview.ts b/apps/web/src/i18n/locales/en/overview.ts index 74abaa88a..bc169ef3f 100644 --- a/apps/web/src/i18n/locales/en/overview.ts +++ b/apps/web/src/i18n/locales/en/overview.ts @@ -1,6 +1,45 @@ export const overview = { title: "Overview", description: "Health, capacity, usage and failures across every project of this Parsar Core deployment.", + gettingStarted: { + title: "Getting started", + help: "Three steps to a working deployment, in any order.", + progress: "{{done}} of {{total}} done", + tour: "Take the tour", + dismiss: "Hide Getting started", + show: "Show", + state: { + done: "Done", + todo: "To do", + checking: "Checking", + unknown: "Unknown", + }, + sandboxes: { + title: "Get sandboxes ready", + body: "Save where sandboxes run, then connect a node that is online and ready.", + bodyCloud: "Save the E2B account; its template build must be ready.", + setup: "Set up sandboxes", + addNode: "Add node", + nodes: "Open Nodes", + backend: "Open sandbox backend", + }, + key: { + title: "Create a project and issue a key", + body: "Applications call Core with a project API key.", + create: "Create project", + issue: "Issue key", + }, + session: { + title: "Run the first Session", + body: "Your app calls the /v1 Agents API with the project API key.", + open: "Projects and keys", + }, + complete: { + title: "You're set", + body: "Sandboxes are ready, a project has a key and the first Session ran.", + dismiss: "Dismiss", + }, + }, kpi: { label: "Deployment health", service: "Service status", diff --git a/apps/web/src/i18n/locales/zh-CN/overview.ts b/apps/web/src/i18n/locales/zh-CN/overview.ts index 4f7f750f2..a6d5535d4 100644 --- a/apps/web/src/i18n/locales/zh-CN/overview.ts +++ b/apps/web/src/i18n/locales/zh-CN/overview.ts @@ -1,6 +1,45 @@ export const overview = { title: "概览", description: "这套 Parsar Core 部署在所有项目下的健康、容量、用量与故障。", + gettingStarted: { + title: "新手引导", + help: "让部署可用的三步,顺序不限。", + progress: "已完成 {{done}} / {{total}}", + tour: "认识控制台", + dismiss: "隐藏新手引导", + show: "展开", + state: { + done: "已完成", + todo: "待完成", + checking: "检查中", + unknown: "未知", + }, + sandboxes: { + title: "准备好沙箱", + body: "保存沙箱的运行位置,再接入一台在线且就绪的节点。", + bodyCloud: "保存 E2B 账号,并等它的模板构建就绪。", + setup: "设置沙箱", + addNode: "添加节点", + nodes: "打开节点", + backend: "打开沙箱后端", + }, + key: { + title: "创建项目并签发 key", + body: "应用用项目 API Key 调用 Core。", + create: "创建项目", + issue: "签发 key", + }, + session: { + title: "运行第一个 Session", + body: "你的应用用项目 API Key 调用 /v1 Agents API。", + open: "项目与 key", + }, + complete: { + title: "一切就绪", + body: "沙箱已就绪,项目已有 key,第一个 Session 也已运行。", + dismiss: "关闭", + }, + }, kpi: { label: "部署健康状况", service: "服务状态", diff --git a/apps/web/src/lib/console-navigation.tsx b/apps/web/src/lib/console-navigation.tsx index 0caa22e57..3be649d12 100644 --- a/apps/web/src/lib/console-navigation.tsx +++ b/apps/web/src/lib/console-navigation.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext } from "react"; +import { createContext, useContext, useEffect, useRef } from "react"; import type { ConsoleView } from "./console-routes"; @@ -32,16 +32,27 @@ export function hashWithParams(base: string, params: RouteParams = {}): string { return `${base || "#overview"}?${suffix}`; } +/** + * A dialog a link asks its target page to open on arrival, such as Add node + * from Getting started. It lives only in memory: a reload, Back or any other + * navigation drops it, so the dialog never reopens on its own. + */ +export type ConsoleIntent = "add-node" | "create-project" | "issue-key"; + export interface ConsoleNavigation { view: ConsoleView; params: RouteParams; - navigate: (view: ConsoleView, params?: RouteParams) => void; + /** What the link that opened this page asked it to open, until the page takes it. */ + intent: ConsoleIntent | null; + navigate: (view: ConsoleView, params?: RouteParams, intent?: ConsoleIntent) => void; /** * Returns to the page the user came from inside the console (a Skill opened * from a template goes back to the template); a page opened directly, from a * link or a reload, goes to `view` instead. */ back: (view: ConsoleView, params?: RouteParams) => void; + /** Drops the intent once its page has acted on it. */ + clearIntent: () => void; } /** How many console pages lie behind the current history entry. */ @@ -54,10 +65,24 @@ export function consoleDepth(): number { export const ConsoleNavigationContext = createContext({ view: "overview", params: {}, + intent: null, navigate: () => undefined, back: () => undefined, + clearIntent: () => undefined, }); export function useConsoleNavigation(): ConsoleNavigation { return useContext(ConsoleNavigationContext); } + +/** Runs `act` once when this page was opened with `intent` and it can act on it. */ +export function useConsoleIntent(intent: ConsoleIntent, ready: boolean, act: () => void): void { + const { intent: current, clearIntent } = useConsoleNavigation(); + const action = useRef(act); + action.current = act; + useEffect(() => { + if (current !== intent || !ready) return; + clearIntent(); + action.current(); + }, [current, intent, ready, clearIntent]); +} From f150cea705d2b0d0756d843ff7cf2af88f815124 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 17:58:14 +0000 Subject: [PATCH 04/14] Lead empty sandbox states to setup and keep count axes whole Before sandboxes are set up, the Overview's fleet card and Sandbox metrics offer "Set up sandboxes" instead of Add node; with a saved own-machines deployment and no node, their Add node opens the dialog on the Nodes page. Session activity keeps whole-number ticks, so a first Session no longer reads 0, 0, 1, 1, 1 on the axis. --- .../src/components/charts/TimeSeriesChart.tsx | 5 ++++- apps/web/src/components/charts/chart-scale.ts | 9 +++++--- .../features/metrics/SandboxMetricsPage.tsx | 21 +++++++++++++------ .../src/features/overview/OverviewPage.tsx | 13 ++++++++---- apps/web/src/i18n/locales/en/metrics.ts | 2 ++ apps/web/src/i18n/locales/en/overview.ts | 2 ++ apps/web/src/i18n/locales/zh-CN/metrics.ts | 2 ++ apps/web/src/i18n/locales/zh-CN/overview.ts | 2 ++ 8 files changed, 42 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/charts/TimeSeriesChart.tsx b/apps/web/src/components/charts/TimeSeriesChart.tsx index d7d7d1cd2..a2da8ffbb 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 ? 1 : 0); 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.ts b/apps/web/src/components/charts/chart-scale.ts index 9210fca69..a37773c25 100644 --- a/apps/web/src/components/charts/chart-scale.ts +++ b/apps/web/src/components/charts/chart-scale.ts @@ -1,12 +1,15 @@ /** 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. + * `minStep` keeps counts on whole numbers: a maximum of 1 gives 0 and 1, not quarters. + */ +export function niceTicks(maximum: number, targetCount = 4, minStep = 0): 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 step = Math.max(minStep, (residual <= 1 ? 1 : residual <= 2 ? 2 : residual <= 2.5 ? 2.5 : residual <= 5 ? 5 : 10) * magnitude); 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/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/overview/OverviewPage.tsx b/apps/web/src/features/overview/OverviewPage.tsx index 590983e5f..de2389244 100644 --- a/apps/web/src/features/overview/OverviewPage.tsx +++ b/apps/web/src/features/overview/OverviewPage.tsx @@ -237,6 +237,7 @@ export function OverviewPage() { series={[{ id: "created", label: t("activity.created"), color: "color-mix(in srgb, var(--data) 62%, var(--surface))", values: activity.created, total: formatInteger(activity.created.reduce((sum, value) => 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} /> @@ -317,7 +318,11 @@ function FleetCard({ fleetState, core }: { fleetState: FleetState; core: CoreSta {t("fleet.help")} {fleetState.status === "ready" ? ( - + !fleet?.deployment.provider + ? + : cloud || hosts.length + ? + : ) : null}
    @@ -333,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 (