feat(dashboard): Workspaces tab β live grid, built-in widgets, Overview-as-data [2/3]#101097
feat(dashboard): Workspaces tab β live grid, built-in widgets, Overview-as-data [2/3]#101097100yenadmin wants to merge 6 commits into
Conversation
New bundled plugin `extensions/dashboard`: a gateway-owned workspace document (atomic JSON store with undo, size caps, schema validation), 14 `dashboard.*` gateway methods with change broadcasts, an `openclaw dashboard` CLI, and 14 `dashboard_*` agent tools β all sharing one validated store so humans, the CLI, and agents mutate the dashboard through the same guarded path. Layout is data; no core changes. Part of the modular-dashboard series (backend layer).
β¦ (PR2) The Workspaces bundled tab view for the dashboard plugin: tab strip, grid with pointer drag/resize/collapse, the 9 builtin widgets + Overview-as-data default workspace, and the UX polish (themed dialogs, page header, onboarding, humane error cards, skeletons, (custom) suffix strip). Wires the plugin's Control UI descriptor (registerControlUiDescriptor) and the BUNDLED_TAB_VIEWS "dashboard/workspaces" entry, plus the #6 breadcrumb label (app-host -> app-topbar -> dashboard-header currentLabel) so "Plugin" reads "Workspaces". Custom-widget (custom:<name>) rendering is the sandboxed-host feature landing in the follow-up PR: this layer ships the TRUSTED widget-cell (builtin dispatch + neutral placeholder for custom kinds, no iframe) and the view without the manifest/custom-host plumbing, so it builds with no L5 files present. i18n: en.ts carries all dashboard.* keys; non-English bundles regenerated with English fallbacks (translation pass runs before the PR opens).
β¦n TM) Reuses existing dashboard.* translations from the integration branch's translation memory instead of an English-fallback sync, so the new Workspaces Control UI keys ship translated in all 20 locales without any LLM calls.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Dependency GuardThis PR changes dependency-related files. Maintainers should confirm these changes are intentional. Changed files:
Maintainer follow-up:
|
Dependency graph changes are blockedOpenClaw does not accept dependency graph changes through PRs unless a repository admin or security explicitly authorizes the current head SHA. Dependency updates are generated internally by maintainers so external PRs cannot change the resolved graph. Detected dependency graph changes:
Auto-scrub was not attempted because this PR changes package manifest dependency graph fields:
Dependency graph changes must be reviewed by security or handled by maintainers internally. Please remove lockfile changes manually if they are not needed. To remove lockfile changes, restore them from the target branch: git fetch origin
git checkout 'origin/main' -- 'pnpm-lock.yaml'
git commit -m 'chore: remove dependency lockfile change'
git pushIf this PR intentionally needs a dependency graph change, ask a repository admin or member of The action will approve the current head SHA ( |
|
Dependency-graph note: this PR inherits #101094's lockfile change (the new |
|
Codex review: needs real behavior proof before merge. Reviewed July 10, 2026, 3:25 AM ET / 07:25 UTC. Summary Reproducibility: yes. for the patch defects: current-head source deterministically sends payloads rejected by its own gateway handlers and omits the persisted hidden flag from the UI model. The proposal itself is a new feature rather than a current-main bug. Review metrics: 2 noteworthy metrics.
Stored data model Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Proof guidance:
Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review findings
Review detailsBest possible solution: Use plugin KV or the shared SQLite state database for workspace and undo history, align every UI request with the gateway contract, retain the persisted hidden flag, preserve the current Overview and make the plugin opt-in until maintainers explicitly approve a new default, then rebase and provide exact-head browser proof. Do we have a high-confidence way to reproduce the issue? Yes for the patch defects: current-head source deterministically sends payloads rejected by its own gateway handlers and omits the persisted hidden flag from the UI model. The proposal itself is a new feature rather than a current-main bug. Is this the best way to solve the issue? No. Reusing the bundled plugin-tab seam is appropriate, but the present implementation breaks central mutation and file-binding contracts, loses hidden state, violates the SQLite storage architecture, and assumes an unapproved default product direction. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against fc507c70c5e3. Label changesLabel changes:
Label justifications:
Evidence reviewedWhat I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (2 earlier review cycles)
|
# Conflicts: # ui/src/app/app-host.ts # ui/src/components/app-topbar.ts # ui/src/components/dashboard-header.ts # ui/src/i18n/.i18n/ar.meta.json # ui/src/i18n/.i18n/ar.tm.jsonl # ui/src/i18n/.i18n/de.meta.json # ui/src/i18n/.i18n/de.tm.jsonl # ui/src/i18n/.i18n/es.meta.json # ui/src/i18n/.i18n/es.tm.jsonl # ui/src/i18n/.i18n/fa.meta.json # ui/src/i18n/.i18n/fa.tm.jsonl # ui/src/i18n/.i18n/fr.meta.json # ui/src/i18n/.i18n/fr.tm.jsonl # ui/src/i18n/.i18n/hi.meta.json # ui/src/i18n/.i18n/hi.tm.jsonl # ui/src/i18n/.i18n/id.meta.json # ui/src/i18n/.i18n/id.tm.jsonl # ui/src/i18n/.i18n/it.meta.json # ui/src/i18n/.i18n/it.tm.jsonl # ui/src/i18n/.i18n/ja-JP.meta.json # ui/src/i18n/.i18n/ja-JP.tm.jsonl # ui/src/i18n/.i18n/ko.meta.json # ui/src/i18n/.i18n/ko.tm.jsonl # ui/src/i18n/.i18n/nl.meta.json # ui/src/i18n/.i18n/nl.tm.jsonl # ui/src/i18n/.i18n/pl.meta.json # ui/src/i18n/.i18n/pl.tm.jsonl # ui/src/i18n/.i18n/pt-BR.meta.json # ui/src/i18n/.i18n/pt-BR.tm.jsonl # ui/src/i18n/.i18n/ru.meta.json # ui/src/i18n/.i18n/ru.tm.jsonl # ui/src/i18n/.i18n/th.meta.json # ui/src/i18n/.i18n/th.tm.jsonl # ui/src/i18n/.i18n/tr.meta.json # ui/src/i18n/.i18n/tr.tm.jsonl # ui/src/i18n/.i18n/uk.meta.json # ui/src/i18n/.i18n/uk.tm.jsonl # ui/src/i18n/.i18n/vi.meta.json # ui/src/i18n/.i18n/vi.tm.jsonl # ui/src/i18n/.i18n/zh-CN.meta.json # ui/src/i18n/.i18n/zh-CN.tm.jsonl # ui/src/i18n/.i18n/zh-TW.meta.json # ui/src/i18n/.i18n/zh-TW.tm.jsonl # ui/src/pages/plugin/plugin-page.ts # ui/src/styles.css
# Conflicts: # ui/src/i18n/.i18n/ar.meta.json # ui/src/i18n/.i18n/de.meta.json # ui/src/i18n/.i18n/es.meta.json # ui/src/i18n/.i18n/fa.meta.json # ui/src/i18n/.i18n/fr.meta.json # ui/src/i18n/.i18n/hi.meta.json # ui/src/i18n/.i18n/id.meta.json # ui/src/i18n/.i18n/it.meta.json # ui/src/i18n/.i18n/ja-JP.meta.json # ui/src/i18n/.i18n/ko.meta.json # ui/src/i18n/.i18n/nl.meta.json # ui/src/i18n/.i18n/pl.meta.json # ui/src/i18n/.i18n/pt-BR.meta.json # ui/src/i18n/.i18n/ru.meta.json # ui/src/i18n/.i18n/th.meta.json # ui/src/i18n/.i18n/tr.meta.json # ui/src/i18n/.i18n/uk.meta.json # ui/src/i18n/.i18n/vi.meta.json # ui/src/i18n/.i18n/zh-CN.meta.json # ui/src/i18n/.i18n/zh-TW.meta.json
# Conflicts: # ui/src/i18n/.i18n/ar.meta.json # ui/src/i18n/.i18n/de.meta.json # ui/src/i18n/.i18n/es.meta.json # ui/src/i18n/.i18n/fa.meta.json # ui/src/i18n/.i18n/fr.meta.json # ui/src/i18n/.i18n/hi.meta.json # ui/src/i18n/.i18n/id.meta.json # ui/src/i18n/.i18n/it.meta.json # ui/src/i18n/.i18n/ja-JP.meta.json # ui/src/i18n/.i18n/ko.meta.json # ui/src/i18n/.i18n/nl.meta.json # ui/src/i18n/.i18n/pl.meta.json # ui/src/i18n/.i18n/pt-BR.meta.json # ui/src/i18n/.i18n/ru.meta.json # ui/src/i18n/.i18n/th.meta.json # ui/src/i18n/.i18n/tr.meta.json # ui/src/i18n/.i18n/uk.meta.json # ui/src/i18n/.i18n/vi.meta.json # ui/src/i18n/.i18n/zh-CN.meta.json # ui/src/i18n/.i18n/zh-TW.meta.json
β¦ sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
β¦ sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
β¦ sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
β¦ sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
β¦ sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
β¦ sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
β¦ sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
β¦ sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
β¦ sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
β¦ sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
β¦ sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
* feat(dashboard): modular dashboard β workspace store, Workspaces tab, sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]> * fix(dashboard): UI review fixes β grid, error boundary, embed sandbox, locale * fix(dashboard): make the CLI and agent broadcasts actually reachable Three defects only a live run surfaces, all invisible to the unit suites: - The plugin claimed the CLI command name `dashboard`, which core already owns (it opens the Control UI). A plugin CLI group that overlaps a core command is dropped at registration behind a `logger.debug`, so the entire CLI face was unreachable while `cli.test.ts` kept passing against its own Commander program. Renamed to `openclaw workspaces`, matching the tab it drives. - The manifest never declared `activation.onCommands`, so the CLI root resolved to no owning plugin even once the name was free. - `dashboard.widget.approve` needs `operator.approvals`; the CLI asked for `operator.write` on every call. It now requests the approvals scope only for the approve call, matching `operator-approvals-client.ts`. Also: agent tools resolved their broadcast from the plugin runtime's gateway-request scope, an AsyncLocalStorage set only around gateway RPCs and plugin HTTP routes. An agent turn started from a channel, cron, or heartbeat therefore wrote the document without emitting `plugin.dashboard.changed`, so an open Control UI never saw the edit β the feature's headline promise. The gateway broadcast is server-lifetime, so the plugin now remembers it in a single slot and agent tools fall back to it. * docs(web): document dashboard workspaces, provenance, and the custom-widget sandbox * fix(dashboard): agent-tool ergonomics + close two approval-boundary gaps From a source-blind agent driving the dashboard_* tools with nothing but their schemas, and from a Codex review of the hardening delta. - dashboard_widget_update could never succeed. It passed its whole parameter record to the patch reader, whose allowlist rejects the very `tab`/`id` keys the tool's own schema marks required, so every call died on "unexpected param: tab". Its test only ran Value.Check against the schema and never executed the tool. - dashboard_data_read surfaced an `rpc` binding as a thrown error, though its description promised `binding_client_resolved`. It now returns that as a result the model can act on. - Valid widget kinds and the rpc allowlist were undiscoverable: a model saw only "builtin:<name> or custom:<name>" and "Allowlisted gateway read method", then brute-forced ~40 calls against errors that named no alternatives. Both schemas and both validator errors now enumerate them, and the kind description says what each builtin renders and which binding id it reads. widget_move documents that grid and toTab are exclusive; widget_scaffold says an operator must approve, because no agent tool can. - workspace.replace could mint a pending registry entry for a name that was never scaffolded. An operator could then approve a widget whose code did not exist yet, and the agent could write it afterwards. Registry entries now come from dashboard_widget_scaffold and nowhere else, and approve refuses a name with no manifest on disk. - dashboard.widget.approve answered with the whole workspace document, so a connection holding only operator.approvals could read it through the approvals door. It now returns the registry entry it changed. * fix(dashboard): approval pins the code it approves Codex review found the scaffold-before-approval gate still nameable rather than binding: approve only proved that widget.json parsed, and the Control UI loaded a hardcoded index.html rather than the manifest's entrypoint. An agent could scaffold a widget, win approval on an innocuous or absent entrypoint, then write the real payload afterwards β code appearing after the human said yes. Approval now hashes every servable file in the widget directory and stores the digests on the registry entry, refusing a manifest whose declared entrypoint is missing. The asset route re-hashes each file it reads and 404s anything that does not match, so a file edited or added after approval never reaches a browser. The Control UI loads the manifest's entrypoint, which is the file that was hashed. The content-type allowlist moves to manifest.ts so the set of files approval hashes and the set the route can serve cannot drift apart. Proof, against a running gateway: scaffold -> 404, approve -> 200, rewrite index.html -> 404, add late.js -> 404. * fix(dashboard): parse the approved manifest from the bytes that were hashed Codex found a TOCTOU in the approval path: it loaded and validated widget.json, then walked the directory again to compute the digests. An agent could swap widget.json between the two reads, so the operator validated one entrypoint while the digest froze β and the Control UI later mounted β a different one. snapshotApprovedWidget now reads the widget directory once: it hashes every servable file, parses the manifest out of the same widget.json bytes it hashed, and requires the declared entrypoint to be among them. Proof, against a running gateway: approve -> index.html 200; rewrite widget.json to point at evil.html and drop evil.html in -> both 404. * fix(dashboard): cap approval asset reads; bound the grid fallback search Two findings from the fourth Codex pass. Approval hashes agent-authored files that are untrusted until it runs, and read each one into memory with no size check β dropping one huge .png into a scaffold directory would stall or OOM the gateway during approve. Sizes are now checked before the read, with a 2 MB per-file and 8 MB total cap. nearestFreeSlot searched one band below the lowest occupied row, so a crowded layout near the bottom could return y=500 as the closest free slot: a placement the store rejects, which the UI applies optimistically and then snaps back. The search now stops at the last row a widget of that height can legally occupy. * fix(dashboard): refuse oversized widget assets before reading them Approved widget files stay writable and the asset route is unauthenticated, so swapping an approved small file for a very large one made every GET buffer the whole file before the digest check rejected it. The route now refuses anything past the same per-file cap approval enforces, on the stat it already performs. * fix(dashboard): enforce widget approval boundaries * docs(changelog): note modular dashboard workspaces * fix(dashboard): enforce static custom-widget data boundary * fix(dashboard): satisfy UI lint * test(dashboard): avoid legacy proto access * feat(dashboard): make plugin opt-in * docs(dashboard): refresh workspaces map * refactor(workspaces): standardize plugin naming * fix(workspaces): make widget prompt sends idempotent * docs(workspaces): fix internal path references * test(workspaces): make prompt assertion lint-safe * test(workspaces): type prompt request mock * fix(workspaces): harden approval and binding boundaries * test(workspaces): complete stale binding client mock * fix(workspaces): harden widget file boundaries * fix(workspaces): scope custom widget capabilities * fix(workspaces): align approval provenance * fix(workspaces): close branch contract gaps * test(workspaces): complete builtin context fixtures * fix(workspaces): aggregate overview usage * chore(workspaces): defer release note * chore(workspaces): refresh i18n metadata --------- Co-authored-by: 100yenadmin <[email protected]>
|
Superseded by the integrated implementation in #104139, landed as The Workspaces tab, live grid, built-in widgets, and Overview-as-data scope are now on Closing this stacked UI PR so #104139 remains the canonical implementation. Thank you @100yenadmin for the original work. |
* feat(dashboard): modular dashboard β workspace store, Workspaces tab, sandboxed custom widgets Squashes openclaw#101094 + openclaw#101097 + openclaw#101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]> * fix(dashboard): UI review fixes β grid, error boundary, embed sandbox, locale * fix(dashboard): make the CLI and agent broadcasts actually reachable Three defects only a live run surfaces, all invisible to the unit suites: - The plugin claimed the CLI command name `dashboard`, which core already owns (it opens the Control UI). A plugin CLI group that overlaps a core command is dropped at registration behind a `logger.debug`, so the entire CLI face was unreachable while `cli.test.ts` kept passing against its own Commander program. Renamed to `openclaw workspaces`, matching the tab it drives. - The manifest never declared `activation.onCommands`, so the CLI root resolved to no owning plugin even once the name was free. - `dashboard.widget.approve` needs `operator.approvals`; the CLI asked for `operator.write` on every call. It now requests the approvals scope only for the approve call, matching `operator-approvals-client.ts`. Also: agent tools resolved their broadcast from the plugin runtime's gateway-request scope, an AsyncLocalStorage set only around gateway RPCs and plugin HTTP routes. An agent turn started from a channel, cron, or heartbeat therefore wrote the document without emitting `plugin.dashboard.changed`, so an open Control UI never saw the edit β the feature's headline promise. The gateway broadcast is server-lifetime, so the plugin now remembers it in a single slot and agent tools fall back to it. * docs(web): document dashboard workspaces, provenance, and the custom-widget sandbox * fix(dashboard): agent-tool ergonomics + close two approval-boundary gaps From a source-blind agent driving the dashboard_* tools with nothing but their schemas, and from a Codex review of the hardening delta. - dashboard_widget_update could never succeed. It passed its whole parameter record to the patch reader, whose allowlist rejects the very `tab`/`id` keys the tool's own schema marks required, so every call died on "unexpected param: tab". Its test only ran Value.Check against the schema and never executed the tool. - dashboard_data_read surfaced an `rpc` binding as a thrown error, though its description promised `binding_client_resolved`. It now returns that as a result the model can act on. - Valid widget kinds and the rpc allowlist were undiscoverable: a model saw only "builtin:<name> or custom:<name>" and "Allowlisted gateway read method", then brute-forced ~40 calls against errors that named no alternatives. Both schemas and both validator errors now enumerate them, and the kind description says what each builtin renders and which binding id it reads. widget_move documents that grid and toTab are exclusive; widget_scaffold says an operator must approve, because no agent tool can. - workspace.replace could mint a pending registry entry for a name that was never scaffolded. An operator could then approve a widget whose code did not exist yet, and the agent could write it afterwards. Registry entries now come from dashboard_widget_scaffold and nowhere else, and approve refuses a name with no manifest on disk. - dashboard.widget.approve answered with the whole workspace document, so a connection holding only operator.approvals could read it through the approvals door. It now returns the registry entry it changed. * fix(dashboard): approval pins the code it approves Codex review found the scaffold-before-approval gate still nameable rather than binding: approve only proved that widget.json parsed, and the Control UI loaded a hardcoded index.html rather than the manifest's entrypoint. An agent could scaffold a widget, win approval on an innocuous or absent entrypoint, then write the real payload afterwards β code appearing after the human said yes. Approval now hashes every servable file in the widget directory and stores the digests on the registry entry, refusing a manifest whose declared entrypoint is missing. The asset route re-hashes each file it reads and 404s anything that does not match, so a file edited or added after approval never reaches a browser. The Control UI loads the manifest's entrypoint, which is the file that was hashed. The content-type allowlist moves to manifest.ts so the set of files approval hashes and the set the route can serve cannot drift apart. Proof, against a running gateway: scaffold -> 404, approve -> 200, rewrite index.html -> 404, add late.js -> 404. * fix(dashboard): parse the approved manifest from the bytes that were hashed Codex found a TOCTOU in the approval path: it loaded and validated widget.json, then walked the directory again to compute the digests. An agent could swap widget.json between the two reads, so the operator validated one entrypoint while the digest froze β and the Control UI later mounted β a different one. snapshotApprovedWidget now reads the widget directory once: it hashes every servable file, parses the manifest out of the same widget.json bytes it hashed, and requires the declared entrypoint to be among them. Proof, against a running gateway: approve -> index.html 200; rewrite widget.json to point at evil.html and drop evil.html in -> both 404. * fix(dashboard): cap approval asset reads; bound the grid fallback search Two findings from the fourth Codex pass. Approval hashes agent-authored files that are untrusted until it runs, and read each one into memory with no size check β dropping one huge .png into a scaffold directory would stall or OOM the gateway during approve. Sizes are now checked before the read, with a 2 MB per-file and 8 MB total cap. nearestFreeSlot searched one band below the lowest occupied row, so a crowded layout near the bottom could return y=500 as the closest free slot: a placement the store rejects, which the UI applies optimistically and then snaps back. The search now stops at the last row a widget of that height can legally occupy. * fix(dashboard): refuse oversized widget assets before reading them Approved widget files stay writable and the asset route is unauthenticated, so swapping an approved small file for a very large one made every GET buffer the whole file before the digest check rejected it. The route now refuses anything past the same per-file cap approval enforces, on the stat it already performs. * fix(dashboard): enforce widget approval boundaries * docs(changelog): note modular dashboard workspaces * fix(dashboard): enforce static custom-widget data boundary * fix(dashboard): satisfy UI lint * test(dashboard): avoid legacy proto access * feat(dashboard): make plugin opt-in * docs(dashboard): refresh workspaces map * refactor(workspaces): standardize plugin naming * fix(workspaces): make widget prompt sends idempotent * docs(workspaces): fix internal path references * test(workspaces): make prompt assertion lint-safe * test(workspaces): type prompt request mock * fix(workspaces): harden approval and binding boundaries * test(workspaces): complete stale binding client mock * fix(workspaces): harden widget file boundaries * fix(workspaces): scope custom widget capabilities * fix(workspaces): align approval provenance * fix(workspaces): close branch contract gaps * test(workspaces): complete builtin context fixtures * fix(workspaces): aggregate overview usage * chore(workspaces): defer release note * chore(workspaces): refresh i18n metadata --------- Co-authored-by: 100yenadmin <[email protected]>
Control UI: Workspaces tab β live grid, built-in widgets, Overview-as-data (2/3)
βΆ Full-quality recording (light / dark).
What problem this solves
#101094 gives the document and the control plane; this PR gives operators somewhere to see it, touch it, and use it on day one. Small-business operators need drag/drop, hide/collapse, and a layout that's actually useful the moment they open it β no widget-building required. Maintainers need the same operational picture the current Overview page gives them (cost, sessions, cron, activity, instance health), just now expressed as workspace content they can rearrange and extend, and they need to see, live, when an agent has changed something β with the ability to immediately push back with their own drag/drop, on the exact same document.
Shipping the grid and the content together, in one PR, is the point: an empty grid isn't a product, and a set of widgets with nowhere to live isn't either. This is the PR where the default Control UI experience actually changes.
What's in this PR
The shell β tab strip, grid, interaction:
BUNDLED_TAB_VIEWSmechanism feat(logbook): automatic work journal plugin with Control UI timeline tabΒ #99930 just introduced for logbook. This is the second consumer of that seam, not a new one.workspace.json, honoring saved tab order, with hidden tabs in an overflow menu. Deep-linkable (?plugin=dashboard&id=workspaces&ws=<slug>) β back/forward and reload preserve the active tab.plugin.dashboard.changedand refetches (or falls back to a short poll if push events aren't available on a given connection path β the PR states explicitly which one shipped).The content β nine built-in widgets, and a real default workspace:
stat-cardβ big number + label + optional deltamarkdownβ static or file-bound markdown bodytableβ compact rows + count footeriframe-embedβ embedded URL, respecting the existing embed-sandbox and external-URL policy exactly as chat embeds do todaysessionsβ latest sessions with live-run indicatorusageβ cost/tokens mini-summarycronβ next runs + last status per jobinstancesβ connected instances + healthactivityβ recent activity feedmainworkspace ships as a seededworkspace.json(createdBy: "system") β usage + instance-health cards, sessions + cron side by side, activity feed below. It reads the same at 1280px and reflows to single-column below ~900px. This replaces the old hand-coded Overview page with the same information, expressed as workspace content instead of markup.Shell and content share one lifecycle here on purpose: the grid's error boundary is what makes the widget set safe to ship as "always on," and the widget set is what makes the grid worth landing as the new default rather than an opt-in extra.
See it
Overview-as-data β the default workspace, light and dark:
An agent composing a tab live, and human interaction on the composed grid:
How it works
The whole surface is one bundled Control-UI tab. There is no new UI mechanism here β this is the second consumer of the plugin-tab seam #99930 introduced for logbook.
From plugin descriptor to a native bundled view
extensions/dashboardregisters a single tab descriptor:That descriptor is projected into the per-connection hello
controlUiTabspayload (scope-filtered, so the tab is hidden for connections lackingoperator.read). The Control UI already knows how to render a bundled view for a plugin tab: this PR adds one entry to theBUNDLED_TAB_VIEWSmap inplugin-page.tskeyed"dashboard/workspaces", whose lazy import returns our render + stop functions. No descriptorpathis set, so the descriptor's generic iframe branch never triggers β the tab renders as first-party Lit, not a sandboxed page.The page itself follows workboard's three-way split, because the controller is stateful: a thin
LitElementpage (@consume(applicationContext)+ subscription lifecycle), a puredashboard-view.tsrender layer, and alib/dashboard/controller that owns data and grid logic.The 12-column grid and hand-rolled drag/drop
The grid is plain CSS grid, 12 columns, widgets absolutely placed by
grid-column/grid-rowspans from their doc coordinates. Drag/drop and resize are hand-rolled pointer-event math inlib/dashboard/grid.tsβ deliberately nogridstack/interact.js, per the repo's lean-deps rule, because snap-to-grid and a ghost preview are simple enough not to justify a dependency. A drag shows a ghost snapped to the grid; on drop the layout updates optimistically and persists viadashboard.widget.move/dashboard.widget.setLayout; an RPC failure reverts the local state. Overlapping drops are rejected with a nearest-free-slot fallback rather than silently stacking cells.Collapse, hide, remove, and move-to-tab all live in each cell's kebab menu and persist through the same #101094 RPC surface β this PR adds no new mutation path, only a renderer and an interaction layer over the existing store.
Live updates and the per-cell error boundary
The view subscribes to
plugin.dashboard.changedon the gateway client and refetches on a version bump (skipping its own echo) β the push path is what shipped (the Control UI's WS client surfaces event frames), with a short poll retained only as a fallback. This is what makes the agent's edits appear live in an already-open browser with no reload.Every widget render is wrapped in a per-cell error boundary: if a widget throws, only that cell shows an error card β the rest of the grid, and every other tab, is untouched. That boundary is precisely what makes it safe to ship the widget set as always-on rather than opt-in, and it's what PR3's sandboxed custom widgets mount inside.
Builtins bind to read RPCs through an allowlist
The 9 builtins (
stat-card,markdown,table,iframe-embed,sessions,usage,cron,instances,activity) are trusted first-party Lit templates β there is no sandboxing story here because nothing is agent- or user-authored HTML (that's PR3). Each data widget is a thin re-implementation against an existing read RPC, deliberately not reusing the current page view functions, which are welded to their own pages' state. The RPCs a builtin may call are frozen into an explicit allowlist, enforced at schema-validation time in #101094's store β a widget gets a named door, not an open one.iframe-embedis the only widget that touches an external URL, and it reuses the existing embed-sandbox / external-URL config the Control UI already enforces for chat embeds β no new policy surface.Overview-as-data
The default
mainworkspace ships as a seededworkspace.json(createdBy: "system"): usage + instance-health cards, sessions and cron side by side, activity below. It reflows to single-column under ~900px. This replaces the old hand-coded Overview page with the same information expressed as workspace content β so the redesign goals from #77774 / #86460 become arrangeable content instead of a second hand-coded page.The core footprint β evidence of a lean core
The entire diff to core
ui/is five existing files, all small additive edits:ui/src/pages/plugin/plugin-page.tsβ oneBUNDLED_TAB_VIEWSentry;ui/src/styles.cssβ one stylesheet import;ui/src/app/app-host.ts,ui/src/components/app-topbar.ts, andui/src/components/dashboard-header.ts(a generic improvement β every plugin tab, e.g. logbook, gets a proper breadcrumb label, not just this one).Everything else is new files under
ui/src/pages/plugin/dashboard-*,ui/src/lib/dashboard/, andui/src/components/dashboard-widget-cell.ts. No new UI framework, no new grid library, no new npm dependency β the Lit shell stays exactly as it is.Real behavior proof
Behavior or issue addressed: The Control UI is a fixed set of hand-coded views β an operator (or an agent acting for them) cannot arrange their own dashboard. This PR adds the bundled "Workspaces" Control-UI tab: a live 12-column grid with hand-rolled drag/drop and resize, 9 trusted built-in widgets, and the Overview-as-data default workspace, all rendered from
workspace.jsonand live-updating onplugin.dashboard.changedwith no reload.Evidence: 246 dashboard unit/browser tests pass (grid math including overlap-rejection with nearest-free-slot fallback, the workboard-style controller/lib split, widget-cell chrome + the per-cell error boundary), plus the mocked-gateway E2E scenarios and a clean
pnpm ui:build. The rendered result β Overview-as-data in light and dark, an agent composing a Financials tab live with thedashboard.*RPCs firing, and human drag/collapse/menu interaction on the composed grid β is in the screenshots above and the recording on the tracking issue.Verification
Size β why the diff looks big but isn't: GitHub shows ~14K changed lines, but the reviewable source is ~4,000 lines across 23 files. The remainder is 61 generated i18n locale-bundle files (regenerated by
pnpm ui:i18n:sync, never hand-edited β English source lives inui/src/i18n/locales/en.ts) plus tests. The hand-written UI-source increment is well under VISION.md's ~5K reviewable-line guideline.iframe-embedsandbox-mode Γ internal/external URL matrix tested against the existing embed policy.pnpm ui:i18n:checkandpnpm ui:buildclean; no newui/dependencies.lib/dashboard, the plugin page, and the widget cells), plus the mocked-gateway E2E scenarios andui:build. CI runs on this PR.Closes / part of
Closes #66138 ("make OpenClaw feel like WordPress" β this is the modular, plugin-driven surface that issue asked for). Supersedes #77774 / #86460 (the competing Overview redesign proposals) by making that content a workspace document instead of a second hand-coded page β the redesign goals those issues wanted become workspace content, arrangeable like everything else. Partially addresses #72545 (exec-approvals surface β activity/cron widgets surface this operational picture, though a dedicated approvals widget is future work) and #76089 (spawn-subagent button β the
sendPromptbridge capability lands in #101098, but the sessions/activity widgets here are the visibility half of that story). Part of #101093. Depends on #101094. Precedes #101098.