mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
0c25b52f7c
commit
7be5d78fd0
@@ -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
|
||||
```
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}]`)),
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user