feat(workspaces): add full-bleed single-widget tabs (#109627)

* feat(workspaces): add full-bleed app tabs

* fix(workspaces): harden full-bleed rendering

* fix(workspaces): eliminate full-bleed iframe gap

* fix(workspaces): keep tab layout type internal

---------

Co-authored-by: Eva <eva@100yen.org>
This commit is contained in:
Peter Steinberger
2026-07-16 21:56:57 -07:00
committed by GitHub
parent 0c25b52f7c
commit 7be5d78fd0
15 changed files with 308 additions and 31 deletions
+6
View File
@@ -65,6 +65,11 @@ lists only pending entries from the Workspaces custom-widget registry. Its Appro
Reject controls are disabled unless the current connection holds `operator.approvals`;
it does not expose exec, plugin, or system-agent approvals.
Tabs use the 12-column widget grid by default. A tab containing zero or one widget can
instead use the `full` layout, which removes the widget card chrome and lets the widget
fill the tab. Switch layouts with `openclaw workspaces tabs full <slug>` and
`openclaw workspaces tabs grid <slug>`.
Widgets declare data through **bindings**, they never fetch on their own:
| Binding | Resolves to |
@@ -110,6 +115,7 @@ per-invocation confirmation quoting the exact text, and passes a rate limit.
```sh
openclaw workspaces tabs list
openclaw workspaces tabs create --title Financials
openclaw workspaces tabs full financials
openclaw workspaces widget-scaffold revenue-chart --title "Revenue Chart"
openclaw workspaces widget-approve revenue-chart
```
+15
View File
@@ -287,6 +287,21 @@ export function registerWorkspaceCli(options: RegisterWorkspaceCliOptions): void
});
}
for (const [verb, tabLayout] of [
["grid", "grid"],
["full", "full"],
] as const) {
addGatewayOptions(
tabs.command(verb).argument("<slug>", "Tab slug").description(`Use ${tabLayout} layout`),
).action(async (slug: string, commandOptions: GatewayOptions) => {
const result = await callWorkspaceGateway("workspaces.tab.update", commandOptions, {
slug,
patch: { layout: tabLayout },
});
writeTabs(readWorkspaceResult(result).doc, commandOptions);
});
}
addGatewayOptions(
widgets
.command("list")
+9 -2
View File
@@ -276,8 +276,10 @@ function readRequiredBoolean(record: Record<string, unknown>, key: string): bool
return value;
}
function readTabPatch(value: unknown): Partial<Pick<WorkspaceTab, "title" | "icon" | "hidden">> {
const patch = readParams(value, ["title", "icon", "hidden"]);
function readTabPatch(
value: unknown,
): Partial<Pick<WorkspaceTab, "title" | "icon" | "hidden" | "layout">> {
const patch = readParams(value, ["title", "icon", "hidden", "layout"]);
const title = readOptionalString(patch, "title");
if (title !== undefined && (title.length < 1 || title.length > 80)) {
throw new Error("patch.title must be 1-80 characters");
@@ -287,10 +289,15 @@ function readTabPatch(value: unknown): Partial<Pick<WorkspaceTab, "title" | "ico
throw new Error("patch.icon must be 40 characters or fewer");
}
const hidden = readBooleanPatch(patch, "hidden");
const layout = patch.layout;
if (layout !== undefined && layout !== "grid" && layout !== "full") {
throw new Error('patch.layout must be "grid" or "full"');
}
return {
...(title !== undefined ? { title } : {}),
...(icon !== undefined ? { icon } : {}),
...(hidden !== undefined ? { hidden } : {}),
...(layout !== undefined ? { layout } : {}),
};
}
+18
View File
@@ -24,6 +24,24 @@ describe("Workspaces document schema", () => {
expect(validateWorkspaceDoc(doc).tabs[0]!.widgets[0]!.kind).toBe(kind);
});
it("accepts full-bleed tabs and rejects unknown layouts", () => {
const doc = validDoc();
doc.tabs[0]!.widgets = doc.tabs[0]!.widgets.slice(0, 1);
doc.tabs[0]!.layout = "full";
expect(validateWorkspaceDoc(doc).tabs[0]?.layout).toBe("full");
expectInvalid((invalid) => {
invalid.tabs[0]!.layout = "columns" as never;
}, 'layout must be "grid" or "full"');
expectInvalid((invalid) => {
invalid.tabs[0]!.layout = "full";
invalid.tabs[0]!.widgets.push({
...structuredClone(invalid.tabs[0]!.widgets[0]!),
id: "second",
});
}, "at most one entry for full layout");
});
it("rejects invalid tab slugs", () => {
expectInvalid((doc) => {
doc.tabs[0]!.slug = "Bad Slug";
+15 -1
View File
@@ -35,6 +35,8 @@ export type WorkspaceTab = {
title: string;
icon?: string;
hidden: boolean;
/** Default grid, or a single app-like widget without grid chrome. */
layout?: "grid" | "full";
createdBy: WorkspaceActor;
widgets: WorkspaceWidget[];
};
@@ -299,7 +301,11 @@ function validateWidget(value: unknown, path: string): WorkspaceWidget {
function validateTab(value: unknown, path: string): WorkspaceTab {
const record = assertRecord(value, path);
assertKnownKeys(record, ["slug", "title", "icon", "hidden", "createdBy", "widgets"], path);
assertKnownKeys(
record,
["slug", "title", "icon", "hidden", "layout", "createdBy", "widgets"],
path,
);
const slug = requireString(record, "slug", path);
if (!TAB_SLUG_PATTERN.test(slug)) {
throw new Error(`${path}.slug is invalid`);
@@ -312,15 +318,23 @@ function validateTab(value: unknown, path: string): WorkspaceTab {
if (icon !== undefined && icon.length > 40) {
throw new Error(`${path}.icon must be 40 characters or fewer`);
}
const layout = record.layout;
if (layout !== undefined && layout !== "grid" && layout !== "full") {
throw new Error(`${path}.layout must be "grid" or "full"`);
}
const widgets = requireArray(record.widgets, `${path}.widgets`);
if (widgets.length > 24) {
throw new Error(`${path}.widgets must contain at most 24 entries`);
}
if (layout === "full" && widgets.length > 1) {
throw new Error(`${path}.widgets must contain at most one entry for full layout`);
}
return {
slug,
title,
...(icon !== undefined ? { icon } : {}),
hidden: requireBoolean(record, "hidden", path),
...(layout !== undefined ? { layout } : {}),
createdBy: validateActor(record.createdBy, `${path}.createdBy`),
widgets: widgets.map((widget, index) => validateWidget(widget, `${path}.widgets[${index}]`)),
};
+1 -1
View File
@@ -91,7 +91,7 @@ describe("workspace tools", () => {
const validSamples: Record<string, unknown> = {
workspace_get: {},
workspace_tab_create: { title: "Finance" },
workspace_tab_update: { slug: "main", hidden: true },
workspace_tab_update: { slug: "main", hidden: true, layout: "full" },
workspace_tab_delete: { slug: "old" },
workspace_tabs_reorder: { order: ["main"] },
workspace_widget_add: {
+12 -2
View File
@@ -539,22 +539,31 @@ export function createWorkspaceTools(params: WorkspaceToolParams): AnyAgentTool[
{
name: "workspace_tab_update",
label: "Workspace Tab Update",
description: toolDescription("Update a workspace tab title, icon, or hidden state."),
description: toolDescription("Update a workspace tab title, icon, hidden state, or layout."),
parameters: Type.Object(
{
slug: Type.String({ description: "Tab slug." }),
title: Type.Optional(Type.String({ description: "New title." })),
icon: Type.Optional(Type.String({ description: "New icon." })),
hidden: Type.Optional(Type.Boolean({ description: "Hide or show the tab." })),
layout: Type.Optional(
Type.Union([Type.Literal("grid"), Type.Literal("full")], {
description: "Grid layout, or one full-bleed app widget.",
}),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = readRecord(rawParams, ["slug", "title", "icon", "hidden"]);
const record = readRecord(rawParams, ["slug", "title", "icon", "hidden", "layout"]);
const slug = readSlug(record);
const title = readOptionalString(record, "title");
const icon = readOptionalString(record, "icon");
const hidden = readOptionalBoolean(record, "hidden");
const layout = record.layout;
if (layout !== undefined && layout !== "grid" && layout !== "full") {
throw new Error('layout must be "grid" or "full"');
}
return await runMutation({
...mutationBase,
changedTabSlug: slug,
@@ -563,6 +572,7 @@ export function createWorkspaceTools(params: WorkspaceToolParams): AnyAgentTool[
...(title !== undefined ? { title } : {}),
...(icon !== undefined ? { icon } : {}),
...(hidden !== undefined ? { hidden } : {}),
...(layout !== undefined ? { layout } : {}),
});
},
});
+1 -1
View File
@@ -324,7 +324,7 @@ class CustomWidgetFrameDirective extends AsyncDirective {
return iframe;
} catch (error) {
// A directive's render runs at Lit COMMIT time, outside the try/catch in
// `renderWidgetBody`. A throw here would escape the per-cell error boundary
// `renderWorkspaceWidgetBody`. A throw here would escape the per-cell error boundary
// and take down the whole tab, so the boundary has to exist here too.
this.detach = null;
this.iframe = null;
+3 -3
View File
@@ -132,7 +132,7 @@ function renderBuiltinWidget(
return renderer(widget, value, ctx);
}
if (widget.kind.startsWith("custom:")) {
// Custom widgets are dispatched by renderWidgetBody BEFORE this builtin path;
// Custom widgets are dispatched by renderWorkspaceWidgetBody BEFORE this builtin path;
// reaching here means no L5 host context was supplied (e.g. a unit test
// rendering the builtin body in isolation). Neutral placeholder — never an
// iframe without a manifest.
@@ -219,7 +219,7 @@ function renderCustomWidget(
* broken widget, a bad binding) is caught and rendered as an error card in THIS
* cell — siblings and the shell keep rendering (spec-30 acceptance criterion).
*/
function renderWidgetBody(
export function renderWorkspaceWidgetBody(
widget: WorkspaceWidget,
binding: WorkspaceBindingResult | null,
ctx: BuiltinWidgetContext,
@@ -346,7 +346,7 @@ export function renderWidgetCell(props: WorkspaceWidgetCellProps): TemplateResul
? nothing
: html`
<div class="workspace-widget__body">
${renderWidgetBody(
${renderWorkspaceWidgetBody(
widget,
props.binding,
props.builtinContext,
+135
View File
@@ -0,0 +1,135 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const proofDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const baselineOnly = process.env.OPENCLAW_UI_E2E_BASELINE === "1";
let browser: Browser;
let server: ControlUiE2eServer;
function workspaceDoc() {
return {
doc: {
schemaVersion: 1,
workspaceVersion: 1,
tabs: [
{
slug: "release-room",
title: "Release room",
hidden: false,
layout: baselineOnly ? "grid" : "full",
createdBy: "agent:main",
widgets: [
{
id: "release_room_app",
kind: "builtin:iframe-embed",
title: "Release room app",
grid: { x: 2, y: 0, w: 8, h: 6 },
collapsed: false,
createdBy: "agent:main",
props: { url: "/full-bleed-proof" },
},
],
},
],
widgetsRegistry: {},
prefs: { tabOrder: ["release-room"] },
},
workspaceVersion: 1,
};
}
async function capture(page: Page, name: string): Promise<void> {
if (!proofDir) {
return;
}
await mkdir(proofDir, { recursive: true });
await page.screenshot({ fullPage: true, path: path.join(proofDir, name) });
}
describeControlUiE2e("Control UI full-bleed workspace tabs", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(`Playwright Chromium is not available at ${chromiumExecutablePath}`);
}
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
it("removes grid chrome and lets the single sandboxed app fill the tab", async () => {
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1440 },
});
const page = await context.newPage();
await page.route("**/full-bleed-proof", (route) =>
route.fulfill({
contentType: "text/html",
body: `<!doctype html><style>
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #071a2b; color: #e8f4ff; font: 18px system-ui; }
main { width: min(760px, 80%); padding: 48px; border: 1px solid #3278a8; border-radius: 18px; background: #0d2942; }
h1 { margin-top: 0; color: #62d9ff; } .status { color: #8ce99a; }
</style><main><p class="status">All systems ready</p><h1>Release room</h1><p>One app. The whole workspace tab.</p></main>`,
}),
);
await installMockGateway(page, {
controlUiTabs: [
{ pluginId: "workspaces", id: "workspaces", label: "Workspaces", group: "control" },
],
featureMethods: ["workspaces.get"],
methodResponses: { "workspaces.get": workspaceDoc() },
});
try {
const response = await page.goto(`${server.baseUrl}plugin?plugin=workspaces&id=workspaces`);
expect(response?.status()).toBe(200);
await page.locator('[data-test-id="workspace-onboarding-dismiss"]').click();
const frame = page.locator('[data-test-id="workspace-embed-frame"]');
await frame.waitFor({ timeout: 10_000 });
expect(await frame.getAttribute("sandbox")).toBe("allow-scripts");
if (baselineOnly) {
expect(await page.locator('[data-test-id="workspace-grid"]').count()).toBe(1);
expect(await page.locator('[data-test-id="workspace-widget"]').count()).toBe(1);
await capture(page, "workspace-full-bleed-before.png");
return;
}
const fullBleed = page.locator('[data-test-id="workspace-fullbleed"]');
await fullBleed.waitFor();
expect(await page.locator('[data-test-id="workspace-grid"]').count()).toBe(0);
expect(await page.locator('[data-test-id="workspace-widget"]').count()).toBe(0);
const [containerBox, frameBox] = await Promise.all([
fullBleed.boundingBox(),
frame.boundingBox(),
]);
expect(containerBox).not.toBeNull();
expect(frameBox).not.toBeNull();
expect(Math.abs(frameBox!.width - containerBox!.width)).toBeLessThan(2);
expect(Math.abs(frameBox!.height - containerBox!.height)).toBeLessThan(2);
expect(frameBox!.height).toBeGreaterThanOrEqual(480);
await capture(page, "workspace-full-bleed-after.png");
} finally {
await context.close();
}
});
});
+1
View File
@@ -221,6 +221,7 @@ function normalizeTab(value: unknown): WorkspaceTab | null {
title: readString(value.title, slug),
hidden: value.hidden === true,
widgets,
...(value.layout === "grid" || value.layout === "full" ? { layout: value.layout } : {}),
...(typeof value.icon === "string" ? { icon: value.icon } : {}),
...(typeof value.createdBy === "string" ? { createdBy: value.createdBy } : {}),
};
+2
View File
@@ -12,6 +12,7 @@ export const WORKSPACE_GRID_COLUMNS = 12;
export type WorkspaceCreatedBy = string;
type WorkspaceWidgetKind = string;
type WorkspaceTabLayout = "grid" | "full";
type WorkspaceBindingSource = "rpc" | "file" | "static";
@@ -51,6 +52,7 @@ export type WorkspaceTab = {
title: string;
icon?: string;
hidden: boolean;
layout?: WorkspaceTabLayout;
createdBy?: WorkspaceCreatedBy;
widgets: WorkspaceWidget[];
};
@@ -90,6 +90,19 @@ describe("renderWorkspace", () => {
expect(container.querySelector('[data-test-id="workspace-empty-tab"]')).not.toBeNull();
});
it("renders the first widget full bleed without grid chrome", () => {
const host = {};
const state = getWorkspaceState(host);
state.loaded = true;
state.workspace = structuredClone(doc);
state.workspace.tabs[0]!.layout = "full";
state.activeSlug = "main";
const container = renderView(host);
expect(container.querySelector('[data-test-id="workspace-fullbleed"]')).not.toBeNull();
expect(container.querySelector('[data-test-id="workspace-grid"]')).toBeNull();
expect(container.textContent).toContain("hello");
});
it("surfaces an action error toast", () => {
const host = {};
const state = getWorkspaceState(host);
+62 -21
View File
@@ -13,6 +13,7 @@ import {
type CustomWidgetHostContext,
} from "../../components/workspace-custom-widget.ts";
import {
renderWorkspaceWidgetBody,
renderWidgetCell,
type WorkspaceCustomWidgetContext,
type WorkspaceWidgetCellCallbacks,
@@ -551,6 +552,36 @@ function buildCustomContext(
};
}
/** Shared ambient builtin state; full-bleed tabs must preserve every grid capability. */
function buildBuiltinContext(
props: WorkspaceProps,
state: WorkspaceUiState,
viewState: WorkspaceViewState,
workspace: WorkspaceDocument,
): BuiltinWidgetContext {
const decideCustomWidget = props.canApproveWidgets
? (name: string, decision: "approved" | "rejected") => {
void approveWidget(state, props.client, { name, decision });
}
: undefined;
return {
basePath: props.basePath ?? "",
embed: props.embed ?? DEFAULT_EMBED_CONTEXT,
preview: {
getViewport: (widgetId, fallback) => viewState.previewViewports.get(widgetId) ?? fallback,
setViewport: (widgetId, viewport) => {
viewState.previewViewports.set(widgetId, viewport);
props.onRequestUpdate?.();
},
},
customWidgetApprovals: buildCustomWidgetApprovalsSource(
workspace,
state.pendingApprovalNames,
decideCustomWidget,
),
};
}
function renderGrid(
props: WorkspaceProps,
state: WorkspaceUiState,
@@ -571,28 +602,11 @@ function renderGrid(
</div>
`;
}
if (tab.layout === "full") {
return renderFullBleed(props, state, viewState, workspace, tab);
}
const callbacks = makeCallbacks(props, state, viewState, tab);
const decideCustomWidget = props.canApproveWidgets
? (name: string, decision: "approved" | "rejected") => {
void approveWidget(state, props.client, { name, decision });
}
: undefined;
const builtinContext: BuiltinWidgetContext = {
basePath: props.basePath ?? "",
embed: props.embed ?? DEFAULT_EMBED_CONTEXT,
preview: {
getViewport: (widgetId, fallback) => viewState.previewViewports.get(widgetId) ?? fallback,
setViewport: (widgetId, viewport) => {
viewState.previewViewports.set(widgetId, viewport);
props.onRequestUpdate?.();
},
},
customWidgetApprovals: buildCustomWidgetApprovalsSource(
workspace,
state.pendingApprovalNames,
decideCustomWidget,
),
};
const builtinContext = buildBuiltinContext(props, state, viewState, workspace);
const rows = gridRowCount(tab.widgets);
const minHeight = rows * WORKSPACE_ROW_HEIGHT + Math.max(0, rows - 1) * WORKSPACE_GRID_GAP;
return html`
@@ -615,6 +629,33 @@ function renderGrid(
`;
}
/** App-like tabs render their first widget through the same approval and sandbox path. */
function renderFullBleed(
props: WorkspaceProps,
state: WorkspaceUiState,
viewState: WorkspaceViewState,
workspace: WorkspaceDocument,
tab: WorkspaceTab,
): TemplateResult {
const widget = tab.widgets[0]!;
const callbacks = makeCallbacks(props, state, viewState, tab);
const builtinContext = buildBuiltinContext(props, state, viewState, workspace);
const custom = buildCustomContext(props, state, viewState, workspace, widget);
return html`<div
class="workspace-fullbleed"
data-test-id="workspace-fullbleed"
data-widget-id=${widget.id}
>
${renderWorkspaceWidgetBody(
widget,
viewState.bindingResults.get(widget.id) ?? null,
builtinContext,
callbacks,
custom ?? undefined,
)}
</div>`;
}
/**
* Snapped drop-target ghost for the active move/resize drag (#4). Placed in the
* same grid slot the drop would land in so the target is obvious. An overlapping
+15
View File
@@ -129,6 +129,20 @@ button.workspace-tab:hover {
min-height: 0;
}
.workspace-fullbleed {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
}
.workspace-fullbleed > .workspace-widget__custom,
.workspace-fullbleed > .workspace-embed__frame {
width: 100%;
height: 100%;
min-height: 480px;
}
/* --- Widget cell --------------------------------------------------------- */
.workspace-widget {
@@ -742,6 +756,7 @@ button.workspace-tab:hover {
/* iframe-embed widget. */
.workspace-embed__frame {
display: block;
width: 100%;
height: 100%;
min-height: 120px;