Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1965,6 +1967,8 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"settings.general": "通用",
"settings.goalConnections": "Goal 连接",
"settings.language": "语言",
"settings.modelProvider": "模型 Provider 配置",
"settings.globalCapabilities": "全局能力配置",
"settings.languageDescription": "选择 LoopX Desktop 工作区使用的界面语言。",
"settings.languageEnglishDescription": "使用英文显示导航、设置和工作区控件。",
"settings.languageEnglish": "English",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -294,16 +293,18 @@ export function MachineConfigurationSettings() {
<p>{t("machine.liveDefaultDescription")}</p>
</details>

{inspection?.status === "invalid" ? (
<section className="personal-machine-error" data-testid="machine-invalid-repair" role="alert">
<strong>{t("machine.invalidStoredConfiguration")}</strong>
<p>{t("machine.invalidStoredConfigurationDescription")}</p>
</section>
) : null}

<OperatorCredentialSettings />

<div className="personal-capability-layout">
{/* 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. */}
<div className="personal-capability-body">
{inspection?.status === "invalid" ? (
<section className="personal-machine-error" data-testid="machine-invalid-repair" role="alert">
<strong>{t("machine.invalidStoredConfiguration")}</strong>
<p>{t("machine.invalidStoredConfigurationDescription")}</p>
</section>
) : null}

<div className="personal-capability-layout">
<CapabilityCatalogNavigation capabilities={capabilities} locale={locale} onSelect={setSelectedCapabilityId} scope="machine" selectedCapabilityId={selected.capability_id} t={t} />

<article aria-label={selected.display_name} className="personal-capability-detail" tabIndex={0}>
Expand Down Expand Up @@ -404,6 +405,7 @@ export function MachineConfigurationSettings() {
t={t}
/> : null}
</article>
</div>
</div>
</section>
);
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
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<WorkspaceSettingsTab, typeof Settings2> = {
appearance: Palette,
capabilities: SlidersHorizontal,
language: Languages,
lark: Settings2,
machine: ServerCog,
provider: KeyRound,
};

export function WorkspaceSettingsPage({
Expand Down Expand Up @@ -46,7 +48,12 @@ export function WorkspaceSettingsPage({
const [tab, setTab] = useState<WorkspaceSettingsTab>(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") },
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -123,6 +133,12 @@ export function WorkspaceSettingsPage({
/>
) : null}

{tab === "provider" ? (
<div className="personal-provider-settings">
<OperatorCredentialSettings />
</div>
) : null}

{tab === "machine" ? <MachineConfigurationSettings /> : null}
{tab === "capabilities" ? (
<GoalCapabilitySettings
Expand Down
47 changes: 47 additions & 0 deletions examples/personal-workspace-browser/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,14 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true
// Goals a single action sent back through the loader.
goalStatusRequests: [],
workspaceDirectoryRequests: 0,
operatorCredential: {
providerKeyConfigured: true,
providerKeySource: "service_environment",
providerKeyFingerprint: "3efe046b2b3d",
baseUrl: null,
baseUrlSource: "unset",
},
operatorCredentialWrites: [],
turnRequests: [],
get larkConnections() { return runtime.larkConnections; },
get goalSubagentConfigurations() { return runtime.goalSubagentConfigurations; },
Expand Down Expand Up @@ -994,6 +1002,45 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true
}, status: 200 });
return;
}
if (url.pathname === "/api/chat/operator-credential") {
const body = request.method() === "POST" ? request.postDataJSON() : null;
if (body) {
state.operatorCredentialWrites.push(body);
if (body.clear_provider_key) {
state.operatorCredential = { ...state.operatorCredential, providerKeyConfigured: false, providerKeySource: "unset", providerKeyFingerprint: null };
}
if (typeof body.provider_key === "string" && body.provider_key.trim()) {
// The fixture never stores the key itself: the readback only ever
// carries the fingerprint the real projection returns.
state.operatorCredential = { ...state.operatorCredential, providerKeyConfigured: true, providerKeySource: "machine_store", providerKeyFingerprint: "fixture-fingerprint" };
}
if (typeof body.base_url === "string" && body.base_url.trim()) {
state.operatorCredential = { ...state.operatorCredential, baseUrl: body.base_url.trim(), baseUrlSource: "machine_store" };
}
if (body.clear_base_url) {
state.operatorCredential = { ...state.operatorCredential, baseUrl: null, baseUrlSource: "unset" };
}
}
const credential = state.operatorCredential;
await route.fulfill({ contentType: "application/json", json: {
ok: true,
schema_version: "operator_provider_credential_projection_v0",
action: body ? "readback" : undefined,
store_ref: "fixture-operator-store",
store_revision: "sha256:fixture-operator-store",
record_present: credential.providerKeyConfigured || Boolean(credential.baseUrl),
status: credential.providerKeyConfigured ? "configured" : "absent",
repair: "",
provider_key: {
configured: credential.providerKeyConfigured,
env_var: credential.providerKeySource === "service_environment" ? "DEEPSEEK_API_KEY" : undefined,
fingerprint: credential.providerKeyFingerprint,
source: credential.providerKeySource,
},
base_url: { configured: Boolean(credential.baseUrl), source: credential.baseUrlSource, value: credential.baseUrl },
}, status: 200 });
return;
}
if (url.pathname === "/api/chat/machine-configuration/preview" && request.method() === "POST") {
const body = request.postDataJSON();
state.machineConfigurationRequests.push({ phase: "preview", ...body });
Expand Down
75 changes: 69 additions & 6 deletions examples/personal-workspace-browser/typed-actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1018,8 +1018,71 @@ export const typedActionsScenario = {
if (await page.locator("[data-context-drawer]").count()) throw new Error("Workspace Settings left the context drawer visible");
await page.screenshot({ path: resolve(outputDir, "workspace-settings.png"), fullPage: false, animations: "disabled" });

await page.getByRole("button", { name: /机器配置/ }).click();
await page.getByRole("heading", { level: 1, name: "机器配置", exact: true }).waitFor({ state: "visible" });
// Two categories, two questions: the model provider holds the operator
// credential, the global capabilities hold the machine defaults. Sharing
// one surface is what let the credential panel overlap the workbench.
// Two invariants per stacking container: its blocks must not overlap, and
// a block that does not scroll must not spill its own content outside its
// box. The second one is what a squeezed grid row actually produced: the
// credential panel kept a 32px box while its content ran over the catalog
// workbench below it.
const stackedBlocks = () => 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" });
Expand Down Expand Up @@ -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");
Expand All @@ -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
Expand Down Expand Up @@ -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" });
Expand All @@ -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();
Expand Down
Loading