From a8f8a493650de01cda1d99cdb784ce5d7df1e699 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:22:11 +1000 Subject: [PATCH] feat(plugin): add a home.main slot to Home --- .../component-tests/plugin-host.fixture.tsx | 58 +++++++++ .../app/component-tests/plugin-host.spec.ts | 117 ++++++++++++++++++ packages/app/src/home/plugin-slot.tsx | 20 +++ packages/app/src/home/sessions/controller.tsx | 1 + packages/app/src/home/sessions/region.tsx | 1 + packages/app/src/home/sessions/view.tsx | 34 ++--- packages/gui-plugins/README.md | 36 +++--- packages/plugin/src/gui/registries.ts | 15 +++ .../docs/content/build/plugins/gui/index.mdx | 14 ++- 9 files changed, 265 insertions(+), 31 deletions(-) create mode 100644 packages/app/src/home/plugin-slot.tsx diff --git a/packages/app/component-tests/plugin-host.fixture.tsx b/packages/app/component-tests/plugin-host.fixture.tsx index e54ccb77587..e4059938491 100644 --- a/packages/app/component-tests/plugin-host.fixture.tsx +++ b/packages/app/component-tests/plugin-host.fixture.tsx @@ -53,6 +53,7 @@ import { LanguageProvider } from "../src/runtime/i18n/language" import { ExtensionPlugins } from "../src/runtime/plugin/extension-plugins" import { SessionPluginPills } from "../src/session/composer/plugin-pills" import { ui } from "../src/runtime/plugin/ui" +import { HomePluginSlot } from "../src/home/plugin-slot" export { bindPlugin, @@ -702,6 +703,63 @@ export function mountSessionRegion(input: { } } +/** + * Mounts the real host and Home's plugin slot, 600px wide, for the server and project Home shows. `show(server, + * directory)` changes them as the user's picks on Home do. `height()` is the slot's height. + */ +export function mountHomeSlot(input: { definitions: readonly Entry[] }) { + const container = document.createElement("div") + container.style.width = "600px" + document.body.appendChild(container) + const hosts: PluginHost[] = [] + + const [shown, setShown] = createStore<{ server: string; directory: string | undefined }>({ + server: "local", + directory: undefined, + }) + + function Home() { + const host = usePluginHost() + hosts.push(host) + + return ( + + + + ) + } + + const dispose = render( + () => ( + + + input.definitions} + disabled={() => new Set()} + apis={fakeApis(unused)} + whenMounted={mounted} + > + + + + + ), + container, + ) + + return { + container, + show: (server: string, directory: string | undefined) => setShown({ server, directory }), + height: () => container.firstElementChild?.getBoundingClientRect().height, + status: (id: string) => hosts[0]?.status(id), + failure: (id: string) => hosts[0]?.failure(id), + unmount: () => { + dispose() + container.remove() + }, + } +} + /** * Mounts the real host's theme contributions into the app's theme provider, as the window does. `options` reads the * theme picker's choices as Settings › Appearance lists them; `disable` and `enable` toggle a plugin as diff --git a/packages/app/component-tests/plugin-host.spec.ts b/packages/app/component-tests/plugin-host.spec.ts index 3a5fb6b5b4f..1c34cef20ea 100644 --- a/packages/app/component-tests/plugin-host.spec.ts +++ b/packages/app/component-tests/plugin-host.spec.ts @@ -9,6 +9,7 @@ import type { Live, SessionRef, SessionScreen, + SlotMap, } from "@opencode/plugin/gui" import type { BuiltinPanelTab } from "@opencode/plugin/gui/builtin" import type { Bridge, BridgeMessage, EnableState, Installed } from "@opencode/plugin/gui/host" @@ -985,6 +986,122 @@ story("a contribution that throws renders nothing and records the error; the oth }) }) +story( + "Home's slot renders in order, follows the shown server and project without a remount, and isolates a throw", + async ({ page }) => { + const result = await page.evaluate(async (fixture) => { + const { mountHomeSlot, until, Slot } = await import(fixture) + const mounts = { count: 0 } + + const host = mountHomeSlot({ + definitions: [ + { + id: "fixture", + windowEntry: async () => ({ + default: (ctx: Context) => { + ctx.add(Slot, { + at: "home.main", + render: () => { + throw new Error("broken home card") + }, + }) + ctx.add(Slot, { + at: "home.main", + render: (input: SlotMap["home.main"]) => { + mounts.count++ + + // A render runs once; its own reactivity follows the input's getters. + return () => `[${input.server} ${input.directory ?? "all"}]` + }, + }) + }, + }), + }, + { + id: "other", + windowEntry: async () => ({ + default: (ctx: Context) => + void ctx.add(Slot, { + at: "home.main", + order: -1, + render: () => "[first]", + }), + }), + }, + ], + }) + + await until( + () => !!host.failure("fixture") && mounts.count > 0 && !!host.container.textContent?.includes("first"), + ) + const seen = () => host.container.textContent + const before = seen() + host.show("http://remote:4096", "/repo") + const after = seen() + const failure = host.failure("fixture") + + const outcome = { + before, + after, + mounts: mounts.count, + status: [host.status("fixture"), host.status("other")], + failure: { phase: failure?.phase, named: !!failure?.error.includes("broken home card") }, + } + + host.unmount() + + return outcome + }, fixture) + + expect(result).toEqual({ + before: "[first][local all]", + after: "[first][http://remote:4096 /repo]", + mounts: 1, + status: ["active", "active"], + failure: { phase: "render", named: true }, + }) + }, +) + +story("Home's slot takes no room until a contribution renders something", async ({ page }) => { + const result = await page.evaluate(async (fixture) => { + const { mountHomeSlot, until, Slot, createSignal } = await import(fixture) + const [loaded, setLoaded] = createSignal(false) + const rendered = { count: 0 } + + const host = mountHomeSlot({ + definitions: [ + { + id: "fixture", + windowEntry: async () => ({ + default: (ctx: Context) => + void ctx.add(Slot, { + at: "home.main", + // Nothing until its data loads, as a card that fetches does. + render: () => { + rendered.count++ + + return () => (loaded() ? Object.assign(document.createElement("p"), { textContent: "card" }) : null) + }, + }), + }), + }, + ], + }) + + await until(() => rendered.count > 0) + const empty = host.height() + setLoaded(true) + await until(() => !!host.container.textContent?.includes("card")) + const filled = host.height() + host.unmount() + + return { empty, filled: (filled ?? 0) > 0 } + }, fixture) + + expect(result).toEqual({ empty: 0, filled: true }) +}) + story( "plugin GUIs join, hot-reload in place, run their returned cleanup and leave, without holding startup", async ({ page }) => { diff --git a/packages/app/src/home/plugin-slot.tsx b/packages/app/src/home/plugin-slot.tsx new file mode 100644 index 00000000000..4f60f172ef7 --- /dev/null +++ b/packages/app/src/home/plugin-slot.tsx @@ -0,0 +1,20 @@ +import { PluginSlot } from "@/runtime/plugin/render" + +/** Plugin content in Home's main column; it takes no room while no contribution renders anything. */ +export function HomePluginSlot(props: { server: string; directory: string | undefined }) { + return ( +
+ +
+ ) +} diff --git a/packages/app/src/home/sessions/controller.tsx b/packages/app/src/home/sessions/controller.tsx index 2babe18602d..9f29d844478 100644 --- a/packages/app/src/home/sessions/controller.tsx +++ b/packages/app/src/home/sessions/controller.tsx @@ -501,6 +501,7 @@ export function createHomeSessionsController(home: HomeController) { session: { showProjectName: () => !home.project.selected(), server: () => home.selection.value().server, + directory: () => home.selection.value().directory, canCreate: () => !!home.project.newSession(), lookup: async (sessionID: string, signal?: AbortSignal) => { const ctx = home.server.focusedContext() diff --git a/packages/app/src/home/sessions/region.tsx b/packages/app/src/home/sessions/region.tsx index be3b7b7cb4e..277b0753e71 100644 --- a/packages/app/src/home/sessions/region.tsx +++ b/packages/app/src/home/sessions/region.tsx @@ -22,6 +22,7 @@ export function HomeSessions(props: { onRevealLocations={props.sessions.location.sync} showProjectName={props.sessions.session.showProjectName()} server={server()} + directory={props.sessions.session.directory()} canCreateSession={props.sessions.session.canCreate()} searchValue={props.search.query.value()} searchPlaceholder={props.search.query.placeholder()} diff --git a/packages/app/src/home/sessions/view.tsx b/packages/app/src/home/sessions/view.tsx index ad3f4fc4a31..6df9a3c2eca 100644 --- a/packages/app/src/home/sessions/view.tsx +++ b/packages/app/src/home/sessions/view.tsx @@ -18,6 +18,7 @@ import { showToast } from "@/shell/notifications/toast" import { sessionLabel } from "@/session/title" import { shouldOpenSessionInBackground } from "./open" import { createAltHold } from "./alt-hold" +import { HomePluginSlot } from "../plugin-slot" import "./view.css" import { HomeSessionStatusController, @@ -76,6 +77,7 @@ export type HomeSessionsViewProps = { onRevealLocations: (record?: HomeSessionRecord) => void showProjectName: boolean server: ServerConnection.Key + directory: string | undefined canCreateSession: boolean searchValue: string searchPlaceholder: string @@ -228,20 +230,6 @@ export function HomeSessionsView(props: HomeSessionsViewProps) { hoveredSession={hover.sessionID} onHoverSession={(sessionID) => setHover("sessionID", sessionID)} /> - 0 && props.canCreateSession}> -
- -
-