feat(plugin): add a slot below the new session composer

This commit is contained in:
Luke Parker
2026-11-06 04:22:12 +10:00
Unverified
parent 174200d95e
commit c80792ee7e
9 changed files with 477 additions and 19 deletions
@@ -8,7 +8,10 @@ import {
type App,
type Keybinds,
type MountedSession,
type Project,
type Router,
type ServerRef,
type SlotMap,
type Servers,
type SessionScreen,
type Ipc,
@@ -53,6 +56,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 { NewSessionComposerSlot } from "../src/new-session/composer-after"
export {
bindPlugin,
@@ -826,6 +830,110 @@ export function mountComposerPills(input: { definitions: readonly Entry[] }) {
}
}
/**
* Mounts the real host and the `new-session.composer.after` slot under a composer, in a 480px new session panel laid
* out as the screen lays it out. The draft starts in `/repo` of the `repo` project on the `local` server; `target`
* moves it as the location picker does, and its composer records each call. `define` replaces the plugins, as plugin
* GUIs join and leave.
*/
export function mountNewSessionComposer(input: { definitions: readonly Entry[] }) {
const container = document.createElement("div")
container.dataset.testid = "new-session"
document.body.appendChild(container)
const hosts: PluginHost[] = []
const captured: Captured = {}
const calls: string[] = []
const [definitions, setDefinitions] = createSignal(input.definitions)
const [target, setTarget] = createStore<{ directory: string | undefined; project: string | undefined }>({
directory: "/repo",
project: "repo",
})
// SAFETY: the slot passes the server through; the test's plugins read only `id`.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
const server = { id: "local" } as unknown as ServerRef
const slot: SlotMap["new-session.composer.after"] = {
server,
get project() {
// SAFETY: the test's plugins read only the project's `name`.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
return target.project ? ({ name: target.project, worktree: "/repo" } as unknown as Project) : undefined
},
get directory() {
return target.directory
},
composer: {
text: () => "",
insert: (text) => void calls.push(`insert ${text}`),
setText: (text) => void calls.push(`setText ${text}`),
focus: () => void calls.push("focus"),
},
}
function Panel() {
const host = usePluginHost()
hosts.push(host)
return (
<Show when={host.ready()}>
<div data-component="new-session" style={{ position: "relative", height: "480px", overflow: "hidden" }}>
<div style={{ position: "absolute", top: "120px", left: "0", right: "0" }}>
<div class="flex flex-col gap-3">
<div data-testid="composer" style={{ height: "120px" }} />
<NewSessionComposerSlot input={slot} />
</div>
</div>
</div>
</Show>
)
}
function Host() {
const apis = createHostApis()
captured.apis = apis
return (
<PluginHostProvider
definitions={definitions}
disabled={() => new Set<string>()}
apis={apis.apis}
whenMounted={apis.whenMounted}
>
<Panel />
</PluginHostProvider>
)
}
const dispose = render(
() => (
<LanguageProvider locale="en">
<PlatformProvider value={web}>
<DialogProvider>
<Host />
</DialogProvider>
</PlatformProvider>
</LanguageProvider>
),
container,
)
return {
/** Moves the draft, as picking a project or worktree does. */
target: (next: { directory: string | undefined; project: string | undefined }) => setTarget(next),
define: (next: readonly Entry[]) => setDefinitions(next),
/** What plugins called on the draft's composer, in order. */
calls,
status: (id: string) => hosts[0]?.status(id),
failure: (id: string) => hosts[0]?.failure(id),
unmount: () => {
dispose()
container.remove()
},
}
}
/**
* Mounts the real host and one message's production Markdown inside the session screen's `PluginLinks`, for a session
* whose directory is `/repo`. `show(text, streaming)` replaces the message as a stream does; `define` replaces the
@@ -9,11 +9,12 @@ 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"
import type { JSHandle } from "@playwright/test"
import type { mountCodeBlocks, mountThemes } from "./plugin-host.fixture"
import type { mountCodeBlocks, mountNewSessionComposer, mountThemes } from "./plugin-host.fixture"
import { expect, sourceURL, story } from "../../stories/playwright/story"
const fixture = sourceURL(new URL("./plugin-host.fixture.tsx", import.meta.url))
@@ -21,6 +22,12 @@ const fixture = sourceURL(new URL("./plugin-host.fixture.tsx", import.meta.url))
/** What the code block cases drive from the page. */
type CodeBlocksHost = ReturnType<typeof mountCodeBlocks>
/** What the new session composer cases drive from the page. */
type NewSessionHost = ReturnType<typeof mountNewSessionComposer>
/** What a `new-session.composer.after` render receives. */
type NewSessionInput = SlotMap["new-session.composer.after"]
/** What the theme preview case drives from the page. */
type ThemesHost = ReturnType<typeof mountThemes>
@@ -2231,3 +2238,133 @@ story("code block content follows plugins that join and leave after the message
await expect(root.getByTestId("chart")).toHaveCount(0)
await expect(root.locator("pre code")).toHaveText("A,B")
})
story(
"plugins fill the space below the new session composer in order, follow the draft's target, and fail alone",
async ({ page }) => {
const host = await page.evaluateHandle(async (fixture) => {
const { mountNewSessionComposer, until, Slot } = await import(fixture)
const host: NewSessionHost = mountNewSessionComposer({
definitions: [
{
id: "recent",
windowEntry: async () => ({
default: (ctx: Context) =>
void ctx.add(Slot, {
at: "new-session.composer.after",
order: 1,
render: (input: NewSessionInput) => {
document.body.dataset.renders = `${Number(document.body.dataset.renders ?? 0) + 1}`
return () => `${input.server.id} ${input.project?.name ?? "none"} ${input.directory ?? "new"}`
},
}),
}),
},
{
id: "starter",
windowEntry: async () => ({
default: (ctx: Context) =>
void ctx.add(Slot, {
at: "new-session.composer.after",
render: (input: NewSessionInput) =>
Object.assign(document.createElement("button"), {
textContent: "Plan",
onclick: () => {
input.composer.insert("Plan the release")
input.composer.focus()
},
}),
}),
}),
},
{
id: "broken",
windowEntry: async () => ({
default: (ctx: Context) =>
void ctx.add(Slot, {
at: "new-session.composer.after",
render: () => {
throw new Error("slot failed")
},
}),
}),
},
],
})
await until(() => !!host.failure("broken") && host.status("recent") === "active")
return host
}, fixture)
const area = page.getByTestId("new-session").locator('[data-slot="new-session-composer-after"]')
await expect(area).toHaveText("Planlocal repo /repo")
expect(await host.evaluate((host) => [host.status("starter"), host.failure("broken")])).toEqual([
"active",
{ phase: "render", error: expect.stringContaining("slot failed") },
])
// A new worktree has no directory yet; the render stays mounted and reads the next target.
await host.evaluate((host) => host.target({ directory: undefined, project: "repo" }))
await expect(area).toHaveText("Planlocal repo new")
await host.evaluate((host) => host.target({ directory: "/other", project: undefined }))
await expect(area).toHaveText("Planlocal none /other")
await expect(page.locator("body")).toHaveAttribute("data-renders", "1")
await area.getByRole("button", { name: "Plan" }).click()
expect(await host.evaluate((host) => host.calls)).toEqual(["insert Plan the release", "focus"])
},
)
story(
"the space below the new session composer takes no room while empty and scrolls above the panel's bottom",
async ({ page }) => {
const host = await page.evaluateHandle(async (fixture) => {
const { mountNewSessionComposer, until } = await import(fixture)
const host: NewSessionHost = mountNewSessionComposer({ definitions: [] })
await until(
() => !!document.querySelector('[data-testid="new-session"] [data-slot="new-session-composer-after"]'),
)
return host
}, fixture)
const root = page.getByTestId("new-session")
const area = root.locator('[data-slot="new-session-composer-after"]')
const composer = root.getByTestId("composer")
await expect(area).toBeHidden()
const before = await composer.boundingBox()
await host.evaluate(async (host, fixture) => {
const { Slot } = await import(fixture)
host.define([
{
id: "tall",
windowEntry: async () => ({
default: (ctx: Context) =>
void ctx.add(Slot, {
at: "new-session.composer.after",
render: () =>
Object.assign(document.createElement("div"), { textContent: "tall", style: "height: 600px" }),
}),
}),
},
])
}, fixture)
await expect(area).toHaveText("tall")
expect(await composer.boundingBox()).toEqual(before)
const panel = await root.locator('[data-component="new-session"]').boundingBox()
await expect
.poll(async () => {
const box = await area.boundingBox()
return box && panel ? panel.y + panel.height - (box.y + box.height) : 0
})
.toBeGreaterThanOrEqual(56)
expect(await area.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
},
)
@@ -0,0 +1,96 @@
import { Show, createSignal, onMount } from "solid-js"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import type { NewSessionComposer, SlotMap } from "@opencode/plugin/gui"
import { createComposerEditorActions } from "@/composer/editor/actions"
import type { ComposerModel } from "@/composer/model"
import { useComposerState } from "@/composer/persistence"
import { usePluginAttachment } from "@/runtime/plugin/host-apis"
import { PluginSlot } from "@/runtime/plugin/render"
import { useServer } from "@/runtime/server/current"
import type { NewSessionWorkspaceController } from "./workspace/controller"
// Room the slot leaves at the bottom of the panel, for the tip pinned there.
const BOTTOM_INSET = 56
/** Plugin content below the new session composer, for the draft the screen shows. */
export function NewSessionComposerAfter(props: { composer: ComposerModel; workspace: NewSessionWorkspaceController }) {
const server = useServer()
const attachment = usePluginAttachment()
// The draft's own prompt: the screen suspends until it loads, so writes never replace a stored draft.
const draft = useComposerState().capture()
const edit = () => createComposerEditorActions(draft.store)
const composer: NewSessionComposer = {
text: () =>
draft
.current()
.map((part) => ("content" in part ? part.content : ""))
.join(""),
insert(text) {
const value = text.replace(/\r\n?/g, "\n")
if (value) edit().addText(value)
},
setText: (text) => edit().setText(text.replace(/\r\n?/g, "\n")),
focus: () => props.composer.restoreFocus(),
}
return (
<Show when={attachment.server(server.key)}>
{(ref) => (
<NewSessionComposerSlot
input={{
get server() {
return ref()
},
get project() {
return server.ctx.projects.forDirectory(props.workspace.project.root())
},
get directory() {
const worktree = props.workspace.selection.value()
if (worktree === "create") return
return worktree === "main" ? props.workspace.project.root() : worktree
},
composer,
}}
/>
)}
</Show>
)
}
/**
* The `new-session.composer.after` slot in the new session panel's content column. It takes no room while nothing
* renders, and scrolls once it reaches the bottom of the panel, so the composer above it never moves.
*/
export function NewSessionComposerSlot(props: { input: SlotMap["new-session.composer.after"] }) {
const [height, setHeight] = createSignal<number>()
let area!: HTMLDivElement
onMount(() => {
const panel = area.closest<HTMLElement>('[data-component="new-session"]')
if (!panel || !area.parentElement) return
// The composer growing moves the area down, and the panel resizing moves its bottom.
createResizeObserver([panel, area.parentElement], () =>
setHeight(
Math.max(0, Math.floor(panel.getBoundingClientRect().bottom - area.getBoundingClientRect().top - BOTTOM_INSET)),
),
)
})
return (
<div
ref={area}
data-slot="new-session-composer-after"
data-scrollable
class="flex min-h-0 flex-col gap-3 overflow-y-auto empty:hidden *:shrink-0"
style={{ "max-height": height() === undefined ? undefined : `${height()}px` }}
>
<PluginSlot at="new-session.composer.after" input={props.input} />
</div>
)
}
+2
View File
@@ -19,6 +19,7 @@ import { Schema } from "effect"
import { createPresence } from "@/runtime/presence"
import { Composer } from "@/composer/composer"
import { ComposerDropzone } from "@/composer/dropzone"
import { NewSessionComposerAfter } from "@/new-session/composer-after"
import type { ComposerModel } from "@/composer/model"
import {
PromptProjectAddButton,
@@ -133,6 +134,7 @@ export function NewSessionView(props: {
<Show when={props.project.empty()}>
<PromptProjectAddButton controller={props.project} />
</Show>
<NewSessionComposerAfter composer={props.composer} workspace={props.workspace} />
</div>
</div>
</div>
@@ -1207,6 +1207,8 @@ export function createPluginAttachment(apis: HostApis) {
attach: () => apis.attach(mounted),
/** The routed `MountedSession`. */
current,
/** The `ServerRef` `Servers.get` returns for a listed server's id. */
server,
closed,
/** Opens and selects a side tab by key, as a reopened closed tab comes back. */
openTab: (key: string, session: SessionRef) => open(key, session, { tab: "select" }),
+21 -16
View File
@@ -252,28 +252,33 @@ 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`, `new-session.composer.after`; 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.
`new-session.composer.after` stacks content below the new session composer, before a session exists. It receives the
draft's `server`, `project` and `directory` (undefined while a new worktree is selected) as reactive getters, and the
draft's `composer` (`text`, `insert`, `setText`, `focus`). The composer never moves: the area takes no room while
empty and scrolls once it reaches the bottom of the panel.
`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
+35
View File
@@ -685,6 +685,41 @@ export interface Composer {
focused(): boolean
}
/**
* The new session screen's composer: the draft's prompt, before a session exists. Nothing here sends the prompt; the
* user does. The draft has loaded by the time a slot receives it.
*/
export interface NewSessionComposer {
/**
* The prompt's text, as plain text: a mention reads as its `@` text, and attachments are left out. In shell mode, the
* shell command. Reactive.
*
* @example
* const empty = () => input.composer.text().trim() === ""
*/
text(): string
/**
* Inserts plain text into the prompt as typing would, at the cursor the user last left in it, else at the end, and
* moves the cursor after it. Selected text is not replaced. The text is not parsed: `@path` stays text rather than a
* mention, and line breaks stay line breaks. Focus stays where it is; call `focus` to move it. Does nothing for empty
* text.
*
* @param text - The text to insert.
*
* @example
* input.composer.insert("Summarize the open pull requests")
*/
insert(text: string): void
/**
* Replaces the prompt's text, mentions included, and moves the cursor to the end. Attachments stay.
*
* @param text - The new text. Empty clears the text.
*/
setText(text: string): void
/** Moves keyboard focus to the prompt, at its cursor. */
focus(): void
}
/** Work the session moved to the background. */
export interface BackgroundTask {
/** The task's id. */
+21 -1
View File
@@ -1,7 +1,7 @@
import type { JSX } from "solid-js"
import type { IconName } from "./icon-name.js"
import { Registry } from "./core.js"
import type { SessionRef, MountedSession, SessionScreen } from "./host-apis.js"
import type { SessionRef, MountedSession, NewSessionComposer, Project, ServerRef, SessionScreen } from "./host-apis.js"
/** The name of a shared icon; derived from the dependency-free UI artwork catalog, not a component's props. */
export type { IconName }
@@ -897,6 +897,26 @@ export interface SlotMap {
/** The routed session; a new object when another session is routed. */
readonly session: MountedSession
}
/**
* Below the composer of the new session screen, where the user writes the first prompt of a session that does not
* exist yet: for example recent sessions of the project or starter prompts. Contributions stack in `order`, the
* composer's width. The composer never moves: the area takes no room while nothing renders, grows down from under
* the composer, and scrolls once it reaches the bottom of the screen, which on a short window or behind a touch
* keyboard can leave it no room at all. The fields are reactive getters: read them where you use them.
*/
readonly "new-session.composer.after": {
/** The server the session starts on: the same object `ctx.servers.get` returns for its id. */
readonly server: ServerRef
/** The selected project, with its worktrees; undefined while the draft has none or the server is still listing it. */
readonly project: Project | undefined
/**
* The directory the session runs in: the project's root, or the selected worktree. Undefined while a new worktree
* is selected, which the server creates when the session starts.
*/
readonly directory: string | undefined
/** The draft's composer, to fill the prompt in for the user to send. */
readonly composer: NewSessionComposer
}
}
/** 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,8 @@ 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.
- `new-session.composer.after` sits below the new session composer, before the session exists. See
[Below the new session composer](#below-the-new-session-composer).
- `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 +1037,44 @@ ctx.add(Slot, {
})
```
#### Below the new session composer
`new-session.composer.after` shows content under the composer of a new session, such as starter prompts or the
project's recent sessions. It receives the draft's target and its composer, all as reactive getters:
```tsx
ctx.add(Slot, {
at: "new-session.composer.after",
render: (input) => (
<ctx.ui.Button
icon="review"
disabled={!input.directory}
onClick={() => {
input.composer.insert(ctx.t("prompt.review"))
input.composer.focus()
}}
>
{ctx.t("review")}
</ctx.ui.Button>
),
})
```
- `server` is the server the session starts on, the same `ServerRef` that `ctx.servers.get` returns.
- `project` is the selected project. It is `undefined` while the draft has none.
- `directory` is where the session runs: the project root or the selected worktree. It is `undefined` while a new
worktree is selected, because the server creates that worktree when the session starts.
- `composer` reads and fills the draft's prompt: `text()`, `insert(text)`, `setText(text)` and `focus()`. The user
sends it.
- The composer never moves. The area takes no room while nothing renders, and scrolls once it reaches the bottom of the
screen. Keep content compact: on a short window or behind a touch keyboard it may have no room.
To open one of the project's sessions from the slot, pass its id to `ctx.tabs.open`:
```ts
ctx.tabs.open({ server: input.server.id, id: session.id }, { focus: true })
```
#### Reference
```ts
@@ -1047,6 +1087,19 @@ 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 }
"new-session.composer.after": {
server: ServerRef
project: Project | undefined
directory: string | undefined
composer: NewSessionComposer
}
}
interface NewSessionComposer {
text(): string
insert(text: string): void
setText(text: string): void
focus(): void
}
```