From 1905d7ea42a825cc50aa3160bd6002108b2cac93 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 24 Sep 2026 23:03:24 +0800 Subject: [PATCH 01/13] fix(web): clarify and recheck API key management availability --- apps/web/e2e/first-run.spec.ts | 35 ++++++++++++++++++- .../web/src/features/api-keys/ApiKeyPanel.tsx | 8 +++-- apps/web/src/features/api-keys/api-keys.css | 16 ++++++--- apps/web/src/lib/api-key-strings.ts | 6 ++-- docs/getting-started/install.md | 5 +++ 5 files changed, 59 insertions(+), 11 deletions(-) diff --git a/apps/web/e2e/first-run.spec.ts b/apps/web/e2e/first-run.spec.ts index ec78fdc80..74c1459c2 100644 --- a/apps/web/e2e/first-run.spec.ts +++ b/apps/web/e2e/first-run.spec.ts @@ -338,7 +338,7 @@ 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 expect(page.getByRole("heading", { name: "Connect your own machine.", exact: true })).toBeVisible(); @@ -353,3 +353,36 @@ 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: "I've saved it. Continue", 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.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); +}); diff --git a/apps/web/src/features/api-keys/ApiKeyPanel.tsx b/apps/web/src/features/api-keys/ApiKeyPanel.tsx index cd9796b5c..d12cc4e25 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 revoke 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.")}

