feat(desktop): add third-party web search consent

This commit is contained in:
usrnk1 2026-09-06 06:38:55 +02:00 committed by GitHub
parent cf212a4235
commit 33ef66746b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 912 additions and 26 deletions

View file

@ -0,0 +1,79 @@
import { expect, story } from "../../storybook/playwright/story"
story("enables Any only after confirmation and supports resetting the preview", async ({ mount }) => {
const component = await mount("app-current-session-surface--web-search-request")
const card = component.getByRole("region", { name: "Third-party web search" })
await expect(card.getByRole("button", { name: "Enable", exact: true })).toBeEnabled()
await expect(card.getByRole("button", { name: "Search provider Any", exact: true })).toBeVisible()
await card.getByRole("button", { name: "Enable", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): random")
await expect(card).toHaveCount(0)
await component.getByRole("button", { name: "Reset", exact: true }).click()
await expect(card.getByRole("button", { name: "Enable", exact: true })).toBeEnabled()
})
story("declining search is an explicit disabled selection", async ({ mount }) => {
const component = await mount("app-current-session-surface--web-search-request")
const card = component.getByRole("region", { name: "Third-party web search" })
await card.getByRole("button", { name: "Dont use search", exact: true }).click()
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): false")
await expect(card).toHaveCount(0)
})
story("sizes provider options to their content", async ({ mount, page }) => {
const component = await mount("app-current-session-surface--web-search-request")
await component.getByRole("button", { name: "Search provider Any", exact: true }).click()
const menu = page.getByRole("listbox", { name: "Search provider", exact: true })
await expect(menu).toBeVisible()
const metrics = await menu.evaluate((listbox) => {
const items = Array.from(listbox.querySelectorAll('[data-component="menu-v2-item"]'))
const longest = items
.flatMap((item) => {
const label = item.querySelector('[data-slot="menu-v2-item-content"]')?.getBoundingClientRect()
const check = item.querySelector('[data-slot="menu-v2-item-indicator"]')?.getBoundingClientRect()
return label && check ? [{ label, check }] : []
})
.toSorted((a, b) => b.label.width - a.label.width)[0]
return {
width: listbox.getBoundingClientRect().width,
gap: longest ? longest.check.left - longest.label.right : 0,
}
})
expect(metrics.width).toBeLessThan(160)
expect(metrics.gap).toBe(24)
})
for (const width of [360, 1200]) {
for (const direction of ["ltr", "rtl"]) {
for (const theme of ["light", "dark"]) {
story(`selects and confirms a provider at ${width}px in ${direction} ${theme}`, async ({ mount, page }) => {
await page.setViewportSize({ width, height: 900 })
const component = await mount("app-current-session-surface--web-search-request", {
globals: { theme, direction },
})
const card = component.getByRole("region", { name: "Third-party web search" })
const select = card.getByRole("button", { name: /^Search provider/ })
await expect(select).toBeEnabled()
await expect(card).toHaveCSS("direction", direction)
expect(await card.evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true)
await select.focus()
await select.press("Enter")
const list = page.getByRole("listbox", { name: "Search provider", exact: true })
await expect(list).toHaveCSS("direction", direction)
await list.getByRole("option", { name: "Parallel", exact: true }).click()
await expect(select).toHaveText("Parallel")
await expect(card).toBeVisible()
await expect(component.getByRole("status")).toHaveText("Ready")
await expect(select).toBeFocused()
await select.press("Tab")
await expect(card.getByRole("button", { name: "Dont use search", exact: true })).toBeFocused()
await page.keyboard.press("Tab")
const enable = card.getByRole("button", { name: "Enable", exact: true })
await expect(enable).toBeFocused()
await enable.press("Enter")
await expect(component.getByRole("status")).toHaveText("Web search selection (local only): parallel")
await expect(card).toHaveCount(0)
})
}
}
}

View file

