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
12 changes: 7 additions & 5 deletions apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ Page structure comes from typography, spacing and dividers. Reserve elevation fo

### Shadow Vocabulary
- **Page surface**: flat and full-height, with no outer radius or shadow.
- **Card** (no shadow; a 1px `frame-line` ring at 11% ink): KPI strips, chart grids, Overview cards, the Session transcript and the deployment panel. Cards are flat; no page surface is translucent or blurred.
- **Card** (no shadow; a 1px `frame-line` ring at 11% ink): KPI strips, chart grids, Overview cards, the Session transcript and Settings row cards. Cards are flat; no page surface is translucent or blurred.
- **Control ring** (a 1px Firm Rule ring with an extra-small shadow): outline buttons, the active segment, and search fields and selects in toolbars. Inputs inside cards and dialogs carry only a Hairline ring.
- **Overlay** (a 1px Hairline ring with a large soft shadow): anchored popovers, menus, dialogs, help tips and chart tooltips.

Expand Down Expand Up @@ -427,13 +427,15 @@ A persistent progress panel uses Core's busy, idle and cleanup counts, deadline
Read deployment progress independently of node details. Partial failures retain successful facts with a visible stale/unavailable notice. An uncertain write opens the recovery dialog and requires a new authoritative read before another mutation; refresh reads state and never resubmits the write. The connection's QueryClient owns both the authoritative deployment and pending or uncertain writes across route transitions. Leaving Sandbox configuration cannot cancel or forget a submitted reset, and a cached node snapshot cannot replace a newer reset or completion learned on Overview. Returning to Nodes or Sandbox configuration reads the shared deployment immediately and refreshes node evidence separately. Only a successful authoritative read begun after the write settles can release the mutation block; an earlier or still-pending read cannot. Submitting consumes the reset confirmation even if its outcome is uncertain; recovery uses the separate read-and-review dialog. Observation retries preserve applicable non-secret configuration drafts. A changed installation, owner epoch, backend, mode or generation discards the prior draft and confirmation. Logout clears this connection-scoped state.

### Settings page
Settings is the single platform configuration entry, with four tabs: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Each tab has a stable address; links from Nodes, metrics and Getting started open the owning tab and, where appropriate, its node editor or enrollment flow. Keep the page header and tab row consistent. Forms save independently through their resource APIs, never through one cross-resource Save all action.
Settings is the single platform configuration entry, with four tabs: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Each tab has a stable address; links from Nodes, metrics and Getting started open the owning tab and, where appropriate, its node editor or enrollment flow. Forms save independently through their resource APIs, never through one cross-resource Save all action.

Sandbox and scheduling contains sandbox setup, resource edits, the rollout row and explicit reset, followed by Capacity and suspension with the deployment-wide idle policy and Sandbox nodes with per-node limits. Add node opens the enrollment form here. Nodes remains an operational inventory: status, allocations, host figures and removal, with links to the canonical settings forms. Resource changes never imply rewriting existing allocations. Lower limits do not imply forced termination or snapshot deletion.
The hierarchy is page, tab, section, row. The page header loses its rule to an underline tab row: 40px text tabs in Graphite 13px/500, the current one Ink with a 2px Ink underline. The body is a left-aligned column at most 880px wide with 32px between sections. A section is the shared Section heading (14px/600, help tip, section-wide outline actions on the right) over one card of label/value rows (`settings.css`): a 200px Graphite label column with its help tip and an Ink value column, 8px by 16px padding, 32px minimum line height, Faint Rules between rows; below 640px the label sits above the value. An action that changes one value sits at the end of its row (Edit tenant capacity; Save beside the idle-seconds field, whose error appears beneath it). Read-only status is a row too (Configuration rollout, Snapshot retention); tables sit in one framed card. Settings never repeats the KPI strips of Nodes or the metrics pages. The irreversible operation closes the page in its own section: a red-ringed card with one Ink line stating the consequence and a red-text outline button.

