From 36bb4582e70c459b218c7894e9aa26e2fda84db6 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:40:09 +0800 Subject: [PATCH] fix(dashboard): give the model provider its own settings category The machine-configuration surface rendered a two-row grid while the component had gained a third block, so the operator credential panel was squeezed into the leftover row and its content ran over the capability catalog below it: the panel kept a 32px box for 389px of content and overlapped the workbench. The credential panel and the capability catalog answer different questions, so they are now two categories: "Model provider" holds the write-only operator credential, and "Global capabilities" holds the machine defaults every Goal inherits. The catalog workbench keeps one body element that owns the flexible row, so the surface stays two rows however many notices the editor needs, and the fixture gained the credential endpoint its new category reads. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../src/features/personal-workspace/i18n.tsx | 4 + .../machine-configuration-settings.tsx | 24 +++--- .../personal-workspace/personal-workspace.css | 8 ++ .../workspace-settings-page.tsx | 24 +++++- .../personal-workspace-browser/fixture.mjs | 47 ++++++++++++ .../typed-actions.mjs | 75 +++++++++++++++++-- 6 files changed, 161 insertions(+), 21 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index ecaa63b998..d488435b6d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -929,6 +929,8 @@ const en = { "settings.general": "General", "settings.goalConnections": "Goal connections", "settings.language": "Language", + "settings.modelProvider": "Model provider", + "settings.globalCapabilities": "Global capabilities", "settings.languageDescription": "Choose the language used by the LoopX desktop workspace.", "settings.languageEnglishDescription": "Use English for navigation, settings, and workspace controls.", "settings.languageEnglish": "English", @@ -1965,6 +1967,8 @@ const zhCN: Record = { "settings.general": "通用", "settings.goalConnections": "Goal 连接", "settings.language": "语言", + "settings.modelProvider": "模型 Provider 配置", + "settings.globalCapabilities": "全局能力配置", "settings.languageDescription": "选择 LoopX Desktop 工作区使用的界面语言。", "settings.languageEnglishDescription": "使用英文显示导航、设置和工作区控件。", "settings.languageEnglish": "English", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx index 07ab2f258d..6d7d23d630 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx @@ -21,7 +21,6 @@ import { withReportScheduleTimezone } from "./periodic-report-schedule-field"; import { localizeCapability, localizedCapabilityFieldCopy } from "./capability-localization"; import { canEditCapability, CapabilityCatalogNavigation, CapabilityConfigurationSummary, CapabilityDetailHeader, CapabilityEditorStatus, orderCapabilitiesForPresentation } from "./capability-workbench"; import { useWorkspaceI18n } from "./i18n"; -import { OperatorCredentialSettings } from "./operator-credential-settings"; type CapabilityDescriptor = CapabilityConfigurationCatalog["capabilities"][number]; type EditorMode = "guided" | "json"; @@ -294,16 +293,18 @@ export function MachineConfigurationSettings() {

{t("machine.liveDefaultDescription")}

- {inspection?.status === "invalid" ? ( -
- {t("machine.invalidStoredConfiguration")} -

{t("machine.invalidStoredConfigurationDescription")}

-
- ) : null} - - - -
+ {/* The catalog workbench is the only flexible block on this surface. It + lives in one body element so the surface keeps exactly two grid rows + however many notices the editor needs. */} +
+ {inspection?.status === "invalid" ? ( +
+ {t("machine.invalidStoredConfiguration")} +

{t("machine.invalidStoredConfigurationDescription")}

+
+ ) : null} + +
@@ -404,6 +405,7 @@ export function MachineConfigurationSettings() { t={t} /> : null}
+
); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 7f4b7af32d..69359ab67f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -693,6 +693,14 @@ /* Kanban layout for the goal Tasks tab: columns are task states. */ .personal-task-lane-filter { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 50px; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 10px; background: #fff; } .personal-capability-settings { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 16px; width: 100%; max-width: 1050px; min-width: 0; min-height: 0; overflow: hidden; } +/* The surface is a two-row grid: the scope note, then the body that owns the + rest of the height. The body is a column so the workbench takes the leftover + space whether or not a notice precedes it; a taller sibling must never be + squeezed into a fixed row and spill over the block below it. */ +.personal-capability-body { display: flex; flex-direction: column; gap: 16px; min-width: 0; min-height: 0; overflow: hidden; } +.personal-capability-body > .personal-capability-layout { flex: 1 1 auto; } +.personal-provider-settings { width: 100%; max-width: 1050px; min-width: 0; } +.personal-provider-settings > .personal-operator-credential { margin-top: 0; } .personal-capability-scope-note { max-height: 120px; overflow: auto; overscroll-behavior: contain; padding: 12px 0; color: var(--pw-muted); } .personal-capability-scope-note summary { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 12px; } .personal-capability-scope-note p { padding: 10px 0 0 25px; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx index 21f4843270..f68dc2152a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx @@ -1,15 +1,16 @@ import { useState } from "react"; -import { ArrowLeft, Check, Languages, Palette, ServerCog, Settings2, SlidersHorizontal } from "lucide-react"; +import { ArrowLeft, Check, KeyRound, Languages, Palette, ServerCog, Settings2, SlidersHorizontal } from "lucide-react"; import type { WorkspaceLocale } from "./i18n"; import { useWorkspaceI18n } from "./i18n"; import { LarkSettingsPage } from "./lark-settings-page"; import { GoalCapabilitySettings } from "./goal-capability-settings"; import { MachineConfigurationSettings } from "./machine-configuration-settings"; +import { OperatorCredentialSettings } from "./operator-credential-settings"; import type { PersonalWorkspaceCallbacks, WorkspaceGoal, WorkspaceGoalNotification } from "./personal-workspace-model"; import type { WorkspaceTheme } from "./workspace-theme"; -type WorkspaceSettingsTab = "machine" | "capabilities" | "lark" | "appearance" | "language"; +type WorkspaceSettingsTab = "provider" | "machine" | "capabilities" | "lark" | "appearance" | "language"; const tabIcons: Record = { appearance: Palette, @@ -17,6 +18,7 @@ const tabIcons: Record = { language: Languages, lark: Settings2, machine: ServerCog, + provider: KeyRound, }; export function WorkspaceSettingsPage({ @@ -46,7 +48,12 @@ export function WorkspaceSettingsPage({ const [tab, setTab] = useState(initialTab); const tabs: Array<{ key: WorkspaceSettingsTab; label: string }> = [ ...(initialGoalId ? [{ key: "capabilities" as const, label: t("capabilities.title") }] : []), - { key: "machine", label: t("machine.title") }, + // The model provider is one machine decision (which endpoint and key the + // operator credential holds); the capability catalog is another (which + // machine defaults every Goal inherits). They answer different questions + // and are edited on different surfaces, so they are separate categories. + { key: "provider", label: t("settings.modelProvider") }, + { key: "machine", label: t("settings.globalCapabilities") }, { key: "lark", label: "Lark" }, { key: "appearance", label: t("settings.appearance") }, { key: "language", label: t("settings.language") }, @@ -75,7 +82,10 @@ export function WorkspaceSettingsPage({ title: "Lark", }, machine: { - title: t("machine.title"), + title: t("settings.globalCapabilities"), + }, + provider: { + title: t("settings.modelProvider"), }, }; const heading = headings[tab]; @@ -123,6 +133,12 @@ export function WorkspaceSettingsPage({ /> ) : null} + {tab === "provider" ? ( +
+ +
+ ) : null} + {tab === "machine" ? : null} {tab === "capabilities" ? ( page.evaluate(() => { + for (const selector of [".personal-settings-body", ".personal-capability-settings", ".personal-capability-body"]) { + const container = document.querySelector(selector); + if (!container) continue; + const children = [...container.children].filter((node) => node.getBoundingClientRect().height > 4); + const blocks = children.map((node) => node.getBoundingClientRect()); + for (let index = 1; index < blocks.length; index += 1) { + if (blocks[index].top < blocks[index - 1].bottom - 1) { + return `${selector} blocks overlap`; + } + } + for (const child of children) { + const overflow = getComputedStyle(child).overflowY; + if (overflow !== "visible") continue; + if (child.scrollHeight > child.clientHeight + 2) { + return `${selector} clips ${child.className.toString().split(/\s+/)[0]} (${child.clientHeight} < ${child.scrollHeight})`; + } + } + } + return ""; + }); + await page.getByRole("button", { name: /模型 Provider 配置/ }).click(); + await page.getByRole("heading", { level: 1, name: "模型 Provider 配置", exact: true }).waitFor({ state: "visible" }); + await page.locator(".personal-operator-credential").waitFor({ state: "visible" }); + if (await page.locator(".personal-operator-credential").count() !== 1) { + throw new Error("The model provider category did not host exactly one credential panel"); + } + await page.locator(".personal-operator-credential-readback").waitFor({ state: "visible" }); + for (const label of [/^API key$/u, /^指纹$/u, /^Endpoint base URL$/u]) { + await page.getByText(label).first().waitFor({ state: "visible" }); + } + // The readback is redacted by construction: the key shows its fingerprint + // and the fixture's own value never appears. + const providerReadback = await page.locator(".personal-operator-credential-readback").innerText(); + if (!providerReadback.includes("已配置") || !providerReadback.includes("3efe046b2b3d")) { + throw new Error(`Model provider readback lost its redacted projection: ${providerReadback}`); + } + if (providerReadback.includes("api-key-fixture")) { + throw new Error("Model provider readback exposed a credential value"); + } + if (api.operatorCredentialWrites.length) { + throw new Error("Opening the model provider category wrote a credential"); + } + const providerOverlap = await stackedBlocks(); + if (providerOverlap) throw new Error(`Model provider category ${providerOverlap}`); + await page.screenshot({ path: resolve(outputDir, "model-provider-settings-zh-cn.png"), fullPage: false, animations: "disabled" }); + + await page.getByRole("button", { name: /全局能力配置/ }).click(); + await page.getByRole("heading", { level: 1, name: "全局能力配置", exact: true }).waitFor({ state: "visible" }); + // The catalog workbench mounts after its inspection resolves, so the + // category's contents are asserted only once the workbench itself exists. + await page.locator(".personal-capability-layout").waitFor({ state: "visible" }); + if (await page.locator(".personal-operator-credential").count()) { + throw new Error("The global capability category still hosted the operator credential panel"); + } + const capabilityOverlap = await stackedBlocks(); + if (capabilityOverlap) throw new Error(`Global capability category ${capabilityOverlap}`); const machineCatalog = page.getByRole("navigation", { name: "机器能力目录" }); const firstMachineCapability = machineCatalog.getByRole("button").filter({ hasText: "机器" }).first(); await firstMachineCapability.waitFor({ state: "visible" }); @@ -1098,7 +1161,7 @@ export const typedActionsScenario = { await page.getByRole("button", { name: /语言/ }).click(); await page.getByRole("radio", { name: /English/ }).click(); - await page.getByRole("button", { name: /Machine configuration/ }).click(); + await page.getByRole("button", { name: /Global capabilities/ }).click(); await page.getByRole("heading", { level: 2, name: "Periodic reports", exact: true }).waitFor({ state: "visible" }); const rawValues = page.locator(".personal-capability-raw-values"); if (await rawValues.getAttribute("open") !== null) throw new Error("Raw JSON must be collapsed by default"); @@ -1124,7 +1187,7 @@ export const typedActionsScenario = { await page.screenshot({ path: resolve(outputDir, "goal-subagent-capability-en.png"), fullPage: false, animations: "disabled" }); await page.getByRole("button", { name: /Language/ }).click(); await page.getByRole("radio", { name: /Simplified Chinese/ }).click(); - await page.getByRole("button", { name: /机器配置/ }).click(); + await page.getByRole("button", { name: /全局能力配置/ }).click(); await page.locator(".personal-settings-body").evaluate((element) => element.scrollTo({ top: 0 })); await page.screenshot({ path: resolve(outputDir, "machine-capability-zh-cn.png"), fullPage: false, animations: "disabled" }); // The steward's own executor is a machine setting like any other: the @@ -1169,7 +1232,7 @@ export const typedActionsScenario = { api.machineInspectionStatus = "invalid"; api.invalidMachineNamespaces = ["manager_runtime"]; - await page.getByRole("button", { name: /机器配置/ }).click(); + await page.getByRole("button", { name: /全局能力配置/ }).click(); const invalidRepair = page.getByTestId("machine-invalid-repair"); await invalidRepair.waitFor({ state: "visible" }); await page.getByRole("heading", { level: 2, name: "管家 Runtime", exact: true }).waitFor({ state: "visible" }); @@ -1192,7 +1255,7 @@ export const typedActionsScenario = { await page.getByRole("button", { name: /Lark/ }).click(); api.machineInspectionStatus = "invalid"; api.invalidMachineNamespaces = ["periodic_report"]; - await page.getByRole("button", { name: /机器配置/ }).click(); + await page.getByRole("button", { name: /全局能力配置/ }).click(); await invalidRepair.waitFor({ state: "visible" }); await page.getByRole("heading", { level: 2, name: "周期报告", exact: true }).waitFor({ state: "visible" }); await page.getByRole("button", { name: "预览变更", exact: true }).click();