diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index 9de471d89..bdcaa6810 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -163,7 +163,7 @@ function connectedLiveEvents(page: Page) { async function setDarkTheme(page: Page) { await page.getByRole("button", { name: "Language and appearance" }).click(); - await page.getByRole("menuitemradio", { name: "Dark theme" }).click(); + await page.getByRole("menuitem", { name: "Switch to dark theme" }).click(); } async function openEnvironmentDialog(page: Page) { diff --git a/apps/web/e2e/core-connection.spec.ts b/apps/web/e2e/core-connection.spec.ts index c61f531ac..c20934905 100644 --- a/apps/web/e2e/core-connection.spec.ts +++ b/apps/web/e2e/core-connection.spec.ts @@ -22,12 +22,13 @@ async function resetFixture(request: APIRequestContext) { async function boot(page: Page, request: APIRequestContext) { await resetFixture(request); await page.goto("/"); - await expect(page.getByRole("button", { name: "Configure Agent Core connection" })).toBeVisible(); + await expect(page.locator(".sidebar-footer .appearance-menu-trigger")).toBeVisible(); } async function openConnection(page: Page) { - const trigger = page.getByRole("button", { name: "Configure Agent Core connection" }); + const trigger = page.locator(".sidebar-footer .appearance-menu-trigger"); await trigger.click(); + await page.getByRole("menuitem", { name: "Configure Agent Core connection" }).click(); const dialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); await expect(dialog).toBeVisible(); return { dialog, trigger }; diff --git a/apps/web/e2e/environment-templates.spec.ts b/apps/web/e2e/environment-templates.spec.ts index d56661715..840ebd6c1 100644 --- a/apps/web/e2e/environment-templates.spec.ts +++ b/apps/web/e2e/environment-templates.spec.ts @@ -267,7 +267,8 @@ for (const operation of ["read", "write"] as const) { // it lets an in-flight write finish after its owning page has unmounted. await page.evaluate(() => { location.hash = "system"; }); await expect(page.getByRole("dialog", { name: "Edit Template", exact: true })).toHaveCount(0); - await page.getByRole("button", { name: "Configure Agent Core connection", exact: true }).click(); + await page.locator(".sidebar-footer .appearance-menu-trigger").click(); + await page.getByRole("menuitem", { name: "Configure Agent Core connection", exact: true }).click(); const connection = page.getByRole("dialog", { name: "Connect an Agent Core", exact: true }); await connection.getByRole("radio", { name: /Other compatible Core/ }).check(); await connection.getByLabel("Compatible Core base URL").fill(`${new URL(page.url()).origin}/v1`); diff --git a/apps/web/e2e/first-run-keyboard.spec.ts b/apps/web/e2e/first-run-keyboard.spec.ts index e20a3befc..228955fb7 100644 --- a/apps/web/e2e/first-run-keyboard.spec.ts +++ b/apps/web/e2e/first-run-keyboard.spec.ts @@ -19,7 +19,7 @@ test("moves focus from disappearing step actions to the new heading and retains const accessHeading = page.getByRole("heading", { name: "Keep your sign-in details.", exact: true }); await expect(accessHeading).toBeVisible(); await expect(accessHeading).not.toBeFocused(); - const continueButton = page.getByRole("button", { name: "I've saved it. Continue", exact: true }); + const continueButton = page.getByRole("button", { name: "Continue to machines", exact: true }); await expect(continueButton).toBeEnabled(); await continueButton.focus(); await page.keyboard.press("Enter"); @@ -29,7 +29,7 @@ test("moves focus from disappearing step actions to the new heading and retains await page.keyboard.press("Tab"); await expect(page.getByRole("button", { name: "Refresh sandbox state", exact: true })).toBeFocused(); - await page.getByRole("button", { name: "Skip for now", exact: true }).focus(); + await page.getByRole("button", { name: "Continue to the API", exact: true }).focus(); await page.keyboard.press("Enter"); const requestHeading = page.getByRole("heading", { name: "Make your first API request.", exact: true }); await expect(requestHeading).toBeFocused(); diff --git a/apps/web/e2e/first-run.spec.ts b/apps/web/e2e/first-run.spec.ts index ec78fdc80..4b1838703 100644 --- a/apps/web/e2e/first-run.spec.ts +++ b/apps/web/e2e/first-run.spec.ts @@ -72,7 +72,7 @@ test("validates administrator setup before sending credentials and keeps only no await expect(page.locator(".app-shell")).toHaveCount(0); await expect(page.locator(".first-run-home")).toHaveCSS("width", "1440px"); await expect(page.locator(".first-run-home")).toHaveCSS("height", "1000px"); - await expect(page.getByRole("button", { name: "Skip introduction", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Go to console", exact: true })).toBeVisible(); await page.getByRole("button", { name: "Copy sign-in details", exact: true }).click(); const copied = await page.evaluate(() => navigator.clipboard.readText()); expect(copied).toContain(new URL(page.url()).origin); @@ -85,26 +85,29 @@ test("validates administrator setup before sending credentials and keeps only no test("remembers the introduction step and dismissal across reloads and sign-in, and allows replay", async ({ page }) => { const writes = await mockAccount(page, { mode: "authenticated", username }); await page.goto("/"); - await page.getByRole("button", { name: "I've saved it. Continue", exact: true }).click(); + await page.getByRole("button", { name: "Continue to machines", exact: true }).click(); await expect(page.getByRole("heading", { name: "Connect your own machine." })).toBeVisible(); await page.reload(); await expect(page.getByRole("heading", { name: "Connect your own machine." })).toBeVisible(); - await page.getByRole("button", { name: "Skip for now", exact: true }).click(); + await page.getByRole("button", { name: "Continue to the API", exact: true }).click(); await expect(page.getByRole("heading", { name: "Make your first API request." })).toBeVisible(); await page.getByRole("button", { name: "Skip introduction", exact: true }).first().click(); await expect(page.locator(".first-run-home")).toHaveCount(0); await expect(page.locator(".app-sidebar")).toBeVisible(); await page.reload(); - await expect(page.getByRole("button", { name: "Sign out", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Account, language and appearance", exact: true })).toBeVisible(); await expect(page.locator(".first-run-home")).toHaveCount(0); - await page.getByRole("button", { name: "Sign out", exact: true }).click(); + await page.getByRole("button", { name: "Account, language and appearance", exact: true }).click(); + await page.getByRole("menuitem", { name: "Sign out", exact: true }).click(); await expect(page.getByRole("heading", { name: "Welcome back", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Configure Agent Core connection" })).toHaveCount(0); await fillAccount(page); await page.getByRole("button", { name: "Sign in", exact: true }).click(); - await expect(page.getByRole("button", { name: "Sign out", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Account, language and appearance", exact: true })).toBeVisible(); await expect(page.locator(".first-run-home")).toHaveCount(0); - await page.getByRole("button", { name: "Getting started", exact: true }).click(); + await expect(page.locator(".app-sidebar").getByRole("button", { name: "Getting started", exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "Account, language and appearance", exact: true }).click(); + await page.getByRole("menuitem", { name: "Replay introduction", exact: true }).click(); await expect(page.getByRole("heading", { name: "Keep your sign-in details." })).toBeVisible(); expect(writes.map(({ action }) => action)).toEqual(["logout", "login"]); }); @@ -123,7 +126,7 @@ test("keeps tutorial preferences inside the viewport and restores console naviga await page.locator(".first-run-toolbar .appearance-menu-trigger").click(); await page.getByRole("menuitemradio", { name: "简体中文", exact: true }).click(); await expect(page.getByRole("heading", { name: "保存你的登录信息。" })).toBeVisible(); - await page.getByRole("button", { name: "跳过导览", exact: true }).click(); + await page.getByRole("button", { name: "进入控制台", exact: true }).click(); await expect(page.locator(".app-sidebar")).toBeVisible(); await expect(page.locator(".first-run-home")).toHaveCount(0); }); @@ -300,8 +303,8 @@ test("requires acknowledgement of a new API key and never recovers its secret fr return route.fulfill({ json: { data: metadata ? [metadata] : [] } }); }); await page.goto("/"); - const next = page.getByRole("button", { name: "I've saved it. Continue", exact: true }); - await expect(page.getByText("No API keys yet. Create one to get started.", { exact: true })).toBeVisible(); + const next = page.getByRole("button", { name: "Continue to machines", exact: true }); + await expect(page.getByText("No active keys. Create one to call the API.", { exact: true })).toBeVisible(); await expect(next).toBeDisabled(); await expect(page.getByRole("button", { name: /Your first Agent/ })).toBeDisabled(); await page.getByLabel("Key name", { exact: true }).fill("Local terminal"); @@ -320,15 +323,47 @@ test("requires acknowledgement of a new API key and never recovers its secret fr await expect(page.getByLabel("Your new API key", { exact: true })).toHaveCount(0); await expect(page.locator(".api-key-list")).toContainText("Local terminal"); await expect(page.locator(".api-key-panel")).not.toContainText(secret); - await page.getByRole("button", { name: "Revoke", exact: true }).click(); + await page.getByRole("button", { name: "Delete", exact: true }).click(); expect(revokes).toBe(0); - await page.getByRole("button", { name: "Confirm revocation", exact: true }).click(); - await expect(page.locator(".api-key-list")).toContainText("Revoked"); + await expect(page.locator(".api-key-confirm")).toContainText("Existing Agents and Sessions remain."); + await page.getByRole("button", { name: "Delete key", exact: true }).click(); + await expect(page.locator(".api-key-list li")).toHaveCount(0); + await expect(page.getByText("No active keys. Create one to call the API.", { exact: true })).toBeVisible(); await expect(next).toBeDisabled(); expect(creates).toBe(1); expect(revokes).toBe(1); }); +test("shows only active API keys and copies a usable read-only curl example", async ({ page }) => { + await mockAccount(page, { mode: "authenticated", username }); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page.route("**/console/config", (route) => route.fulfill({ json: { api_keys: true, sandbox_admin: false, node_installer: false } })); + await page.route("**/console/api-keys", (route) => route.fulfill({ json: { data: [ + { id: "11111111-1111-4111-8111-111111111111", name: "Current key", prefix: "pc_current", created_at: "2026-09-24T00:00:00Z", revoked_at: null }, + { id: "22222222-2222-4222-8222-222222222222", name: "Old key", prefix: "pc_old", created_at: "2026-09-23T00:00:00Z", revoked_at: "2026-09-24T00:00:00Z" }, + ] } })); + await page.goto("/#api-keys"); + await expect(page.locator(".api-key-list li")).toHaveCount(1); + await expect(page.locator(".api-key-list")).toContainText("Current key"); + await expect(page.locator(".api-key-list")).not.toContainText("Old key"); + await expect(page.locator(".api-key-list-heading strong")).toHaveText("1"); + await expect(page.locator(".api-key-example-frame")).toHaveCount(3); + await expect(page.locator(".api-key-example-frame.frame-one")).toContainText("Create a key"); + await expect(page.locator(".api-key-example-frame.frame-two")).toContainText("Send a request"); + await expect(page.locator(".api-key-example-frame.frame-three")).toContainText("See the result"); + await expect(page.getByRole("button", { name: "Copy curl", exact: true })).toBeHidden(); + await page.getByText("View a curl example", { exact: true }).click(); + await page.getByRole("button", { name: "Copy curl", exact: true }).click(); + const command = await page.evaluate(() => navigator.clipboard.readText()); + expect(command).toContain(`--url '${new URL(page.url()).origin}/v1/agents'`); + expect(command).toContain('Authorization: Bearer ${AGENT_CORE_API_KEY}'); + expect(command).toContain("OpenAI-Beta: agents=v1"); + expect(command).not.toContain("pc_current"); + expect(command).not.toContain("pc_old"); + expect(command.split("\n")).toHaveLength(4); + await expect(page.getByRole("button", { name: "Command copied", exact: true })).toBeVisible(); +}); + test("lets a web-only console use an existing project connection without key management", async ({ page, request }) => { await mockAccount(page, { mode: "authenticated", username }); await page.route("**/console/config", (route) => route.fulfill({ json: { api_keys: false, sandbox_admin: false, node_installer: false } })); @@ -338,11 +373,11 @@ test("lets a web-only console use an existing project connection without key man return route.fulfill({ status: 503, json: { error: { message: "Key management is not paired." } } }); }); await page.goto("/"); - await expect(page.getByRole("heading", { name: "Use an existing Agent API key.", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "API key management is not enabled", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Create API key", exact: true })).toHaveCount(0); - await page.getByRole("button", { name: "I've saved it. Continue", exact: true }).click(); + await page.getByRole("button", { name: "Continue to machines", exact: true }).click(); await expect(page.getByRole("heading", { name: "Connect your own machine.", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Skip for now", exact: true }).click(); + await page.getByRole("button", { name: "Continue to the API", exact: true }).click(); await expect(page.getByRole("heading", { name: "Make your first API request.", exact: true })).toBeVisible(); await page.getByLabel("Model ID", { exact: true }).fill("fixture/web-only-model"); await page.getByLabel("Set a model provider for this Agent", { exact: true }).uncheck(); @@ -353,3 +388,146 @@ test("lets a web-only console use an existing project connection without key man expect(writes[0]?.body).toMatchObject({ model: "fixture/web-only-model" }); expect(keyRequests).toBe(0); }); + +test("rechecks key management without sending key requests while disabled", async ({ page }) => { + await mockAccount(page, { mode: "authenticated", username }); + let enabled = false; + let keyRequests = 0; + await page.route("**/console/config", (route) => route.fulfill({ json: { api_keys: enabled, sandbox_admin: enabled, node_installer: false } })); + await page.route("**/console/api-keys", (route) => { + keyRequests++; + return route.fulfill({ json: { data: [] } }); + }); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "API key management is not enabled", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Check again", exact: true }).click(); + await expect(page.getByRole("heading", { name: "API key management is not enabled", exact: true })).toBeVisible(); + expect(keyRequests).toBe(0); + enabled = true; + await page.getByRole("button", { name: "Check again", exact: true }).click(); + await expect(page.getByRole("button", { name: "Create API key", exact: true })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Continue to machines", exact: true })).toBeDisabled(); + expect(keyRequests).toBe(1); +}); + +test("keeps the API key management page separate from introduction guidance", async ({ page }) => { + await mockAccount(page, { mode: "authenticated", username }); + await page.route("**/console/config", (route) => route.fulfill({ json: { api_keys: false, sandbox_admin: false, node_installer: false } })); + await page.goto("/#api-keys"); + await expect(page.locator(".api-keys-page > .page-header").getByRole("heading", { name: "API keys" })).toBeVisible(); + await expect(page.locator(".api-keys-page")).not.toContainText("AGENT CORE /"); + await expect(page.getByRole("heading", { name: "API key management is not enabled", exact: true })).toBeVisible(); + await expect(page.getByText("You can continue the introduction and use your signed-in console connection to create an Agent.", { exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Check again", exact: true })).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.getByRole("button", { name: "Check again", exact: true })).toBeInViewport(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); +}); + +test("fits API key content at desktop and mobile widths", async ({ page }) => { + await mockAccount(page, { mode: "authenticated", username }); + await page.route("**/console/config", (route) => route.fulfill({ json: { api_keys: true, sandbox_admin: false, node_installer: false } })); + await page.route("**/console/api-keys", (route) => route.fulfill({ json: { data: [] } })); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/#api-keys"); + await expect(page.getByRole("button", { name: "Create API key", exact: true })).toBeVisible(); + await expect(page.locator(".api-key-example")).toBeInViewport(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.getByRole("button", { name: "Create API key", exact: true })).toBeInViewport(); + await page.getByText("View a curl example", { exact: true }).click(); + await expect(page.getByRole("button", { name: "Copy curl", exact: true })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); +}); + +test("keeps page headings fixed while switching console sections", async ({ page }) => { + await mockAccount(page, { mode: "authenticated", username }); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + await page.getByRole("button", { name: "Go to console", exact: true }).click(); + await page.locator(".page-transition").evaluate((element) => Promise.all(element.getAnimations().map((animation) => animation.finished))); + + const sections = [ + ["Dashboard", ".dashboard-header"], + ["Agents", ".agents-page > .page-header"], + ["Sessions", ".session-browser-header"], + ["Vaults", ".vaults-page > .page-header"], + ["API keys", ".api-keys-page > .page-header"], + ] as const; + let first: { top: number; left: number; height: number } | null = null; + for (const [name, selector] of sections) { + await page.locator(".app-sidebar").getByRole("button", { name, exact: true }).click(); + const header = page.locator(selector); + await expect(header.getByRole("heading", { level: 1 })).toBeVisible(); + const position = await header.evaluate((element) => { + const header = element.getBoundingClientRect(); + const title = element.querySelector("h1")!.getBoundingClientRect(); + return { top: Math.round(title.top), left: Math.round(title.left), height: Math.round(header.height) }; + }); + first ??= position; + expect(position, `${name} heading position`).toEqual(first); + } +}); + +test("keeps all three introduction steps usable on a narrow screen", async ({ page }) => { + await mockAccount(page, { mode: "authenticated", username }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + const withinViewport = () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth); + await expect(page.getByRole("heading", { name: "Keep your sign-in details." })).toBeVisible(); + expect(await withinViewport()).toBe(true); + await page.getByRole("button", { name: "Continue to machines" }).click(); + await expect(page.getByRole("heading", { name: "Connect your own machine." })).toBeVisible(); + expect(await withinViewport()).toBe(true); + await page.getByRole("button", { name: "Continue to the API" }).click(); + await expect(page.getByRole("heading", { name: "Make your first API request." })).toBeVisible(); + expect(await withinViewport()).toBe(true); + await expect(page.getByRole("button", { name: "Go to console" })).toBeInViewport(); +}); + +test("keeps account, language and theme in one reachable sidebar menu", async ({ page }) => { + await mockAccount(page, { mode: "authenticated", username }); + await page.goto("/"); + await page.getByRole("button", { name: "Go to console" }).click(); + const accountMenu = page.getByRole("button", { name: "Account, language and appearance" }); + await expect(accountMenu).toBeVisible(); + await expect(page.locator(".sidebar-footer .appearance-menu-trigger")).toHaveCount(1); + await expect(accountMenu).toContainText("Agent Core"); + await expect(accountMenu).not.toContainText("API ready"); + await expect(accountMenu.locator(".appearance-core-dot-ready")).toHaveCount(1); + await accountMenu.click(); + const coreSettings = page.getByRole("menuitem", { name: "Configure Agent Core connection" }); + await expect(coreSettings).toBeVisible(); + await expect(coreSettings).not.toContainText("API ready"); + await expect(coreSettings.locator(".appearance-core-dot-ready")).toHaveCount(1); + await coreSettings.click(); + const connectionDialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); + await expect(connectionDialog).toBeVisible(); + await connectionDialog.getByRole("button", { name: "Close dialog" }).click(); + await accountMenu.click(); + await expect(page.getByText(username, { exact: true }).last()).toBeVisible(); + await expect(page.getByRole("menuitemradio", { name: "简体中文" })).toHaveCount(0); + await expect(page.getByRole("menuitem", { name: "Sign out" })).toBeVisible(); + await page.getByRole("menuitem", { name: "Switch to dark theme" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await accountMenu.click(); + await page.getByRole("menuitem", { name: /Language/ }).click(); + await page.getByRole("menuitemradio", { name: "简体中文" }).click(); + await expect(page.getByRole("button", { name: "账号、语言和外观" })).toBeVisible(); + await page.getByRole("button", { name: "账号、语言和外观" }).click(); + await expect(page.getByRole("menuitem", { name: "退出登录" })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "再次查看引导" })).toBeVisible(); + await page.getByRole("menuitem", { name: /语言/ }).click(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menuitemradio", { name: "English" })).toHaveCount(0); + await page.keyboard.press("Escape"); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("button", { name: "账号、语言和外观" }).click(); + const menu = await page.getByRole("menu", { name: "账号、语言和外观" }).boundingBox(); + expect(menu).not.toBeNull(); + expect(menu!.x).toBeGreaterThanOrEqual(0); + expect(menu!.x + menu!.width).toBeLessThanOrEqual(390); + await page.getByRole("menuitem", { name: /语言/ }).click(); + await page.getByRole("menuitemradio", { name: "English" }).click(); + await expect(page.getByRole("button", { name: "Account, language and appearance" })).toBeVisible(); +}); diff --git a/apps/web/e2e/fixture-sandbox.mjs b/apps/web/e2e/fixture-sandbox.mjs index 58aac8cf0..f2e9964fe 100644 --- a/apps/web/e2e/fixture-sandbox.mjs +++ b/apps/web/e2e/fixture-sandbox.mjs @@ -10,7 +10,7 @@ let provider = "docker"; let diagnostic = ""; let coreUrl = ""; export function resetSandboxFixture() { - nodes = [node("node-local", "Core server"), node("node-offline", "Offline host", false)]; calls = []; provider = "docker"; diagnostic = ""; coreUrl = ""; + nodes = [node("node-local", "Core server"), node("node-offline", "Offline host", false)]; calls = []; provider = "docker"; diagnostic = ""; coreUrl = "https://core.example"; } resetSandboxFixture(); export function handleSandboxFixture(request, response, url, sendJson, sendError) { diff --git a/apps/web/e2e/sandbox-manager.spec.ts b/apps/web/e2e/sandbox-manager.spec.ts index 4b92f6b1d..a405f5c6c 100644 --- a/apps/web/e2e/sandbox-manager.spec.ts +++ b/apps/web/e2e/sandbox-manager.spec.ts @@ -45,6 +45,11 @@ test("add opens one command, copy works, existing hosts do not imply connection, await context.grantPermissions(["clipboard-read", "clipboard-write"]); await openManager(page); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } const dialog = page.getByRole("dialog", { name: "Add node" }); const command = dialog.getByLabel("One-time enrollment command"); await expect(command).toHaveValue(/fixture-once-token/); @@ -67,6 +72,11 @@ test("unavailable installer shows compact guidance and never creates an enrollme await page.route("**/console/config", (route) => route.fulfill({ json: { sandbox_admin: true, node_installer: false } })); await openManager(page); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } const dialog = page.getByRole("dialog"); await expect(dialog).toContainText("Node installation is unavailable"); await expect(dialog.getByRole("textbox")).toHaveCount(0); @@ -101,19 +111,23 @@ for (const value of ["node_unavailable", "resource_missing"]) { test("Chinese actions, diagnostics, and enrollment are translated and language persists", async ({ page, request }) => { await page.getByRole("button", { name: "Language and appearance" }).click(); + await page.getByRole("menuitem", { name: /Language/ }).click(); await page.getByRole("menuitemradio", { name: "简体中文" }).click(); await request.post(`${fixture}/__fixture/sandbox-diagnostic?value=resource_missing`); await page.getByRole("button", { name: "托管沙箱管理", exact: true }).click(); await page.locator(".sandbox-topology-node").first().click(); await expect(page.getByRole("region", { name: "沙箱资源分配" }).first()).toContainText("沙箱资源缺失"); await page.getByRole("button", { name: "添加节点", exact: true }).click(); + await page.getByLabel("新节点可访问的控制台地址").fill("https://console.example"); + await page.getByRole("button", { name: "生成注册命令", exact: true }).click(); await expect(page.getByLabel("一次性注册命令")).toHaveValue(/fixture-once-token/); await expect(page.getByRole("dialog")).toContainText("等待节点连接"); await page.keyboard.press("Escape"); await page.getByRole("button", { name: "语言和外观" }).click(); + await page.getByRole("menuitem", { name: /语言/ }).click(); await page.getByRole("menuitemradio", { name: "English" }).click(); await page.reload(); - await expect(page.getByRole("button", { name: "Language and appearance" })).toContainText("EN"); + await expect(page.getByRole("button", { name: "Language and appearance" })).toBeVisible(); await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); }); @@ -128,12 +142,22 @@ test("an uncertain write is never retried; closing discards a late token and all await route.fulfill({ json: { token: attempts === 1 ? "stale-token" : "fresh-token", expires_at: new Date(Date.now() + 600000).toISOString() } }).catch(() => {}); }); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } await expect.poll(() => attempts).toBe(1); await page.keyboard.press("Escape"); release(); await expect(page.getByRole("dialog")).toHaveCount(0); expect(attempts).toBe(1); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/fresh-token/); expect(attempts).toBe(2); }); @@ -148,6 +172,11 @@ test("a connected node remains successful after its enrollment token expires", a }); await openManager(page); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } const dialog = page.getByRole("dialog", { name: "Add node" }); await expect(dialog.getByLabel("One-time enrollment command")).toHaveValue(/short-lived-token/); await request.post(`${fixture}/__fixture/sandbox-add-node`); @@ -171,6 +200,11 @@ test("expired commands and failed writes require an explicit retry", async ({ pa return attempts === 1 ? route.fulfill({ json: { token: "expired-token", expires_at: "2020-01-01T00:00:00Z" } }) : route.fulfill({ status: 503, json: { error: { message: "Unavailable" } } }); }); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } await expect(page.getByRole("dialog")).toContainText("Command expired"); await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); expect(attempts).toBe(1); @@ -220,6 +254,11 @@ for (const width of [1280, 1440]) { expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ animations: "disabled", path: testInfo.outputPath(`nodes-${width}-${theme}.png`) }); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/fixture-once-token/); const copyButton = page.getByRole("button", { name: "Copy node command" }); await expect(copyButton).toBeInViewport(); @@ -293,6 +332,7 @@ for (const theme of ["light", "dark"]) { await page.setViewportSize({ width: 1440, height: 1000 }); await page.evaluate((theme) => document.documentElement.dataset.theme = theme, theme); await page.getByRole("button", { name: "Language and appearance" }).click(); + await page.getByRole("menuitem", { name: /Language/ }).click(); await page.getByRole("menuitemradio", { name: "简体中文" }).click(); await page.getByRole("button", { name: "托管沙箱管理", exact: true }).click(); await expect(page.getByRole("region", { name: "沙箱节点", exact: true })).toContainText("可用"); @@ -302,3 +342,37 @@ for (const theme of ["light", "dark"]) { await page.locator("#sandbox-selected-node").screenshot({ path: testInfo.outputPath(`node-details-zh-${theme}.png`), animations: "disabled" }); }); } + +test("SSH tunnel enrollment requires a reachable console address before issuing a token", async ({ page, request }) => { + await openManager(page); + await page.getByRole("button", { name: "Add node", exact: true }).click(); + const dialog = page.getByRole("dialog"); + const source = dialog.getByLabel("Console address reachable from the new node"); + const generate = dialog.getByRole("button", { name: "Generate enrollment command", exact: true }); + await expect(source).toHaveValue(""); + for (const invalid of ["http://127.0.0.1:14173", "https://localhost", "https://console.example/v1", "https://user:secret@console.example"]) { + await source.fill(invalid); + await expect(generate).toBeDisabled(); + } + const before = (await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls; + expect(before.filter((call: { method: string }) => call.method === "POST")).toHaveLength(0); + await source.fill("https://console.example"); + await generate.click(); + const command = dialog.getByLabel("One-time enrollment command"); + await expect(command).toHaveValue(/--source-url 'https:\/\/console.example'/); + await expect(command).toHaveValue(/--core-url 'https:\/\/core.example'/); + await expect(command).not.toHaveValue(/127\.0\.0\.1/); +}); + +test("a loopback deployment origin cannot issue a remote-node command", async ({ page, request }) => { + await page.route("**/core/v1/sandbox/deployment", async (route) => { + const deployment = await (await route.fetch()).json(); + await route.fulfill({ json: { ...deployment, core_url: "http://127.0.0.1:8080" } }); + }); + await openManager(page); + await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog")).toContainText("deployment needs an HTTPS Core address"); + await expect(page.getByLabel("One-time enrollment command")).toHaveCount(0); + const calls = (await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls; + expect(calls.filter((call: { method: string }) => call.method === "POST")).toHaveLength(0); +}); diff --git a/apps/web/e2e/sandbox-provider.spec.ts b/apps/web/e2e/sandbox-provider.spec.ts index 2bdb523f8..7b521327c 100644 --- a/apps/web/e2e/sandbox-provider.spec.ts +++ b/apps/web/e2e/sandbox-provider.spec.ts @@ -117,6 +117,7 @@ for (const theme of ["light", "dark"]) { await openManager(page); await page.evaluate((theme) => document.documentElement.dataset.theme = theme, theme); await page.getByRole("button", { name: "Language and appearance" }).click(); + await page.getByRole("menuitem", { name: /Language/ }).click(); await page.getByRole("menuitemradio", { name: "简体中文" }).click(); await expect(page.getByRole("heading", { name: "部署运行后端", exact: true })).toBeVisible(); await expect(page.getByText("已配置", { exact: true })).toBeVisible(); diff --git a/apps/web/e2e/sandbox-setup.spec.ts b/apps/web/e2e/sandbox-setup.spec.ts index c58cd027d..a212a3c09 100644 --- a/apps/web/e2e/sandbox-setup.spec.ts +++ b/apps/web/e2e/sandbox-setup.spec.ts @@ -31,6 +31,11 @@ test("bundled console needs no extra admin key and provides one install command await page.getByLabel("Sandbox provider").selectOption("docker"); await page.getByRole("button", { name: "Initialize sandbox deployment" }).click(); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } const command = page.getByLabel("One-time enrollment command"); await expect(command).toHaveValue(/fixture-once-token/); await expect(command).toHaveValue(/\/node-install\/node-install.pyz/); @@ -68,6 +73,11 @@ for (const provider of ["docker", "microsandbox"]) { await expect(page.getByLabel("Sandbox provider")).toHaveCount(0); await expect(page.getByText("No nodes registered. Add a node to provide hosted capacity.")).toBeVisible(); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } await expect(page.getByLabel("One-time enrollment command")).toHaveValue(/--core-url 'https:\/\/core.example'/); expect(await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } }))).not.toMatch(/fixture-admin-key|fixture-once-token/); await request.post(`${fixture}/__fixture/sandbox-add-node`); @@ -167,10 +177,16 @@ for (const operation of ["setup", "enrollment"] as const) { await request.post(setupUrl, { data: { provider: "docker", core_url: "https://core.example" } }); await page.getByRole("button", { name: "Refresh sandbox state" }).click(); await page.getByRole("button", { name: "Add node", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Add node" })).toBeVisible(); + if (await page.getByLabel("Console address reachable from the new node").isVisible()) { + await page.getByLabel("Console address reachable from the new node").fill("https://console.example"); + await page.getByRole("button", { name: "Generate enrollment command", exact: true }).click(); + } } await expect.poll(() => page.evaluate(() => typeof (window as Window & { releaseSandboxResponse?: () => void }).releaseSandboxResponse)).toBe("function"); await page.evaluate(() => { location.hash = "system"; }); - await page.getByRole("button", { name: "Configure Agent Core connection", exact: true }).click(); + await page.locator(".sidebar-footer .appearance-menu-trigger").click(); + await page.getByRole("menuitem", { name: "Configure Agent Core connection", exact: true }).click(); const connection = page.getByRole("dialog", { name: "Connect an Agent Core", exact: true }); await connection.getByRole("radio", { name: /Other compatible Core/ }).check(); await connection.getByLabel("Compatible Core base URL").fill(`${new URL(page.url()).origin}/v1`); @@ -193,7 +209,7 @@ test("unpaired or unavailable consoles show setup guidance without admin credent await page.route("**/console/config", (route) => route.fulfill(body ? { contentType: "application/json", body: JSON.stringify(body) } : { status: 404, body: "Not found" })); await page.reload(); await page.getByRole("button", { name: "Hosted Sandbox Manager", exact: true }).click(); - await expect(page.getByRole("alert")).toContainText("Sandbox administration is not configured"); + await expect(page.getByRole("alert")).toContainText(body ? "Sandbox administration is not configured" : "Console access could not be checked"); await expect(page.getByLabel("Deployment admin key")).toHaveCount(0); expect((await (await request.get(`${fixture}/__fixture/sandbox`)).json()).calls).toHaveLength(0); } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7765467be..551bba84e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,3 @@ -import { Settings2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -19,9 +18,8 @@ import type { import { ConsoleNavigation } from "./features/first-run/ConsoleNavigation"; import { isLocalProxyBaseUrl } from "./lib/connection"; -import { ApiKeyPanel } from "./features/api-keys/ApiKeyPanel"; +import { ApiKeyExample, ApiKeyPanel } from "./features/api-keys/ApiKeyPanel"; import { FirstRunHome } from "./features/first-run/FirstRunHome"; -import { ConsoleAccountMenu } from "./features/first-run/ConsoleAccess"; import { useIntroduction } from "./features/first-run/useIntroduction"; import { SandboxManagerView } from "./features/sandbox/SandboxManagerView"; import { SandboxProvider } from "./features/sandbox/SandboxContext"; @@ -29,7 +27,6 @@ import { SystemNavigation } from "./components/SystemNavigation"; import { ConnectionModal } from "./components/ConnectionModal"; import { CreateMenu } from "./components/CreateMenu"; import { ProductNavigation, type ProductView } from "./components/ProductNavigation"; -import { StatusIcon } from "./components/StatusIcon"; import { AppearanceMenu } from "./components/AppearanceMenu"; import { useToast } from "./components/Toast"; import { AgentsView } from "./features/agents/AgentsView"; @@ -2305,31 +2302,12 @@ export function App() { showTemplates={__AGENTS_CORE_WEB_OPENAI_HOSTED_SESSIONS__} /> - { introduction.replay(); setView("dashboard"); }} onKeys={() => setView("api-keys")} /> + setView("api-keys")} />
- - -
- -
+ setConnectionOpen(true)} + onReplayIntroduction={introduction.available ? () => { introduction.replay(); setView("dashboard"); } : undefined} />
@@ -2463,7 +2441,13 @@ export function App() { operations={vaultOperations} /> ) : null} - {view === "api-keys" && isLocalProxyBaseUrl(connection.baseUrl) ?

{t("API keys", { ns: "firstRun" })}

: null} + {view === "api-keys" && isLocalProxyBaseUrl(connection.baseUrl) ?
+

{t("API keys", { ns: "firstRun" })}

+
+

{t("Give your machine a secure way to call Agent Core.", { ns: "firstRun" })}

+
+
+
: null} {view === "sandbox" ? : null} {view === "system" ? ( void; + onReplayIntroduction?: () => void; +}) { const { i18n, t } = useTranslation("navigation"); + const { t: tAuth } = useTranslation("firstRun"); + const account = useConsoleAccount(); const { resolvedTheme, setPreference } = useTheme(); const language: SupportedLanguage = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const [open, setOpen] = useState(false); + const [languageOpen, setLanguageOpen] = useState(false); + const [signingOut, setSigningOut] = useState(false); + const [signOutFailed, setSignOutFailed] = useState(false); const rootRef = useRef(null); const triggerRef = useRef(null); + const languageRef = useRef(null); const ThemeIcon = resolvedTheme === "dark" ? Moon : Sun; useEffect(() => { if (!open) return; const closeOnOutsidePointer = (event: MouseEvent) => { - if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + if (!rootRef.current?.contains(event.target as Node)) { setOpen(false); setLanguageOpen(false); } }; document.addEventListener("mousedown", closeOnOutsidePointer); return () => document.removeEventListener("mousedown", closeOnOutsidePointer); @@ -25,24 +39,26 @@ export function AppearanceMenu() { const focusMenuItem = (edge: "first" | "last") => { window.requestAnimationFrame(() => { - const items = rootRef.current?.querySelectorAll('button[role="menuitemradio"]'); + const items = rootRef.current?.querySelectorAll('button[role^="menuitem"]'); (edge === "first" ? items?.[0] : items?.[items.length - 1])?.focus(); }); }; const close = () => { setOpen(false); + setLanguageOpen(false); triggerRef.current?.focus(); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); + if (languageOpen) { setLanguageOpen(false); languageRef.current?.focus(); return; } close(); return; } if (!open || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; - const items = Array.from(rootRef.current?.querySelectorAll('button[role="menuitemradio"]') ?? []); + const items = Array.from(rootRef.current?.querySelectorAll('button[role^="menuitem"]') ?? []); if (!items.length) return; event.preventDefault(); if (event.key === "Home") return void items[0]?.focus(); @@ -61,18 +77,27 @@ export function AppearanceMenu() { close(); }; - return
+ const showConsole = withAccount && Boolean(onConfigureCore) && Boolean(coreState); + const showAccount = withAccount && Boolean(account); + const coreStatus = coreState === "connecting" ? t("coreConnecting") : coreState === "ready" ? t("coreReady") : t("coreFailed"); + const toggleTheme = () => { + setPreference(resolvedTheme === "dark" ? "light" : "dark"); + close(); + }; + return
- {open ?
+ {open ?
+ {showConsole ? <> + {showAccount ?
{account?.username}{t("account")}
: null} +
+
+ + {languageOpen ?
+ + +
: null} + + {onReplayIntroduction ? : null} +
+ {showAccount ?
+ {signOutFailed ? {tAuth("Could not sign out. Try again.")} : null}
: null} + : <>
{t("language")} @@ -98,6 +139,7 @@ export function AppearanceMenu() {
+ }
: null}
; } diff --git a/apps/web/src/features/api-keys/ApiKeyPanel.tsx b/apps/web/src/features/api-keys/ApiKeyPanel.tsx index cd9796b5c..8a533434c 100644 --- a/apps/web/src/features/api-keys/ApiKeyPanel.tsx +++ b/apps/web/src/features/api-keys/ApiKeyPanel.tsx @@ -23,9 +23,11 @@ export function ApiKeyPanel({ onReady }: { onReady?: (ready: boolean) => void }) if (capability === "available") return ; return
{capability === "unavailable" ? <> -

{t("Use an existing Agent API key.")}

-

{t("This console cannot create API keys. Use a key supplied by your Core administrator for requests from your machine or application.")}

-

{t("You can continue the introduction and use your signed-in console connection to create an Agent.")}

+

{t("API key management is not enabled")}

+

{t("A deployment administrator needs to enable key management for this console. You can then create, view and delete keys here.")}

+

{t("If you already have an Agent API key, you can keep using it for requests from your machine or application.")}

+ {onReady ?

{t("You can continue the introduction and use your signed-in console connection to create an Agent.")}

: null} + : capability === "loading" ?

{t("Checking API key management…")}

: <>

{t("Could not check API key management. Try again.")}

@@ -33,6 +35,45 @@ export function ApiKeyPanel({ onReady }: { onReady?: (ready: boolean) => void })
; } +export function ApiKeyExample() { + const { t } = useTranslation("firstRun"); + const [copied, setCopied] = useState(false); + const [copyFailed, setCopyFailed] = useState(false); + const origin = typeof window === "undefined" ? "https://core.example" : window.location.origin; + const command = [ + "curl --fail-with-body --silent --show-error \\", + ` --url '${origin}/v1/agents' \\`, + ' --header "Authorization: Bearer ${AGENT_CORE_API_KEY}" \\', + " --header 'OpenAI-Beta: agents=v1'", + ].join("\n"); + async function copyCommand() { + try { await navigator.clipboard.writeText(command); setCopied(true); setCopyFailed(false); } + catch { setCopied(false); setCopyFailed(true); } + } + return ; +} + function ManagedApiKeyPanel({ onReady }: { onReady?: (ready: boolean) => void }) { const { t } = useTranslation("firstRun"); const defaultName = t("My API key"); @@ -73,7 +114,8 @@ function ManagedApiKeyPanel({ onReady }: { onReady?: (ready: boolean) => void }) setUncertain(null); setId(crypto.randomUUID()); } }, [uncertain, fresh, keys]); - const hasKey = keys.some((key) => !key.revoked_at); + const activeKeys = keys.filter((key) => !key.revoked_at); + const hasKey = activeKeys.length > 0; useEffect(() => { ready.current?.(fresh && hasKey && !issued && !uncertain); }, [fresh, hasKey, issued, uncertain]); async function create(event: FormEvent) { event.preventDefault(); @@ -103,7 +145,7 @@ function ManagedApiKeyPanel({ onReady }: { onReady?: (ready: boolean) => void }) if (uncertain === keyId) { setUncertain(null); setId(crypto.randomUUID()); } if (issued?.id === keyId) setIssued(null); } catch { - if (!controller.signal.aborted) { setFresh(false); setError(t("Could not confirm revocation. Refresh the list to check its status.")); } + if (!controller.signal.aborted) { setFresh(false); setError(t("Could not confirm deletion. Refresh the list to check its status.")); } } finally { activeRequest.current = false; if (!controller.signal.aborted) setBusy(false); } } async function copy() { @@ -113,9 +155,8 @@ function ManagedApiKeyPanel({ onReady }: { onReady?: (ready: boolean) => void }) catch { if (!controller.signal.aborted) setCopyFailed(true); } } return
-

{t("Create a key for your API requests.")}

-

{t("Use this key when calling Agent API from your own machine or application.")}

-

{t("Your Web password is for signing in. This key is for API requests.")}

+

{t("Create a key")}

+

{t("Name it so you can recognize it later.")}

{issued ?
{t("Keep this key somewhere safe. It is shown only now.")} event.target.select()} />
@@ -124,13 +165,13 @@ function ManagedApiKeyPanel({ onReady }: { onReady?: (ready: boolean) => void })
void create(event)}>
} {error ?

{error}

: null} - {uncertain && keys.some((key) => key.id === uncertain) ?

{t("This key was created, but its secret cannot be shown again. Revoke it and create a new key.")}

: null} + {uncertain && keys.some((key) => key.id === uncertain) ?

{t("This key was created, but its secret cannot be shown again. Delete it and create a new key.")}

: null} {uncertain && fresh && !keys.some((key) => key.id === uncertain) ? : null} -
{t(hasKey ? "API key ready" : "API keys")}
+
{t("Active keys")} {fresh ? activeKeys.length : ""}
{loading ?

{t("Loading API keys…")}

: null} - {!keys.length && fresh ?

{t("No API keys yet. Create one to get started.")}

: null} -
    {keys.map((key) =>
  • {key.name}{key.prefix}… · {t(key.revoked_at ? "Revoked" : "Active")}
    - {!key.revoked_at ? : null} - {confirm === key.id ?

    {t("Revoke this API key? Requests using it will stop working.")}

    : null}
  • )}
+ {!activeKeys.length && fresh ?

{t("No active keys. Create one to call the API.")}

: null} +
    {activeKeys.map((key) =>
  • {key.name}{key.prefix}…
    + + {confirm === key.id ?

    {t("Delete this API key? It will stop authenticating new requests and disappear from this list. Existing Agents and Sessions remain.")}

    : null}
  • )}
; } diff --git a/apps/web/src/features/api-keys/api-keys.css b/apps/web/src/features/api-keys/api-keys.css index 8aaa83caf..ca9d8a7dc 100644 --- a/apps/web/src/features/api-keys/api-keys.css +++ b/apps/web/src/features/api-keys/api-keys.css @@ -1,20 +1,61 @@ -.api-key-panel { padding: 26px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); } -.api-key-heading { display: flex; align-items: center; gap: 10px; } -.api-key-heading h3 { font-size: 16px; font-weight: 550; letter-spacing: -.02em; margin: 0; } -.api-key-panel header p, .api-key-caption { font-size: 12px; line-height: 1.7; color: var(--fg-muted); } -.api-key-create { display: flex; gap: 12px; align-items: end; margin: 22px 0; } -.api-key-create .field { flex: 1; } +.api-keys-page { display: flex; flex-direction: column; overflow: hidden; } +.api-keys-body { min-height: 0; flex: 1; overflow: auto; padding: 24px; } +.api-keys-content { width: min(100%, 1160px); margin-inline: auto; } +.api-keys-body .page-description { margin-bottom: 24px; } +.api-keys-layout { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(280px, 1fr); gap: 20px; align-items: start; } +.api-key-panel { min-width: 0; padding: 28px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow-control); } +.api-key-heading { display: flex; align-items: center; gap: 11px; } +.api-key-heading > svg { color: var(--accent); } +.api-key-heading h3 { font-size: 20px; font-weight: 600; letter-spacing: -.025em; margin: 0; } +.api-key-panel header p { margin: 7px 0 0 29px; color: var(--fg-muted); font-size: 13px; line-height: 1.5; } +.api-key-caption { color: var(--fg-muted); font-size: 13px; line-height: 1.6; } +.api-key-create { display: flex; gap: 10px; align-items: end; margin: 24px 0 32px; } +.api-key-create .field { flex: 1; min-width: 0; } .api-key-create .field span { font-size: 12px; } -.api-key-create input { min-height: 36px; } +.api-key-create input { min-height: 40px; } +.api-key-create .button { min-height: 40px; white-space: nowrap; } .api-key-actions { display: flex; flex-wrap: wrap; gap: 8px; } -.api-key-secret { display: grid; gap: 14px; padding: 20px; margin: 20px 0; background: var(--surface-subtle); border: 1px solid var(--line-strong); border-radius: 10px; } -.api-key-secret strong { font-size: 13px; font-weight: 500; } +.api-key-secret { display: grid; min-width: 0; gap: 14px; padding: 18px; margin: 22px 0; background: var(--surface-subtle); border: 1px solid var(--line-strong); border-radius: 9px; } +.api-key-secret strong { font-size: 13px; font-weight: 600; } .api-key-secret input { width: 100%; padding: 12px; border: 1px solid var(--line); border-radius: 6px; font-family: var(--font-mono); font-size: 12px; color: var(--fg); background: var(--surface); } -.api-key-list-heading { display: flex; align-items: center; justify-content: space-between; font-size: 12px; margin-top: 20px; color: var(--fg-muted); } -.api-key-list { padding: 0; margin: 0; list-style: none; } +.api-key-list-heading { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; padding-top: 22px; border-top: 1px solid var(--line); color: var(--fg); font-size: 14px; font-weight: 600; } +.api-key-list-heading strong { display: inline-flex; align-items: center; justify-content: center; min-width: 21px; height: 21px; margin-left: 6px; padding: 0 5px; color: var(--fg-muted); background: var(--surface-subtle); border-radius: 99px; font-size: 11px; } +.api-key-list { padding: 0; margin: 9px 0 0; list-style: none; } .api-key-list > li { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; padding: 14px 0; border-top: 1px solid var(--line); } -.api-key-list strong { font-size: 13px; display: block; font-weight: 500; } +.api-key-list strong { font-size: 13px; display: block; font-weight: 600; overflow-wrap: anywhere; } .api-key-list span { display: block; font-size: 11px; color: var(--fg-muted); margin-top: 5px; } .api-key-confirm { flex-basis: 100%; font-size: 12px; } -.api-key-error { color: var(--danger); font-size: 12px; line-height: 1.7; } -.api-keys-page { max-width: 920px; margin: 0 auto; } +.api-key-error { color: var(--danger); font-size: 12px; line-height: 1.6; } +.api-key-example { overflow: hidden; padding: 28px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-subtle); } +.api-key-example-topline { display: inline-flex; align-items: center; gap: 8px; color: var(--accent); font: 600 11px/1.4 var(--font-mono); letter-spacing: .04em; } +.api-key-example-live { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 12%, transparent); } +.api-key-example h2 { margin: 15px 0 8px; font-size: 22px; font-weight: 600; letter-spacing: -.035em; } +.api-key-example > p { margin: 0; max-width: 34ch; color: var(--fg-muted); font-size: 13px; line-height: 1.6; } +.api-key-example-scene { margin-top: 28px; } +.api-key-example-terminal { position: relative; height: 190px; overflow: hidden; border: 1px solid var(--line-strong); border-radius: 10px; color: var(--fg); background: var(--surface); box-shadow: var(--shadow-control); } +.api-key-example-terminal-top { display: flex; align-items: center; gap: 5px; height: 31px; padding: 0 11px; border-bottom: 1px solid var(--line); } +.api-key-example-terminal-top > span { width: 6px; height: 6px; border-radius: 50%; background: var(--line-strong); } +.api-key-example-terminal-top code { margin-left: auto; color: var(--fg-muted); font-size: 10px; } +.api-key-example-frame { position: absolute; inset: 31px 0 0; display: flex; flex-direction: column; justify-content: center; gap: 15px; padding: 22px; opacity: 0; animation: api-example-frame 9s infinite; } +.api-key-example-frame small { color: var(--accent); font-size: 12px; font-weight: 600; } +.api-key-example-frame code { overflow-wrap: anywhere; font: 12px/1.6 var(--font-mono); } +.api-key-example-frame code span { color: var(--success); font-weight: 600; } +.api-key-example-frame.frame-two { animation-delay: 3s; } +.api-key-example-frame.frame-three { animation-delay: 6s; } +.api-key-example-progress { display: flex; gap: 5px; margin-top: 12px; } +.api-key-example-progress span { flex: 1; height: 3px; border-radius: 3px; background: var(--line-strong); animation: api-example-progress 9s infinite; } +.api-key-example-progress span:nth-child(2) { animation-delay: 3s; } +.api-key-example-progress span:nth-child(3) { animation-delay: 6s; } +.api-key-example .api-key-example-note { margin-top: 25px; font-size: 12px; } +.api-key-example-request { margin-top: 19px; padding-top: 16px; border-top: 1px solid var(--line); } +.api-key-example-request summary { cursor: pointer; color: var(--accent); font-size: 12px; font-weight: 600; } +.api-key-example-request > p { margin: 13px 0 0; color: var(--fg-muted); font-size: 12px; line-height: 1.6; } +.api-key-example-code { max-width: 100%; overflow-x: auto; margin: 13px 0 10px; padding: 14px; border: 1px solid var(--line-strong); border-radius: 9px; color: var(--fg); background: var(--surface); font: 11px/1.7 var(--font-mono); } +.api-key-example-code code { white-space: pre; } +.api-key-example-copy { min-height: 32px; padding: 5px 9px; font-size: 12px; } +.api-key-example-request > .api-key-example-note { margin-top: 10px; } +@keyframes api-example-frame { 0%, 30% { opacity: 1; transform: translateY(0); } 33%, 100% { opacity: 0; transform: translateY(8px); } } +@keyframes api-example-progress { 0%, 30% { background: var(--accent); } 33%, 100% { background: var(--line-strong); } } +@media (max-width: 1000px) { .api-keys-layout { grid-template-columns: minmax(0, 1fr); } } +@media (max-width: 640px) { .api-keys-body { padding: 20px 16px; } .api-key-panel, .api-key-example { padding: 20px; } .api-key-create { flex-direction: column; align-items: stretch; } } +@media (prefers-reduced-motion: reduce) { .api-key-example-frame, .api-key-example-progress span { animation: none; } .api-key-example-frame.frame-one { opacity: 1; } .api-key-example-progress span:first-child { background: var(--accent); } } diff --git a/apps/web/src/features/dashboard/DashboardView.css b/apps/web/src/features/dashboard/DashboardView.css index 994880b84..7b2052b57 100644 --- a/apps/web/src/features/dashboard/DashboardView.css +++ b/apps/web/src/features/dashboard/DashboardView.css @@ -5,21 +5,6 @@ overflow: hidden; } -.dashboard-header > div:first-child { - min-width: 0; -} - -.dashboard-header h1 { - margin: 0; -} - -.dashboard-header p { - margin: 3px 0 0; - color: var(--fg-muted); - font-size: 12px; - line-height: 16px; -} - .dashboard-scroll { min-height: 0; flex: 1; diff --git a/apps/web/src/features/dashboard/DashboardView.tsx b/apps/web/src/features/dashboard/DashboardView.tsx index 5ac9a7cd2..4b421fc5e 100644 --- a/apps/web/src/features/dashboard/DashboardView.tsx +++ b/apps/web/src/features/dashboard/DashboardView.tsx @@ -294,10 +294,7 @@ export function DashboardView({ return (
-
-

{t("dashboard.title")}

-

{t("dashboard.subtitle")}

-
+

{t("dashboard.title")}

- {failed ? {t("Could not sign out. Try again.")} : null} -
; -} - export function ConsoleAccess({ children }: { children: ReactNode }) { const { t } = useTranslation("firstRun"); const [status, setStatus] = useState(null); diff --git a/apps/web/src/features/first-run/ConsoleNavigation.tsx b/apps/web/src/features/first-run/ConsoleNavigation.tsx index bf2262044..e10120eea 100644 --- a/apps/web/src/features/first-run/ConsoleNavigation.tsx +++ b/apps/web/src/features/first-run/ConsoleNavigation.tsx @@ -1,17 +1,13 @@ -import { KeyRound, Sparkles } from "lucide-react"; +import { KeyRound } from "lucide-react"; import { useTranslation } from "react-i18next"; -export function ConsoleNavigation({ showIntroduction, introductionAvailable, keysAvailable, activeView, onIntroduction, onKeys }: { - showIntroduction: boolean; introductionAvailable: boolean; keysAvailable: boolean; - activeView: string; onIntroduction: () => void; onKeys: () => void; +export function ConsoleNavigation({ keysAvailable, activeView, onKeys }: { + keysAvailable: boolean; activeView: string; onKeys: () => void; }) { const { t } = useTranslation("firstRun"); - if (!introductionAvailable && !keysAvailable) return null; - return ; } diff --git a/apps/web/src/features/first-run/FirstRunHome.css b/apps/web/src/features/first-run/FirstRunHome.css index 71a69fa95..e4abc7716 100644 --- a/apps/web/src/features/first-run/FirstRunHome.css +++ b/apps/web/src/features/first-run/FirstRunHome.css @@ -1,29 +1,29 @@ -.first-run-home { display: flex; flex-direction: column; width: 100%; height: 100dvh; min-height: 0; overflow: hidden; color: var(--fg); background: radial-gradient(ellipse at 78% 20%, color-mix(in srgb, var(--accent) 4%, transparent), transparent 62%), var(--surface); animation: first-run-screen 560ms var(--ease-settle) both; } -.first-run-toolbar { position: relative; z-index: 10; display: flex; flex: 0 0 auto; justify-content: space-between; align-items: center; gap: 24px; padding: 20px 36px; } +.first-run-home { display: flex; flex-direction: column; width: 100%; height: 100dvh; min-height: 0; overflow: hidden; color: var(--fg); background: var(--surface); } +.first-run-toolbar { position: relative; z-index: 10; display: flex; flex: 0 0 auto; justify-content: space-between; align-items: center; gap: 16px; min-height: 56px; padding: 12px 24px; border-bottom: 1px solid var(--line); } .first-run-toolbar .appearance-menu-panel { position: absolute; top: calc(100% + 8px); bottom: auto; left: 0; right: auto; } .first-run-toolbar-preferences { display: flex; align-items: center; gap: 16px; } -.first-run-chapters, .first-run-stage-content, .first-run-footer { width: 100%; max-width: 1280px; margin-inline: auto; } +.first-run-chapters, .first-run-stage-content, .first-run-footer { width: 100%; max-width: 1080px; min-width: 0; margin-inline: auto; } .first-run-skip, .first-run-text-button { display: inline-flex; align-items: center; gap: 8px; background: transparent; border: 0; padding: 7px 0; color: var(--fg-muted); font-size: 12px; white-space: nowrap; transition: color 160ms ease; } .first-run-skip:hover, .first-run-text-button:hover { color: var(--fg); } -.first-run-chapters { display: flex; flex: 0 0 auto; gap: 4px; padding: 0 36px 18px; } -.first-run-chapters button { position: relative; flex: 1; display: flex; align-items: center; gap: 10px; padding: 15px 18px; border: 1px solid transparent; border-radius: 9px; background: transparent; text-align: left; color: var(--fg-muted); font-size: 12px; transition: color 220ms ease, background 220ms ease, border-color 220ms ease; } +.first-run-chapters { display: flex; flex: 0 0 auto; gap: 8px; padding: 24px 24px 0; } +.first-run-chapters button { position: relative; flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; padding: 12px 14px; border: 1px solid transparent; border-radius: 9px; background: transparent; text-align: left; color: var(--fg-muted); font-size: 12px; transition: color 220ms ease, background 220ms ease, border-color 220ms ease; } .first-run-chapters button:hover { background: var(--hover); } .first-run-chapters button.active { color: var(--fg); background: var(--surface-subtle); border-color: var(--line); } .first-run-chapters button.active::after { content: ""; position: absolute; width: 20px; height: 2px; left: 18px; bottom: -1px; background: var(--accent); animation: first-run-indicator 400ms var(--ease-spring); } .first-run-chapter-number { font-size: 10px; font-variant-numeric: tabular-nums; color: var(--fg-muted); } .first-run-chapters small { margin-left: auto; font-size: 10px; color: var(--fg-muted); } -.first-run-stage { flex: 1; min-height: 0; overflow: auto; padding: 12px 36px 24px; } -.first-run-stage-content { min-height: 100%; display: flex; flex-direction: column; justify-content: center; animation: first-run-enter 540ms cubic-bezier(.16, 1, .3, 1) both; } -.first-run-access { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); min-height: min(620px, 70vh); gap: clamp(40px, 6vw, 96px); align-items: center; } -.first-run-story { display: flex; flex-direction: column; align-items: flex-start; justify-content: center; padding: 32px 0 32px 24px; } +.first-run-stage { flex: 1; min-height: 0; overflow: auto; padding: 24px; } +.first-run-stage-content { display: flex; flex-direction: column; gap: 24px; } +.first-run-access { display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); gap: 32px; align-items: start; } +.first-run-story { display: flex; flex-direction: column; align-items: flex-start; justify-content: center; min-width: 0; padding: 20px 0; } .first-run-section-number { display: inline-block; margin-bottom: 14px; color: var(--fg-muted); font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em; } -.first-run-story h2, .first-run-step-heading h2 { margin: 0 0 12px; font-size: clamp(28px, 3vw, 44px); font-weight: 550; line-height: 1.25; letter-spacing: -.035em; } -.first-run-story p { max-width: 300px; margin: 0 0 28px; color: var(--fg-muted); font-size: 13px; line-height: 1.7; } +.first-run-story h2, .first-run-step-heading h2 { margin: 0 0 12px; font-size: 24px; font-weight: 550; line-height: 1.25; letter-spacing: -.035em; } +.first-run-story p { max-width: 380px; margin: 0 0 20px; color: var(--fg-muted); font-size: 13px; line-height: 1.7; } .first-run-home .button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; } -.first-run-access-scene { display: flex; flex-direction: column; align-items: stretch; justify-content: center; padding: 32px; border: 1px solid var(--line); border-radius: 20px; box-shadow: var(--shadow-floating); background: radial-gradient(ellipse at 50% 32%, color-mix(in srgb, var(--accent) 5%, transparent), transparent 68%), var(--surface-subtle); } +.first-run-access-scene { display: flex; flex-direction: column; align-items: stretch; justify-content: center; min-width: 0; padding: 20px; border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow-control); background: var(--surface); } .first-run-access-brand { display: flex; align-items: center; gap: 17px; margin-bottom: 18px; } -.first-run-access-brand .first-run-core-symbol { width: 50px; height: 50px; margin: 0; border-radius: 16px; flex-shrink: 0; } -.first-run-access-brand .first-run-core-symbol::before { inset: -7px; } +.first-run-access-brand .first-run-core-symbol { width: 32px; height: 32px; margin: 0; border-radius: 8px; flex-shrink: 0; } +.first-run-access-brand .first-run-core-symbol::before { display: none; } .first-run-access-brand h3 { margin: 0; } .first-run-copy-access { align-self: flex-start; margin-bottom: 24px; } .first-run-access-scene .api-key-panel { padding-top: 22px; border-top: 1px solid var(--line); } @@ -36,10 +36,10 @@ .first-run-access-details > div + div { border-top: 1px solid var(--line); } .first-run-access-details dt { margin-bottom: 5px; color: var(--fg-muted); font-size: 10px; } .first-run-access-details dd { margin: 0; font-size: 12px; overflow-wrap: anywhere; user-select: all; } -.first-run-step-heading { display: flex; align-items: flex-start; justify-content: space-between; padding: 30px 32px 24px; gap: 20px; } +.first-run-step-heading { display: flex; align-items: flex-start; justify-content: space-between; padding: 0; gap: 20px; } .first-run-step-heading p { margin: 0; max-width: 760px; color: var(--fg-muted); font-size: 13px; line-height: 1.7; } .first-run-optional { margin-top: 29px; border: 1px solid var(--line); border-radius: 99px; padding: 3px 9px; font-size: 10px; color: var(--fg-muted); } -.first-run-machine { min-width: 0; padding: 0 26px; } +.first-run-machine { min-width: 0; } .first-run-machine .sandbox-manager { height: auto; overflow: visible; padding: 0; } .first-run-machine .sandbox-content { max-width: none; padding: 0; } .first-run-machine .sandbox-heading h2 { margin: 0; font-size: 13px; font-weight: 500; } @@ -50,10 +50,10 @@ .first-run-machine .sandbox-enrollment { padding: 20px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-subtle); } .first-run-machine .sandbox-selected-node .sandbox-node-card { display: block; } .first-run-machine .sandbox-selected-node .sandbox-node-details { border-left: 0; } -.first-run-machine-footer { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 24px 32px 28px; } +.first-run-machine-footer { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 0; } .first-run-machine-footer > span { color: var(--fg-muted); font-size: 12px; } -.first-run-footer { display: flex; align-items: center; justify-content: space-between; flex: 0 0 auto; padding: 16px 60px 24px; } -.first-request-workspace { padding: 0 28px 28px; } +.first-run-footer { display: flex; align-items: center; justify-content: space-between; flex: 0 0 auto; padding: 12px 24px; border-top: 1px solid var(--line); } +.first-request-workspace { min-width: 0; } .first-request { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 26px; align-items: start; } .first-request-form { min-width: 0; padding: 3px 0; } .first-request-form > header h3 { font-size: 14px; font-weight: 550; margin: 0 0 6px; } @@ -113,7 +113,7 @@ .first-run-chapters small { display: none; } .first-request { grid-template-columns: minmax(0, 1fr); } .first-request-code pre { max-height: 390px; } - .first-run-story, .first-run-access-scene { padding: 28px; } + .first-run-access { gap: 24px; } } @keyframes first-run-screen { from { opacity: 0; transform: scale(1.02); } to { opacity: 1; transform: scale(1); } } @media (prefers-reduced-motion: reduce) { @@ -121,3 +121,88 @@ .first-run-stage-content, .first-run-core-symbol, .first-run-chapters button.active::after, .first-run-machine .sandbox-topology-node, .first-agent-card, .first-request-result.has-agent { animation: none; } .first-run-home *, .first-run-home *::after { transition: none; scroll-behavior: auto; } } + +@media (max-width: 760px) { + .first-run-access { grid-template-columns: minmax(0, 1fr); gap: 20px; } + .first-run-story { padding: 0; } + .first-run-story h2, .first-run-step-heading h2 { font-size: 22px; } + .first-run-chapters { gap: 4px; padding: 16px 16px 0; } + .first-run-chapters button { flex-direction: column; align-items: flex-start; gap: 4px; padding: 10px; font-size: 11px; line-height: 16px; } + .first-run-chapters button > svg { display: none; } + .first-run-chapters button.active::after { left: 10px; } + .first-run-toolbar, .first-run-stage, .first-run-footer { padding: 12px 16px; } + .first-run-toolbar { flex-wrap: wrap; gap: 8px; } + .first-run-toolbar-preferences { gap: 8px; } + .first-run-toolbar .appearance-menu-trigger { width: auto; min-width: 72px; padding-inline: 7px; } + .first-run-toolbar .appearance-menu-trigger > span { display: inline; } + .first-run-toolbar .appearance-menu-panel { position: absolute; top: calc(100% + 8px); bottom: auto; left: 0; } + .first-run-step-heading { gap: 12px; } + .first-run-optional { margin-top: 0; } + .first-run-machine-footer, .first-request-local-action, .first-request-actions, .first-agent-card { flex-wrap: wrap; } + .first-request-form-row { grid-template-columns: minmax(0, 1fr); } + .first-agent-card > code { max-width: 100%; margin-left: 0; } +} + +/* A compact, shared frame for all three introduction steps. */ +.first-run-home { background: var(--surface); } +.first-run-toolbar { min-height: 64px; padding: 0 clamp(20px, 4vw, 64px); background: var(--surface); } +.first-run-toolbar-brand, .first-run-toolbar-actions { display: flex; align-items: center; gap: 12px; } +.first-run-toolbar-brand strong { font-size: 14px; font-weight: 650; letter-spacing: -.02em; } +.first-run-toolbar-brand > span:last-child { padding-left: 12px; border-left: 1px solid var(--line); color: var(--fg-muted); font-size: 12px; } +.first-run-toolbar-mark { display: grid; place-items: center; width: 29px; height: 29px; border: 1px solid var(--line); border-radius: 8px; color: var(--accent); background: var(--surface-subtle); } +.first-run-toolbar-actions { gap: 16px; } +.first-run-toolbar-divider { width: 1px; height: 22px; background: var(--line); } +.first-run-skip { color: var(--fg); font-size: 12px; font-weight: 550; } +.first-run-chapters, .first-run-stage-content, .first-run-footer { max-width: 1060px; } +.first-run-chapters { gap: 0; padding: 26px 24px 18px; border-bottom: 1px solid var(--line); } +.first-run-chapters button { flex: 1; gap: 9px; min-height: 38px; padding: 4px 14px 4px 0; border: 0; border-radius: 0; background: transparent; font-size: 12px; font-weight: 500; } +.first-run-chapters button + button { padding-left: 23px; border-left: 1px solid var(--line); } +.first-run-chapters button.active { color: var(--fg); background: transparent; border-color: var(--line); font-weight: 650; } +.first-run-chapters button.active::after { width: 100%; height: 2px; left: 0; bottom: -19px; } +.first-run-chapter-number { display: grid; place-items: center; flex: 0 0 28px; width: 28px; height: 28px; border: 1px solid var(--line-strong); border-radius: 50%; color: var(--fg-muted); font: 600 11px/1 var(--font-mono); } +.first-run-chapters button.active .first-run-chapter-number { border-color: var(--accent); color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); } +.first-run-chapters button:disabled { opacity: .5; } +.first-run-stage { padding: 42px 24px 48px; } +.first-run-stage-content { gap: 26px; } +.first-run-access { grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); gap: clamp(28px, 5vw, 72px); } +.first-run-story { justify-content: flex-start; padding: 4px 0 0; } +.first-run-section-number { margin-bottom: 12px; color: var(--accent); font-weight: 650; } +.first-run-story h2, .first-run-step-heading h2 { max-width: 650px; font-size: clamp(26px, 3vw, 36px); font-weight: 620; line-height: 1.16; } +.first-run-story p { max-width: 360px; font-size: 14px; line-height: 1.6; } +.first-run-access-details-wrap { width: 100%; margin: 10px 0 24px; } +.first-run-access-details { padding: 0; border: 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); border-radius: 0; } +.first-run-access-details > div { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 10px; align-items: center; padding: 12px 0; } +.first-run-access-details dt { margin: 0; font-size: 11px; } +.first-run-access-details dd { font-size: 12px; } +.first-run-copy-access { margin: 13px 0 0; } +.first-run-access-scene { display: block; padding: 0; border: 0; border-radius: 0; background: transparent; box-shadow: none; } +.first-run-access-scene .api-key-panel { padding: 26px; border: 1px solid var(--line); border-radius: 12px; } +.first-run-step-heading { align-items: center; padding: 0 0 10px; border-bottom: 1px solid var(--line); } +.first-run-step-heading p { max-width: 620px; font-size: 14px; line-height: 1.6; } +.first-run-optional { margin-top: 0; } +.first-run-machine { max-width: 100%; } +.first-run-machine-footer { padding: 18px 0 0; border-top: 1px solid var(--line); } +.first-request { gap: 22px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +.first-run-footer { min-height: 52px; padding-block: 8px; } +@media (max-width: 900px) { + .first-run-access, .first-request { grid-template-columns: minmax(0, 1fr); } + .first-run-access { gap: 28px; } + .first-run-story { max-width: 620px; } +} +@media (max-width: 600px) { + .first-run-toolbar { min-height: 56px; padding-inline: 16px; } + .first-run-toolbar-brand > span:last-child { display: none; } + .first-run-toolbar-actions { gap: 9px; } + .first-run-toolbar-divider { display: none; } + .first-run-chapters { padding: 16px 16px 12px; } + .first-run-chapters button { flex-direction: row; align-items: center; gap: 5px; min-height: 34px; padding-right: 5px; font-size: 10px; } + .first-run-chapters button + button { padding-left: 8px; } + .first-run-chapters button > svg { display: none; } + .first-run-chapters button.active::after { bottom: -13px; } + .first-run-chapter-number { flex-basis: 21px; width: 21px; height: 21px; font-size: 9px; } + .first-run-stage { padding: 24px 16px; } + .first-run-story h2, .first-run-step-heading h2 { font-size: 26px; } + .first-run-step-heading { align-items: flex-start; } + .first-run-access-details > div { grid-template-columns: 95px minmax(0, 1fr); } + .first-run-access-scene .api-key-panel { padding: 20px; } +} diff --git a/apps/web/src/features/first-run/FirstRunHome.tsx b/apps/web/src/features/first-run/FirstRunHome.tsx index 8a8de36a1..9c698da95 100644 --- a/apps/web/src/features/first-run/FirstRunHome.tsx +++ b/apps/web/src/features/first-run/FirstRunHome.tsx @@ -6,7 +6,6 @@ import type { CoreConnection } from "../../lib/connection"; import { SandboxManagerView } from "../sandbox/SandboxManagerView"; import { FirstRequest } from "./FirstRequest"; import { AppearanceMenu } from "../../components/AppearanceMenu"; -import { ConsoleAccountMenu } from "./ConsoleAccess"; import { ApiKeyPanel } from "../api-keys/ApiKeyPanel"; import "./FirstRunHome.css"; @@ -74,22 +73,22 @@ function FirstRunBody({ connection, core, username, initialStep = 0, onStepChang function agentCreated(_agent: SavedAgent) { setCreated(true); onRefresh?.(); } return
-
- +
Agents Core{t("First steps")}
+
{step === 0 ?
-
01 / 03

{t("Keep your sign-in details.")}

{t("Use this address and administrator account to return to your console.")}

{!keyReady ? {t("Save a key before continuing.")} : null}
-

{t("This is your Agent cloud.")}

{t("Console address")}
{origin}
{t("Administrator")}
{account}
{copyFailed ?

{t("Select the details and copy them manually.")}

: null}
+
01 / 03 · {t("Your access")}

{t("Keep your sign-in details.")}

{t("Use this address and administrator account to return to your console.")}

{t("Console address")}
{origin}
{t("Administrator")}
{account}
{copyFailed ?

{t("Select the details and copy them manually.")}

: null}
{!keyReady ? {t("Save a key before continuing.")} : null}
+
: step === 1 ? <> -
02 / 03

{t("Connect your own machine.")}

{t("Add this machine as a host. In hosted mode, Core creates and manages sandboxes on these hosts to provide resources for Agents.")}

{t("Optional")}
+
02 / 03

{t("Connect your own machine.")}

{t("Connect a host to run your Agents. You can do this later.")}

{t("Optional")}
{t("You can add more machines whenever you need them.")}
: <> @@ -98,6 +97,6 @@ function FirstRunBody({ connection, core, username, initialStep = 0, onStepChang }
-
{step > 0 ? : }{step === 1 ? : step === 2 ? : null}
+ {step > 0 ?
{step === 2 ? : null}
: null}
; } diff --git a/apps/web/src/features/first-run/console-access.css b/apps/web/src/features/first-run/console-access.css index 011db9f7d..06d98d2f9 100644 --- a/apps/web/src/features/first-run/console-access.css +++ b/apps/web/src/features/first-run/console-access.css @@ -1,12 +1,13 @@ +.app-main.console-access-main { overflow-y: auto; } .console-access-main > header { display: flex; justify-content: flex-end; align-items: center; gap: 18px; padding: 24px 36px; } .console-language { border: 0; color: var(--fg-muted); background: transparent; font: inherit; font-size: 12px; padding: 6px 8px; border-radius: 6px; } -.console-access-stage { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(40px, 6vw, 100px); max-width: 1080px; margin: auto; padding: clamp(28px, 6vh, 80px) 48px; align-items: center; min-height: 70vh; } +.console-access-stage { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; width: 100%; max-width: 960px; margin: auto; padding: clamp(28px, 6vh, 80px) 48px; align-items: center; min-height: 70vh; } .console-access-story { animation: console-arrive 650ms var(--ease-settle) both; } -.console-access-story h1 { font-size: clamp(32px, 3.2vw, 48px); letter-spacing: -.045em; font-weight: 530; line-height: 1.13; max-width: 410px; margin: 22px 0; } +.console-access-story h1 { font-size: 30px; letter-spacing: -.045em; font-weight: 530; line-height: 1.13; max-width: 410px; margin: 22px 0; } .console-access-story p { color: var(--fg-muted); font-size: 15px; line-height: 1.8; max-width: 340px; } -.console-cloud-symbol { position: relative; display: grid; place-items: center; width: 86px; height: 86px; border: 1px solid var(--line); border-radius: 24px; margin-bottom: 44px; background: var(--surface-subtle); box-shadow: var(--shadow-floating); color: var(--fg); } -.console-cloud-symbol::after { content: ""; position: absolute; inset: -12px; border: 1px solid var(--line-muted); border-radius: 34px; } -.console-auth-form { max-width: 390px; width: 100%; gap: 20px; animation: console-arrive 600ms 90ms var(--ease-settle) both; } +.console-cloud-symbol { position: relative; display: grid; place-items: center; width: 48px; height: 48px; border: 1px solid var(--line); border-radius: 10px; margin-bottom: 24px; background: var(--surface-subtle); box-shadow: var(--shadow-control); color: var(--fg); } + +.console-auth-form { min-width: 0; padding: 24px; border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow-control); max-width: 390px; width: 100%; gap: 20px; animation: console-arrive 600ms 90ms var(--ease-settle) both; } .console-auth-form h2 { font-size: 22px; letter-spacing: -.025em; font-weight: 550; margin: 0 0 10px; } .console-auth-form p { font-size: 13px; color: var(--fg-muted); line-height: 1.6; margin: 0; } .console-auth-form .field { gap: 7px; } @@ -15,9 +16,16 @@ .console-auth-form .field small { font-size: 11px; line-height: 1.6; color: var(--fg-muted); } .console-auth-form .button { min-height: 42px; justify-content: center; gap: 12px; } .console-auth-form .console-auth-error { color: var(--danger); } -.console-account-menu > button { background: transparent; border: 0; display: flex; gap: 9px; align-items: center; padding: 8px; color: var(--fg-muted); font: inherit; font-size: 12px; cursor: pointer; } -.console-account-menu small { color: var(--danger); } -.console-account-menu > button:hover { color: var(--fg); } @keyframes console-arrive { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } -@media (max-width: 980px) { .console-access-stage { gap: 32px; padding: 28px; } .console-access-story h1 { font-size: 32px; } } +@media (max-width: 980px) { .console-access-stage { gap: 32px; padding: 28px; } .console-access-story h1 { font-size: 28px; } } @media (prefers-reduced-motion: reduce) { .console-access-story, .console-auth-form { animation: none; } } + +@media (max-width: 640px) { + .console-access-main > header { padding: 16px; } + .console-access-stage { grid-template-columns: minmax(0, 1fr); min-height: 0; gap: 24px; padding: 16px 20px 32px; } + .console-access-story h1 { margin: 16px 0; font-size: 24px; } + .console-access-story p { font-size: 13px; } + .console-cloud-symbol { margin-bottom: 16px; } + .console-auth-form { max-width: none; padding: 20px; } + .console-auth-form h2 { font-size: 20px; } +} diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index e0b9d49a9..bffa75393 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -5,7 +5,7 @@ import type { SandboxAdminClient, SandboxDeployment, SandboxNode } from "@agents import { useTranslation } from "react-i18next"; import { Modal } from "../../components/Modal"; import { sandboxRequestError } from "../../lib/sandbox-labels"; -import { sandboxCoreOrigin } from "./core-origin"; +import { sandboxSetupOrigin } from "./core-origin"; import type { SandboxConsoleConfig } from "./console-config"; import { nodeInstallCommand } from "./enrollment-command"; @@ -32,8 +32,9 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, disab const request = useRef(null); const knownIds = useRef(new Set()); const revealedId = useRef(null); - const sourceUrl = sandboxCoreOrigin(window.location.origin); - const coreUrl = sandboxCoreOrigin(deployment.core_url || window.location.origin); + const [sourceDraft, setSourceDraft] = useState(() => sandboxSetupOrigin(window.location.origin) ?? ""); + const sourceUrl = sandboxSetupOrigin(sourceDraft); + const coreUrl = sandboxSetupOrigin(deployment.core_url || window.location.origin); const available = Boolean(consoleConfig.node_installer && sourceUrl && coreUrl); const expired = Boolean(enrollment && new Date(enrollment.expires_at).getTime() <= now); const connected = fresh && nodes.find((node) => !knownIds.current.has(node.id) && node.online && node.provider_ready); @@ -62,7 +63,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, disab setCopied(false); setCopyFailed(false); } async function generate() { - if (request.current || !available) return; + if (request.current || !available || disabled || !fresh) return; generation.current++; const controller = new AbortController(); request.current = controller; knownIds.current = new Set(nodes.map((node) => node.id)); @@ -87,8 +88,13 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, disab {createPortal(
- {!available ?

{t("Node installation is unavailable. Ask the deployment administrator to enable the node installer on this console.")}

: <> + {!consoleConfig.node_installer || !coreUrl ?

{t(!consoleConfig.node_installer ? "Node installation is unavailable. Ask the deployment administrator to enable the node installer on this console." : "The deployment needs an HTTPS Core address reachable from nodes and sandbox guests. Ask the deployment administrator to configure it.")}

: <> {!connected ?

{t("Run on the host you want to add.")}

: null} + {!enrollment && !busy && error === null ?
+ +

{t("Use the HTTPS address of this console. A localhost address or SSH tunnel on your computer cannot be reached from another machine.")}

+ +
: null} {busy ?

{t("Preparing your command…")}

: null} {error !== null ? <>

{sandboxRequestError(error, locale)}

: null} {enrollment ? <> diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index 0713a0a4f..fa78eb075 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -34,7 +34,13 @@ function SandboxAccess({ presentation }: { presentation: "manager" | "home" }) { return () => controller.abort(); }, [revision]); if (checking) return

{t("Connecting to this console's Core…")}

; - if (!config?.sandbox_admin) return

{t("Sandbox administration is not configured on this console. Ask the deployment administrator to configure access.")}

; + if (!config?.sandbox_admin) return
+

{t("Hosted Sandbox Manager")}

{t("Manage hosted execution for this Core deployment.")}

+

{t(config ? "Machine management needs setup" : "Cannot connect to this console")}

+

{t(config ? "Sandbox administration is not configured on this console. Ask the deployment administrator to configure access." : "Console access could not be checked. Check your connection or sign in again, then refresh.")}

+ +
+
; return ; } diff --git a/apps/web/src/features/vaults/VaultsView.tsx b/apps/web/src/features/vaults/VaultsView.tsx index 6385509ab..03ec1aaf6 100644 --- a/apps/web/src/features/vaults/VaultsView.tsx +++ b/apps/web/src/features/vaults/VaultsView.tsx @@ -104,10 +104,7 @@ export function VaultsView({ busy, catalog, coreError, coreState, operations }: return (
-
-

{tPages("vaults.title")}

-

{tPages("vaults.subtitle")}

-
+

{tPages("vaults.title")}

+

{tPages("vaults.subtitle")}