From da696ed3baff30370abfc689afe7ba6aa7cbdf2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 21 Sep 2026 17:23:11 +0200 Subject: [PATCH] feat(chat): add native /btw side questions Reserve /btw in the composer and autocomplete, using session.generate to answer from the current native context in a non-modal Markdown window. Bare /btw opens the question form; answers can be copied or retried without adding transcript, inbox or child-session records. Keep side-request cancellation independent of the main execution, fence late results on cancellation, replacement, session changes and disposal, and preserve unsent file/image attachments. Authorize only the reviewed session-scoped generation route through existing ownership and worktree-deletion checks. Add all ten locales and shared square window styling. Cover routing, Markdown, attachments, concurrent main prompts, cancellation races and narrow layouts with ten browser scenarios, plus proxy regressions. Verify native idle and busy behavior with isolated local-provider fixtures on OpenCode 2.0.7, 2.0.11 and 2.0.12; include the fixture in minimum/latest CI. UI/server typechecks, twelve adjacent unit tests and diff checks pass. --- .github/workflows/pr-build.yml | 2 + AGENTS.md | 1 + dev-docs/OPENCODE_V2_COMPATIBILITY.md | 13 ++ .../server/__tests__/instance-proxy.test.ts | 32 +++ packages/server/src/server/http-server.ts | 2 +- packages/ui/src/components/prompt-input.tsx | 43 +++- .../prompt-input/PromptAsideWindow.tsx | 74 +++++++ .../components/prompt-input/usePromptAside.ts | 96 +++++++++ .../ui/src/lib/i18n/messages/de/messaging.ts | 14 ++ .../ui/src/lib/i18n/messages/en/messaging.ts | 14 ++ .../ui/src/lib/i18n/messages/es/messaging.ts | 14 ++ .../ui/src/lib/i18n/messages/fr/messaging.ts | 14 ++ .../ui/src/lib/i18n/messages/he/messaging.ts | 14 ++ .../ui/src/lib/i18n/messages/ja/messaging.ts | 14 ++ .../ui/src/lib/i18n/messages/ne/messaging.ts | 14 ++ .../ui/src/lib/i18n/messages/ru/messaging.ts | 14 ++ .../ui/src/lib/i18n/messages/tr/messaging.ts | 14 ++ .../lib/i18n/messages/zh-Hans/messaging.ts | 14 ++ .../src/styles/components/session-aside.css | 61 ++++++ packages/ui/src/styles/controls.css | 1 + .../tests/browser/fixtures/session-aside.tsx | 45 ++++ .../ui/tests/browser/session-aside.test.ts | 199 ++++++++++++++++++ scripts/test-session-aside-native.mjs | 117 ++++++++++ 23 files changed, 822 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/components/prompt-input/PromptAsideWindow.tsx create mode 100644 packages/ui/src/components/prompt-input/usePromptAside.ts create mode 100644 packages/ui/src/styles/components/session-aside.css create mode 100644 packages/ui/tests/browser/fixtures/session-aside.tsx create mode 100644 packages/ui/tests/browser/session-aside.test.ts create mode 100644 scripts/test-session-aside-native.mjs diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 856a12fff..23d842532 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -176,6 +176,8 @@ jobs: if ($LASTEXITCODE) { exit $LASTEXITCODE } node scripts/test-session-fork-native.mjs $cli if ($LASTEXITCODE) { exit $LASTEXITCODE } + node scripts/test-session-aside-native.mjs $cli + if ($LASTEXITCODE) { exit $LASTEXITCODE } tests: needs: authorize diff --git a/AGENTS.md b/AGENTS.md index d3dad8fa6..060cc8e93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ - Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere. - Use the shared `.window-*` primitives from `src/styles/components/window.css` for dialog, popover, and floating-window headers, toolbars, bodies, footers, titles, and actions. - Persistent command/search utility windows use `components/dismissible-window.tsx`: non-modal, no scrim, outside interactions keep them open, and explicit toggles use `.icon-toggle` with `aria-expanded`/`aria-controls`. Keep search state scoped to its instance/session and close it when that view becomes inactive. +- The composer reserves `/btw` for native `session.generate`, outside ordinary prompt/command submission. Its ephemeral question/answer window uses `DismissibleWindow` and `styles/components/session-aside.css`; cancellation and inactive/session transitions fence late results without interrupting the main session. - OpenCode settings show a compact runtime summary with update availability and a direct install/update action; additional management actions live in the shared setup dialog, also mounted by the native preferences window. Keep diagnostics, executable selection and configuration reload in explicit disclosure sections styled in `styles/components/opencode-setup.css`. - Full-history search/counts use the bundled pruning plugin's bounded queries, outside the transcript store. Current-session results and the global timeline navigate through bounded anchor windows; other-session previews fetch one native message only. Search/results/progress styles live in `styles/components/history-search.css`. See `dev-docs/SESSION_HISTORY_QUERIES.md` for scope, snapshot and cleanup semantics. - Keep agent, model, and thinking controls in the composer footer via `PromptContextControls`; adapt that footer with the named `prompt-composer` container rather than viewport-only breakpoints. diff --git a/dev-docs/OPENCODE_V2_COMPATIBILITY.md b/dev-docs/OPENCODE_V2_COMPATIBILITY.md index 79d3da76e..fd1da1d72 100644 --- a/dev-docs/OPENCODE_V2_COMPATIBILITY.md +++ b/dev-docs/OPENCODE_V2_COMPATIBILITY.md @@ -28,6 +28,19 @@ The isolated 2.0.3→2.0.11 seed confirms native workspace-selector collapse whi preserving session IDs and complete history. See the [transition register](OPENCODE_V2_POST_BETA.md) for actual coverage and release gates. +### Side questions (`/btw`) + +The composer-owned command calls native `session.generate({ sessionID, prompt })` +through the ownership-checked session proxy. It does not use `session.command`, +create a fork, replace session environment, or add transcript/inbox records. +The native API returns one text answer; there is no client-side context assembly +or tool loop. Closing/cancelling the ephemeral window aborts that request only. +`scripts/test-session-aside-native.mjs` uses a private daemon and local provider +to verify context reuse, unchanged idle history and generation during an active +main turn. It passes on 2.0.7 (technical minimum), 2.0.11 and 2.0.12, and runs in +the minimum/latest-stable CI matrix. These targeted results do not change the +global minimum, dependency pins or recommended release-tested version. + The audit and implementation record below is historical context, not a runtime qualification matrix to maintain. diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index eba0249d3..351ef220f 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -172,6 +172,38 @@ async function harness( } describe("instance proxy location enforcement", () => { + it("forwards side generation for an owned busy session without mutating its environment", async () => { + const { app, manager, sessionGets, requestCount } = await harness("/repo/worktree", { owned: { type: "running" } }) + manager.getSessionEnvironment = async () => { throw new Error("Side generation must not replace the session environment") } + const response = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session/owned/generate", + payload: { prompt: "Explain the current approach" } }) + assert.equal(response.statusCode, 200) + assert.deepEqual(sessionGets, ["owned"]) + assert.deepEqual(response.json().body, { prompt: "Explain the current approach" }) + assert.equal(response.json().url, "/api/session/owned/generate") + assert.equal(requestCount(), 1) + // Only the reviewed session-scoped route is opened, never generic generation. + const generic = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/experimental/generate", payload: { prompt: "test" } }) + assert.equal(generic.statusCode, 403) + assert.equal(requestCount(), 1) + }) + + it("rejects side generation for a foreign session and during worktree deletion", async () => { + const { app, requestCount, worktreeDeletionFence } = await harness("/repo/worktree", {}, { foreign: "/other" }) + const foreign = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session/foreign/generate", payload: { prompt: "test" } }) + assert.equal(foreign.statusCode, 403) + assert.equal(requestCount(), 0) + let release!: () => void + const deletion = worktreeDeletionFence.run("workspace:worktree", ["workspace:worktree"], () => ( + new Promise(resolve => { release = resolve }) + )) + try { + const deleting = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session/owned/generate", payload: { prompt: "test" } }) + assert.equal(deleting.statusCode, 409) + assert.equal(requestCount(), 0) + } finally { release(); await deletion } + }) + it("authorizes transcript reads without resolving the mutation checkout identity", async () => { const { app, manager, sessionGets } = await harness() manager.getWorktreeIdentityForPath = async () => { throw new Error("Unexpected Git mutation identity read") } diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 48bd43d82..4922cf44c 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -1271,7 +1271,7 @@ function isAllowedInstanceApiRoute(method: string, pathname: string): boolean { ["DELETE", /^\/api\/session\/[^/]+$/], ["DELETE", /^\/api\/session\/[^/]+\/inbox\/[^/]+$/], ["PATCH", /^\/api\/session\/[^/]+$/], - ["POST", /^\/api\/session\/[^/]+\/(?:agent|model|move|prompt|command|shell|compact|interrupt|background|fork)$/], + ["POST", /^\/api\/session\/[^/]+\/(?:agent|model|move|prompt|command|shell|compact|interrupt|background|fork|generate)$/], ["POST", /^\/api\/experimental\/session\/[^/]+\/wait$/], ["PATCH", /^\/api\/session\/[^/]+\/inbox\/[^/]+$/], ["POST", /^\/api\/session\/[^/]+\/revert\/stage$/], diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index cf35f8077..53d5b663c 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -1,4 +1,4 @@ -import { Suspense, createEffect, createSignal, lazy, on, onCleanup, onMount, Show } from "solid-js" +import { Suspense, createEffect, createSignal, createUniqueId, lazy, on, onCleanup, onMount, Show } from "solid-js" import { Loader2, Mic, Paperclip, Volume2, X } from "lucide-solid" import { addAttachment, clearAttachments, removeAttachment } from "../stores/attachments" import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders" @@ -12,6 +12,7 @@ import { showAlertDialog } from "../stores/alerts" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" import { getOpencodeErrorMessage } from "../lib/opencode-api" +import { createAttachmentPlaceholderRegex, getAttachmentPlaceholder } from "../lib/attachment-placeholders" import { serverApi } from "../lib/api-client" import { isDesktopHost, isLocalWindow } from "../lib/runtime-env" import { preferences } from "../stores/preferences" @@ -24,6 +25,8 @@ import { usePromptAttachments } from "./prompt-input/usePromptAttachments" import { usePromptPicker } from "./prompt-input/usePromptPicker" import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown" import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput" +import { usePromptAside } from "./prompt-input/usePromptAside" +import PromptAsideWindow from "./prompt-input/PromptAsideWindow" import { initializePromptInputHeight, persistPromptInputHeight, @@ -86,6 +89,17 @@ function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachmen export default function PromptInput(props: PromptInputProps) { const { t } = useI18n() + const asideId = createUniqueId() + const aside = usePromptAside({ + instanceId: () => props.instanceId, + sessionId: () => props.sessionId, + active: () => props.isActive !== false, + }) + // /btw is a local UI command, like OpenCode's TUI command of the same name. + const promptCommands = () => [ + { name: "btw", description: t("promptInput.btw.commandDescription") }, + ...getCommands(props.instanceId).filter(command => command.name !== "btw"), + ] initializePromptInputHeight() const [, setIsFocused] = createSignal(false) const [mode, setMode] = createSignal("normal") @@ -320,7 +334,7 @@ export default function PromptInput(props: PromptInputProps) { setPrompt, getTextarea: () => textareaRef ?? null, instanceAgents, - commands: () => getCommands(props.instanceId), + commands: promptCommands, }) const { @@ -522,6 +536,28 @@ export default function PromptInput(props: PromptInputProps) { const isShellMode = mode() === "shell" + const asideMatch = !isShellMode && /^\/btw(?:\s+([\s\S]*))?$/.exec(text) + if (asideMatch) { + const question = preparePromptSubmission({ mode: "slash", text, attachments: currentAttachments, + commandToken: "/btw", commandArgs: asideMatch[1] ?? "" }).resolvedCommandArgs + if (aside.launch(question)) { + // Pasted text is consumed by the side question; image/file attachments + // are not sent. Retain image tokens so normal attachment cleanup keeps them. + const imageTokens = currentAttachments.flatMap(attachment => { + if (attachment.source.type !== "file") return [] + const placeholder = getAttachmentPlaceholder(attachment.display) + return placeholder?.kind === "image" + ? draftText.match(createAttachmentPlaceholderRegex("image", placeholder.counter)) ?? [] : [] + }) + if (imageTokens.length) setPrompt(imageTokens.join(" ")) + else clearPrompt() + clearHistoryDraft() + setShowPicker(false) + restoredQueuedPayload = undefined + } + return + } + // Slash command routing (match OpenCode TUI): only run if the command exists. const isSlashCandidate = !isShellMode && text.startsWith("/") const firstSpace = isSlashCandidate ? text.indexOf(" ") : -1 @@ -973,7 +1009,7 @@ export default function PromptInput(props: PromptInputProps) { void handleSend() }} agents={instanceAgents()} - commands={getCommands(props.instanceId)} + commands={promptCommands()} searchQuery={searchQuery()} textareaRef={textareaRef} workspaceId={props.instanceId} @@ -1119,6 +1155,7 @@ export default function PromptInput(props: PromptInputProps) { + { + aside.answer() + aside.open() + setCopied(false) + setCopyFailed(false) + }) + + return ( + aside.pending() ? closeButton : input}> +
+

{t("promptInput.btw.title")}

+ +
+
+

{t("promptInput.btw.description")}

+
{ event.preventDefault(); void aside.ask() }}> + +