diff --git a/packages/app/component-tests/plugin-host.fixture.tsx b/packages/app/component-tests/plugin-host.fixture.tsx
index e54ccb77587..30004d0f42c 100644
--- a/packages/app/component-tests/plugin-host.fixture.tsx
+++ b/packages/app/component-tests/plugin-host.fixture.tsx
@@ -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 (
+
+
+
+ )
+ }
+
+ function Host() {
+ const apis = createHostApis()
+ captured.apis = apis
+
+ return (
+ new Set()}
+ apis={apis.apis}
+ whenMounted={apis.whenMounted}
+ >
+
+
+ )
+ }
+
+ const dispose = render(
+ () => (
+
+
+
+
+
+
+
+ ),
+ 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
diff --git a/packages/app/component-tests/plugin-host.spec.ts b/packages/app/component-tests/plugin-host.spec.ts
index 3a5fb6b5b4f..b0e4f06dc80 100644
--- a/packages/app/component-tests/plugin-host.spec.ts
+++ b/packages/app/component-tests/plugin-host.spec.ts
@@ -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
+/** What the new session composer cases drive from the page. */
+type NewSessionHost = ReturnType
+
+/** 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
@@ -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)
+ },
+)
diff --git a/packages/app/src/new-session/composer-after.tsx b/packages/app/src/new-session/composer-after.tsx
new file mode 100644
index 00000000000..3d1075f92d3
--- /dev/null
+++ b/packages/app/src/new-session/composer-after.tsx
@@ -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 (
+
+ {(ref) => (
+
+ )}
+
+ )
+}
+
+/**
+ * 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()
+ let area!: HTMLDivElement
+
+ onMount(() => {
+ const panel = area.closest('[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 (
+
+ )
+}
diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx
index 8bb849109f2..482bde9cab5 100644
--- a/packages/app/src/new-session/view.tsx
+++ b/packages/app/src/new-session/view.tsx
@@ -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: {
+
diff --git a/packages/app/src/runtime/plugin/host-apis.tsx b/packages/app/src/runtime/plugin/host-apis.tsx
index d74740f03af..2d200eaac81 100644
--- a/packages/app/src/runtime/plugin/host-apis.tsx
+++ b/packages/app/src/runtime/plugin/host-apis.tsx
@@ -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" }),
diff --git a/packages/gui-plugins/README.md b/packages/gui-plugins/README.md
index d3a685c55af..d63ad7e8cb0 100644
--- a/packages/gui-plugins/README.md
+++ b/packages/gui-plugins/README.md
@@ -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
diff --git a/packages/plugin/src/gui/host-apis.ts b/packages/plugin/src/gui/host-apis.ts
index fe7fc28731f..5fa0bcfd5ab 100644
--- a/packages/plugin/src/gui/host-apis.ts
+++ b/packages/plugin/src/gui/host-apis.ts
@@ -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. */
diff --git a/packages/plugin/src/gui/registries.ts b/packages/plugin/src/gui/registries.ts
index 09481f4dabf..2f39f7a8e2d 100644
--- a/packages/plugin/src/gui/registries.ts
+++ b/packages/plugin/src/gui/registries.ts
@@ -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`. */
diff --git a/services/www/src/docs/content/build/plugins/gui/index.mdx b/services/www/src/docs/content/build/plugins/gui/index.mdx
index 4adbbfacd92..ca8b03d0e5b 100644
--- a/services/www/src/docs/content/build/plugins/gui/index.mdx
+++ b/services/www/src/docs/content/build/plugins/gui/index.mdx
@@ -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) => (
+ {
+ input.composer.insert(ctx.t("prompt.review"))
+ input.composer.focus()
+ }}
+ >
+ {ctx.t("review")}
+
+ ),
+})
+```
+
+- `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
}
```