Workspace storage shows the selected immutable configuration and tenant capacity. Select new configuration accepts an adapter identifier, configuration ID, parameters JSON object and optional write-only credential JSON object. Edit tenant capacity keeps the current configuration identity. Explain adapter preparation and existing-workspace behavior behind help tips, with a link to the owning configuration document; keep required form labels and validation visible. Never prefill credentials or persist their drafts across navigation. A failed read is not an unconfigured filesystem, and an uncertain write is not a safe automatic retry.
Sandbox and scheduling contains sandbox setup or Deployment provider (Provider, Each sandbox, the E2B connection rows and the rollout row, with Change resources in the section header), then Capacity and suspension (the deployment-wide idle policy and read-only snapshot retention, shown only when the deployment declares suspension), then Sandbox nodes (Add node in the header, per-node limits in a table), then Reset. While a reset runs, its progress section leads the page with Force reset now and Cancel reset in its header. Add node opens the enrollment form here. Nodes remains an operational inventory: status, allocations, host figures and removal, with links to the canonical settings forms. Resource changes never imply rewriting existing allocations. Lower limits do not imply forced termination or snapshot deletion.

Models and credentials has one default-model card per harness in an auto-fill grid, with Set, Replace and Clear actions. Facts include read-only startup state, model ID, provider protocol, base URL, key presence, token limits when set and update time. Set and Replace open one form dialog. The adapter declaration supplies supported protocols, native JSON support and required limits without harness-specific branches. Native settings use an optional JSON object editor with formatting and inline syntax errors. Changing model identity clears native JSON; replacing only the required write-only API key does not. Keys are never prefilled and are forgotten when the form closes. Core rejections remain visible in the form; Clear uses ConfirmDialog. Usage details opens Core's observations separately.
Workspace storage shows the selected immutable configuration and tenant capacity under Storage configuration, with Select new configuration in the section header and Edit tenant capacity on the capacity row. Select new configuration accepts an adapter identifier, configuration ID, parameters JSON object and optional write-only credential JSON object. Edit tenant capacity keeps the current configuration identity. Explain adapter preparation and existing-workspace behavior behind help tips, with a link to the owning configuration document; keep required form labels and validation visible. Never prefill credentials or persist their drafts across navigation. A failed read is not an unconfigured filesystem, and an uncertain write is not a safe automatic retry.

Models and credentials stacks one default-model card per harness. Its header carries the harness name, its read-only startup state (Enabled or Disabled, a Default pill, a help tip) and the Set, Replace and Clear actions; rows hold model ID, provider protocol, base URL, key presence, token limits when set and update time, and a foot holds Usage details. Set and Replace open one form dialog. The adapter declaration supplies supported protocols, native JSON support and required limits without harness-specific branches. Native settings use an optional JSON object editor with formatting and inline syntax errors. Changing model identity clears native JSON; replacing only the required write-only API key does not. Keys are never prefilled and are forgotten when the form closes. Core rejections remain visible in the form; Clear uses ConfirmDialog. Usage details opens Core's observations separately.

Service and deployment shows installation addresses, installation ID and source commit, existing Domain and HTTPS actions, and the startup settings Core loaded. Process settings remain read-only here; their source stays the installation configuration. Sensitive settings show only Configured or Not set. Default and Fixed after install use neutral pills.

Expand Down
2 changes: 1 addition & 1 deletion apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ The console runs beside the administrator's own Core, with execution, files and
## Capabilities and Constraints

