Compare commits

...
30 changed files with 749 additions and 47 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-/jah4P2a0aGJNJ0aMdlFEbGHAXx33lqxHbc7UmpLFDg=",
"aarch64-linux": "sha256-L3SoZ24qNXicsE2FK6LATQjOmjPxY679RjugrUyO/1Y=",
"aarch64-darwin": "sha256-pI9NT8KWUPi+JCk6DYMqIAYmBqTbi13uL4VdNV3WS6Y=",
"x86_64-darwin": "sha256-rMAGhTTz46KA5Ya7E5J0af7Bn1QzTDhTaNfNJm8qfsw="
"x86_64-linux": "sha256-8hc0Typ9cA1NDpToM0Pq7q3AutSp+I80Sakthq10F4c=",
"aarch64-linux": "sha256-7bzI4zWOdxuoMdMHMuqIAOgnuzWiHmpdCMYCYPbs+3c=",
"aarch64-darwin": "sha256-EWDHUSVH2AjsNvoKvzC392H6GjCWVh07cOg2x0mAsng=",
"x86_64-darwin": "sha256-7b2BRdRRVG+PMCSd/cm4E+slziwWIKLVttYkotdSrK4="
}
}
@@ -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<void>()
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)
})
@@ -2,6 +2,8 @@ import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({ permissions: ["clipboard-read", "clipboard-write"] })
test.beforeEach(async ({ page }) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
await mockOpenCodeServer(page, {
@@ -95,7 +97,7 @@ test("renames and closes the session tab from its context menu", async ({ page }
await expect(tab).toBeFocused()
await tab.press("Shift+F10")
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
const input = page.locator('[data-slot="tab-title"][contenteditable="plaintext-only"]')
await expect(input).toBeFocused()
await input.fill("Renamed from tab")
await input.press("Enter")
@@ -112,6 +114,28 @@ test("renames and closes the session tab from its context menu", async ({ page }
).toBeVisible()
})
test("pastes rich text into the session tab title as plain text", async ({ page }) => {
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
await tab.click({ button: "right" })
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const input = page.locator('[data-slot="tab-title"][contenteditable="plaintext-only"]')
await expect(input).toBeFocused()
await page.evaluate(async () => {
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob(['<span style="font-size: 48px">Rich title</span>'], { type: "text/html" }),
"text/plain": new Blob(["Rich title"], { type: "text/plain" }),
}),
])
})
await input.press("ControlOrMeta+A")
await input.press("ControlOrMeta+V")
await expect(input).toHaveText("Rich title")
await expect(input.locator("*")).toHaveCount(0)
await input.press("Enter")
await expect(page.getByRole("heading", { name: "Rich title", exact: true })).toBeVisible()
})
test("renames an inactive tab without switching sessions", async ({ page }) => {
await page.getByRole("button", { name: "Home", exact: true }).click()
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }).click()
@@ -119,7 +143,7 @@ test("renames an inactive tab without switching sessions", async ({ page }) => {
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
await tab.click({ button: "right" })
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
const input = page.locator('[data-slot="tab-title"][contenteditable="plaintext-only"]')
await expect(input).toBeFocused()
await input.fill("Inactive tab renamed")
await input.press("Tab")
+7
View File
@@ -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,
+7
View File
@@ -42,6 +42,7 @@ export interface MockServerConfig {
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => 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<string, st
},
}
}),
sessionGenerate: (ctx) =>
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" }),
@@ -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()
})
})
@@ -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,
}
}
+9 -3
View File
@@ -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: {
+60 -7
View File
@@ -54,14 +54,17 @@ function submitInput(
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
history: string[] = [],
clientCommand?: (text: string) => (() => void | Promise<void>) | 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([
+39 -10
View File
@@ -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<object>()
@@ -37,6 +38,7 @@ type ComposerSubmitInput = {
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
delivery?: (alternate: boolean) => ComposerDelivery
clientCommand?: (text: string) => (() => void | Promise<void>) | 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<ComposerSubmitInput["commands"]>, 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,
@@ -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)
+7
View File
@@ -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": "Couldnt answer that question",
"session.btw.retry": "Retry",
"session.btw.copy": "Copy answer",
"common.moreOptions": "More options",
"common.learnMore": "Learn more",
"common.rename": "Rename",
+114
View File
@@ -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<Record<string, typeof empty>>({})
const requests = new Map<string, number>()
const controllers = new Map<string, AbortController>()
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<typeof createSessionBtw>
+80
View File
@@ -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 (
<div class="flex h-full min-h-0 flex-col bg-v2-background-bg-base" data-slot="session-btw-panel">
<div class="flex shrink-0 items-start justify-between gap-3 border-b border-v2-border-border-base px-5 py-4">
<div class="min-w-0 text-13-regular text-text-weak">{props.btw.question()}</div>
<Show when={props.btw.answer()}>
<Tooltip value={copied() ? language.t("common.copied") : language.t("session.btw.copy")}>
<IconButton
size="small"
variant="ghost-muted"
icon={<Icon name={copied() ? "check" : "outline-copy"} />}
aria-label={copied() ? language.t("common.copied") : language.t("session.btw.copy")}
onClick={copy}
/>
</Tooltip>
</Show>
</div>
<div class="relative min-h-0 flex-1">
<Switch>
<Match when={props.btw.pending()}>
<div
data-component="session-working"
role="status"
class="flex h-9 items-center px-5 pt-3 text-[13px] font-[530] leading-text-compact"
>
<TextShimmer text={language.t("session.timeline.working")} active />
</div>
</Match>
<Match when={props.btw.error()}>
<div class="flex h-full flex-col items-center justify-center gap-3 px-8 pb-24 text-center">
<div class="text-13-regular text-text-weak">{language.t("session.btw.error")}</div>
<Button size="small" variant="outline" onClick={props.btw.retry}>
{language.t("session.btw.retry")}
</Button>
</div>
</Match>
<Match when={props.btw.answer()}>
<ScrollView class="absolute inset-0">
<div class="px-5 py-4 pb-8">
<Markdown text={props.btw.answer()} class="text-14-regular" />
</div>
</ScrollView>
</Match>
</Switch>
</div>
</div>
)
}
@@ -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<typeof createSessionBrowser>
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: {
/>
}
>
<Match when={tab === SESSION_BTW_TAB}>
<SortableTab tab={tab} index={tabs().all().indexOf(tab)} onTabClose={tabs().close}>
<div class="flex items-center gap-1.5">
<Icon name="bubble-5" size="small" />
<span>{language.t("session.tab.btw")}</span>
</div>
</SortableTab>
</Match>
<Match when={isSessionBrowserTab(tab)}>
<Show when={props.browser.tabs().find((item) => sessionBrowserTab(item.id) === tab)}>
{(item) => (
@@ -583,6 +593,12 @@ export function SessionSidePanel(props: {
</Tabs.Content>
</Show>
<Show when={activeTab() === SESSION_BTW_TAB}>
<Tabs.Content value={SESSION_BTW_TAB} class="flex h-full min-h-0 flex-col overflow-hidden">
{props.btwPanel()}
</Tabs.Content>
</Show>
<Show when={props.browser.opened()}>
<div
id={browserTabPanelID}
+19
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import {
SESSION_BTW_TAB,
SESSION_BROWSER_TAB,
sessionBrowserTab,
SESSION_OPEN_FILE_TAB,
@@ -236,6 +237,24 @@ describe("createSessionTabs", () => {
})
})
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] }))
+8 -2
View File
@@ -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<string | undefined>(() => {
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
+4
View File
@@ -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<typeof createSessionBrowser>
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={() => <SessionBtwPanel btw={props.btw} />}
/>
</Suspense>
)
+8 -1
View File
@@ -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)
}}
>
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
<SessionDesktopReview
review={review}
browser={browser}
btw={btw}
present={store.sideReviewPresent}
/>
</div>
</Show>
</div>
+7 -6
View File
@@ -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<void>
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)
+1 -1
View File
@@ -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") {
+1 -1
View File
@@ -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 } })
+4 -3
View File
@@ -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<typeof createPathHelpers> | 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,
}
}
@@ -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}:`))
+1 -1
View File
@@ -276,7 +276,7 @@ export function TabNavItem(props: {
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
contenteditable={editing() ? "plaintext-only" : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
@@ -298,7 +298,7 @@ export function TitlebarTabStrip(props: {
: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
isTabCloseTarget(event.target) ||
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
(event.target instanceof Element && !!event.target.closest("[contenteditable]")),
}),
]}
modifiers={[
+9
View File
@@ -422,6 +422,15 @@ describe("MCP OAuth", () => {
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
})
test("requests offline_access without forcing a consent prompt", async () => {
const { server } = authorizationServer({ scopes_supported: ["read", "offline_access"] })
const { url } = await Effect.runPromise(
Effect.scoped(start(server, { client_id: "client", scope: "read" })),
).finally(() => server.stop(true))
expect(url.searchParams.get("scope")).toBe("read offline_access")
expect(url.searchParams.has("prompt")).toBe(false)
})
test("forwards iss from the redirect so issuer-advertising servers can complete", async () => {
const { server } = authorizationServer({ authorization_response_iss_parameter_supported: true })
const result = await Effect.runPromise(
+28 -1
View File
@@ -727,7 +727,7 @@ function App(props: { pair?: DialogPairCredentials }) {
title: "New session",
suggested: route.data.type === "session",
category: "Session",
slash: { name: "new", aliases: ["clear"] },
slash: { name: "new" },
run: () => {
const model = local.model.current()
const agent = local.agent.current()
@@ -749,6 +749,33 @@ function App(props: { pair?: DialogPairCredentials }) {
dialog.clear()
},
},
{
name: "session.clear",
title: "Clear session",
category: "Session",
slash: { name: "clear" },
run: () => {
const model = local.model.current()
const agent = local.agent.current()
const current =
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
: undefined
sessionTabs.close()
route.navigate({
type: "home",
location: newSessionLocation(
config.data.session.new_location,
data.location.default().directory,
current,
location.error?.location,
),
})
if (agent) local.agent.set(agent.id)
if (model) local.model.set(model)
dialog.clear()
},
},
{
name: "open.menu",
title: "Open session or project",
+67
View File
@@ -0,0 +1,67 @@
import { expect, test } from "bun:test"
import { createAppFixture } from "./fixture/app"
import { directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
const location = { directory, project: { id: "project", directory, canonical: directory } }
const session = {
id: "ses_clear",
title: "Session to clear",
projectID: "project",
location: { directory },
agent: "build",
model: { providerID: "provider", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
function render(state: string) {
return createAppFixture({
state,
args: { sessionID: session.id },
config: { animations: false, tabs: { mode: "on" } },
fetch: (url) => {
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
if (url.pathname === "/api/model")
return json({
location,
data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
})
},
})
}
test("/clear replaces the active session tab", async () => {
await using state = await tmpdir()
await using setup = await render(state.path)
await setup.waitForFrame((frame) => frame.includes("Session to clear"))
await setup.mockInput.typeText("/clear")
setup.mockInput.pressEnter()
const frame = await setup.waitForFrame((frame) => !frame.includes("Session to clear"))
expect(frame).not.toContain("Session to clear")
})
test("/new keeps the active session tab", async () => {
await using state = await tmpdir()
await using setup = await render(state.path)
await setup.waitForFrame((frame) => frame.includes("Session to clear"))
await setup.mockInput.typeText("/new")
setup.mockInput.pressEnter()
const frame = await setup.waitForFrame(
(frame) => frame.includes("New session") && frame.includes("Session to clear"),
)
expect(frame).toContain("Session to clear")
})
@@ -1,5 +1,5 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..0054b779ff7b550ecf6d4008e177a888003f9c92 100644
index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..5eaa7b451c8c7e12e51f61c2a835ff6f7a8ea0ae 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -977,7 +977,6 @@ async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, o
@@ -10,8 +10,16 @@ index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..0054b779ff7b550ecf6d4008e177a888
}
let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) response = await tryMetadataDiscovery(new URL(`/.well-known/${wellKnownType}`, issuer), protocolVersion, fetchFn);
@@ -1158,7 +1157,6 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));
if (state) authorizationUrl.searchParams.set("state", state);
if (scope) authorizationUrl.searchParams.set("scope", scope);
- if (scope?.split(" ").includes("offline_access")) authorizationUrl.searchParams.append("prompt", "consent");
if (resource) authorizationUrl.searchParams.set("resource", resource.href);
return {
authorizationUrl,
diff --git a/dist/index.mjs b/dist/index.mjs
index f02ce3ca394e826fc27c9848dbe9a214bf6ba7a9..8e27c9320e14ba8816fda752f250f6ddb1cc7ed0 100644
index f02ce3ca394e826fc27c9848dbe9a214bf6ba7a9..73a93a066c3540e131fa0770a74b4a7e8d9343a0 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -974,7 +974,6 @@ async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, o
@@ -22,3 +30,11 @@ index f02ce3ca394e826fc27c9848dbe9a214bf6ba7a9..8e27c9320e14ba8816fda752f250f6dd
}
let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) response = await tryMetadataDiscovery(new URL(`/.well-known/${wellKnownType}`, issuer), protocolVersion, fetchFn);
@@ -1155,7 +1154,6 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));
if (state) authorizationUrl.searchParams.set("state", state);
if (scope) authorizationUrl.searchParams.set("scope", scope);
- if (scope?.split(" ").includes("offline_access")) authorizationUrl.searchParams.append("prompt", "consent");
if (resource) authorizationUrl.searchParams.set("resource", resource.href);
return {
authorizationUrl,