diff --git a/packages/app/src/session/requests/model.ts b/packages/app/src/session/requests/model.ts
index 506a9f3f1a4..91f9e52792f 100644
--- a/packages/app/src/session/requests/model.ts
+++ b/packages/app/src/session/requests/model.ts
@@ -7,7 +7,8 @@ import { useServerSDK } from "@/runtime/server/client"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
-import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
+import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
+import { createWebSearchRequest } from "./websearch"
import { createSessionBackground } from "@/session/requests/background"
import { useData } from "@/runtime/server/current"
@@ -24,12 +25,40 @@ export function createSessionRequestModel() {
void Promise.all([
data.shell.sync({ directory: sdk().directory }),
data.session.permission.sync(id),
- data.session.form.sync(id),
]).catch(() => undefined)
})
+ createEffect(() => {
+ const id = params.id
+ if (!id || serverSDK.connection.status() !== "connected") return
+ void Promise.all(
+ sessionTreeIDs(data.session.list(), id).map((sessionID) => data.session.form.sync(sessionID)),
+ ).catch(() => undefined)
+ })
- const questionRequest = createMemo((): FormInfo | undefined => {
- return sessionQuestionForm(data.session.list(), data.session.form.list, params.id)
+ const formRequest = createMemo((): FormInfo | undefined => {
+ return sessionFormRequest(data.session.list(), data.session.form.list, params.id)
+ })
+ const websearch = createWebSearchRequest({
+ owner: () => params.id,
+ connected: () => serverSDK.connection.status() === "connected",
+ request: () => {
+ const form = formRequest()
+ return form?.metadata?.kind === "websearch.provider" ? form : undefined
+ },
+ providers: async (sessionID) => {
+ const session = data.session.get(sessionID) ?? (await serverSDK.api.session.get({ sessionID }))
+ const result = await serverSDK.api.websearch.providers({
+ location: { directory: session.location.directory, workspace: session.location.workspaceID },
+ })
+ return result.data.map((provider) => ({ value: provider.id, label: provider.name }))
+ },
+ reply: (input) => data.session.form.reply(input),
+ events: serverSDK.event,
+ })
+ const questionRequest = createMemo(() => {
+ if (websearch.request()) return
+ const form = formRequest()
+ return form?.metadata?.kind === "question" ? form : undefined
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
@@ -40,7 +69,7 @@ export function createSessionRequestModel() {
const blocked = createMemo(() => {
const id = params.id
if (!id) return false
- return !!permissionRequest() || !!questionRequest()
+ return !!permissionRequest() || !!questionRequest() || !!websearch.request()
})
const primary = () => {
@@ -96,6 +125,7 @@ export function createSessionRequestModel() {
return {
blocked,
questionRequest,
+ websearch,
permissionRequest,
permissionResponding,
background: {
diff --git a/packages/app/src/session/requests/session-request-tree.test.ts b/packages/app/src/session/requests/session-request-tree.test.ts
index fc0c3ebee7d..ce461b6db2e 100644
--- a/packages/app/src/session/requests/session-request-tree.test.ts
+++ b/packages/app/src/session/requests/session-request-tree.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
-import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
+import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
({
@@ -23,6 +23,22 @@ const question = (id: string, sessionID: string) =>
fields: [{ key: "q0", type: "string" }],
}) as FormInfo
+describe("sessionTreeIDs", () => {
+ test("returns only the current session and its descendants", () => {
+ const sessions = [
+ session({ id: "root" }),
+ session({ id: "child", parentID: "root" }),
+ session({ id: "grand", parentID: "child" }),
+ session({ id: "sibling", parentID: "root" }),
+ session({ id: "other" }),
+ ]
+
+ expect(sessionTreeIDs(sessions, "child")).toEqual(["child", "grand"])
+ expect(sessionTreeIDs(sessions, "root")).toEqual(["root", "child", "sibling", "grand"])
+ expect(sessionTreeIDs(sessions)).toEqual([])
+ })
+})
+
describe("sessionPermissionRequest", () => {
test("prefers the current session permission", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
@@ -81,7 +97,7 @@ describe("sessionPermissionRequest", () => {
})
})
-describe("sessionQuestionForm", () => {
+describe("sessionFormRequest", () => {
test("prefers the current session question", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
const questions = {
@@ -89,7 +105,7 @@ describe("sessionQuestionForm", () => {
child: [question("q-child", "child")],
}
- expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-root")
+ expect(sessionFormRequest(sessions, questions, "root")?.id).toBe("q-root")
})
test("returns a nested child question", () => {
@@ -102,15 +118,29 @@ describe("sessionQuestionForm", () => {
grand: [question("q-grand", "grand")],
}
- expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-grand")
+ expect(sessionFormRequest(sessions, questions, "root")?.id).toBe("q-grand")
})
- test("skips forms that are not questions", () => {
+ test("skips unsupported forms", () => {
const sessions = [session({ id: "root" })]
const forms = {
root: [{ ...question("form", "root"), metadata: { kind: "integration" } }],
}
- expect(sessionQuestionForm(sessions, forms, "root")).toBeUndefined()
+ expect(sessionFormRequest(sessions, forms, "root")).toBeUndefined()
+ })
+
+ test("finds web search consent in a nested child session", () => {
+ const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
+ const form = { ...question("search", "child"), metadata: { kind: "websearch.provider" } }
+ expect(sessionFormRequest(sessions, { child: [form] }, "root")).toBe(form)
+ })
+
+ test("preserves request order across questions and web search", () => {
+ const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
+ const form = { ...question("search", "root"), metadata: { kind: "websearch.provider" } }
+ expect(sessionFormRequest(sessions, { root: [form, question("q", "root")] }, "root")).toBe(form)
+ expect(sessionFormRequest(sessions, { root: [question("q", "root"), form] }, "root")?.id).toBe("q")
+ expect(sessionFormRequest(sessions, { root: [form], child: [question("q", "child")] }, "root")).toBe(form)
})
})
diff --git a/packages/app/src/session/requests/session-request-tree.ts b/packages/app/src/session/requests/session-request-tree.ts
index d37211bff88..c3d933fc4cf 100644
--- a/packages/app/src/session/requests/session-request-tree.ts
+++ b/packages/app/src/session/requests/session-request-tree.ts
@@ -6,8 +6,16 @@ function sessionTreeRequest
(
sessionID?: string,
include: (item: T) => boolean = () => true,
) {
- if (!sessionID) return
+ const ids = sessionTreeIDs(session, sessionID)
+ if (!ids.length) return
+ const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
+ const id = ids.find((id) => list(id)?.some(include))
+ if (!id) return
+ return list(id)?.find(include)
+}
+export function sessionTreeIDs(session: SessionInfo[], sessionID?: string) {
+ if (!sessionID) return []
const map = session.reduce((acc, item) => {
if (!item.parentID) return acc
const list = acc.get(item.parentID)
@@ -27,11 +35,7 @@ function sessionTreeRequest(
ids.push(child)
}
}
-
- const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
- const id = ids.find((id) => list(id)?.some(include))
- if (!id) return
- return list(id)?.find(include)
+ return ids
}
export function sessionPermissionRequest(
@@ -43,10 +47,15 @@ export function sessionPermissionRequest(
return sessionTreeRequest(session, request, sessionID, include)
}
-export function sessionQuestionForm(
+export function sessionFormRequest(
session: SessionInfo[],
request: Record | ((sessionID: string) => FormInfo[] | undefined),
sessionID?: string,
) {
- return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
+ return sessionTreeRequest(
+ session,
+ request,
+ sessionID,
+ (item) => item.metadata?.kind === "question" || item.metadata?.kind === "websearch.provider",
+ )
}
diff --git a/packages/app/src/session/requests/session-websearch-dock.css b/packages/app/src/session/requests/session-websearch-dock.css
new file mode 100644
index 00000000000..a0c15f6d787
--- /dev/null
+++ b/packages/app/src/session/requests/session-websearch-dock.css
@@ -0,0 +1,58 @@
+[data-component="session-websearch-dock"] {
+ width: 100%;
+ border: 0.5px solid var(--v2-border-border-base);
+ border-radius: 12px;
+
+ .websearch-body {
+ box-shadow: var(--v2-elevation-raised);
+ }
+
+ .websearch-setting {
+ padding: 16px;
+ }
+
+ .websearch-setting [data-component="settings-row"] {
+ column-gap: 24px;
+ padding-block: 0;
+ border: 0;
+ }
+
+ .websearch-footer {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 16px;
+ padding: 24px 16px 12px;
+ border: 0;
+ }
+
+ .websearch-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-inline-end: auto;
+ color: var(--v2-text-text-base);
+ font-size: 13px;
+ line-height: var(--line-height-compact);
+ }
+
+ .websearch-status:empty {
+ display: none;
+ }
+
+ .websearch-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 16px;
+ }
+}
+
+[data-component="menu-v2-content"][data-slot="select-v2-content"].websearch-provider-menu {
+ width: max-content;
+ min-width: 0;
+
+ [data-component="menu-v2-item"] {
+ gap: 24px;
+ }
+}
diff --git a/packages/app/src/session/requests/session-websearch-dock.tsx b/packages/app/src/session/requests/session-websearch-dock.tsx
new file mode 100644
index 00000000000..c3f564dbdd0
--- /dev/null
+++ b/packages/app/src/session/requests/session-websearch-dock.tsx
@@ -0,0 +1,86 @@
+import { createMemo, Show } from "solid-js"
+import { Button } from "@opencode-ai/ui/button"
+import { DockShell, DockTray } from "@opencode-ai/ui/dock-surface"
+import { Select } from "@opencode-ai/ui/select"
+import { useLanguage } from "@/runtime/i18n/language"
+import { SettingsRow } from "@/settings/row"
+import type { WebSearchRequestModel } from "./websearch"
+import "./session-websearch-dock.css"
+
+export function SessionWebSearchDock(props: { model: WebSearchRequestModel; onSubmit: () => void }) {
+ const language = useLanguage()
+ const options = createMemo(() => [
+ ...(props.model.specific() ? [] : [{ value: "random", label: language.t("session.websearch.any") }]),
+ ...props.model.options(),
+ ])
+ const current = createMemo(() => options().find((option) => option.value === props.model.selected()))
+ const busy = () => props.model.sending() || !props.model.connected()
+ const status = () => {
+ if (props.model.loading()) return language.t("common.loading")
+ if (props.model.failed()) return language.t("session.websearch.failed")
+ if (!props.model.options().length) return language.t("session.websearch.empty")
+ }
+ const unavailable = () => props.model.loading() || props.model.loadFailed() || !props.model.options().length
+ const submit = (selection: string | false) => {
+ if (busy()) return
+ props.onSubmit()
+ void props.model.submit(selection)
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/session/requests/websearch.test.ts b/packages/app/src/session/requests/websearch.test.ts
new file mode 100644
index 00000000000..bc515f752ec
--- /dev/null
+++ b/packages/app/src/session/requests/websearch.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, test } from "bun:test"
+import type { FormAnswer, FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode-ai/client/promise"
+import { replyWebSearch } from "./websearch"
+
+const consent: FormCreated["data"]["form"] = {
+ id: "frm_consent",
+ sessionID: "ses_child",
+ title: "Web Search",
+ metadata: { kind: "websearch.provider" },
+ fields: [{ key: "choice", type: "string", required: true, custom: false }],
+}
+const provider: FormCreated["data"]["form"] = {
+ ...consent,
+ id: "frm_provider",
+ fields: [
+ {
+ key: "provider",
+ type: "string",
+ required: true,
+ custom: false,
+ options: [
+ { value: "exa", label: "Exa" },
+ { value: "parallel", label: "Parallel" },
+ ],
+ },
+ ],
+}
+
+function fixture() {
+ const listeners = new Set<(event: OpenCodeEvent) => void>()
+ const replies: FormReplyInput[] = []
+ const abort = new AbortController()
+ const emit = (event: OpenCodeEvent) => listeners.forEach((listener) => listener(event))
+ return {
+ form: consent,
+ signal: abort.signal,
+ abort,
+ listeners,
+ replies,
+ events: {
+ listen(listener: (event: OpenCodeEvent) => void) {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+ },
+ },
+ reply: async (input: FormReplyInput) => {
+ replies.push(input)
+ },
+ create: (form = provider) => emit({ id: "evt_create", created: 0, type: "form.created", data: { form } }),
+ cancel: (id: string) =>
+ emit({
+ id: "evt_cancel",
+ created: 0,
+ type: "form.cancelled",
+ data: { id, sessionID: consent.sessionID },
+ }),
+ answer: (id: string, answer: FormAnswer) =>
+ emit({
+ id: "evt_reply",
+ created: 0,
+ type: "form.replied",
+ data: { id, sessionID: consent.sessionID, answer },
+ }),
+ }
+}
+
+describe("web search desktop consent", () => {
+ test.each([
+ ["random", "allow"],
+ [false, "disable"],
+ ] as const)("submits %s without a second form", async (selection, choice) => {
+ const input = fixture()
+ await replyWebSearch({ ...input, selection })
+ expect(input.replies).toEqual([{ sessionID: consent.sessionID, formID: consent.id, answer: { choice } }])
+ expect(input.listeners.size).toBe(0)
+ })
+
+ test("subscribes before the first reply and answers the owning session's provider form", async () => {
+ const input = fixture()
+ await replyWebSearch({
+ ...input,
+ selection: "parallel",
+ reply: async (answer) => {
+ input.replies.push(answer)
+ if (answer.answer.choice === "choose") input.create()
+ },
+ })
+ expect(input.replies).toEqual([
+ { sessionID: consent.sessionID, formID: consent.id, answer: { choice: "choose" } },
+ { sessionID: consent.sessionID, formID: provider.id, answer: { provider: "parallel" } },
+ ])
+ expect(input.listeners.size).toBe(0)
+ })
+
+ test("ignores questions, other sessions, and repeated consent forms during handoff", async () => {
+ const input = fixture()
+ const pending = replyWebSearch({ ...input, selection: "exa" })
+ input.create({ ...provider, sessionID: "ses_other" })
+ input.create({ ...provider, metadata: { kind: "question" } })
+ input.create(consent)
+ input.create()
+ await pending
+ expect(input.replies).toHaveLength(2)
+ expect(input.replies[1]?.formID).toBe(provider.id)
+ })
+
+ test("leaves a changed provider list for explicit confirmation", async () => {
+ const input = fixture()
+ const pending = replyWebSearch({ ...input, selection: "removed" })
+ input.create()
+ await pending
+ expect(input.replies).toHaveLength(1)
+ expect(input.listeners.size).toBe(0)
+ })
+
+ test("supports direct entry into the provider form", async () => {
+ const input = fixture()
+ await replyWebSearch({ ...input, form: provider, selection: "exa" })
+ expect(input.replies).toEqual([{ sessionID: consent.sessionID, formID: provider.id, answer: { provider: "exa" } }])
+ expect(input.listeners.size).toBe(0)
+ })
+
+ test("does not misrepresent cancelling the provider form as disabling search", async () => {
+ const input = fixture()
+ await replyWebSearch({ ...input, form: provider, selection: false })
+ expect(input.replies).toEqual([])
+ })
+
+ test.each(["cancel", "other-client", "abort"])("ends a pending handoff on %s", async (action) => {
+ const input = fixture()
+ const pending = replyWebSearch({ ...input, selection: "exa" })
+ if (action === "cancel") input.cancel(consent.id)
+ if (action === "other-client") input.answer(consent.id, { choice: "disable" })
+ if (action === "abort") input.abort.abort()
+ await pending
+ input.create()
+ expect(input.replies).toHaveLength(1)
+ expect(input.listeners.size).toBe(0)
+ })
+
+ test("cleans up after a failed consent submission", async () => {
+ const input = fixture()
+ await expect(
+ replyWebSearch({
+ ...input,
+ selection: "exa",
+ reply: async () => {
+ throw new Error("offline")
+ },
+ }),
+ ).rejects.toThrow("offline")
+ expect(input.listeners.size).toBe(0)
+ })
+
+ test("propagates provider submission failures for retry", async () => {
+ const input = fixture()
+ await expect(
+ replyWebSearch({
+ ...input,
+ selection: "exa",
+ reply: async (answer) => {
+ if (answer.formID === provider.id) throw new Error("offline")
+ input.create()
+ },
+ }),
+ ).rejects.toThrow("offline")
+ expect(input.listeners.size).toBe(0)
+ })
+})
diff --git a/packages/app/src/session/requests/websearch.ts b/packages/app/src/session/requests/websearch.ts
new file mode 100644
index 00000000000..bdc4efd2c70
--- /dev/null
+++ b/packages/app/src/session/requests/websearch.ts
@@ -0,0 +1,154 @@
+import type { FormInfo, FormOption, FormReplyInput, FormStringField } from "@opencode-ai/client/promise"
+import { createEffect, createMemo, createResource, on, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import type { OpenCodeEventStream } from "@/runtime/server/client"
+
+export function webSearchProviderField(form: FormInfo) {
+ return form.fields.find(
+ (field): field is FormStringField => field.type === "string" && field.key === "provider" && !!field.options,
+ )
+}
+
+export function createWebSearchRequest(input: {
+ owner: () => string | undefined
+ connected: () => boolean
+ request: () => FormInfo | undefined
+ providers: (sessionID: string) => Promise
+ reply: (input: FormReplyInput) => Promise
+ events: Pick
+}) {
+ const [store, setStore] = createStore({
+ selected: "random",
+ sending: undefined as { form: FormInfo; abort: AbortController } | undefined,
+ error: false,
+ })
+ const [providers, resource] = createResource(input.request, async (form) => {
+ const field = webSearchProviderField(form)
+ if (field) return field.options ?? []
+ return input.providers(form.sessionID)
+ })
+ const request = createMemo(() => store.sending?.form ?? input.request())
+ const specific = createMemo(() => {
+ const form = request()
+ return !!form && !!webSearchProviderField(form)
+ })
+ const options = createMemo(() => (providers.error ? [] : (providers() ?? [])))
+ const selected = createMemo(() => {
+ if (!specific()) return store.selected
+ return options().some((option) => option.value === store.selected) ? store.selected : options()[0]?.value
+ })
+
+ createEffect(
+ on([input.owner, input.connected], () => {
+ store.sending?.abort.abort()
+ setStore({ sending: undefined, selected: "random", error: false })
+ }),
+ )
+ createEffect(
+ on(
+ () => input.request()?.id,
+ () => {
+ const form = input.request()
+ if (!form || store.sending || webSearchProviderField(form)) return
+ setStore({ selected: "random", error: false })
+ },
+ ),
+ )
+ onCleanup(() => store.sending?.abort.abort())
+
+ const submit = async (selection: string | false) => {
+ const form = input.request()
+ if (!form || store.sending || !input.connected()) return
+ if (selection === false && webSearchProviderField(form)) return
+ const sending = { form, abort: new AbortController() }
+ setStore({ sending, error: false })
+ await replyWebSearch({ ...input, form, selection, signal: sending.abort.signal })
+ .catch(() => {
+ if (!sending.abort.signal.aborted) setStore("error", true)
+ })
+ .finally(() => {
+ if (store.sending?.abort === sending.abort) setStore("sending", undefined)
+ })
+ }
+
+ return {
+ request,
+ options,
+ selected,
+ specific,
+ loading: () => providers.loading,
+ loadFailed: () => !!providers.error,
+ failed: () => store.error,
+ sending: () => !!store.sending,
+ connected: input.connected,
+ select: (value: string) => setStore({ selected: value, error: false }),
+ retry: () => void resource.refetch(),
+ submit,
+ }
+}
+
+export type WebSearchRequestModel = ReturnType
+
+export async function replyWebSearch(input: {
+ form: FormInfo
+ selection: string | false
+ signal: AbortSignal
+ reply: (input: FormReplyInput) => Promise
+ events: Pick
+}) {
+ if (input.signal.aborted) return
+ if (webSearchProviderField(input.form)) {
+ if (input.selection === false) return
+ return input.reply({
+ sessionID: input.form.sessionID,
+ formID: input.form.id,
+ answer: { provider: input.selection },
+ })
+ }
+ if (input.selection === false || input.selection === "random") {
+ return input.reply({
+ sessionID: input.form.sessionID,
+ formID: input.form.id,
+ answer: { choice: input.selection === false ? "disable" : "allow" },
+ })
+ }
+
+ const next = Promise.withResolvers()
+ const stop = input.events.listen((event) => {
+ if (event.type === "form.created") {
+ const form = event.data.form
+ if (
+ form.sessionID !== input.form.sessionID ||
+ form.id === input.form.id ||
+ form.metadata?.kind !== "websearch.provider" ||
+ !webSearchProviderField(form)
+ )
+ return
+ next.resolve(form)
+ }
+ if (event.type === "form.cancelled" && event.data.id === input.form.id) next.resolve(undefined)
+ if (event.type === "form.replied" && event.data.id === input.form.id && event.data.answer.choice !== "choose")
+ next.resolve(undefined)
+ })
+ const cancel = () => next.resolve(undefined)
+ input.signal.addEventListener("abort", cancel, { once: true })
+
+ return Promise.all([
+ input.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: { choice: "choose" } }),
+ next.promise,
+ ])
+ .then(([, form]) => {
+ if (!form || input.signal.aborted) return
+ const field = webSearchProviderField(form)
+ if (!field?.options?.some((option) => option.value === input.selection)) return
+ return input.reply({
+ sessionID: form.sessionID,
+ formID: form.id,
+ answer: { provider: input.selection },
+ })
+ })
+ .finally(() => {
+ stop()
+ input.signal.removeEventListener("abort", cancel)
+ })
+}
diff --git a/packages/app/src/session/session-screen.stories.tsx b/packages/app/src/session/session-screen.stories.tsx
index ebcdc038a3e..8119f28023c 100644
--- a/packages/app/src/session/session-screen.stories.tsx
+++ b/packages/app/src/session/session-screen.stories.tsx
@@ -138,6 +138,26 @@ export const QuestionRequest = {
),
}
+export const WebSearchRequest = {
+ render: () => (
+
+ ),
+}
+
export const RetryAndInterruption = {
render: () => (
diff --git a/packages/app/src/session/story-model.tsx b/packages/app/src/session/story-model.tsx
index 74ca3c9d119..111fdaa2687 100644
--- a/packages/app/src/session/story-model.tsx
+++ b/packages/app/src/session/story-model.tsx
@@ -23,6 +23,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { ReviewPanelView } from "@/session/review/panel"
import { createReviewPanelState } from "@/session/review/panel-state"
import { TerminalSurface } from "@/session/terminal/surface"
+import type { WebSearchRequestModel } from "./requests/websearch"
const modelReady = Object.assign(() => true, { promise: undefined }) satisfies ModelSelection["ready"]
const storyComposerModel = {
@@ -82,7 +83,7 @@ export type SessionPreviewProps = {
description: string
document: SessionDocument
draft?: string
- request?: { type: "permission"; value: PermissionRequest } | { type: "question"; value: FormInfo }
+ request?: { type: "permission"; value: PermissionRequest } | { type: "question" | "websearch"; value: FormInfo }
reviewOpened?: boolean
child?: { parentID: string }
terminal?: { title: string; lines: string[] }
@@ -173,10 +174,12 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
activity: string
reviewOpened: boolean
request: SessionPreviewProps["request"]
+ searchProvider: string
}>({
activity: "Ready",
reviewOpened: props.reviewOpened ?? false,
request: props.request,
+ searchProvider: "random",
})
const prompt = createPromptController({
initial: props.draft ?? "",
@@ -189,6 +192,27 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
const region = {
state: {
questionRequest: () => (state.request?.type === "question" ? state.request.value : undefined),
+ websearch: {
+ request: () => (state.request?.type === "websearch" ? state.request.value : undefined),
+ options: () => [
+ { value: "exa", label: "Exa" },
+ { value: "parallel", label: "Parallel" },
+ { value: "tavily", label: "Tavily" },
+ ],
+ selected: () => state.searchProvider,
+ specific: () => false,
+ loading: () => false,
+ loadFailed: () => false,
+ failed: () => false,
+ sending: () => false,
+ connected: () => true,
+ select: (value) => setState("searchProvider", value),
+ retry() {},
+ submit: async (value) => {
+ setState("request", undefined)
+ setState("activity", `Web search selection (local only): ${value}`)
+ },
+ } satisfies WebSearchRequestModel,
permissionRequest: () => (state.request?.type === "permission" ? state.request.value : undefined),
permissionResponding: () => false,
decide: (response) => {
diff --git a/packages/app/src/shell/layout/project-avatar-state.ts b/packages/app/src/shell/layout/project-avatar-state.ts
index 37e14d9f8a1..32480e0f01a 100644
--- a/packages/app/src/shell/layout/project-avatar-state.ts
+++ b/packages/app/src/shell/layout/project-avatar-state.ts
@@ -1,6 +1,6 @@
import { createMemo, type Accessor } from "solid-js"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
-import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
+import { sessionPermissionRequest, sessionFormRequest } from "@/session/requests/session-request-tree"
import { ServerConnection } from "@/runtime/server/registry"
import { useSettings } from "@/settings/model"
@@ -29,12 +29,12 @@ export function useSessionTabAvatarState(
if (!ctx) return false
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
})
- const hasQuestions = createMemo(() => {
+ const hasForms = createMemo(() => {
const data = serverCtx()?.data
if (!data) return false
- return !!sessionQuestionForm(sessions(), data.session.form.list, sessionId())
+ return !!sessionFormRequest(sessions(), data.session.form.list, sessionId())
})
- const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
+ const needsAttention = createMemo(() => hasPermissions() || hasForms())
const unread = createMemo(
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
)
diff --git a/packages/app/test-browser/websearch-request.test.ts b/packages/app/test-browser/websearch-request.test.ts
new file mode 100644
index 00000000000..0faadec481f
--- /dev/null
+++ b/packages/app/test-browser/websearch-request.test.ts
@@ -0,0 +1,209 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import type { FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode-ai/client/promise"
+import { createEffect, createRoot } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createWebSearchRequest } from "@/session/requests/websearch"
+
+const consent: FormCreated["data"]["form"] = {
+ id: "frm_consent",
+ sessionID: "ses_child",
+ title: "Web Search",
+ metadata: { kind: "websearch.provider" },
+ fields: [{ key: "choice", type: "string", required: true, custom: false }],
+}
+const options = [
+ { value: "exa", label: "Exa" },
+ { value: "parallel", label: "Parallel" },
+]
+const provider: FormCreated["data"]["form"] = {
+ ...consent,
+ id: "frm_provider",
+ fields: [{ key: "provider", type: "string", options, required: true, custom: false }],
+}
+
+const cleanups: VoidFunction[] = []
+afterEach(() => cleanups.splice(0).forEach((dispose) => dispose()))
+
+function ready(condition: () => boolean) {
+ return new Promise((resolve) => {
+ createRoot((dispose) => {
+ cleanups.push(dispose)
+ createEffect(() => {
+ if (!condition()) return
+ dispose()
+ resolve()
+ })
+ })
+ })
+}
+
+function fixture(form: FormCreated["data"]["form"] | null = consent) {
+ return createRoot((dispose) => {
+ cleanups.push(dispose)
+ const listeners = new Set<(event: OpenCodeEvent) => void>()
+ const replies: FormReplyInput[] = []
+ const loads: string[] = []
+ const [state, setState] = createStore({
+ request: form ?? undefined,
+ owner: "ses_root",
+ connected: true,
+ loadFails: false,
+ replyFails: false,
+ })
+ const model = createWebSearchRequest({
+ owner: () => state.owner,
+ connected: () => state.connected,
+ request: () => state.request,
+ providers: async (sessionID) => {
+ loads.push(sessionID)
+ if (state.loadFails) throw new Error("offline")
+ return options
+ },
+ reply: async (input) => {
+ replies.push(input)
+ if (state.replyFails) throw new Error("offline")
+ setState("request", undefined)
+ },
+ events: {
+ listen(listener) {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+ },
+ },
+ })
+ return {
+ model,
+ state,
+ setState,
+ replies,
+ loads,
+ dispose,
+ listeners,
+ create(form = provider) {
+ setState("request", form)
+ listeners.forEach((listener) =>
+ listener({
+ id: "evt_create",
+ created: 0,
+ type: "form.created",
+ data: { form },
+ }),
+ )
+ },
+ cancel(id: string) {
+ setState("request", undefined)
+ listeners.forEach((listener) =>
+ listener({
+ id: "evt_cancel",
+ created: 0,
+ type: "form.cancelled",
+ data: { id, sessionID: consent.sessionID },
+ }),
+ )
+ },
+ }
+ })
+}
+
+describe("web search request state", () => {
+ test("loads providers for the form owner, not the viewed parent, and waits for consent", async () => {
+ const input = fixture()
+ await ready(() => !input.model.loading())
+ expect(input.loads).toEqual(["ses_child"])
+ expect(input.model.selected()).toBe("random")
+ input.model.select("exa")
+ expect(input.replies).toEqual([])
+ await input.model.submit(false)
+ expect(input.replies[0]?.answer).toEqual({ choice: "disable" })
+ expect(input.model.request()).toBeUndefined()
+ expect(input.model.sending()).toBe(false)
+ })
+
+ test("does not load providers when there is no consent request", () => {
+ const input = fixture(null)
+ expect(input.model.request()).toBeUndefined()
+ expect(input.loads).toEqual([])
+ })
+
+ test("holds the card across both forms and prevents duplicate submissions", async () => {
+ const input = fixture()
+ await ready(() => !input.model.loading())
+ input.model.select("parallel")
+ const pending = input.model.submit("parallel")
+ await input.model.submit("parallel")
+ expect(input.state.request).toBeUndefined()
+ expect(input.model.request()?.id).toBe(consent.id)
+ expect(input.model.sending()).toBe(true)
+ input.create()
+ await pending
+ expect(input.replies.map((reply) => reply.answer)).toEqual([{ choice: "choose" }, { provider: "parallel" }])
+ expect(input.model.request()).toBeUndefined()
+ expect(input.model.sending()).toBe(false)
+ expect(input.listeners.size).toBe(0)
+ })
+
+ test("direct provider requests need confirmation and do not load a different provider list", async () => {
+ const input = fixture(provider)
+ await ready(() => !input.model.loading())
+ expect(input.loads).toEqual([])
+ expect(input.model.specific()).toBe(true)
+ expect(input.model.options()).toEqual(options)
+ expect(input.replies).toEqual([])
+ await input.model.submit("exa")
+ expect(input.replies.map((reply) => reply.answer)).toEqual([{ provider: "exa" }])
+ })
+
+ test("keeps the selected provider after failure and retries only the provider form", async () => {
+ const input = fixture()
+ await ready(() => !input.model.loading())
+ input.model.select("parallel")
+ const pending = input.model.submit("parallel")
+ input.setState("replyFails", true)
+ input.create()
+ await pending
+ expect(input.model.failed()).toBe(true)
+ expect(input.model.sending()).toBe(false)
+ expect(input.model.request()?.id).toBe(provider.id)
+ expect(input.model.selected()).toBe("parallel")
+ input.setState("replyFails", false)
+ await input.model.submit("parallel")
+ expect(input.replies.map((reply) => reply.answer)).toEqual([
+ { choice: "choose" },
+ { provider: "parallel" },
+ { provider: "parallel" },
+ ])
+ expect(input.model.request()).toBeUndefined()
+ })
+
+ test("can retry loading providers without submitting consent", async () => {
+ const input = fixture()
+ await ready(() => !input.model.loading())
+ input.setState("loadFails", true)
+ input.model.retry()
+ await ready(() => input.model.loadFailed())
+ expect(input.model.options()).toEqual([])
+ input.setState("loadFails", false)
+ input.model.retry()
+ await ready(() => !input.model.loading())
+ expect(input.model.options()).toEqual(options)
+ expect(input.model.loadFailed()).toBe(false)
+ expect(input.replies).toEqual([])
+ })
+
+ test.each(["navigate", "disconnect", "dispose", "cancel"])("stops automatic replies on %s", async (action) => {
+ const input = fixture()
+ await ready(() => !input.model.loading())
+ const pending = input.model.submit("exa")
+ if (action === "navigate") input.setState("owner", "ses_other")
+ if (action === "disconnect") input.setState("connected", false)
+ if (action === "dispose") input.dispose()
+ if (action === "cancel") input.cancel(consent.id)
+ await pending
+ expect(input.model.sending()).toBe(false)
+ expect(input.listeners.size).toBe(0)
+ input.create()
+ expect(input.replies.map((reply) => reply.answer)).toEqual([{ choice: "choose" }])
+ })
+})
diff --git a/packages/ui/src/forms/select/select.tsx b/packages/ui/src/forms/select/select.tsx
index 475f0dfead9..b2e440c07fe 100644
--- a/packages/ui/src/forms/select/select.tsx
+++ b/packages/ui/src/forms/select/select.tsx
@@ -68,6 +68,7 @@ export type SelectProps = Omit<
numeric?: boolean
children?: (item: T) => JSX.Element
valueClass?: string
+ contentClass?: string
}
export function Select(props: SelectProps) {
@@ -88,6 +89,7 @@ export function Select(props: SelectProps) {
"numeric",
"disabled",
"valueClass",
+ "contentClass",
"placement",
"gutter",
"sameWidth",
@@ -208,7 +210,7 @@ export function Select(props: SelectProps) {
-
+