diff --git a/packages/app/e2e/regression/btw-sidebar.spec.ts b/packages/app/e2e/regression/btw-sidebar.spec.ts new file mode 100644 index 00000000000..4486701102c --- /dev/null +++ b/packages/app/e2e/regression/btw-sidebar.spec.ts @@ -0,0 +1,145 @@ +import { base64Encode } from "@opencode/util/encode" +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/BtwSidebar" +const projectID = "proj_btw_sidebar" +const sessionID = "ses_btw_sidebar" +const otherSessionID = "ses_btw_sidebar_other" +const title = "Side question session" +const otherTitle = "Other side question session" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const sessionHref = (id: string) => `/server/${base64Encode(server)}/session/${id}` + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("answers /btw in the side panel without admitting a prompt", async ({ page }) => { + const generations: { sessionID: string; prompt: string }[] = [] + const prompts: unknown[] = [] + const generated = Promise.withResolvers() + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "btw-sidebar", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: sessionID, + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + { + id: otherSessionID, + slug: otherSessionID, + projectID, + directory, + title: otherTitle, + version: "dev", + time: { created: 1700000001000, updated: 1700000001000 }, + }, + ], + pageMessages: () => ({ items: [] }), + vcsDiff: [], + onPrompt: (input) => prompts.push(input), + generate: async (input) => { + generations.push(input) + if (input.sessionID === otherSessionID) return { text: "This answer belongs to the **other session**." } + await generated.promise + return { + text: "The retry loop uses **exponential backoff** and stops after three attempts.\n\n```ts\nconst delay = 2 ** attempt\n```", + } + }, + }) + await page.addInitScript( + ({ directory, server, sessionID, otherSessionID }) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server, sessionId: sessionID }, + { type: "session", server, sessionId: otherSessionID }, + ]), + ) + }, + { directory, server, sessionID, otherSessionID }, + ) + + await page.goto(sessionHref(sessionID)) + await expectSessionTitle(page, title) + const editor = page.locator('[data-component="composer-editor"]') + await expect(editor).toBeEditable() + + await editor.fill("/btw") + const suggestion = page.locator('[data-suggestion-id="session.btw"]') + await expect(suggestion).toBeVisible() + await suggestion.click() + await expect(editor).toHaveText("/btw ") + await editor.press("Enter") + + const panel = page.locator('[data-slot="session-btw-panel"]') + await expect(panel).toBeHidden() + await expect(page.getByText("Add a question after /btw", { exact: true })).toBeVisible() + expect(generations).toEqual([]) + expect(prompts).toEqual([]) + + await editor.fill("/btw how does the retry loop work?") + await editor.press("Enter") + + const tab = page.getByRole("tab", { name: "/btw" }) + await expect(panel).toBeVisible() + await expect(panel.getByRole("textbox")).toHaveCount(0) + await expect(panel.getByRole("status")).toContainText("Working") + await expect(tab).toHaveAttribute("data-selected", "") + generated.resolve() + await expect(panel.getByText("how does the retry loop work?", { exact: true })).toBeVisible() + await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible() + await expect(panel.getByText("const delay = 2 ** attempt", { exact: true })).toBeVisible() + expect(generations).toHaveLength(1) + expect(generations[0]?.sessionID).toBe(sessionID) + expect(generations[0]?.prompt).toContain("how does the retry loop work?") + expect(prompts).toEqual([]) + await expect(editor).toHaveText("") + + await page.locator(`[data-titlebar-tab-link][href="${sessionHref(otherSessionID)}"]`).click() + await expectSessionTitle(page, otherTitle) + await editor.fill("/btw what belongs here?") + await editor.press("Enter") + await expect(panel.getByText("other session", { exact: false })).toBeVisible() + + await page.locator(`[data-titlebar-tab-link][href="${sessionHref(sessionID)}"]`).click() + await expectSessionTitle(page, title) + await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible() + await expect(panel.getByText("other session", { exact: false })).toHaveCount(0) + + await page.reload() + await expectSessionTitle(page, title) + await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0) + await expect(page.locator('[data-slot="session-btw-panel"]')).toHaveCount(0) +}) diff --git a/packages/app/e2e/utils/mock-api.ts b/packages/app/e2e/utils/mock-api.ts index d51f96264ca..a0a6bc14a25 100644 --- a/packages/app/e2e/utils/mock-api.ts +++ b/packages/app/e2e/utils/mock-api.ts @@ -197,6 +197,13 @@ const Group = HttpApiGroup.make("mock") success: Json, }), ) + .add( + HttpApiEndpoint.post("sessionGenerate", "/api/session/:sessionID/generate", { + params: SessionParams, + payload: Schema.Struct({ prompt: Schema.String }), + success: Json, + }), + ) .add( HttpApiEndpoint.post("sessionSwitchAgent", "/api/session/:sessionID/agent", { params: SessionParams, diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 1612ca3583b..f5d19f7a027 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -42,6 +42,7 @@ export interface MockServerConfig { sessionStatus?: Record | (() => Record) inbox?: unknown[] | (() => unknown[]) onPrompt?: (input: { sessionID: string; body: Record }) => void + generate?: (input: { sessionID: string; prompt: string }) => { text: string } | Promise<{ text: string }> onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void } @@ -456,6 +457,12 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map + Effect.promise(async () => ({ + data: (await config.generate?.({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })) ?? { + text: "Side-question answer", + }, + })), sessionInboxCancel: (ctx) => Effect.sync(() => config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }), diff --git a/packages/app/src/composer/client-slash-command.test.ts b/packages/app/src/composer/client-slash-command.test.ts new file mode 100644 index 00000000000..7047164b50b --- /dev/null +++ b/packages/app/src/composer/client-slash-command.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import { parseClientSlashCommand } from "./client-slash-command" + +const options = [ + { id: "session.btw", trigger: "btw", arguments: true, type: "builtin" as const }, + { id: "custom.btw", trigger: "custom", type: "custom" as const }, + { id: "model.choose", trigger: "model", type: "builtin" as const }, +] + +describe("parseClientSlashCommand", () => { + test("parses inline and multiline arguments", () => { + expect(parseClientSlashCommand(options, "/btw why this approach?")).toEqual({ + id: "session.btw", + input: "why this approach?", + }) + expect(parseClientSlashCommand(options, "/btw\nwhy this approach?")).toEqual({ + id: "session.btw", + input: "why this approach?", + }) + }) + + test("accepts a bare argument command", () => { + expect(parseClientSlashCommand(options, "/btw")).toEqual({ id: "session.btw", input: "" }) + }) + + test("rejects prefixes, custom commands, and ordinary slash commands", () => { + expect(parseClientSlashCommand(options, "/btwx nope")).toBeUndefined() + expect(parseClientSlashCommand(options, "/custom nope")).toBeUndefined() + expect(parseClientSlashCommand(options, "/model opus")).toBeUndefined() + expect(parseClientSlashCommand(options, "ask /btw later")).toBeUndefined() + }) +}) diff --git a/packages/app/src/composer/client-slash-command.ts b/packages/app/src/composer/client-slash-command.ts new file mode 100644 index 00000000000..9e67ef393da --- /dev/null +++ b/packages/app/src/composer/client-slash-command.ts @@ -0,0 +1,24 @@ +type ClientSlashCommand = { + id: string + trigger: string + arguments?: boolean + type: "builtin" | "custom" +} + +export function parseSlashCommand(text: string) { + if (!text.startsWith("/")) return + const separator = text.search(/\s/) + const name = text.slice(1, separator === -1 ? undefined : separator) + return { name, input: separator === -1 ? "" : text.slice(separator).trim() } +} + +export function parseClientSlashCommand(options: readonly ClientSlashCommand[], text: string) { + const command = parseSlashCommand(text) + if (!command) return + const option = options.find((item) => item.type === "builtin" && item.arguments && item.trigger === command.name) + if (!option) return + return { + id: option.id, + input: command.input, + } +} diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index c785e95da7c..1faa70cd920 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -24,6 +24,7 @@ import { createComposerHistory } from "./history/store" import { composerPlaceholder } from "./placeholder" import { createComposerSubmit } from "./submit" import { useAttachmentDestination } from "./attachments/destination" +import { parseClientSlashCommand } from "./client-slash-command" export type ComposerModel = ComposerEditorModel & { readonly model: ComposerControls["model"] @@ -73,9 +74,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? return [...result, path] }, []) }) - const attachments = createMemo(() => - prompt.current().filter(isAttachment), - ) + const attachments = createMemo(() => prompt.current().filter(isAttachment)) const commentCount = createMemo(() => { if (mode() === "shell") return 0 return prompt.context.items().filter((item) => !!item.comment?.trim()).length @@ -242,6 +241,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? trigger: item.slash!, title: item.title, description: item.description, + arguments: item.slashArguments, type: "builtin" as const, })), ]) @@ -299,6 +299,11 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? clear: comments.clear, restore: restoreHistoryComments, }, + clientCommand: (text) => { + const selected = parseClientSlashCommand(slashCommands(), text) + if (!selected) return + return () => command.trigger(selected.id, "slash", selected.input) + }, }) const controller = createComposerEditor({ store: prompt.store, @@ -340,6 +345,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? if (item.kind !== "command") return const selected = slashCommands().find((entry) => entry.id === item.id) if (!selected || selected.type === "custom") return + if (selected.arguments) return return () => command.trigger(selected.id, "slash") }, attachments: { diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index 9db0102c368..355cbcd2a53 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -54,14 +54,17 @@ function submitInput( mode: "normal" | "shell" = "normal", commands: () => readonly { name: string }[] | undefined = () => [], history: string[] = [], + clientCommand?: (text: string) => (() => void | Promise) | undefined, ) { return createComposerSubmit({ adapter, mode: () => mode, commands, + clientCommand, editor: () => undefined, queueScroll() {}, - addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`), + addToHistory: (prompt) => + history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`), removeFromHistory: (prompt) => history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`), resetHistory() {}, @@ -118,6 +121,61 @@ function session(input: { } describe("Composer submission", () => { + test("runs a client argument command without admitting it to the session", async () => { + const state = createMemoryComposerState().capture() + state.set([ + { type: "text", content: "/btw why this approach?", start: 0, end: 23 }, + { + type: "image", + id: "attachment", + filename: "diagram.png", + mime: "image/png", + blob: { id: "attachment", url: "data:image/png;base64,YQ==" }, + }, + ]) + state.context.add({ type: "file", path: "src/retry.ts" }) + const calls: string[] = [] + const target = session({ + calls, + prompt: async () => { + throw new Error("client command must not call prompt") + }, + }) + const adapter: ActiveComposerAdapter = { + kind: "active-session", + state, + ready: () => true, + controls, + working: () => false, + session: () => target, + interrupt: async () => undefined, + submitted() {}, + setEditor() {}, + } + + const history: string[] = [] + await submitInput(adapter, undefined, "normal", undefined, history, (text) => { + expect(text).toBe("/btw why this approach?") + return () => { + calls.push("btw") + } + }).submit(new Event("submit")) + + expect(calls).toEqual(["btw"]) + expect(history).toEqual([]) + expect(state.current()).toEqual([ + { type: "text", content: "", start: 0, end: 0 }, + { + type: "image", + id: "attachment", + filename: "diagram.png", + mime: "image/png", + blob: { id: "attachment", url: "data:image/png;base64,YQ==" }, + }, + ]) + expect(state.context.items()).toHaveLength(1) + }) + test("applies the captured agent and model before a custom command without passing over its overrides", async () => { const state = createMemoryComposerState({ prompt: "/review changes" }).capture() const calls: string[] = [] @@ -625,12 +683,7 @@ describe("Composer submission", () => { }, } - await submitInput( - adapter, - undefined, - "normal", - () => catalog, - ).submit(new Event("submit")) + await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit")) expect(await sent.promise).toBe("command") expect(requests).toEqual([ diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index 762fc07978a..f327aa6a0f1 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -11,6 +11,7 @@ import { setCursorPosition } from "./editor/dom" import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts" import { isAttachment } from "./prompt-parts" import type { ModelSelection } from "@/providers/models/selection" +import { parseSlashCommand } from "./client-slash-command" const submitting = new WeakSet() @@ -37,6 +38,7 @@ type ComposerSubmitInput = { setMode: (mode: "normal" | "shell") => void closePopover: () => void delivery?: (alternate: boolean) => ComposerDelivery + clientCommand?: (text: string) => (() => void | Promise) | undefined notify: { missingSelection: () => void failed: (kind: "shell" | "command" | "prompt", error: unknown) => void @@ -52,15 +54,31 @@ export function createComposerSubmit(input: ComposerSubmitInput) { const submit = async (event: globalThis.Event, options?: { alternate?: boolean }) => { event.preventDefault() + const prompt = clonePrompt(input.adapter.state.current()) + const text = submissionText(prompt) + const clientCommand = input.mode() === "normal" ? input.clientCommand?.(text) : undefined + if (clientCommand) { + if (submitting.has(input.adapter.state)) return + submitting.add(input.adapter.state) + try { + clearClientCommand(input, prompt) + await clientCommand() + } catch (error) { + input.notify.failed("command", error) + } finally { + submitting.delete(input.adapter.state) + } + return + } const submission = createComposerSubmission({ target: input.adapter.state, - prompt: clonePrompt(input.adapter.state.current()), + prompt, context: input.adapter.state.context.items().map((item) => ({ ...item, selection: item.selection ? { ...item.selection } : undefined, })), }) - const read = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false) + const read = readSubmission(input, submission.prompt, submission.context, text, options?.alternate ?? false) if (!read) { if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt() return @@ -150,6 +168,17 @@ export function createComposerSubmit(input: ComposerSubmitInput) { } } +function clearClientCommand(input: ComposerSubmitInput, prompt: Prompt) { + input.adapter.state.set([{ type: "text", content: "", start: 0, end: 0 }, ...prompt.filter(isAttachment)], 0) + input.adapter.state.mode.set("normal") + input.setMode("normal") + input.closePopover() +} + +function submissionText(prompt: Prompt) { + return prompt.map((part) => ("content" in part ? part.content : "")).join("") +} + function handoffMessage(value: ComposerSubmission): SessionMessageUser { return { id: value.id, @@ -193,9 +222,9 @@ function readSubmission( input: ComposerSubmitInput, prompt: Prompt, context: ComposerSubmission["context"], + text: string, alternate: boolean, ): ComposerSubmission | undefined { - const text = prompt.map((part) => ("content" in part ? part.content : "")).join("") const mode = input.mode() if (mode === "shell" && !text.trim()) return const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image") @@ -298,14 +327,11 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) { } function findCommand(commands: ReturnType, text: string) { - if (!text.startsWith("/")) return - const [name, ...arguments_] = text.split(" ") - const command = name.slice(1) - if (!commands?.some((item) => item.name === command)) return - return { command, arguments: arguments_.join(" ") } + const parsed = parseSlashCommand(text) + if (!parsed || !commands?.some((item) => item.name === parsed.name)) return + return { command: parsed.name, arguments: parsed.input } } - async function sendCommand( session: ComposerSession, value: ComposerSubmission, @@ -390,7 +416,10 @@ async function sendPrompt( async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) { const images = await Promise.all( - value.images.map(async (attachment) => ({ ...attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) })), + value.images.map(async (attachment) => ({ + ...attachment, + dataUrl: await blobDataUrl(attachment.blob, attachment.mime), + })), ) return buildPromptRequest({ prompt: value.prompt, diff --git a/packages/app/src/home/sessions/command-palette.tsx b/packages/app/src/home/sessions/command-palette.tsx index ec4deb74673..6e690f92f94 100644 --- a/packages/app/src/home/sessions/command-palette.tsx +++ b/packages/app/src/home/sessions/command-palette.tsx @@ -47,7 +47,7 @@ export function HomeCommandPalette(props: { state.cleanup = undefined dialog.close() if (item.type === "command") { - item.option?.onSelect?.("palette") + void item.option?.onSelect?.("palette") return } if (item.type === "session") props.onSelectSession(item) diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index a8f199f2669..ecba2b01850 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -151,6 +151,8 @@ export const dict = { "command.session.compact.description": "Summarize the session to reduce context size", "command.session.fork": "Fork from message", "command.session.fork.description": "Create a new session from a previous message", + "command.session.btw": "Ask a side question", + "command.session.btw.description": "Get a one-shot answer without adding to the conversation", "command.session.export": "Export session", "command.session.export.description": "Export the full session transcript as JSON", "command.session.import": "Import session", @@ -756,6 +758,7 @@ export const dict = { "session.tab.browser": "Browser", "session.tab.add": "Add tab", "session.tab.context": "Context", + "session.tab.btw": "/btw", "session.tab.unknown": "Unknown Session", "session.panel.reviewAndFiles": "Review and files", "session.error.notFound": "This session cannot be found", @@ -933,6 +936,10 @@ export const dict = { "common.dismiss": "Dismiss", "common.moreCountSuffix": " (+{{count}} more)", "common.requestFailed": "Request failed", + "session.btw.questionRequired": "Add a question after /btw", + "session.btw.error": "Couldn’t answer that question", + "session.btw.retry": "Retry", + "session.btw.copy": "Copy answer", "common.moreOptions": "More options", "common.learnMore": "Learn more", "common.rename": "Rename", diff --git a/packages/app/src/session/btw/model.ts b/packages/app/src/session/btw/model.ts new file mode 100644 index 00000000000..606dae5689f --- /dev/null +++ b/packages/app/src/session/btw/model.ts @@ -0,0 +1,114 @@ +import { createEffect, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/runtime/i18n/language" +import { useServerSDK } from "@/runtime/server/client" +import { useCommand } from "@/shell/commands/command" +import { showToast } from "@/shell/notifications/toast" +import { SESSION_BTW_TAB } from "@/session/helpers" +import type { SessionModel } from "../model" + +const instructions = [ + "The user is asking a quick side question about the conversation so far.", + "Answer directly and concisely in markdown from what you already know.", + "Do not call any tools and do not take any actions.", +].join(" ") + +const empty = { + question: "", + answer: "", + error: false, + pending: false, +} + +export function createSessionBtw(session: SessionModel) { + const command = useCommand() + const language = useLanguage() + const server = useServerSDK() + const [states, setStates] = createStore>({}) + const requests = new Map() + const controllers = new Map() + const state = () => states[session.identity.sessionKey()] ?? empty + + createEffect(() => { + const key = session.identity.sessionKey() + onCleanup(() => { + const controller = controllers.get(key) + if (!controller) return + controller.abort() + controllers.delete(key) + if (states[key]?.pending) setStates(key, { pending: false, error: true }) + }) + }) + + const open = () => { + session.layout.view().reviewPanel.open() + const tabs = session.layout.tabs() + if (tabs.active() !== SESSION_BTW_TAB) tabs.open(SESSION_BTW_TAB) + } + const ask = (value?: string) => { + const question = value?.trim() + if (!question) { + showToast({ title: language.t("session.btw.questionRequired") }) + return + } + open() + const sessionID = session.identity.sessionID() + if (!sessionID) return + + const key = session.identity.sessionKey() + const request = (requests.get(key) ?? 0) + 1 + requests.set(key, request) + controllers.get(key)?.abort() + const controller = new AbortController() + controllers.set(key, controller) + const owner = session.ownership.capture() + setStates(key, { question, answer: "", error: false, pending: true }) + return server.api.session + .generate( + { + sessionID, + prompt: [instructions, question].join("\n\n"), + }, + { signal: controller.signal }, + ) + .then((result) => { + owner.run(() => { + if (requests.get(key) !== request) return + setStates(key, { answer: result.text.trim(), pending: false }) + }) + }) + .catch(() => { + owner.run(() => { + if (controller.signal.aborted || requests.get(key) !== request) return + setStates(key, { error: true, pending: false }) + }) + }) + .finally(() => { + if (controllers.get(key) === controller) controllers.delete(key) + }) + } + + command.register("session.btw", () => [ + { + id: "session.btw", + title: language.t("command.session.btw"), + description: language.t("command.session.btw.description"), + category: language.t("command.category.session"), + slash: "btw", + slashArguments: true, + hidden: true, + disabled: !session.isDesktop(), + onSelect: (_source, input) => ask(input), + }, + ]) + + return { + answer: () => state().answer, + error: () => state().error, + pending: () => state().pending, + question: () => state().question, + retry: () => ask(state().question), + } +} + +export type SessionBtwModel = ReturnType diff --git a/packages/app/src/session/btw/panel.tsx b/packages/app/src/session/btw/panel.tsx new file mode 100644 index 00000000000..5b17d41b98a --- /dev/null +++ b/packages/app/src/session/btw/panel.tsx @@ -0,0 +1,80 @@ +import { createEffect, createSignal, Match, Show, Switch } from "solid-js" +import { Button } from "@opencode/ui/button" +import { Icon } from "@opencode/ui/icon" +import { IconButton } from "@opencode/ui/icon-button" +import { ScrollView } from "@opencode/ui/scroll-view" +import { TextShimmer } from "@opencode/ui/text-shimmer" +import { Tooltip } from "@opencode/ui/tooltip" +import { Markdown } from "@opencode/session-ui/markdown" +import { useLanguage } from "@/runtime/i18n/language" +import { usePlatform } from "@/runtime/platform/platform" +import { showToast } from "@/shell/notifications/toast" +import type { SessionBtwModel } from "./model" + +export function SessionBtwPanel(props: { btw: SessionBtwModel }) { + const language = useLanguage() + const platform = usePlatform() + const [copied, setCopied] = createSignal(false) + + createEffect(() => { + props.btw.answer() + setCopied(false) + }) + + const copy = () => { + const answer = props.btw.answer() + if (!answer) return + void (platform.writeClipboardText?.(answer) ?? navigator.clipboard.writeText(answer)).then( + () => setCopied(true), + () => showToast({ title: language.t("common.requestFailed") }), + ) + } + + return ( +
+
+
{props.btw.question()}
+ + + } + aria-label={copied() ? language.t("common.copied") : language.t("session.btw.copy")} + onClick={copy} + /> + + +
+ +
+ + +
+ +
+
+ +
+
{language.t("session.btw.error")}
+ +
+
+ + +
+ +
+
+
+
+
+
+ ) +} diff --git a/packages/app/src/session/files/session-side-panel.tsx b/packages/app/src/session/files/session-side-panel.tsx index a48ace00b86..02ab8468705 100644 --- a/packages/app/src/session/files/session-side-panel.tsx +++ b/packages/app/src/session/files/session-side-panel.tsx @@ -37,6 +37,7 @@ import { useSettings } from "@/settings/model" import { createFileTabListSync } from "@/session/files/file-tab-scroll" import { SESSION_OPEN_FILE_TAB, + SESSION_BTW_TAB, isSessionBrowserTab, sessionBrowserTab, createOpenSessionFileTab, @@ -74,6 +75,7 @@ export function SessionSidePanel(props: { size: Sizing stacked?: boolean browser: ReturnType + btwPanel: () => JSX.Element }) { const layout = useLayout() const settings = useSettings() @@ -227,7 +229,7 @@ export function SessionSidePanel(props: { }) const fileBrowserVisible = createMemo(() => { const active = activeTab() - return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active) + return active === SESSION_OPEN_FILE_TAB || active === activeFileTab() }) const openFileKeybind = createMemo(() => command.keybindParts("file.open")) const openBrowserKeybind = createMemo(() => command.keybindParts("browser.open")) @@ -385,6 +387,14 @@ export function SessionSidePanel(props: { /> } > + + +
+ + {language.t("session.tab.btw")} +
+
+
sessionBrowserTab(item.id) === tab)}> {(item) => ( @@ -583,6 +593,12 @@ export function SessionSidePanel(props: { + + + {props.btwPanel()} + + +
{ }) }) + test("exposes the BTW tab without treating it as a file tab", () => { + createRoot((dispose) => { + const tabs = createMemo(() => ({ active: () => SESSION_BTW_TAB, all: () => [SESSION_BTW_TAB] })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + }) + + expect(result.panelTabs()).toEqual([SESSION_BTW_TAB]) + expect(result.openedTabs()).toEqual([]) + expect(result.activeTab()).toBe(SESSION_BTW_TAB) + expect(result.activeFileTab()).toBeUndefined() + expect(result.closableTab()).toBe(SESSION_BTW_TAB) + dispose() + }) + }) + test("exposes one browser tab without treating it as a file tab", () => { createRoot((dispose) => { const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] })) diff --git a/packages/app/src/session/helpers.ts b/packages/app/src/session/helpers.ts index f337725200b..b5438942e49 100644 --- a/packages/app/src/session/helpers.ts +++ b/packages/app/src/session/helpers.ts @@ -2,10 +2,11 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { same } from "@/runtime/persistence/equality" -import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs" +import { isSessionBrowserTab, SESSION_BTW_TAB, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs" export { SESSION_BROWSER_TAB, + SESSION_BTW_TAB, SESSION_OPEN_FILE_TAB, sessionBrowserTab, isSessionBrowserTab, @@ -63,13 +64,17 @@ export const createSessionTabs = (input: TabsInput) => { { equals: same }, ) const openedTabs = createMemo( - () => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isSessionBrowserTab(tab)), + () => + panelTabs().filter( + (tab) => tab !== SESSION_OPEN_FILE_TAB && tab !== SESSION_BTW_TAB && !isSessionBrowserTab(tab), + ), emptyTabs, { equals: same }, ) const activeTab = createMemo(() => { const active = input.tabs().active() if (active === "context") return active + if (active === SESSION_BTW_TAB) return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active if (active && isSessionBrowserTab(active) && browser()) return active if (active === "review" && review()) return active @@ -89,6 +94,7 @@ export const createSessionTabs = (input: TabsInput) => { const closableTab = createMemo(() => { const active = activeTab() if (active === "context") return active + if (active === SESSION_BTW_TAB) return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active if (active && isSessionBrowserTab(active) && browser()) return active if (!openedTabs().includes(active)) return diff --git a/packages/app/src/session/review/view.tsx b/packages/app/src/session/review/view.tsx index 783c74d200e..e2e81013609 100644 --- a/packages/app/src/session/review/view.tsx +++ b/packages/app/src/session/review/view.tsx @@ -14,6 +14,8 @@ import { ReviewPanel } from "./panel" import { SessionReviewTab } from "./review-tab" import type { ChangeMode, SessionReviewModel } from "./model" import type { createSessionBrowser } from "../browser/model" +import type { SessionBtwModel } from "../btw/model" +import { SessionBtwPanel } from "../btw/panel" const MobilePanelDrawer = lazy(async () => { const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer") @@ -127,6 +129,7 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) { export function SessionDesktopReview(props: { review: SessionReviewModel browser: ReturnType + btw: SessionBtwModel present?: boolean }) { return ( @@ -153,6 +156,7 @@ export function SessionDesktopReview(props: { size={props.review.screen.size} stacked={props.review.screen.side.layout().stacked} browser={props.browser} + btwPanel={() => } /> ) diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 8e048a272f0..81d76c143c2 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -39,6 +39,7 @@ import { createAnimatedPresence } from "@/runtime/animated-presence" import { createSessionBrowser } from "./browser/model" import { createTimelineCache } from "./timeline/cache" import { ArtifactMarkdownProvider, ArtifactOpenerProvider } from "./files/open-artifact" +import { createSessionBtw } from "./btw/model" const SessionMobileFiles = lazy(async () => { const { SessionMobileFiles } = await import("./files/session-mobile-files") @@ -71,6 +72,7 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp return info ? projectForSession(info, server.ctx.sync.data.project) : undefined }) const isDesktop = session.isDesktop + const btw = createSessionBtw(session) const screen = createSessionScreenLayout(session) const timeline = createSessionTimelineInteraction(session) const timelineSearch = createTimelineSearchController({ @@ -451,7 +453,12 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp setStore("sideReviewPresent", false) }} > - +
diff --git a/packages/app/src/shell/commands/command.tsx b/packages/app/src/shell/commands/command.tsx index 4ce001a1a35..22368d31d87 100644 --- a/packages/app/src/shell/commands/command.tsx +++ b/packages/app/src/shell/commands/command.tsx @@ -88,11 +88,12 @@ export interface CommandOption { category?: string keybind?: KeybindConfig slash?: string + slashArguments?: boolean suggested?: boolean disabled?: boolean hidden?: boolean when?: (event: KeyboardEvent) => boolean - onSelect?: (source?: "palette" | "keybind" | "slash") => void + onSelect?: (source?: "palette" | "keybind" | "slash", input?: string) => void | Promise onHighlight?: () => (() => void) | void } @@ -389,9 +390,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex return map }) - const run = (id: string, source?: CommandSource) => { + const run = (id: string, source?: CommandSource, input?: string) => { const option = optionMap().get(id) - option?.onSelect?.(source) + return option?.onSelect?.(source, input) } const showPalette = () => { @@ -420,7 +421,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex if (!option) return event.preventDefault() event.stopPropagation() - option.onSelect?.("keybind") + void option.onSelect?.("keybind") } onMount(() => { @@ -454,8 +455,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex return { register, - trigger(id: string, source?: CommandSource) { - run(id, source) + trigger(id: string, source?: CommandSource, input?: string) { + return run(id, source, input) }, keybind(id: string) { const config = keybindConfig(id) diff --git a/packages/app/src/shell/commands/palette.ts b/packages/app/src/shell/commands/palette.ts index fa886c4a5bd..998b2c1ca02 100644 --- a/packages/app/src/shell/commands/palette.ts +++ b/packages/app/src/shell/commands/palette.ts @@ -166,7 +166,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on state.cleanup = undefined dialog.close() if (item.type === "command") { - item.option?.onSelect?.("palette") + void item.option?.onSelect?.("palette") return } if (item.type === "session") { diff --git a/packages/app/src/shell/state/layout.test.ts b/packages/app/src/shell/state/layout.test.ts index 2470b037e80..e69bf23f28b 100644 --- a/packages/app/src/shell/state/layout.test.ts +++ b/packages/app/src/shell/state/layout.test.ts @@ -96,7 +96,7 @@ describe("layout persistence", () => { test("keeps scoped state and salvages valid tab entries", () => { const key = "local\u0000L3Byb2plY3Q/session" const value = decode({ - sessionTabs: { old: { all: ["old"] }, [key]: { all: ["a", null, "a", "b"], active: 12 } }, + sessionTabs: { old: { all: ["old"] }, [key]: { all: ["a", null, "a", "b", "btw"], active: "btw" } }, sessionView: { old: { scroll: {} }, [key]: { scroll: {}, reviewOpen: ["a", null, "b"] } }, }) expect(value.sessionTabs).toEqual({ [key]: { all: ["a", "b"], active: undefined } }) diff --git a/packages/app/src/shell/state/layout.tsx b/packages/app/src/shell/state/layout.tsx index 4cf523b55c7..87027d6d07a 100644 --- a/packages/app/src/shell/state/layout.tsx +++ b/packages/app/src/shell/state/layout.tsx @@ -18,7 +18,7 @@ import type { ProjectAvatarVariant } from "@opencode/ui/project-avatar" import { SessionStateKey } from "@/runtime/server/scope" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers" import { requireServerKey } from "@/shell/routes/session" -import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./session-tabs" +import { closeSessionTab, openSessionTab, previewSessionTab, SESSION_BTW_TAB, type SessionTabs } from "./session-tabs" export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } @@ -97,8 +97,9 @@ const normalizeSessionTabList = (path: ReturnType | un const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => { const path = sessionPath(key) return { - all: normalizeSessionTabList(path, tabs.all), - active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active, + all: normalizeSessionTabList(path, tabs.all).filter((tab) => tab !== SESSION_BTW_TAB), + active: + tabs.active === SESSION_BTW_TAB ? undefined : tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active, } } diff --git a/packages/app/src/shell/state/session-tabs.ts b/packages/app/src/shell/state/session-tabs.ts index 1f3f0ad5b0f..0f8c4b57558 100644 --- a/packages/app/src/shell/state/session-tabs.ts +++ b/packages/app/src/shell/state/session-tabs.ts @@ -1,5 +1,6 @@ export const SESSION_OPEN_FILE_TAB = "open-file" export const SESSION_BROWSER_TAB = "browser" +export const SESSION_BTW_TAB = "btw" export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}` export const isSessionBrowserTab = (tab: string | undefined) => !!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`))