Skip to content

Commit dcfe1ec

Browse files
feat(app): add /btw side question panel (#49750)
Co-authored-by: Brendonovich <[email protected]>
1 parent ceace24 commit dcfe1ec

22 files changed

Lines changed: 595 additions & 36 deletions
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { base64Encode } from "@opencode/util/encode"
2+
import { expect, test } from "@playwright/test"
3+
import { mockOpenCodeServer } from "../utils/mock-server"
4+
import { expectSessionTitle } from "../utils/waits"
5+
6+
const directory = "C:/OpenCode/BtwSidebar"
7+
const projectID = "proj_btw_sidebar"
8+
const sessionID = "ses_btw_sidebar"
9+
const otherSessionID = "ses_btw_sidebar_other"
10+
const title = "Side question session"
11+
const otherTitle = "Other side question session"
12+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
13+
const sessionHref = (id: string) => `/server/${base64Encode(server)}/session/${id}`
14+
15+
test.use({ viewport: { width: 1440, height: 900 } })
16+
17+
test("answers /btw in the side panel without admitting a prompt", async ({ page }) => {
18+
const generations: { sessionID: string; prompt: string }[] = []
19+
const prompts: unknown[] = []
20+
const generated = Promise.withResolvers<void>()
21+
await mockOpenCodeServer(page, {
22+
directory,
23+
project: {
24+
id: projectID,
25+
worktree: directory,
26+
vcs: "git",
27+
name: "btw-sidebar",
28+
time: { created: 1700000000000, updated: 1700000000000 },
29+
sandboxes: [],
30+
},
31+
provider: {
32+
all: [
33+
{
34+
id: "opencode",
35+
name: "OpenCode",
36+
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
37+
},
38+
],
39+
connected: ["opencode"],
40+
default: { providerID: "opencode", modelID: "test" },
41+
},
42+
sessions: [
43+
{
44+
id: sessionID,
45+
slug: sessionID,
46+
projectID,
47+
directory,
48+
title,
49+
version: "dev",
50+
time: { created: 1700000000000, updated: 1700000000000 },
51+
},
52+
{
53+
id: otherSessionID,
54+
slug: otherSessionID,
55+
projectID,
56+
directory,
57+
title: otherTitle,
58+
version: "dev",
59+
time: { created: 1700000001000, updated: 1700000001000 },
60+
},
61+
],
62+
pageMessages: () => ({ items: [] }),
63+
vcsDiff: [],
64+
onPrompt: (input) => prompts.push(input),
65+
generate: async (input) => {
66+
generations.push(input)
67+
if (input.sessionID === otherSessionID) return { text: "This answer belongs to the **other session**." }
68+
await generated.promise
69+
return {
70+
text: "The retry loop uses **exponential backoff** and stops after three attempts.\n\n```ts\nconst delay = 2 ** attempt\n```",
71+
}
72+
},
73+
})
74+
await page.addInitScript(
75+
({ directory, server, sessionID, otherSessionID }) => {
76+
localStorage.setItem(
77+
"opencode.global.dat:server",
78+
JSON.stringify({
79+
projects: { local: [{ worktree: directory, expanded: true }] },
80+
lastProject: { local: directory },
81+
}),
82+
)
83+
localStorage.setItem(
84+
"opencode.window.browser.dat:tabs",
85+
JSON.stringify([
86+
{ type: "session", server, sessionId: sessionID },
87+
{ type: "session", server, sessionId: otherSessionID },
88+
]),
89+
)
90+
},
91+
{ directory, server, sessionID, otherSessionID },
92+
)
93+
94+
await page.goto(sessionHref(sessionID))
95+
await expectSessionTitle(page, title)
96+
const editor = page.locator('[data-component="composer-editor"]')
97+
await expect(editor).toBeEditable()
98+
99+
await editor.fill("/btw")
100+
const suggestion = page.locator('[data-suggestion-id="session.btw"]')
101+
await expect(suggestion).toBeVisible()
102+
await suggestion.click()
103+
await expect(editor).toHaveText("/btw ")
104+
await editor.press("Enter")
105+
106+
const panel = page.locator('[data-slot="session-btw-panel"]')
107+
await expect(panel).toBeHidden()
108+
await expect(page.getByText("Add a question after /btw", { exact: true })).toBeVisible()
109+
expect(generations).toEqual([])
110+
expect(prompts).toEqual([])
111+
112+
await editor.fill("/btw how does the retry loop work?")
113+
await editor.press("Enter")
114+
115+
const tab = page.getByRole("tab", { name: "/btw" })
116+
await expect(panel).toBeVisible()
117+
await expect(panel.getByRole("textbox")).toHaveCount(0)
118+
await expect(panel.getByRole("status")).toContainText("Working")
119+
await expect(tab).toHaveAttribute("data-selected", "")
120+
generated.resolve()
121+
await expect(panel.getByText("how does the retry loop work?", { exact: true })).toBeVisible()
122+
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
123+
await expect(panel.getByText("const delay = 2 ** attempt", { exact: true })).toBeVisible()
124+
expect(generations).toHaveLength(1)
125+
expect(generations[0]?.sessionID).toBe(sessionID)
126+
expect(generations[0]?.prompt).toContain("how does the retry loop work?")
127+
expect(prompts).toEqual([])
128+
await expect(editor).toHaveText("")
129+
130+
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(otherSessionID)}"]`).click()
131+
await expectSessionTitle(page, otherTitle)
132+
await editor.fill("/btw what belongs here?")
133+
await editor.press("Enter")
134+
await expect(panel.getByText("other session", { exact: false })).toBeVisible()
135+
136+
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(sessionID)}"]`).click()
137+
await expectSessionTitle(page, title)
138+
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
139+
await expect(panel.getByText("other session", { exact: false })).toHaveCount(0)
140+
141+
await page.reload()
142+
await expectSessionTitle(page, title)
143+
await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0)
144+
await expect(page.locator('[data-slot="session-btw-panel"]')).toHaveCount(0)
145+
})