- **Projects and keys.** A project owns an isolated set of assets shared by all of its named API keys; projects do not see each other's assets. Issuing or revoking a key never touches assets. Archiving a project revokes every key and keeps its assets viewable and deletable; it can't be undone, so its confirmation says so, counts the active keys it revokes and, when there are any, asks for the project's name. Key plaintext is shown once, at issuance, and never stored by the console. Beside it, and without the key on an active project's page, the console tells developers to set `OPENAI_BASE_URL` (the installation's API base URL) and `OPENAI_API_KEY` (a key of the project), with curl and Python samples that list Agents and create a Session.
- **One home for each setting.** Settings owns each platform configuration editor. Overview, Nodes, metrics and onboarding navigate to its relevant group rather than duplicating forms. Each resource saves through its existing API independently; there is no cross-resource save. Process settings remain file-owned and read-only in Web. Projects, API keys and Session executor credentials remain on their resource pages. Capacity is node-owned, idle policy is deployment-wide, and sandbox specifications are independent of node limits. Resource editing uses a dialog; rollout counts and generations appear in its details dialog.
- **One home for each setting.** Settings owns each platform configuration editor. Overview, Nodes, metrics and onboarding navigate to its relevant group rather than duplicating forms. Each resource saves through its existing API independently; there is no cross-resource save. Process settings remain file-owned and read-only in Web. Projects, API keys and Session executor credentials remain on their resource pages. Capacity is node-owned, idle policy is deployment-wide, and sandbox specifications are independent of node limits. Resource editing uses a dialog; rollout counts and generations appear in its details dialog. Settings shows configuration and the status that qualifies it, not capacity figures: Nodes and Sandbox metrics own those counts. Reset sits apart at the end of Sandbox and scheduling, and leads it while a reset runs.
- **Workspace storage.** Settings → Workspace storage owns selecting an immutable configuration and editing tenant capacity through the existing storage API. Adapter parameters and optional write-only credentials are JSON objects validated by Core, with no vendor-specific Web fields or capability discovery by name. Selecting a new configuration does not migrate existing workspaces; changing tenant capacity does not resize them. Credentials are never prefilled, stored in browser persistence or included in error output. Uncertain writes require an authoritative read before another save.
- **Web API only.** Every read and write goes through `/core/v1/**`. The console holds no API key and sends nothing to `/v1`.
- **No asset writes except delete.** Assets are created and changed only by a project's keys through the Agents API. The console does not create or edit Agents or Templates, upload Skills or Files, create or replace Credentials, start Sessions, send input or cancel work. Deletion follows the public deletion rules; a busy Session is not deletable and the console never cancels work to make it so.
Expand Down
42 changes: 41 additions & 1 deletion apps/web/e2e/unified-settings.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
import { expectManagementBoundary, openConsole } from "./console";
import { expectManagementBoundary, openConsole, setDeployment } from "./console";

test.afterEach(async ({ request }) => expectManagementBoundary(request));

Expand Down Expand Up @@ -97,3 +97,43 @@ test("capacity editor refuses to select an old configuration after observing a r
await expect(dialog.getByRole("alert")).toBeVisible();
expect(saves[1].id).toBe(selected.id);
});

test("settings content width does not constrain reset and node dialog backdrops", async ({ page, request }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await openConsole(page, request, "settings?id=sandbox");
for (const action of ["Reset deployment", "Edit node"]) {
await page.getByRole("button", { name: action, exact: true }).first().click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
const backdrop = page.locator(".modal-backdrop");
const bounds = await backdrop.boundingBox();
expect(bounds?.x).toBe(0);
expect(bounds?.y).toBe(0);
expect(bounds?.width).toBe(1440);
expect(bounds?.height).toBe(1000);
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
}
});


