diff --git a/packages/app/component-tests/websearch.spec.ts b/packages/app/component-tests/websearch.spec.ts new file mode 100644 index 00000000000..fb8bf8ef5a7 --- /dev/null +++ b/packages/app/component-tests/websearch.spec.ts @@ -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: "Don’t 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: "Don’t 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) + }) + } + } +} diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index e649a39b0b9..6c2388e4420 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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": "Don’t 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", diff --git a/packages/app/src/session/composer/session-composer-region.tsx b/packages/app/src/session/composer/session-composer-region.tsx index 258f8702cbc..927b3195b39 100644 --- a/packages/app/src/session/composer/session-composer-region.tsx +++ b/packages/app/src/session/composer/session-composer-region.tsx @@ -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(), }} > + + + {(request) => (
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 ( +
+ +
+ +