Repository navigation
refactor(app): move GUI features into built-in extensions - #52369
Merged
Merged
Conversation
The summary popover, mobile drawer panel, project and server cards, background work list and workspace menu now live in packages/gui-extensions/src/summary. The summary reads the review extension's optional Changes service for its changes row, so no contract cycle exists. Project avatar helpers move to @opencode/ui/project-avatar. Parity fixes found by comparing against v2: extension tab styles move out of the shared tab CSS into the file and browser extensions; commands can name their Settings > Shortcuts section; renamed commands leave the persisted command catalog; the browser shortcut stays listed on the web; the review tab loads its count when the side region opens; extension catalogs for the current locale load with their entry.
Each extension owns its catalogs under src/<ext>/i18n with short keys; non-English locales load on demand. English text is byte-identical. The app and desktop native catalogs drop the moved keys and four unreferenced ones.
The extension host's startup gate now stays open once every entry settles, so enabling or reloading an extension no longer unmounts the app. Dialogs is a host service with push and active; dialog renders get the extension's context and error isolation, and close with the extension. The browser, ssh and wsl entries drop their setup workarounds.
An extension store's from key now falls back to the default storage, where host settings such as settings.v3 live. The summary extension's collapse preferences import from there once instead of resetting.
Dialogs opened through the host carry an id, so an extension going away closes its own dialog, and a replaced dialog releases its cleanup. The mobile drawer reports whether it is open, so the summary drawer stops loading changes while closed. Projects without version control no longer show a loading changes row forever. Directory comparison helpers move to @opencode/util/path; the app and the summary extension share them.
Applies the desktop lane of the test audit: channel-independent builder checks collapse into one table, the IPC transport test speaks the raw message format, and pass-through, source-grep and tombstone tests go. Removes the test-only budget, delay and now parameters and an unused export; tests use setSystemTime. The database test expects the extension tables. Desktop test lines 2,072 -> 1,606.
The dialog stack kept one close timer, so closing a second dialog by id cancelled the first one's disposal, and opening a dialog cancelled a pending disposal, leaving hidden dialogs in the stack. Each closing dialog now has its own timer; one Escape or backdrop click still closes one dialog.
A dialog is marked closing before its callbacks run, so a close that re-enters from onClose cannot start a second timer. Escape, backdrop clicks and Kobalte dismissals share one guarded path that only closes the top dialog, one per exit animation; closes by id stay immediate. Layers follow the live stack position, so a new dialog always renders above older ones, and the provider disposes remaining dialogs when it unmounts.
A preservation review found twelve contracts without a remaining proof: queued writes surviving a clear, packaged updater runtime files, build and serve minification, rendering before telemetry, omitUndefined, an independent manifest package list, undefined store values, file platform forwarding, bootstrap omitted fields, RPC error messages, the missing manifest link, and one case per excluded packaging pattern. Each restored keeper failed under a deliberate mutation of its owner.
The provider refuses mounts after it unmounts and detaches its stack before disposing, so cleanups cannot re-admit dialogs. A closing dialog schedules its disposal before running onClose and leaves the stack before its root disposes, so a throwing callback cannot strand it. The focus trap belongs to the top dialog that is not exiting.
Replacing the stack with show now calls onClose for dialogs that were not already closing, so callers such as the workspace delete confirmation release their state. Each dialog callback and root disposal is isolated, so one throw cannot strand the rest. The extension host uses Promise.try, which Safari added in 18.2; the web entry now polyfills it, and the browser polyfill test removes it before the app loads.
The host now names only generic concepts: - The dock and side regions replace the terminal and review names in code. Stored layout fields keep their names through the tab pane schema. - The side-region frame moves from session/files into runtime/extension (side-region, panel-trigger, tab-strip-scroll). - The file extension reports each tab's file (PanelTab.file); the host reads open files from the side tabs instead of parsing file:// keys. - Session-scoped extension stores are recorded and pruned with the session; file.close yields inside any extension command scope. - Commands can be featured in the empty palette (Command.featured) and placed in a Shortcuts section, so the host lists no extension command ids. Leftovers go: - 375 unreferenced app i18n keys leave every locale; feature strings that extensions read through the fallback move into their catalogs; generic server status keys replace the SSH ones the host showed for every extension server. - Dead code: the file picker filters, window.api, getting-started CSS, unused settings getters, review panel source state, legacy.json key maps, and a stale spec document. - uqr leaves app, ghostty-web becomes a dev dependency, and two unused desktop dependencies go. Desktop AGENTS.md describes the current preload and IPC layout.
One harness serves every lane: app.ts (server URLs, links, storage seeding, fixture builders, held routes), workspace.ts (a mocked workspace with sessions, files and diffs), mockServers for several servers each with its own SSE transport, a mock PTY with its WebSocket, and config hooks for projects, worktrees, permissions, MCP, plugins, skills, VCS diffs and session reads. The timeline, stress and prompt-motion fixtures move from performance into utils; benchmarks import them from there. The SSE transport no longer routes every server's commands to the last one installed.
Rules drawn from the test audit: one contract per test at the strongest boundary, keeper suites instead of a spec per bug, the shared e2e harness instead of per-spec setup, no test-only production seams, tests that can fail, tables instead of per-theme or per-channel repeats, and no e2e copies of component tests. gui-extensions gets its own AGENTS.md with the structure, host boundary, performance and localization rules.
The host cleanup dropped the host's normalization of stored file tab keys, so one file stored as an absolute and a relative path showed as two tabs. Panels can now declare normalize(id, session); the host rewrites stored ids and the selected tab to the canonical form and drops duplicates, alongside the legacy key rewrite. The file extension normalizes file tabs once the workspace root is known.
Tab id normalization now goes through a layout remap that rewrites the stored tabs, the selected tab and the preview in one batch, so a normalized preview stays a preview. The file extension resolves a stored tab URL once and encodes the result, so encoded names such as a%23b.txt stay one file and normalization is idempotent.
The e2e job now runs the session UI component tests against the cached Chromium and uploads their results. The suite had 39 failing runs: a story helper rebuilt its messages on every read, some copy was stale, and file tool selectors predated merged file tools. One real regression, an empty write showing no file row, is marked as an expected failure. Pruned duplicates of app e2e contracts, a Space-key check that could not fail, repeated width and theme rows, and screenshot writes: 148 to 129 runs.
Installed archives run their main entry only; the renderer loads built-ins until .ocdx ships renderer bundles, and packaged builds refuse the manager until installs have a trust model.
v2 kept review mode, selected file and expanded files in the layout's per-session views; the review extension's own session store started empty and the layout dropped the fields, so upgrading reset them. The session store now imports its entry once through a generic from.sessions field, which picks one session's slice of an app key, and the layout keeps the legacy fields read-only until every session has migrated.
Both hosts could lose an extension when reload, disable then enable, or a reinstall landed during its async setup: the desktop host joined the stale launch, and the renderer host let an older load set up or report failure over its replacement. Each activation now carries a token that deactivation invalidates; a stale load settles without setup or status, then a fresh one starts. Renderer Dialogs bind to the calling instance and refuse after it is disposed, each root disposes inside the cleanup error isolation, and pairing opens its dialog through Dialogs so disabling it closes the dialog and its polling. Component tests cover the renderer rows; the desktop fix was checked against a real async installed archive and left without a test that would need to mock electron.
A second reload could start the replacement while the previous desktop instance was still disposing, and that instance's surface release then removed the replacement's native surfaces. Activation now waits for the previous instance's disposal, quitting awaits in-flight disposals, and surfaces release by owning instance instead of extension id. The renderer root also disposes its remote bridge subscription on unmount.
Updating a WSL server stops it and starts it on a new endpoint under the same server id, which replaces its controller. Extension session refs and the terminal's app-lifetime workspace cache kept the first controller, so an open terminal talked to the stopped server; v2's route-scoped cache was rebuilt on remount. Server refs now resolve client, data and URL through a reactive lookup of the live controller, and the terminal's exit subscription follows it. A WSL keeper row updates the server under an open terminal and asserts the terminal is recreated on the new endpoint.
Every operation on an extension (activate, reload, enable, disable, install, remove, quit) now queues per id and starts only after the previous one settles. Stopping cancels the current generation at once, so queued or running activations bail out. The host tracks everything an instance contributes (remotes, services, menu items, window and event listeners, surfaces) and withdraws it synchronously when disposal starts; extension cleanups then run isolated under one deadline, so a hung cleanup cannot keep a disabled extension callable or block the queue. A setup that resolves after disposal is torn down before the next step, and calls into a removed remote are refused.
After a 401 the runtime disposes the server's controller and a new sign-in creates another, but ServerProvider kept the first, so an open session's timeline, composer and extension views used the disposed one. The provider now keys its children on the live controller and remounts once the replacement is healthy; a plain reconnect keeps the route mounted. The mock server can require a password, and a WSL and SSH keeper row changes the password under an open session and asserts the next prompt reaches the server with the new one.
The desktop host now withdraws a disposed instance's remotes before its cleanups run, so the browser's suspended event never reached other windows and their panes kept dead bindings after a reload, disable or enable. The renderer connection now treats the remote going away as a suspension: it closes the binding, clears surfaces, keeps the tab inventory, and registers again with it as restore state once the remote returns. Idle eviction keeps its event and its no-retry semantics.
The dialog stack mounts deferred, so an extension that pushed a dialog and was reloaded in the same tick could still mount it after disposal, with no owner left to close it. show and push take an optional AbortSignal that the deferred open checks before mounting; an aborted open mounts nothing and notifies onClose like a replaced dialog. The extension host passes each instance's signal.
Pane disposal awaited the runtime but dropped page-disposal promises, so with an active trace or CPU profile a reload could activate a replacement while old pages and their recording were still alive. Page disposals now go through one helper that keeps each promise until it settles, and disposal waits for them before shutting down the runtime.
A subscribe reply that arrived after an availability or state event overwrote the newer data, so a stale unavailable could leave a remote down and SSH or WSL startup waiting. Remotes now count events per field and a reply only fills fields no event changed while it was in flight. The renderer host also refuses to activate a disabled extension before marking it loading, so disable, reload, enable starts it again.
The disable, reload, enable row in extension-host.spec.ts drives these fixture controls.
While an SSH server reconnects its endpoint leaves main's inventory, so registering throws before any closed-state event; the rejection was ignored and the registration stayed set, so wakes could not register and commands reused the rejected promise. Any rejection other than unavailable now closes the binding, clears surfaces, keeps the tab inventory as restore state and retries on the existing backoff; a command sent through the failed registration rejects once and is not replayed. Idle eviction keeps no retry timer.
The desktop app keyed its lifetime on the live default server, so reloading the extension that provides it removed its server, flipped the default to the sidecar and back, and remounted the whole interface twice. The startup default and its fallback are now resolved once when the window is ready, and a default server that disappears later is unavailable like any other. The renderer root's installed list also drops an initial manager.list reply that a newer extensions push overtook, so a disable from another window is not undone.
Teardown cleared startup tokens but only awaited sidecars that had finished starting, so a reload could start a replacement beside the old process and disable or quit could leave a WSL server running. Each start now carries an abort handle: teardown cancels CLI discovery and health polling, kills a spawned server, and resolves only after it exits. A cancelled start shows the server as stopped.
When a server extension failed to start, briefly dropped its remote, reloaded, or (for WSL) listed a distro that was not running, its server list emptied and the tab layer permanently removed those servers' session and draft tabs, composer memory and pane state. The renderer now keeps each extension's last complete server list (ids, names and labels only) and shows its servers as stopped while the extension is down; tabs close only for servers neither listed nor remembered, and removing a server still closes its tabs. A WSL keeper row takes the extension away and back with a session and a draft tab open, then removes the server.
The summary read the raw project with only its name and icon overridden, so a session in a worktree subfolder showed the subfolder's name and the Worktree submenu waited for worktree.list. SessionView.project now returns the same global-sync project v2's summary used, with discovered worktrees and local overrides; review and file read the same worktree and vcs as before. A summary keeper row covers a worktree subfolder session with worktree.list held.
The drawer closed only when the selected mobile view changed, so tapping the changes row with Changes already selected left Session details open. As v2's details(close) did, the host now gives drawer content a close callback through useDrawer, and the summary's changes row closes the drawer before opening Changes. The mobile keeper row now opens the drawer with Changes selected and taps the row.
Context opened through the generic tab path, so it replaced the preview tab or the Open file launcher and was stored last; closing it then selected the last tab. A tab marked first is now stored first and keeps the preview, as v2 did for Context, so closing it selects the first remaining tab. Restoring the selected file tab at mount also switched the file tree from Changes to All files, which v2 deferred; Panel.focus now says whether the selection is the restored one, and the file extension only switches the tree for later selections. Review keeper rows cover the preview and Open file tab surviving Context, close focus, and the tree tab across a reload.
These locales map a positional array onto DESKTOP_NATIVE_KEYS. Moving the updater menu item, 8 updater dialog strings and 19 WSL errors into their extensions removed 28 keys but left their entries, so 61 of 68 native labels shifted (Croatian Settings showed Install CLI). The stale entries are removed; every native label in all 62 locales now resolves to its v2 string, and the moved strings carry v2's translations.
The side region remembered which panel's toggle opened it even after the user closed and reopened it another way, so toggling Context closed a region the user had opened; as v2's opening source did, the opener is now forgotten whenever the region closes. The narrow-screen view was one app-wide slot that survived session changes and Home; it now resets to the conversation when the routed session changes or unmounts, as v2 did. Review and mobile keeper rows cover both, with in-app navigation instead of a reload.
- Narrow screens: opening a tab its panel does not list (Review, from the summary's changes row) stored it in the side strip, replacing a preview tab and moving the desktop selection; transient cleanup then removed it. As v2's separate mobile view did, such an open now only selects the panel's mobile view and leaves stored tabs alone. File links still store their tab and open Files. - Browser: removal only considered the previous in-memory inventory, so after a session's tab was closed and reopened, stored browser keys whose pages were gone stayed forever and kept the side region open when Context closed. As in v2, every native inventory now closes the stored browser keys it lacks, while suspended, restoring and unavailable states keep the tabs they will restore. Layout.stored(session) reads an extension's stored tab ids, and the state report and strip mirror run in one batch again. - File browser: the heading used the session's parent project, so a session in a subfolder showed the repository's name. SessionView.listedProject is the sidebar project opened at the session's exact directory, as v2 looked it up; the heading uses it, falling back to the directory's name. Keeper rows: a narrow-screen changes row keeps a preview tab and the desktop selection; a stale stored key is closed by the first inventory but not by suspended states; a subfolder session shows its folder name, or a listed project's local name.
v2's layout load stripped the stored btw tab key. The branch kept it as a hidden tab, so after upgrading with a /btw tab open, closing Context left the side region open. The btw panel now maps the legacy key to its panel key, which the transient prune drops like any unlisted btw tab. The Context opener row seeds the legacy key.
3 tasks done
This was referenced Oct 7, 2026
Ichinose-Kazuki
pushed a commit
to Ichinose-Kazuki/opencode
that referenced
this pull request
Oct 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extension-first GUI
Every feature of the desktop and web app outside the core session loop now ships as a built-in GUI extension behind one small SDK.
packages/appandpackages/desktopkeep only generic host concepts: regions, tabs, commands, storage, links, dialogs. Users get the same app: same pixels, same DOM ids, same stored data.flowchart LR subgraph gx["@opencode/gui-extensions"] SDK["sdk/: core · points · services · main · bridge"] R["renderer.ts (12 built-ins)"] M["main.ts (5 main entries)"] E1["usage · btw · debug · terminal · file · review · summary · browser"] E2["pairing · updater · ssh · wsl"] end subgraph app["packages/app: renderer host"] H["runtime/extension/: host · services · side and dock regions · commands · dialogs · links"] end subgraph desktop["packages/desktop: main host"] MH["main/extension/: host · surfaces · SQLite enable state"] end E1 & E2 --> SDK R --> H M --> MH H <-- "IPC bridge (Remote tokens)" --> MHThe SDK in one screen
Four token kinds, one context shape in both processes:
Point<T>ctx.add(Point, value)Host<T>ctx.use(Host)→TService<T>ctx.use(Service)→Accessor<T | undefined>Remote<Spec>ctx.use(Remote)→Accessor<client | undefined>index.ts(manifest),contract.ts(the only file others may import),renderer.tsx, an optionalmain.ts, andi18n/<locale>.ts(short keys, locales load lazily).PanelTab.file,PanelTab.first,Panel.normalize,Panel.focus'srestoredflag,Command.featured,Command.section,App.servers(),App.keys(),SurfaceProps.frozen,useDrawer(),Layout.stored(),SessionView.listedProject, the storagefrom.sessionsimport, theDialogshost service, andComposerNote, a generic composer attachment the browser's element comments use.@opencode/app,@opencode/desktopor@/imports, cross-extension imports only throughcontract.ts, no side-effect CSS, no module state.lazy(); each extension preloads its chunks withonIdlefrom setup, so they compile while the app idles on Home.Host regions
flowchart TB S["session screen"] --> SR["side region: tab strip · inner sidebar · + menu"] S --> DR["dock region: side or bottom"] S --> HS["session.header slot"] SR -- "Panel { region: side }" --> P1["review · usage · file · browser · btw"] DR -- "Panel { region: dock }" --> P2["terminal"] HS -- "Slot" --> P3["usage ring · summary popover"]context,open-file,review,file://…) once, throughPanel.normalizeand the layout's atomicremap.app.checkForUpdates → updater.check,server.pair → pairing.open,server.ssh.add → ssh.add,debugBar.toggle → debug.toggle,session.btw → btw.ask,session.summary.toggle → summary.toggle.Development builds: Settings → Extensions
Enable, disable and hot-reload each built-in live. The state persists in desktop SQLite. Production builds don't show the page.
Before / after
48 screens (desktop, mobile, and the de, ja and ar locales) were compared with the latest v2 at zero pixel tolerance. 46 are identical; the review diff renders sooner (identical once settled); the one deliberate change is below. All 315 Storybook stories keep their ids, and every story that renders on v2 renders here.
Performance
Production builds of v2 (
7878744505) and this PR, each served on its own port and measured by the same benchmark specs and harness. Medians.Streaming (160 deltas into a 320-turn history under 30× CPU throttling) varies ±15% between runs on the benchmark machine. Stream completion, 5 runs per side per pass:
Tests
The GUI test surface was audited so that each contract has one owner at the strongest boundary. E2E specs merged into per-area keeper suites on a shared harness (
e2e/utils), and test-only production seams were deleted. Every keeper that absorbed coverage was checked with a deliberate mutation of its owner, and independent reviews restored each contract that had lost its only proof. The mock server now fails any API request it doesn't define, instead of passing it to the dev server. The session UI component tests now run in CI.e2e/utils, includes fixtures moved out ofe2e/performance)Differences from v2
onClose, and each closing dialog is disposed on its own timer; Escape and backdrop clicks only close the top dialog.Promise.tryis polyfilled for Safari before 18.2.Follow-ups
ui.tool.todoshas no English copy (pre-existing).bench:devex) andfirst-navigationfail on v2 as well in this environment.native,idle,targets) was skipped in CI and stays out; the element picker's DevTools protocol side has no automated test.packages/app/component-tests) do not run in CI.composer/model.ts); make it a generic attach hint..ocdxextensions): the main side installs them and runs their main bundle, but the renderer does not load their UI yet. Packaged builds refuse the extension manager until installs have a trust model.test-browser.