From c80792ee7e5d4e71102cba6cf5626edbc82990fd Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:22:12 +1000 Subject: [PATCH] feat(plugin): add a slot below the new session composer --- .../component-tests/plugin-host.fixture.tsx | 108 ++++++++++++++ .../app/component-tests/plugin-host.spec.ts | 139 +++++++++++++++++- .../app/src/new-session/composer-after.tsx | 96 ++++++++++++ packages/app/src/new-session/view.tsx | 2 + packages/app/src/runtime/plugin/host-apis.tsx | 2 + packages/gui-plugins/README.md | 37 +++-- packages/plugin/src/gui/host-apis.ts | 35 +++++ packages/plugin/src/gui/registries.ts | 22 ++- .../docs/content/build/plugins/gui/index.mdx | 55 ++++++- 9 files changed, 477 insertions(+), 19 deletions(-) create mode 100644 packages/app/src/new-session/composer-after.tsx 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 } ```