@ -736,6 +736,16 @@ export const dict = {
"session.todo.expand": "Expand",
"session.todo.progress": "{{done}} of {{total}} todos completed",
"session.question.progress": "{{current}} of {{total}} questions",
"session.websearch.title": "Third-party web search",
"session.websearch.description": "Select the search provider agents use to search the web",
"session.websearch.provider": "Search provider",
"session.websearch.any": "Any",
"session.websearch.disable": "Dont use search",
"session.websearch.enable": "Enable",
"session.websearch.loadFailed": "Could not load search providers.",
"session.websearch.empty": "No search providers available.",
"session.websearch.failed": "Could not save your choice. Please try again.",
"session.websearch.retry": "Retry",
"session.question.minimize": "Minimize question",
"session.question.restore": "Restore question",
"session.question.pending.one": "{{count}} pending question",

View file

@ -2,11 +2,12 @@ import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { SessionPermissionDock } from "@/session/requests/session-permission-dock"
import { SessionQuestionDock } from "@/session/requests/session-question-dock"
import { SessionWebSearchDock } from "@/session/requests/session-websearch-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
type SessionComposerRegionState = Pick<
SessionComposerRegionController["state"],
"questionRequest" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
"questionRequest" | "websearch" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
>
export type SessionComposerRegionViewController = Pick<
@ -32,6 +33,9 @@ export function SessionComposerRegion(props: {
"md:max-w-[1000px] md:mx-auto": controller.centered(),
}}
>
<Show when={controller.state.websearch.request()}>
<SessionWebSearchDock model={controller.state.websearch} onSubmit={controller.onResponseSubmit} />
</Show>
<Show when={controller.state.questionRequest()} keyed>
{(request) => (
<div>

View file

@ -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: {

View file

@ -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)
})
})

View file

@ -6,8 +6,16 @@ function sessionTreeRequest<T>(
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<T>(
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<string, FormInfo[] | undefined> | ((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",
)
}

View file

@ -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;
}
}

View file

@ -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 (
<section
data-component="session-websearch-dock"
aria-label={language.t("session.websearch.title")}
aria-busy={props.model.sending()}
>
<DockShell class="websearch-body">
<div class="websearch-setting">
<SettingsRow
title={language.t("session.websearch.title")}
description={language.t("session.websearch.description")}
>
<Select
aria-label={language.t("session.websearch.provider")}
options={options()}
current={current()}
value={(option) => option.value}
label={(option) => option.label}
onSelect={(option) => option && props.model.select(option.value)}
disabled={busy() || unavailable()}
placeholder={language.t("session.websearch.provider")}
contentClass="websearch-provider-menu"
/>
</SettingsRow>
</div>
</DockShell>
<DockTray attach="top" class="websearch-footer">
<div class="websearch-status" aria-live="polite">
<Show when={props.model.loadFailed()} fallback={status()}>
<span>{language.t("session.websearch.loadFailed")}</span>
<Button variant="ghost" size="small" onClick={props.model.retry} disabled={busy()}>
{language.t("session.websearch.retry")}
</Button>
</Show>
</div>
<div class="websearch-actions">
<Show when={!props.model.specific()}>
<Button variant="ghost" size="small" onClick={() => submit(false)} disabled={busy()}>
{language.t("session.websearch.disable")}
</Button>
</Show>
<Button
variant="neutral"
size="small"
onClick={() => {
const selected = props.model.selected()
if (selected) submit(selected)
}}
disabled={busy() || unavailable() || !current()}
>
{language.t("session.websearch.enable")}
</Button>
</div>
</DockTray>
</section>
)
}

View file

@ -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)
})
})

View file

@ -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<FormOption[]>
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
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<typeof createWebSearchRequest>
export async function replyWebSearch(input: {
form: FormInfo
selection: string | false
signal: AbortSignal
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
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<FormInfo | undefined>()
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)
})
}

View file

@ -138,6 +138,26 @@ export const QuestionRequest = {
),
}
export const WebSearchRequest = {
render: () => (
<SessionPreview
title="Search for current documentation"
description={description}
document={questionPendingDocument}
request={{
type: "websearch",
value: {
id: "frm_websearch_preview",
sessionID: "ses_websearch_preview",
title: "Web Search",
metadata: { kind: "websearch.provider" },
fields: [{ key: "choice", type: "string", required: true, custom: false }],
},
}}
/>
),
}
export const RetryAndInterruption = {
render: () => (
<SessionPreview title="Recover the interrupted run" description={description} document={retryAfterInterruption} />

View file

@ -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) => {

View file

@ -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,
)

View file

@ -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<void>((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" }])
})
})

View file

@ -68,6 +68,7 @@ export type SelectProps<T> = Omit<
numeric?: boolean
children?: (item: T) => JSX.Element
valueClass?: string
contentClass?: string
}
export function Select<T>(props: SelectProps<T>) {
@ -88,6 +89,7 @@ export function Select<T>(props: SelectProps<T>) {
"numeric",
"disabled",
"valueClass",
"contentClass",
"placement",
"gutter",
"sameWidth",
@ -208,7 +210,7 @@ export function Select<T>(props: SelectProps<T>) {
</span>
</Trigger>
<Portal>
<Content data-component="menu-v2-content" data-slot="select-v2-content">
<Content class={local.contentClass} data-component="menu-v2-content" data-slot="select-v2-content">
<Listbox data-slot="select-v2-listbox" />
</Content>
</Portal>