feat(plugin): add a home.main slot to Home

This commit is contained in:
Luke Parker
2026-11-06 04:22:11 +10:00
Unverified
parent 174200d95e
commit a8f8a49365
9 changed files with 265 additions and 31 deletions
@@ -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 (
<Show when={host.ready()}>
<HomePluginSlot server={shown.server} directory={shown.directory} />
</Show>
)
}
const dispose = render(
() => (
<LanguageProvider locale="en">
<DialogProvider>
<PluginHostProvider
definitions={() => input.definitions}
disabled={() => new Set<string>()}
apis={fakeApis(unused)}
whenMounted={mounted}
>
<Home />
</PluginHostProvider>
</DialogProvider>
</LanguageProvider>
),
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
@@ -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 }) => {
+20
View File
@@ -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 (
<div class="flex flex-col gap-2 py-2 empty:hidden md:py-1">
<PluginSlot
at="home.main"
input={{
get server() {
return props.server
},
get directory() {
return props.directory
},
}}
/>
</div>
)
}
@@ -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()
@@ -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()}
+20 -14
View File
@@ -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)}
/>
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[68px] z-20 flex md:top-[84px] lg:top-[108px]">
<Button
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</Button>
</div>
</Show>
</div>
<div class="pointer-events-none sticky top-[68px] z-40 h-0 -mr-3 md:top-[84px] lg:top-[108px]">
<div
@@ -250,6 +238,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
class="relative ml-auto h-[calc(100cqh-68px)] w-3 md:h-[calc(100cqh-84px)] lg:h-[calc(100cqh-108px)]"
/>
</div>
<HomePluginSlot server={props.server} directory={props.directory} />
<div
data-slot="home-sessions-list"
data-reveal={skeleton() || undefined}
@@ -293,6 +282,23 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
</Match>
<Match when={props.listView.status === "ready"}>
<div ref={props.onSetContent} class="flex flex-col pt-1 pr-3 pb-16 md:pt-3">
{/* Pinned beside the group header row, so content above the list never covers it. */}
<Show when={props.canCreateSession}>
<div class="pointer-events-none sticky top-[68px] z-20 h-0 md:top-[84px] lg:top-[108px]">
<div class="absolute right-0 top-0 flex">
<Button
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</Button>
</div>
</div>
</Show>
{/* Index keeps group subtrees mounted when the group arrays are
rebuilt, so store updates cannot recreate rows mid-gesture. */}
<Index each={props.groups}>
+20 -16
View File
@@ -252,28 +252,32 @@ SDK icon fields take an `IconName` from `@opencode/plugin/gui`: the names of the
### Registries
| Registry | Process | What an item is |
| ---------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [`Command`](../plugin/src/gui/registries.ts) | window | A palette command with an optional keybind and slash command |
| [`MenuItem`](../plugin/src/gui/registries.ts) | window | An item of a host menu: the side panel + menu, Add server, a server row |
| [`Panel`](../plugin/src/gui/registries.ts) | window | Tabs in the session's side region |
| [`SettingsPage`](../plugin/src/gui/registries.ts) | window | A settings page, a section on a host page, or rows in a host section |
| [`Server`](../plugin/src/gui/registries.ts) | window | A source of servers, such as SSH hosts |
| [`LinkHandler`](../plugin/src/gui/registries.ts) | window | Opens local links, such as file paths in messages, and says whether their target exists |
| [`TitlebarItem`](../plugin/src/gui/registries.ts) | window | A pill in the titlebar or the tabs footer |
| [`ComposerPill`](../plugin/src/gui/registries.ts) | window | A pill in the row above a session's composer, optionally opening a menu of rows or a popover of your own content |
| [`CodeBlock`](../plugin/src/gui/registries.ts) | window | Your UI in place of the highlighted code of complete fenced code blocks of the languages you list, such as a diagram or a table |
| [`CodeBlockAction`](../plugin/src/gui/registries.ts) | window | A button beside the Copy button of complete fenced code blocks of the languages you list, such as "Run in terminal" |
| [`Slot`](../plugin/src/gui/registries.ts) | window | Content for `window.bottom`, `session.header`, `session.location.menu`, `session.panel.end`; built-ins also fill `session.location.workspace`, `session.location.branch.menu` and `session.location.path.menu` through `BuiltinSlot` |
| [`Style`](../plugin/src/gui/registries.ts) | window | CSS imported with `?inline` |
| [`Theme`](../plugin/src/gui/registries.ts) | window | A color theme in the app's theme file format, listed in Settings › Appearance › Theme |
| [`MenubarItem`](../plugin/src/gui/main.ts) | main | An item of the native app menu |
| Registry | Process | What an item is |
| ---------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`Command`](../plugin/src/gui/registries.ts) | window | A palette command with an optional keybind and slash command |
| [`MenuItem`](../plugin/src/gui/registries.ts) | window | An item of a host menu: the side panel + menu, Add server, a server row |
| [`Panel`](../plugin/src/gui/registries.ts) | window | Tabs in the session's side region |
| [`SettingsPage`](../plugin/src/gui/registries.ts) | window | A settings page, a section on a host page, or rows in a host section |
| [`Server`](../plugin/src/gui/registries.ts) | window | A source of servers, such as SSH hosts |
| [`LinkHandler`](../plugin/src/gui/registries.ts) | window | Opens local links, such as file paths in messages, and says whether their target exists |
| [`TitlebarItem`](../plugin/src/gui/registries.ts) | window | A pill in the titlebar or the tabs footer |
| [`ComposerPill`](../plugin/src/gui/registries.ts) | window | A pill in the row above a session's composer, optionally opening a menu of rows or a popover of your own content |
| [`CodeBlock`](../plugin/src/gui/registries.ts) | window | Your UI in place of the highlighted code of complete fenced code blocks of the languages you list, such as a diagram or a table |
| [`CodeBlockAction`](../plugin/src/gui/registries.ts) | window | A button beside the Copy button of complete fenced code blocks of the languages you list, such as "Run in terminal" |
| [`Slot`](../plugin/src/gui/registries.ts) | window | Content for `window.bottom`, `session.header`, `session.location.menu`, `session.panel.end`, `home.main`; built-ins also fill `session.location.workspace`, `session.location.branch.menu` and `session.location.path.menu` through `BuiltinSlot` |
| [`Style`](../plugin/src/gui/registries.ts) | window | CSS imported with `?inline` |
| [`Theme`](../plugin/src/gui/registries.ts) | window | A color theme in the app's theme file format, listed in Settings › Appearance › Theme |
| [`MenubarItem`](../plugin/src/gui/main.ts) | main | An item of the native app menu |
`session.location.menu` contributes items after the project menu's location details. It receives the owning `screen`,
the live `session`, and `shown`; keep data subscriptions active only while `shown` is true. Loading headers without an
owning screen show the host's location details without this slot. Render its rows with `ctx.ui.MenuItem`,
`ctx.ui.MenuSub`, `ctx.ui.MenuGroup` and `ctx.ui.MenuSeparator`, so they navigate and close like the host's rows.
`home.main` renders in Home's main column, between the session search and the session list, before any session is
open. It receives no `screen` or `session`: only the `server` Home shows (a `ServerRef.id` for `ctx.servers.get`) and
the project `directory` it filters to, undefined for every project. It takes no room while nothing renders into it.
`session.location.branch.menu` (built-ins only, through `BuiltinSlot`) contributes items after Copy branch name in
the branch submenu, with the same `screen`, live `session`, and project-menu `shown` context. It renders only when a
branch name and owning screen are available. Use it for actions on the branch's working directory, such as opening
+15
View File
@@ -897,6 +897,21 @@ export interface SlotMap {
/** The routed session; a new object when another session is routed. */
readonly session: MountedSession
}
/**
* Home's main column, between the session search and the session list, before any session is open: onboarding, a
* dashboard card, help or a status summary. It renders while Home shows a server's sessions, whether they load, fail
* or are empty. It spans the column's width at every window size and scrolls away with the list. Contributions stack in `order` with a small gap, and the slot takes no room while none renders anything.
* The fields are reactive getters: read them where you use them.
*/
readonly "home.main": {
/**
* The `ServerRef.id` of the server Home shows; read its ref with `ctx.servers.get`. Changes without a remount when
* the user picks another server.
*/
readonly server: string
/** The root directory of the project Home filters its sessions to; undefined while Home lists every project. */
readonly directory: string | undefined
}
}
/** Content for one of the host's slots, typed by `at`. */
@@ -996,7 +996,7 @@ interface SettingEntry {
### Slots
`Slot` renders UI into one of four window slots.
`Slot` renders UI into one of the window's slots.
```tsx
import { Slot } from "@opencode/plugin/gui"
@@ -1013,6 +1013,7 @@ ctx.add(Slot, {
`false`.
- `session.location.menu` adds items to the project menu, after its location details.
- `session.panel.end` holds actions at the trailing end of the side region's tab strip.
- `home.main` is Home's main column, between the session search and the session list. It takes no room while empty.
- `order` sorts the items in one slot.
`session.location.menu` receives `shown`, which is `true` while the project menu is open. Subscribe to location data
@@ -1035,6 +1036,16 @@ ctx.add(Slot, {
})
```
`home.main` shows content before a session is open, such as onboarding or a dashboard card. It receives the `server`
Home shows, as a `ServerRef.id`, and the project `directory` it filters to, or `undefined` for every project:
```tsx
ctx.add(Slot, {
at: "home.main",
render: (input) => <TodayCard server={ctx.servers.get(input.server)} directory={input.directory} />,
})
```
#### Reference
```ts
@@ -1047,6 +1058,7 @@ interface SlotMap {
"session.header": { screen: SessionScreen; session: MountedSession; active: boolean }
"session.location.menu": { screen: SessionScreen; session: MountedSession; shown: boolean }
"session.panel.end": { screen: SessionScreen; session: MountedSession }
"home.main": { server: string; directory: string | undefined }
}
```