‎packages/app/e2e/utils/mock-api.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,13 @@ const Group = HttpApiGroup.make("mock")
197197
success: Json,
198198
}),
199199
)
200+
.add(
201+
HttpApiEndpoint.post("sessionGenerate", "/api/session/:sessionID/generate", {
202+
params: SessionParams,
203+
payload: Schema.Struct({ prompt: Schema.String }),
204+
success: Json,
205+
}),
206+
)
200207
.add(
201208
HttpApiEndpoint.post("sessionSwitchAgent", "/api/session/:sessionID/agent", {
202209
params: SessionParams,

‎packages/app/e2e/utils/mock-server.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export interface MockServerConfig {
4242
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
4343
inbox?: unknown[] | (() => unknown[])
4444
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
45+
generate?: (input: { sessionID: string; prompt: string }) => { text: string } | Promise<{ text: string }>
4546
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
4647
}
4748

@@ -456,6 +457,12 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
456457
},
457458
}
458459
}),
460+
sessionGenerate: (ctx) =>
461+
Effect.promise(async () => ({
462+
data: (await config.generate?.({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })) ?? {
463+
text: "Side-question answer",
464+
},
465+
})),
459466
sessionInboxCancel: (ctx) =>
460467
Effect.sync(() =>
461468
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { parseClientSlashCommand } from "./client-slash-command"
3+
4+
const options = [
5+
{ id: "session.btw", trigger: "btw", arguments: true, type: "builtin" as const },
6+
{ id: "custom.btw", trigger: "custom", type: "custom" as const },
7+
{ id: "model.choose", trigger: "model", type: "builtin" as const },
8+
]
9+
10+
describe("parseClientSlashCommand", () => {
11+
test("parses inline and multiline arguments", () => {
12+
expect(parseClientSlashCommand(options, "/btw why this approach?")).toEqual({
13+
id: "session.btw",
14+
input: "why this approach?",
15+
})
16+
expect(parseClientSlashCommand(options, "/btw\nwhy this approach?")).toEqual({
17+
id: "session.btw",
18+
input: "why this approach?",
19+
})
20+
})
21+
22+
test("accepts a bare argument command", () => {
23+
expect(parseClientSlashCommand(options, "/btw")).toEqual({ id: "session.btw", input: "" })
24+
})
25+
26+
test("rejects prefixes, custom commands, and ordinary slash commands", () => {
27+
expect(parseClientSlashCommand(options, "/btwx nope")).toBeUndefined()
28+
expect(parseClientSlashCommand(options, "/custom nope")).toBeUndefined()
29+
expect(parseClientSlashCommand(options, "/model opus")).toBeUndefined()
30+
expect(parseClientSlashCommand(options, "ask /btw later")).toBeUndefined()
31+
})
32+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
type ClientSlashCommand = {
2+
id: string
3+
trigger: string
4+
arguments?: boolean
5+
type: "builtin" | "custom"
6+
}
7+
8+
export function parseSlashCommand(text: string) {
9+
if (!text.startsWith("/")) return
10+
const separator = text.search(/\s/)
11+
const name = text.slice(1, separator === -1 ? undefined : separator)
12+
return { name, input: separator === -1 ? "" : text.slice(separator).trim() }
13+
}
14+
15+
export function parseClientSlashCommand(options: readonly ClientSlashCommand[], text: string) {
16+
const command = parseSlashCommand(text)
17+
if (!command) return
18+
const option = options.find((item) => item.type === "builtin" && item.arguments && item.trigger === command.name)
19+
if (!option) return
20+
return {
21+
id: option.id,
22+
input: command.input,
23+
}
24+
}

‎packages/app/src/composer/model.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { createComposerHistory } from "./history/store"
2424
import { composerPlaceholder } from "./placeholder"
2525
import { createComposerSubmit } from "./submit"
2626
import { useAttachmentDestination } from "./attachments/destination"
27+
import { parseClientSlashCommand } from "./client-slash-command"
2728

2829
export type ComposerModel = ComposerEditorModel & {
2930
readonly model: ComposerControls["model"]
@@ -73,9 +74,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
7374
return [...result, path]
7475
}, [])
7576
})
76-
const attachments = createMemo(() =>
77-
prompt.current().filter(isAttachment),
78-
)
77+
const attachments = createMemo(() => prompt.current().filter(isAttachment))
7978
const commentCount = createMemo(() => {
8079
if (mode() === "shell") return 0
8180
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
@@ -242,6 +241,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
242241
trigger: item.slash!,
243242
title: item.title,
244243
description: item.description,
244+
arguments: item.slashArguments,
245245
type: "builtin" as const,
246246
})),
247247
])
@@ -299,6 +299,11 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
299299
clear: comments.clear,
300300
restore: restoreHistoryComments,
301301
},
302+
clientCommand: (text) => {
303+
const selected = parseClientSlashCommand(slashCommands(), text)
304+
if (!selected) return
305+
return () => command.trigger(selected.id, "slash", selected.input)
306+
},
302307
})
303308
const controller = createComposerEditor({
304309
store: prompt.store,
@@ -340,6 +345,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
340345
if (item.kind !== "command") return
341346
const selected = slashCommands().find((entry) => entry.id === item.id)
342347
if (!selected || selected.type === "custom") return
348+
if (selected.arguments) return
343349
return () => command.trigger(selected.id, "slash")
344350
},
345351
attachments: {

‎packages/app/src/composer/submit.test.ts‎

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,17 @@ function submitInput(
5454
mode: "normal" | "shell" = "normal",
5555
commands: () => readonly { name: string }[] | undefined = () => [],
5656
history: string[] = [],
57+
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined,
5758
) {
5859
return createComposerSubmit({
5960
adapter,
6061
mode: () => mode,
6162
commands,
63+
clientCommand,
6264
editor: () => undefined,
6365
queueScroll() {},
64-
addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
66+
addToHistory: (prompt) =>
67+
history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
6568
removeFromHistory: (prompt) =>
6669
history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
6770
resetHistory() {},
@@ -118,6 +121,61 @@ function session(input: {
118121
}
119122

120123
describe("Composer submission", () => {
124+
test("runs a client argument command without admitting it to the session", async () => {
125+
const state = createMemoryComposerState().capture()
126+
state.set([
127+
{ type: "text", content: "/btw why this approach?", start: 0, end: 23 },
128+
{
129+
type: "image",
130+
id: "attachment",
131+
filename: "diagram.png",
132+
mime: "image/png",
133+
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
134+
},
135+
])
136+
state.context.add({ type: "file", path: "src/retry.ts" })
137+
const calls: string[] = []
138+
const target = session({
139+
calls,
140+
prompt: async () => {
141+
throw new Error("client command must not call prompt")
142+
},
143+
})
144+
const adapter: ActiveComposerAdapter = {
145+
kind: "active-session",
146+
state,
147+
ready: () => true,
148+
controls,
149+
working: () => false,
150+
session: () => target,
151+
interrupt: async () => undefined,
152+
submitted() {},
153+
setEditor() {},
154+
}
155+
156+
const history: string[] = []
157+
await submitInput(adapter, undefined, "normal", undefined, history, (text) => {
158+
expect(text).toBe("/btw why this approach?")
159+
return () => {
160+
calls.push("btw")
161+
}
162+
}).submit(new Event("submit"))
163+
164+
expect(calls).toEqual(["btw"])
165+
expect(history).toEqual([])
166+
expect(state.current()).toEqual([
167+
{ type: "text", content: "", start: 0, end: 0 },
168+
{
169+
type: "image",
170+
id: "attachment",
171+
filename: "diagram.png",
172+
mime: "image/png",
173+
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
174+
},
175+
])
176+
expect(state.context.items()).toHaveLength(1)
177+
})
178+
121179
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
122180
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
123181
const calls: string[] = []
@@ -625,12 +683,7 @@ describe("Composer submission", () => {
625683
},
626684
}
627685

628-
await submitInput(
629-
adapter,
630-
undefined,
631-
"normal",
632-
() => catalog,
633-
).submit(new Event("submit"))
686+
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
634687

635688
expect(await sent.promise).toBe("command")
636689
expect(requests).toEqual([

0 commit comments

Comments
 (0)