diff --git a/apps/web/src/features/api-keys/api-keys.css b/apps/web/src/features/api-keys/api-keys.css index 8aaa83caf..3098ea969 100644 --- a/apps/web/src/features/api-keys/api-keys.css +++ b/apps/web/src/features/api-keys/api-keys.css @@ -1,20 +1,26 @@ -.api-key-panel { padding: 26px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); } +.api-key-panel { min-width: 0; padding: 20px; border: 1px solid var(--line); border-radius: 8px; 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-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-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 { display: grid; min-width: 0; gap: 14px; padding: 16px; margin: 20px 0; background: var(--surface-subtle); border: 1px solid var(--line-strong); border-radius: 6px; } .api-key-secret strong { font-size: 13px; font-weight: 500; } .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 > 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: 500; 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-keys-page { width: 100%; overflow: auto; } +.api-keys-page > .api-key-panel { margin: 24px; } +@media (max-width: 640px) { + .api-key-panel { padding: 16px; } + .api-keys-page > .api-key-panel { margin: 16px; } + .api-key-create { flex-direction: column; align-items: stretch; } +} diff --git a/apps/web/src/lib/api-key-strings.ts b/apps/web/src/lib/api-key-strings.ts index dbaf7d33f..9df85a69b 100644 --- a/apps/web/src/lib/api-key-strings.ts +++ b/apps/web/src/lib/api-key-strings.ts @@ -1,7 +1,9 @@ export const apiKeyChinese = { "My API key": "我的 API 密钥", - "Use an existing Agent API key.": "使用已有的 Agent API 密钥。", - "This console cannot create API keys. Use a key supplied by your Core administrator for requests from your machine or application.": "此控制台无法创建 API 密钥。从本机或应用发起请求时,请使用 Core 管理员提供的密钥。", + "API key management is not enabled": "API 密钥管理尚未启用", + "A deployment administrator needs to enable key management for this console. You can then create, view and revoke keys here.": "需要部署管理员为此控制台启用密钥管理,之后即可在这里创建、查看和撤销密钥。", + "If you already have an Agent API key, you can keep using it for requests from your machine or application.": "如果你已有 Agent API 密钥,仍可使用它从本机或应用发起请求。", + "Check again": "重新检查", "You can continue the introduction and use your signed-in console connection to create an Agent.": "你可以继续导览,并通过已登录的控制台连接创建 Agent。", "Checking API key management…": "正在检查 API 密钥管理功能…", "Could not check API key management. Try again.": "暂时无法检查 API 密钥管理功能,请重试。", diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index fd7d1d232..cbafdcf63 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -86,6 +86,11 @@ Neither is the administrator password. Examples read environment variables or as for keys privately in the terminal, and reject HTTP redirects. Keep the generated Core API key for later requests from your own machine. A Web-only installation without paired key management guides you to use an existing Core API key and still allows the request workbench. +If **API key management is not enabled** appears, configure the paired +administrator access described below, restart the console, sign in again if +prompted, and select **Check again**. The administrator account alone does not +enable deployment management. Existing caller keys remain valid; the page never +asks you to paste a deployment administrator key into the browser. Creating a saved Agent stores its configuration; it does not start a Session or call the model. The full protocol surface and execution support are documented in the [API guide](./quickstart.md). From b6cf2cdfa8476e20ec7f14c7a5fa506351bd008f Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 24 Sep 2026 23:04:21 +0800 Subject: [PATCH 02/13] fix(web): align console onboarding and sidebar layouts --- .../src/features/first-run/FirstRunHome.css | 61 +++++++++++++------ .../src/features/first-run/console-access.css | 23 +++++-- apps/web/src/style.css | 41 ++++++++++++- 3 files changed, 97 insertions(+), 28 deletions(-) diff --git a/apps/web/src/features/first-run/FirstRunHome.css b/apps/web/src/features/first-run/FirstRunHome.css index 71a69fa95..cd204d6e7 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,24 @@ .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; } +} diff --git a/apps/web/src/features/first-run/console-access.css b/apps/web/src/features/first-run/console-access.css index 011db9f7d..8ca9fa735 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; } @@ -19,5 +20,15 @@ .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/style.css b/apps/web/src/style.css index f4b9e3fbd..8ce59f3fb 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -366,6 +366,20 @@ html[data-theme="dark"] .brand-mark-dark { text-transform: capitalize; } +.sidebar-footer > .core-switcher { + grid-column: 1 / -1; +} + +.sidebar-footer > .console-account-menu { + grid-column: 2; + grid-row: 2; +} + +.sidebar-footer > .sidebar-preferences { + grid-column: 1; + grid-row: 2; +} + .sidebar-preferences { display: flex; align-items: center; @@ -4500,6 +4514,28 @@ button.trace-step-row:hover { gap: 6px; } + .sidebar-footer > .console-account-menu, + .sidebar-footer > .sidebar-preferences { + grid-column: 1; + grid-row: auto; + } + + .sidebar-footer .console-account-menu > button { + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + } + + .sidebar-footer .console-account-menu > button > span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + .core-switcher { display: flex; justify-content: center; @@ -4510,12 +4546,13 @@ button.trace-step-row:hover { } .sidebar-preferences { + max-width: 100%; flex-direction: column; } .appearance-menu-trigger { - width: 40px; - min-width: 40px; + width: 38px; + min-width: 38px; padding: 0; } From 702017800f2329d0530d8493f86d3b0d8fec08e0 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 24 Sep 2026 23:05:35 +0800 Subject: [PATCH 03/13] fix(web): make machine setup actionable through forwarded consoles --- apps/web/e2e/fixture-sandbox.mjs | 2 +- apps/web/e2e/sandbox-manager.spec.ts | 71 +++++++++++++++++++ apps/web/e2e/sandbox-setup.spec.ts | 17 ++++- .../src/features/sandbox/NodeEnrollment.tsx | 16 +++-- .../features/sandbox/SandboxManagerView.tsx | 8 ++- apps/web/src/lib/locale-strings.ts | 7 ++ docs/getting-started/install.md | 6 +- 7 files changed, 118 insertions(+), 9 deletions(-) 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..3deeb0ad7 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); @@ -107,6 +117,8 @@ test("Chinese actions, diagnostics, and enrollment are translated and language p 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"); @@ -128,12 +140,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 +170,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 +198,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 +252,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(); @@ -302,3 +339,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-setup.spec.ts b/apps/web/e2e/sandbox-setup.spec.ts index c58cd027d..d0fdfd940 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,6 +177,11 @@ 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"; }); @@ -193,7 +208,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/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/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts index 52dcb91d8..01a790928 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/lib/locale-strings.ts @@ -2,6 +2,13 @@ import { apiKeyChinese } from "./api-key-strings"; import { firstRunChinese } from "./first-run-strings"; import { consoleAuthChinese } from "./console-auth-strings"; export const chinese = { + "The deployment needs an HTTPS Core address reachable from nodes and sandbox guests. Ask the deployment administrator to configure it.": "部署需要配置节点和沙箱都能访问的 HTTPS Core 地址,请联系部署管理员。", + "Console address reachable from the new node": "新节点可访问的控制台地址", + "Use the HTTPS address of this console. A localhost address or SSH tunnel on your computer cannot be reached from another machine.": "请使用此控制台的 HTTPS 地址。其他机器无法访问你电脑上的 localhost 地址或 SSH 隧道。", + "Generate enrollment command": "生成注册命令", + "Machine management needs setup": "机器管理需要配置", + "Cannot connect to this console": "无法连接此控制台", + "Console access could not be checked. Check your connection or sign in again, then refresh.": "无法确认控制台访问状态。请检查连接或重新登录,然后刷新。", ...apiKeyChinese, ...consoleAuthChinese, ...firstRunChinese, diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index fd7d1d232..cc202ba9d 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -154,7 +154,11 @@ and either Docker socket access or microsandbox's KVM/native-library prerequisit The command checks host access before downloading the Runtime and verifies microsandbox's shared libraries after downloading its native programs. The console serves only fixed, non-secret distribution files at `/node-install/`; -private installation configuration is never part of this payload. Retain the +private installation configuration is never part of this payload. When opening the +console through a localhost SSH tunnel, enter the HTTPS address of this same +console that the new host can reach before generating its enrollment command. +That address must serve `/node-install/`; an API-only origin is not sufficient. +The saved deployment Core origin must also be reachable from nodes and guests. Retain the installed `node-payload/` directory. Manual console deployments enable the same flow with `CORE_CONSOLE_NODE_PAYLOAD_DIR` pointing to the matched distribution payload. TLS verification stays enabled; deployments using a private certificate From 49b5a237bcc696dde37151a9053d4dfb14cff2d4 Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 25 Sep 2026 00:32:00 +0800 Subject: [PATCH 04/13] Refine console onboarding and API key layout --- apps/web/e2e/first-run-keyboard.spec.ts | 4 +- apps/web/e2e/first-run.spec.ts | 32 ++++++--- apps/web/src/App.tsx | 4 +- .../web/src/features/api-keys/ApiKeyPanel.tsx | 25 +++++-- apps/web/src/features/api-keys/api-keys.css | 68 +++++++++++++------ .../src/features/first-run/FirstRunHome.css | 64 +++++++++++++++++ .../src/features/first-run/FirstRunHome.tsx | 15 ++-- apps/web/src/lib/api-key-strings.ts | 11 +++ apps/web/src/lib/first-run-strings.ts | 3 + apps/web/src/style.css | 28 +++++--- 10 files changed, 203 insertions(+), 51 deletions(-) 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 74c1459c2..263bc1e4f 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,11 +85,11 @@ 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); @@ -123,7 +123,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,7 +300,7 @@ 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 }); + const next = page.getByRole("button", { name: "Continue to machines", exact: true }); await expect(page.getByText("No API keys yet. Create one to get started.", { exact: true })).toBeVisible(); await expect(next).toBeDisabled(); await expect(page.getByRole("button", { name: /Your first Agent/ })).toBeDisabled(); @@ -340,9 +340,9 @@ test("lets a web-only console use an existing project connection without key man await page.goto("/"); 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(); @@ -371,7 +371,7 @@ test("rechecks key management without sending key requests while disabled", asyn 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: "I've saved it. Continue", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Continue to machines", exact: true })).toBeDisabled(); expect(keyRequests).toBe(1); }); @@ -386,3 +386,19 @@ test("keeps the API key management page separate from introduction guidance", as await expect(page.getByRole("button", { name: "Check again", exact: true })).toBeInViewport(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); }); + +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(); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7765467be..92ce5aa4a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -19,7 +19,7 @@ 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"; @@ -2463,7 +2463,7 @@ export function App() { operations={vaultOperations} /> ) : null} - {view === "api-keys" && isLocalProxyBaseUrl(connection.baseUrl) ?

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

: null} + {view === "api-keys" && isLocalProxyBaseUrl(connection.baseUrl) ?
AGENT CORE / {t("Your access", { ns: "firstRun" })}

{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 })
; } +export function ApiKeyExample() { + const { t } = useTranslation("firstRun"); + return ; +} + function ManagedApiKeyPanel({ onReady }: { onReady?: (ready: boolean) => void }) { const { t } = useTranslation("firstRun"); const defaultName = t("My API key"); @@ -115,9 +133,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()} />
@@ -128,7 +145,7 @@ function ManagedApiKeyPanel({ onReady }: { onReady?: (ready: boolean) => void }) {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 && fresh && !keys.some((key) => key.id === uncertain) ? : null} -
{t(hasKey ? "API key ready" : "API keys")}
+
{t("Your keys")} {fresh ? keys.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")}
    diff --git a/apps/web/src/features/api-keys/api-keys.css b/apps/web/src/features/api-keys/api-keys.css index 3098ea969..bc32ca370 100644 --- a/apps/web/src/features/api-keys/api-keys.css +++ b/apps/web/src/features/api-keys/api-keys.css @@ -1,26 +1,56 @@ -.api-key-panel { min-width: 0; padding: 20px; border: 1px solid var(--line); border-radius: 8px; 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-keys-page { width: 100%; overflow: auto; padding: clamp(26px, 5vw, 72px); } +.api-keys-intro, .api-keys-layout { width: min(100%, 1160px); margin-inline: auto; } +.api-keys-intro { margin-bottom: 30px; } +.api-keys-eyebrow { color: var(--accent); font: 600 11px/1.4 var(--font-mono); letter-spacing: .12em; } +.api-keys-intro h1 { margin: 9px 0 7px; font-size: clamp(28px, 3vw, 40px); font-weight: 600; letter-spacing: -.045em; } +.api-keys-intro p { margin: 0; color: var(--fg-muted); font-size: 15px; } +.api-keys-layout { display: grid; grid-template-columns: minmax(0, 1.18fr) minmax(280px, .82fr); gap: 22px; 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; min-width: 0; gap: 14px; padding: 16px; margin: 20px 0; background: var(--surface-subtle); border: 1px solid var(--line-strong); border-radius: 6px; } -.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; overflow-wrap: anywhere; } +.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 { width: 100%; overflow: auto; } -.api-keys-page > .api-key-panel { margin: 24px; } -@media (max-width: 640px) { - .api-key-panel { padding: 16px; } - .api-keys-page > .api-key-panel { margin: 16px; } - .api-key-create { flex-direction: column; align-items: stretch; } -} +.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; } +@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-page { padding: 22px 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/first-run/FirstRunHome.css b/apps/web/src/features/first-run/FirstRunHome.css index cd204d6e7..e4abc7716 100644 --- a/apps/web/src/features/first-run/FirstRunHome.css +++ b/apps/web/src/features/first-run/FirstRunHome.css @@ -142,3 +142,67 @@ .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/lib/api-key-strings.ts b/apps/web/src/lib/api-key-strings.ts index 9df85a69b..56d6f3014 100644 --- a/apps/web/src/lib/api-key-strings.ts +++ b/apps/web/src/lib/api-key-strings.ts @@ -9,6 +9,17 @@ export const apiKeyChinese = { "Could not check API key management. Try again.": "暂时无法检查 API 密钥管理功能,请重试。", "Retry this creation": "重试这次创建", "API keys": "API 密钥", + "Give your machine a secure way to call Agent Core.": "让你的机器安全地调用 Agent Core。", + "Create a key": "创建密钥", + "Name it so you can recognize it later.": "取一个容易辨认的名称。", + "Your keys": "已有密钥", + "How an API key works": "API 密钥如何使用", + "A request in three beats": "一次请求,三个步骤", + "Create once. Call your API.": "创建一次,就能调用 API。", + "Your key connects a request from your machine to Agent Core.": "密钥让你的机器向 Agent Core 发起请求。", + "Send a request": "发起请求", + "See the result": "查看结果", + "The secret appears once when you create it. Save it before closing the message.": "密钥内容只在创建时显示一次,请及时保存。", "Create a key for your API requests.": "为你的 API 请求创建一枚密钥。", "Use this key when calling Agent API from your own machine or application.": "从本机或应用调用 Agent API 时,带上这枚密钥即可。", "Your Web password is for signing in. This key is for API requests.": "Web 密码用于登录控制台,这枚密钥用于调用 API。", diff --git a/apps/web/src/lib/first-run-strings.ts b/apps/web/src/lib/first-run-strings.ts index 87f8a5b03..52295d609 100644 --- a/apps/web/src/lib/first-run-strings.ts +++ b/apps/web/src/lib/first-run-strings.ts @@ -22,10 +22,13 @@ export const firstRunChinese = { "Complete the configuration to generate your request.": "填写配置后,这里会生成请求代码。", "Core API key authorizes this request. Model key authorizes your model provider.": "Core API key 用于访问 Core,模型 key 用于访问模型服务。", "Add this machine as a host. In hosted mode, Core creates and manages sandboxes on these hosts to provide resources for Agents.": "把这台机器添加为宿主机。使用 hosted 模式时,Core 会在这些宿主机上创建和管理沙箱,为 Agent 提供运行资源。", + "Connect a host to run your Agents. You can do this later.": "连接一台机器来运行 Agent,也可以稍后再配置。", "Home": "首页", "Your Agent cloud starts here.": "你的 Agent 云,从这里开始。", "One deployment. Your machines. Your Agents.": "一个部署,连接你的机器,管理你的 Agent。", "Skip introduction": "跳过导览", + "Go to console": "进入控制台", + "Continue to machines": "继续配置机器", "First steps": "开始使用", "Your access": "访问方式", "Your machines": "你的机器", diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 8ce59f3fb..6579ccd06 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -313,11 +313,11 @@ html[data-theme="dark"] .brand-mark-dark { .sidebar-footer { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr); align-items: center; margin-top: auto; - padding: 8px 0 0 5px; - gap: 8px; + padding: 12px 4px 2px; + gap: 3px; border-top: 1px solid var(--line); } @@ -326,8 +326,8 @@ html[data-theme="dark"] .brand-mark-dark { grid-template-columns: 14px minmax(0, 1fr) 18px; align-items: center; width: 100%; - min-height: 32px; - padding: 0; + min-height: 43px; + padding: 5px 7px; gap: 7px; color: var(--sidebar-fg); text-align: left; @@ -371,8 +371,11 @@ html[data-theme="dark"] .brand-mark-dark { } .sidebar-footer > .console-account-menu { - grid-column: 2; - grid-row: 2; + grid-column: 1; + grid-row: 3; + border-top: 1px solid var(--line); + margin-top: 7px; + padding-top: 6px; } .sidebar-footer > .sidebar-preferences { @@ -386,6 +389,10 @@ html[data-theme="dark"] .brand-mark-dark { gap: 6px; } +.sidebar-footer .console-account-menu > button { width: 100%; min-height: 32px; padding: 5px 7px; text-align: left; } +.sidebar-footer .appearance-menu-trigger { width: 100%; justify-content: flex-start; border-color: transparent; background: transparent; } +.sidebar-footer .appearance-menu { width: 100%; } + .theme-menu { display: inline-flex; align-items: center; @@ -4510,7 +4517,7 @@ button.trace-step-row:hover { .sidebar-footer { grid-template-columns: 1fr; justify-items: center; - padding-left: 0; + padding: 8px 0 0; gap: 6px; } @@ -4520,6 +4527,8 @@ button.trace-step-row:hover { grid-row: auto; } + .sidebar-footer > .console-account-menu { margin-top: 0; padding-top: 4px; } + .sidebar-footer .console-account-menu > button { justify-content: center; width: 32px; @@ -4556,6 +4565,9 @@ button.trace-step-row:hover { padding: 0; } + .sidebar-footer .appearance-menu-trigger { width: 38px; justify-content: center; } + .sidebar-footer .appearance-menu-panel { position: fixed; right: auto; bottom: 8px; left: 60px; } + .appearance-menu-trigger > span, .appearance-menu-trigger > svg:nth-of-type(2), .appearance-menu-trigger > svg:last-child { From 6e0185c0fd7f7e8995071245c22a799ec7dd688e Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 25 Sep 2026 08:22:37 +0800 Subject: [PATCH 05/13] Unify console account and appearance controls --- apps/web/e2e/first-run.spec.ts | 37 ++++++++++++++++-- apps/web/src/App.tsx | 4 +- apps/web/src/components/AppearanceMenu.tsx | 38 ++++++++++++------- .../src/features/first-run/ConsoleAccess.tsx | 17 +-------- .../src/features/first-run/console-access.css | 3 -- apps/web/src/i18n/locales/en/navigation.ts | 2 + apps/web/src/i18n/locales/zh-CN/navigation.ts | 2 + apps/web/src/style.css | 34 +++-------------- 8 files changed, 71 insertions(+), 66 deletions(-) diff --git a/apps/web/e2e/first-run.spec.ts b/apps/web/e2e/first-run.spec.ts index 263bc1e4f..e615cf434 100644 --- a/apps/web/e2e/first-run.spec.ts +++ b/apps/web/e2e/first-run.spec.ts @@ -95,14 +95,15 @@ test("remembers the introduction step and dismissal across reloads and sign-in, 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.getByRole("heading", { name: "Keep your sign-in details." })).toBeVisible(); @@ -402,3 +403,33 @@ test("keeps all three introduction steps usable on a narrow screen", async ({ pa 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(); + const core = await page.getByRole("button", { name: "Configure Agent Core connection" }).boundingBox(); + const account = await accountMenu.boundingBox(); + expect(core).not.toBeNull(); + expect(account).not.toBeNull(); + expect(account!.y).toBeGreaterThan(core!.y + core!.height); + await accountMenu.click(); + await page.getByRole("menuitemradio", { name: "Dark theme" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await accountMenu.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 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("menuitemradio", { name: "English" }).click(); + await expect(page.getByRole("button", { name: "Account, language and appearance" })).toBeVisible(); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 92ce5aa4a..46efbacd7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -21,7 +21,6 @@ import { ConsoleNavigation } from "./features/first-run/ConsoleNavigation"; import { isLocalProxyBaseUrl } from "./lib/connection"; 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"; @@ -2326,9 +2325,8 @@ export function App() { -
    - +
diff --git a/apps/web/src/components/AppearanceMenu.tsx b/apps/web/src/components/AppearanceMenu.tsx index 33ead4c1b..cda0da0d0 100644 --- a/apps/web/src/components/AppearanceMenu.tsx +++ b/apps/web/src/components/AppearanceMenu.tsx @@ -1,15 +1,20 @@ -import { Check, ChevronDown, Languages, Moon, Sun } from "lucide-react"; +import { Check, ChevronDown, CircleUserRound, Languages, LogOut, Moon, Sun } from "lucide-react"; import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; import { setLanguage, type SupportedLanguage } from "../i18n"; import { useTheme, type ResolvedTheme } from "../lib/theme"; +import { useConsoleAccount } from "../features/first-run/ConsoleAccess"; -export function AppearanceMenu() { +export function AppearanceMenu({ withAccount = false }: { withAccount?: boolean }) { 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 [signingOut, setSigningOut] = useState(false); + const [signOutFailed, setSignOutFailed] = useState(false); const rootRef = useRef(null); const triggerRef = useRef(null); const ThemeIcon = resolvedTheme === "dark" ? Moon : Sun; @@ -25,7 +30,7 @@ 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(); }); }; @@ -42,7 +47,7 @@ export function AppearanceMenu() { 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,15 +66,16 @@ export function AppearanceMenu() { close(); }; - return
+ const showAccount = withAccount && Boolean(account); + return
- {open ?
+ {open ?
{t("language")} @@ -98,6 +101,15 @@ export function AppearanceMenu() {
+ {showAccount ?
+ {account?.username} + + {signOutFailed ? {tAuth("Could not sign out. Try again.")} : null} +
: null}
: null}
; } diff --git a/apps/web/src/features/first-run/ConsoleAccess.tsx b/apps/web/src/features/first-run/ConsoleAccess.tsx index 8a7f66019..baf96d212 100644 --- a/apps/web/src/features/first-run/ConsoleAccess.tsx +++ b/apps/web/src/features/first-run/ConsoleAccess.tsx @@ -1,5 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; -import { ArrowRight, Cloud, LogOut } from "lucide-react"; +import { ArrowRight, Cloud } from "lucide-react"; import { useTranslation } from "react-i18next"; import { ThemeMenu } from "../../components/ThemeMenu"; import { setLanguage } from "../../i18n"; @@ -18,21 +18,6 @@ export function ConsoleLanguage() { ; } -export function ConsoleAccountMenu() { - const account = useConsoleAccount(); - const { t } = useTranslation("firstRun"); - const [busy, setBusy] = useState(false); - const [failed, setFailed] = useState(false); - if (!account) return null; - return
- - {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/console-access.css b/apps/web/src/features/first-run/console-access.css index 8ca9fa735..06d98d2f9 100644 --- a/apps/web/src/features/first-run/console-access.css +++ b/apps/web/src/features/first-run/console-access.css @@ -16,9 +16,6 @@ .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: 28px; } } @media (prefers-reduced-motion: reduce) { .console-access-story, .console-auth-form { animation: none; } } diff --git a/apps/web/src/i18n/locales/en/navigation.ts b/apps/web/src/i18n/locales/en/navigation.ts index 1e00b6700..9dfa8fdc4 100644 --- a/apps/web/src/i18n/locales/en/navigation.ts +++ b/apps/web/src/i18n/locales/en/navigation.ts @@ -24,4 +24,6 @@ export const navigation = { switchToChinese: "Switch to Simplified Chinese", switchToEnglish: "Switch to English", appearanceSettings: "Language and appearance", + accountAndAppearance: "Account, language and appearance", + account: "Account", } as const; diff --git a/apps/web/src/i18n/locales/zh-CN/navigation.ts b/apps/web/src/i18n/locales/zh-CN/navigation.ts index a8b84999c..62811b4f9 100644 --- a/apps/web/src/i18n/locales/zh-CN/navigation.ts +++ b/apps/web/src/i18n/locales/zh-CN/navigation.ts @@ -24,4 +24,6 @@ export const navigation = { switchToChinese: "切换到简体中文", switchToEnglish: "切换到英文", appearanceSettings: "语言和外观", + accountAndAppearance: "账号、语言和外观", + account: "账号", } as const; diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 6579ccd06..5d48281a0 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -370,17 +370,12 @@ html[data-theme="dark"] .brand-mark-dark { grid-column: 1 / -1; } -.sidebar-footer > .console-account-menu { - grid-column: 1; - grid-row: 3; - border-top: 1px solid var(--line); - margin-top: 7px; - padding-top: 6px; -} - .sidebar-footer > .sidebar-preferences { grid-column: 1; grid-row: 2; + margin-top: 7px; + padding-top: 7px; + border-top: 1px solid var(--line); } .sidebar-preferences { @@ -389,9 +384,11 @@ html[data-theme="dark"] .brand-mark-dark { gap: 6px; } -.sidebar-footer .console-account-menu > button { width: 100%; min-height: 32px; padding: 5px 7px; text-align: left; } .sidebar-footer .appearance-menu-trigger { width: 100%; justify-content: flex-start; border-color: transparent; background: transparent; } .sidebar-footer .appearance-menu { width: 100%; } +.sidebar-footer .appearance-menu-account-trigger { min-height: 36px; height: auto; gap: 9px; padding: 6px 7px; color: var(--fg); } +.sidebar-footer .appearance-account-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; font-size: 12px; font-weight: 550; } +.appearance-account-section small[role="alert"] { padding: 6px 7px; color: var(--danger); font-size: 11px; } .theme-menu { display: inline-flex; @@ -4521,30 +4518,11 @@ button.trace-step-row:hover { gap: 6px; } - .sidebar-footer > .console-account-menu, .sidebar-footer > .sidebar-preferences { grid-column: 1; grid-row: auto; } - .sidebar-footer > .console-account-menu { margin-top: 0; padding-top: 4px; } - - .sidebar-footer .console-account-menu > button { - justify-content: center; - width: 32px; - height: 32px; - padding: 0; - } - - .sidebar-footer .console-account-menu > button > span { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - } - .core-switcher { display: flex; justify-content: center; From 8274d68d3803bc6a525aa3ea74c9d292a6cacd73 Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 25 Sep 2026 09:57:04 +0800 Subject: [PATCH 06/13] Compact console account menu controls --- apps/web/e2e/first-run.spec.ts | 10 ++++- apps/web/src/components/AppearanceMenu.tsx | 40 ++++++++++++++----- apps/web/src/i18n/locales/en/navigation.ts | 4 ++ apps/web/src/i18n/locales/zh-CN/navigation.ts | 4 ++ apps/web/src/style.css | 22 +++++++++- 5 files changed, 67 insertions(+), 13 deletions(-) diff --git a/apps/web/e2e/first-run.spec.ts b/apps/web/e2e/first-run.spec.ts index e615cf434..0bb5242e1 100644 --- a/apps/web/e2e/first-run.spec.ts +++ b/apps/web/e2e/first-run.spec.ts @@ -416,13 +416,20 @@ test("keeps account, language and theme in one reachable sidebar menu", async ({ expect(account).not.toBeNull(); expect(account!.y).toBeGreaterThan(core!.y + core!.height); await accountMenu.click(); - await page.getByRole("menuitemradio", { name: "Dark theme" }).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 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(); @@ -430,6 +437,7 @@ test("keeps account, language and theme in one reachable sidebar menu", async ({ 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/src/components/AppearanceMenu.tsx b/apps/web/src/components/AppearanceMenu.tsx index cda0da0d0..93e9f6853 100644 --- a/apps/web/src/components/AppearanceMenu.tsx +++ b/apps/web/src/components/AppearanceMenu.tsx @@ -13,16 +13,18 @@ export function AppearanceMenu({ withAccount = false }: { withAccount?: boolean 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); @@ -37,12 +39,14 @@ export function AppearanceMenu({ withAccount = false }: { withAccount?: boolean 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; } @@ -67,6 +71,10 @@ export function AppearanceMenu({ withAccount = false }: { withAccount?: boolean }; const showAccount = withAccount && Boolean(account); + const toggleTheme = () => { + setPreference(resolvedTheme === "dark" ? "light" : "dark"); + close(); + }; return
- {open ?
+ {open ?
+ {showAccount ? <> +
{account?.username}{t("account")}
+
+ + {languageOpen ?
+ + +
: null} + +
+
+ {signOutFailed ? {tAuth("Could not sign out. Try again.")} : null}
+ : <>
{t("language")} @@ -101,15 +127,7 @@ export function AppearanceMenu({ withAccount = false }: { withAccount?: boolean
- {showAccount ?
- {account?.username} - - {signOutFailed ? {tAuth("Could not sign out. Try again.")} : null} -
: null} + }
: null}
; } diff --git a/apps/web/src/i18n/locales/en/navigation.ts b/apps/web/src/i18n/locales/en/navigation.ts index 9dfa8fdc4..da2f44f51 100644 --- a/apps/web/src/i18n/locales/en/navigation.ts +++ b/apps/web/src/i18n/locales/en/navigation.ts @@ -26,4 +26,8 @@ export const navigation = { appearanceSettings: "Language and appearance", accountAndAppearance: "Account, language and appearance", account: "Account", + lightMode: "Light", + darkMode: "Dark", + switchToLightTheme: "Switch to light theme", + switchToDarkTheme: "Switch to dark theme", } as const; diff --git a/apps/web/src/i18n/locales/zh-CN/navigation.ts b/apps/web/src/i18n/locales/zh-CN/navigation.ts index 62811b4f9..aac34c5b6 100644 --- a/apps/web/src/i18n/locales/zh-CN/navigation.ts +++ b/apps/web/src/i18n/locales/zh-CN/navigation.ts @@ -26,4 +26,8 @@ export const navigation = { appearanceSettings: "语言和外观", accountAndAppearance: "账号、语言和外观", account: "账号", + lightMode: "浅色", + darkMode: "深色", + switchToLightTheme: "切换到浅色主题", + switchToDarkTheme: "切换到深色主题", } as const; diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 5d48281a0..88a42fa96 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -388,7 +388,6 @@ html[data-theme="dark"] .brand-mark-dark { .sidebar-footer .appearance-menu { width: 100%; } .sidebar-footer .appearance-menu-account-trigger { min-height: 36px; height: auto; gap: 9px; padding: 6px 7px; color: var(--fg); } .sidebar-footer .appearance-account-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; font-size: 12px; font-weight: 550; } -.appearance-account-section small[role="alert"] { padding: 6px 7px; color: var(--danger); font-size: 11px; } .theme-menu { display: inline-flex; @@ -463,6 +462,27 @@ html[data-theme="dark"] .brand-mark-dark { box-shadow: var(--shadow-floating); } +.appearance-account-panel { width: 226px; padding: 0; overflow: hidden; } +.appearance-account-header { display: flex; align-items: center; gap: 10px; padding: 14px; border-bottom: 1px solid var(--line); } +.appearance-account-avatar { display: grid; width: 32px; height: 32px; flex: 0 0 auto; place-items: center; border-radius: 50%; color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); font-size: 14px; font-weight: 700; } +.appearance-account-header > div { display: grid; min-width: 0; gap: 2px; } +.appearance-account-header strong { overflow: hidden; color: var(--fg); font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; } +.appearance-account-header small { color: var(--fg-subtle); font-size: 11px; } +.appearance-account-actions { padding: 6px; } +.appearance-account-actions > button, .appearance-account-signout > button { display: flex; width: 100%; min-height: 36px; align-items: center; justify-content: space-between; gap: 8px; padding: 7px 9px; border: 0; border-radius: 6px; background: transparent; color: var(--fg); font: inherit; font-size: 12px; text-align: left; cursor: pointer; } +.appearance-account-actions > button > span, .appearance-account-value { display: inline-flex; align-items: center; gap: 9px; } +.appearance-account-actions > button > span:first-child { font-weight: 550; } +.appearance-account-value { min-width: 0; color: var(--fg-muted); font-size: 11px; white-space: nowrap; } +.appearance-account-actions > button:hover, .appearance-account-actions > button:focus-visible, .appearance-account-signout > button:hover, .appearance-account-signout > button:focus-visible, .appearance-language-options button:hover, .appearance-language-options button:focus-visible { background: var(--surface-subtle); outline: none; } +.appearance-language-options { display: grid; gap: 1px; margin: 2px 0 4px 25px; padding-left: 7px; border-left: 1px solid var(--line); } +.appearance-language-options button { display: flex; min-height: 31px; align-items: center; justify-content: space-between; padding: 5px 8px; border: 0; border-radius: 5px; background: transparent; color: var(--fg-muted); font: inherit; font-size: 12px; text-align: left; cursor: pointer; } +.appearance-language-options button[aria-checked="true"] { color: var(--fg); font-weight: 600; } +.appearance-account-signout { padding: 6px; border-top: 1px solid var(--line); } +.appearance-account-signout > button { justify-content: flex-start; color: var(--danger); } +.appearance-account-signout > button:hover, .appearance-account-signout > button:focus-visible { color: var(--danger); } +.appearance-account-signout > button:disabled { opacity: 0.6; cursor: wait; } +.appearance-account-signout small[role="alert"] { display: block; padding: 6px 9px; color: var(--danger); font-size: 11px; } + .appearance-menu-section { display: grid; gap: 2px; From e8ccc4e6d8768abf52c742239d544ae33a26576c Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 25 Sep 2026 10:32:23 +0800 Subject: [PATCH 07/13] Merge console status and account footer --- apps/web/e2e/agents-lifecycle.spec.ts | 2 +- apps/web/e2e/core-connection.spec.ts | 5 +- apps/web/e2e/environment-templates.spec.ts | 3 +- apps/web/e2e/first-run.spec.ts | 13 ++- apps/web/e2e/sandbox-manager.spec.ts | 5 +- apps/web/e2e/sandbox-provider.spec.ts | 1 + apps/web/e2e/sandbox-setup.spec.ts | 3 +- apps/web/src/App.tsx | 22 +--- apps/web/src/components/AppearanceMenu.tsx | 29 ++++-- apps/web/src/style.css | 114 ++++----------------- 10 files changed, 63 insertions(+), 134 deletions(-) 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.spec.ts b/apps/web/e2e/first-run.spec.ts index 0bb5242e1..b3b392b70 100644 --- a/apps/web/e2e/first-run.spec.ts +++ b/apps/web/e2e/first-run.spec.ts @@ -410,11 +410,14 @@ test("keeps account, language and theme in one reachable sidebar menu", async ({ await page.getByRole("button", { name: "Go to console" }).click(); const accountMenu = page.getByRole("button", { name: "Account, language and appearance" }); await expect(accountMenu).toBeVisible(); - const core = await page.getByRole("button", { name: "Configure Agent Core connection" }).boundingBox(); - const account = await accountMenu.boundingBox(); - expect(core).not.toBeNull(); - expect(account).not.toBeNull(); - expect(account!.y).toBeGreaterThan(core!.y + core!.height); + await expect(page.locator(".sidebar-footer .appearance-menu-trigger")).toHaveCount(1); + await expect(accountMenu).toContainText("Agent Core"); + await accountMenu.click(); + await expect(page.getByRole("menuitem", { name: "Configure Agent Core connection" })).toBeVisible(); + await page.getByRole("menuitem", { name: "Configure Agent Core connection" }).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); diff --git a/apps/web/e2e/sandbox-manager.spec.ts b/apps/web/e2e/sandbox-manager.spec.ts index 3deeb0ad7..a405f5c6c 100644 --- a/apps/web/e2e/sandbox-manager.spec.ts +++ b/apps/web/e2e/sandbox-manager.spec.ts @@ -111,6 +111,7 @@ 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(); @@ -123,9 +124,10 @@ test("Chinese actions, diagnostics, and enrollment are translated and language p 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); }); @@ -330,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("可用"); 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 d0fdfd940..a212a3c09 100644 --- a/apps/web/e2e/sandbox-setup.spec.ts +++ b/apps/web/e2e/sandbox-setup.spec.ts @@ -185,7 +185,8 @@ for (const operation of ["setup", "enrollment"] as const) { } 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`); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 46efbacd7..31aaca3e3 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"; @@ -28,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"; @@ -2309,25 +2307,7 @@ export function App() {
- -
- -
+ setConnectionOpen(true)} />
diff --git a/apps/web/src/components/AppearanceMenu.tsx b/apps/web/src/components/AppearanceMenu.tsx index 93e9f6853..613b7556c 100644 --- a/apps/web/src/components/AppearanceMenu.tsx +++ b/apps/web/src/components/AppearanceMenu.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronDown, CircleUserRound, Languages, LogOut, Moon, Sun } from "lucide-react"; +import { Check, ChevronDown, CircleUserRound, Languages, LogOut, Moon, Settings2, Sun } from "lucide-react"; import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; @@ -6,7 +6,13 @@ import { setLanguage, type SupportedLanguage } from "../i18n"; import { useTheme, type ResolvedTheme } from "../lib/theme"; import { useConsoleAccount } from "../features/first-run/ConsoleAccess"; -export function AppearanceMenu({ withAccount = false }: { withAccount?: boolean }) { +type CoreState = "connecting" | "ready" | "failed"; + +export function AppearanceMenu({ withAccount = false, coreState, onConfigureCore }: { + withAccount?: boolean; + coreState?: CoreState; + onConfigureCore?: () => void; +}) { const { i18n, t } = useTranslation("navigation"); const { t: tAuth } = useTranslation("firstRun"); const account = useConsoleAccount(); @@ -70,15 +76,17 @@ export function AppearanceMenu({ withAccount = false }: { withAccount?: boolean close(); }; + 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
+ return
- {open ?
- {showAccount ? <> -
{account?.username}{t("account")}
+ {open ?
+ {showConsole ? <> + {showAccount ?
{account?.username}{t("account")}
: null} +
{languageOpen ?
@@ -110,12 +119,12 @@ export function AppearanceMenu({ withAccount = false }: { withAccount?: boolean
: null}
-
- {signOutFailed ? {tAuth("Could not sign out. Try again.")} : null}
+ {signOutFailed ? {tAuth("Could not sign out. Try again.")} : null}
: null} : <>
{t("language")} diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 88a42fa96..7e103bb58 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -312,82 +312,21 @@ html[data-theme="dark"] .brand-mark-dark { } .sidebar-footer { - display: grid; - grid-template-columns: minmax(0, 1fr); - align-items: center; margin-top: auto; padding: 12px 4px 2px; - gap: 3px; border-top: 1px solid var(--line); } -.core-switcher { - display: grid; - grid-template-columns: 14px minmax(0, 1fr) 18px; - align-items: center; - width: 100%; - min-height: 43px; - padding: 5px 7px; - gap: 7px; - color: var(--sidebar-fg); - text-align: left; - background: transparent; - border: 0; -} - -.core-switcher:hover { - color: var(--fg); -} - -.core-switcher > span:nth-child(2) { - display: flex; - min-width: 0; - flex-direction: column; -} - -.core-switcher strong, -.core-switcher small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.core-switcher strong { - color: var(--fg); - font-size: 13px; - font-weight: 500; - line-height: 18px; -} - -.core-switcher small { - color: var(--fg-muted); - font-size: 12px; - line-height: 16px; - text-transform: capitalize; -} - -.sidebar-footer > .core-switcher { - grid-column: 1 / -1; -} - -.sidebar-footer > .sidebar-preferences { - grid-column: 1; - grid-row: 2; - margin-top: 7px; - padding-top: 7px; - border-top: 1px solid var(--line); -} - -.sidebar-preferences { - display: flex; - align-items: center; - gap: 6px; -} - .sidebar-footer .appearance-menu-trigger { width: 100%; justify-content: flex-start; border-color: transparent; background: transparent; } .sidebar-footer .appearance-menu { width: 100%; } -.sidebar-footer .appearance-menu-account-trigger { min-height: 36px; height: auto; gap: 9px; padding: 6px 7px; color: var(--fg); } -.sidebar-footer .appearance-account-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; font-size: 12px; font-weight: 550; } +.sidebar-footer .appearance-menu-account-trigger { min-height: 48px; height: auto; gap: 9px; padding: 7px; color: var(--fg); } +.appearance-console-identity { display: grid; flex: 1; min-width: 0; gap: 2px; text-align: left; } +.appearance-console-identity strong, .appearance-console-identity small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.appearance-console-identity strong { color: var(--fg); font-size: 12px; font-weight: 600; } +.appearance-console-identity small { display: flex; align-items: center; gap: 5px; color: var(--fg-muted); font-size: 11px; font-weight: 400; } +.appearance-core-dot { display: inline-block; width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: var(--status-queued); } +.appearance-core-dot-ready { background: var(--success); } +.appearance-core-dot-failed { background: var(--danger); } .theme-menu { display: inline-flex; @@ -463,6 +402,13 @@ html[data-theme="dark"] .brand-mark-dark { } .appearance-account-panel { width: 226px; padding: 0; overflow: hidden; } +.appearance-core-action { padding: 6px; border-bottom: 1px solid var(--line); } +.appearance-core-action > button { display: flex; width: 100%; min-height: 42px; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 9px; border: 0; border-radius: 6px; background: transparent; color: var(--fg-muted); font: inherit; text-align: left; cursor: pointer; } +.appearance-core-action > button > span { display: flex; align-items: center; gap: 9px; } +.appearance-core-action > button > span > span { display: grid; gap: 2px; } +.appearance-core-action strong { color: var(--fg); font-size: 12px; font-weight: 600; } +.appearance-core-action small { color: var(--fg-muted); font-size: 11px; } +.appearance-core-action > button:hover, .appearance-core-action > button:focus-visible { background: var(--surface-subtle); outline: none; } .appearance-account-header { display: flex; align-items: center; gap: 10px; padding: 14px; border-bottom: 1px solid var(--line); } .appearance-account-avatar { display: grid; width: 32px; height: 32px; flex: 0 0 auto; place-items: center; border-radius: 50%; color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); font-size: 14px; font-weight: 700; } .appearance-account-header > div { display: grid; min-width: 0; gap: 2px; } @@ -667,8 +613,7 @@ html[data-theme="dark"] .brand-mark-dark { .field textarea:focus, .field select:focus, .session-row-select:focus-visible, -.main-nav button:focus-visible, -.core-switcher:focus-visible { +.main-nav button:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); @@ -4522,8 +4467,7 @@ button.trace-step-row:hover { .nav-label, .main-nav button span, .main-nav button em, - .core-switcher > span:nth-child(2), - .core-switcher > svg:last-child { + .appearance-console-identity { display: none; } @@ -4532,39 +4476,25 @@ button.trace-step-row:hover { } .sidebar-footer { - grid-template-columns: 1fr; - justify-items: center; padding: 8px 0 0; - gap: 6px; - } - - .sidebar-footer > .sidebar-preferences { - grid-column: 1; - grid-row: auto; - } - - .core-switcher { - display: flex; - justify-content: center; } .theme-menu { flex-direction: column; } - .sidebar-preferences { - max-width: 100%; - flex-direction: column; - } - .appearance-menu-trigger { width: 38px; min-width: 38px; padding: 0; } - .sidebar-footer .appearance-menu-trigger { width: 38px; justify-content: center; } + .sidebar-footer .appearance-menu-trigger { width: 38px; min-height: 38px; justify-content: center; } .sidebar-footer .appearance-menu-panel { position: fixed; right: auto; bottom: 8px; left: 60px; } + .appearance-console-trigger { position: relative; } + .appearance-console-trigger::after { position: absolute; right: 5px; bottom: 4px; width: 6px; height: 6px; border: 1px solid var(--surface); border-radius: 50%; background: var(--status-queued); content: ""; } + .appearance-console-state-ready::after { background: var(--success); } + .appearance-console-state-failed::after { background: var(--danger); } .appearance-menu-trigger > span, .appearance-menu-trigger > svg:nth-of-type(2), From 01d18f27a9627d82cf94e0ad8442c803dac90685 Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 25 Sep 2026 10:45:12 +0800 Subject: [PATCH 08/13] Simplify ready Core status labels --- apps/web/e2e/first-run.spec.ts | 9 +++++++-- apps/web/src/components/AppearanceMenu.tsx | 5 +++-- apps/web/src/style.css | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/web/e2e/first-run.spec.ts b/apps/web/e2e/first-run.spec.ts index b3b392b70..6fe4914c5 100644 --- a/apps/web/e2e/first-run.spec.ts +++ b/apps/web/e2e/first-run.spec.ts @@ -412,9 +412,14 @@ test("keeps account, language and theme in one reachable sidebar menu", async ({ 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(); - await expect(page.getByRole("menuitem", { name: "Configure Agent Core connection" })).toBeVisible(); - await page.getByRole("menuitem", { name: "Configure Agent Core connection" }).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(); diff --git a/apps/web/src/components/AppearanceMenu.tsx b/apps/web/src/components/AppearanceMenu.tsx index 613b7556c..bc090b222 100644 --- a/apps/web/src/components/AppearanceMenu.tsx +++ b/apps/web/src/components/AppearanceMenu.tsx @@ -91,6 +91,7 @@ export function AppearanceMenu({ withAccount = false, coreState, onConfigureCore aria-haspopup="menu" aria-expanded={open} aria-label={t(showAccount ? "accountAndAppearance" : "appearanceSettings")} + aria-description={showConsole ? coreStatus : undefined} title={t(showAccount ? "accountAndAppearance" : "appearanceSettings")} onClick={(event) => { const nextOpen = !open; @@ -105,12 +106,12 @@ export function AppearanceMenu({ withAccount = false, coreState, onConfigureCore focusMenuItem(event.key === "ArrowDown" ? "first" : "last"); }} > - {showConsole ? <>{showAccount ?