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() }}> + +