test("reset progress actions stay inside settings on narrow screens", async ({ page, request }) => {
await page.setViewportSize({ width: 375, height: 844 });
await openConsole(page, request, "settings?id=sandbox");
await setDeployment(request, { resources: { allocations: 1, pending: 0 }, reset: { clear: "auto", requested_at: "2026-10-13T08:00:00Z", deadline_at: "2026-10-13T09:00:00Z", forced_at: null, remaining: { busy: 1, idle: 0, cleanup: 0, on_offline_nodes: 0, offline_nodes: [] } } });
await page.getByRole("button", { name: "Refresh sandbox state" }).click();
const region = page.getByRole("region", { name: "Reset in progress" });
await expect(region).toBeVisible();
const bounds = (await region.boundingBox())!;
for (const name of ["Force reset now", "Cancel reset"]) {
const action = region.getByRole("button", { name, exact: true });
const box = (await action.boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(bounds.x);
expect(box.x + box.width).toBeLessThanOrEqual(bounds.x + bounds.width + 1);
await action.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
}
});
13 changes: 6 additions & 7 deletions apps/web/src/features/sandbox/SandboxCapacitySettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { sandboxAdmin, sandboxConsoleConfigQuery } from "./sandbox-queries";
import { useSandboxPageState } from "./use-sandbox-page-state";
import { NodeEnrollment } from "./NodeEnrollment";
import { NodeEditDialog } from "./NodeEditDialog";
import { SandboxLifecycle } from "./SandboxLifecycle";
import { SuspensionSettings } from "./SandboxLifecycle";
import { sandboxSize } from "./deployment-specification";
import { writeSandboxDeployment } from "./sandbox-deployment-write";

Expand Down Expand Up @@ -79,15 +79,14 @@ export function SandboxCapacitySettings() {
if (!snapshot?.deployment.provider) return null;
const nodes = snapshot.nodes;
return <>
{hostedNodes || snapshot.deployment.suspension ? <SandboxLifecycle deployment={snapshot.deployment} nodes={nodesConfirmed ? nodes : null} stale={!nodesConfirmed} controls={{ disabled: busy || loading || !fresh || setupNeedsRefresh || Boolean(snapshot.deployment.reset), onSave: saveIdle }} /> : null}
{hostedNodes ? <Section headingId="settings-node-capacity" title={t("Sandbox nodes")}>
<div className="sandbox-actions"><button className="button outline" type="button" disabled={disabled || localOnly || !consoleConfig} onClick={() => setAdding(true)}>{t("Add node")}</button></div>
<SuspensionSettings deployment={snapshot.deployment} disabled={busy || loading || !fresh || setupNeedsRefresh || Boolean(snapshot.deployment.reset)} onSave={saveIdle} />
{hostedNodes ? <Section headingId="settings-node-capacity" title={t("Sandbox nodes")} actions={<button className="button outline" type="button" disabled={disabled || localOnly || !consoleConfig} aria-describedby={localOnly ? "settings-add-node-blocked" : undefined} onClick={() => setAdding(true)}>{t("Add node")}</button>}>
{configQuery.isError ? <p role="alert">{t("The console configuration could not be read. Refresh to try again.")} <button type="button" className="text-action" onClick={() => void configQuery.refetch()}>{t("Try again")}</button></p> : null}
{snapshot.nodesError ? <p role="alert">{sandboxRequestError(snapshot.nodesError, locale)}</p> : null}
{localOnly ? <p role="status">{tCommon("installationNotice.addBlocked")}</p> : null}
{localOnly ? <p role="status" id="settings-add-node-blocked">{tCommon("installationNotice.addBlocked")}</p> : null}
{nodes.some((node) => node.active > node.max_active || node.retained > node.max_retained) ? <p role="status">{t("Current usage exceeds a saved limit. Existing sandboxes are preserved; new placements wait for capacity.")}</p> : null}
<div className="table-frame"><table className="data-table" aria-label={t("Sandbox nodes")}><thead><tr><th>{t("Node")}</th><th>{t("Active / limit")}</th>{suspends ? <th>{t("Retained / limit")}</th> : null}<th>{t("Actions")}</th></tr></thead><tbody>
{nodes.map((node) => <tr key={node.id} id={`capacity-${node.id}`}><th>{node.name || node.id}</th><td>{node.active} / {node.max_active}</td>{suspends ? <td>{node.retained} / {node.max_retained}</td> : null}<td><button type="button" className="text-action" disabled={disabled} onClick={() => navigate("settings", { id: "sandbox", node: node.id })}>{t("Edit node")}</button></td></tr>)}
<div className="settings-table"><table className="data-table" aria-label={t("Sandbox nodes")}><thead><tr><th scope="col">{t("Node")}</th><th scope="col" className="numeric">{t("Active / limit")}</th>{suspends ? <th scope="col" className="numeric">{t("Retained / limit")}</th> : null}<th scope="col" className="settings-table-action">{t("Actions")}</th></tr></thead><tbody>
{nodes.map((node) => <tr key={node.id} id={`capacity-${node.id}`}><th scope="row">{node.name || node.id}</th><td className="numeric">{node.active} / {node.max_active}</td>{suspends ? <td className="numeric">{node.retained} / {node.max_retained}</td> : null}<td className="settings-table-action"><button type="button" className="text-action" disabled={disabled} onClick={() => navigate("settings", { id: "sandbox", node: node.id })}>{t("Edit node")}</button></td></tr>)}
</tbody></table></div>
</Section> : null}
{enrollment}{editDialog}
Expand Down
Loading
Loading