refactor(websearch): update provider consumers

This commit is contained in:
Shoubhit Dash 2026-07-09 20:05:54 +05:30
parent 820b1a1a46
commit 86ad303695
7 changed files with 217 additions and 135 deletions

View file

@ -461,8 +461,10 @@ import type {
V2VcsDiffResponses,
V2VcsStatusErrors,
V2VcsStatusResponses,
V2WebsearchProviderGetErrors,
V2WebsearchProviderGetResponses,
V2WebsearchProviderListErrors,
V2WebsearchProviderListResponses,
V2WebsearchProviderSelectedErrors,
V2WebsearchProviderSelectedResponses,
V2WebsearchProviderSelectErrors,
V2WebsearchProviderSelectResponses,
V2WebsearchQueryErrors,
@ -8254,11 +8256,11 @@ export class Debug extends HeyApiClient {
export class Provider3 extends HeyApiClient {
/**
* Get default web search provider
* List web search providers
*
* Return the globally selected web search provider.
* Return the registered web search providers.
*/
public get<ThrowOnError extends boolean = false>(
public list<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string | null
@ -8269,8 +8271,8 @@ export class Provider3 extends HeyApiClient {
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<
V2WebsearchProviderGetResponses,
V2WebsearchProviderGetErrors,
V2WebsearchProviderListResponses,
V2WebsearchProviderListErrors,
ThrowOnError
>({
url: "/api/websearch/provider",
@ -8279,6 +8281,32 @@ export class Provider3 extends HeyApiClient {
})
}
/**
* Get selected web search provider
*
* Return the globally selected web search provider.
*/
public selected<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<
V2WebsearchProviderSelectedResponses,
V2WebsearchProviderSelectedErrors,
ThrowOnError
>({
url: "/api/websearch/provider/selected",
...options,
...params,
})
}
/**
* Select default web search provider
*
@ -8310,7 +8338,7 @@ export class Provider3 extends HeyApiClient {
V2WebsearchProviderSelectErrors,
ThrowOnError
>({
url: "/api/websearch/provider",
url: "/api/websearch/provider/selected",
...options,
...params,
headers: {
@ -8326,7 +8354,7 @@ export class Websearch extends HeyApiClient {
/**
* Search the web
*
* Run one web search through the selected integration. Specify a provider to override the configured default.
* Run one web search through the selected provider. Specify a provider to override the configured default.
*/
public query<ThrowOnError extends boolean = false>(
parameters?: {

View file

@ -87,6 +87,7 @@ export type Event =
| EventFormCreated
| EventFormReplied
| EventFormCancelled
| EventWebsearchUpdated
| EventLspUpdated
| EventPermissionAsked
| EventPermissionReplied
@ -1408,7 +1409,7 @@ export type GlobalEvent = {
id: string
type: "form.created"
properties: {
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
form: FormFormInfo | FormUrlInfo
}
}
| {
@ -1428,6 +1429,13 @@ export type GlobalEvent = {
sessionID: string
}
}
| {
id: string
type: "websearch.updated"
properties: {
[key: string]: unknown
}
}
| {
id: string
type: "lsp.updated"
@ -3084,6 +3092,7 @@ export type V2Event =
| FormCreated
| FormReplied
| FormCancelled
| WebsearchUpdated
| LspUpdated
| PermissionAsked
| PermissionReplied
@ -3563,15 +3572,6 @@ export type FormUrlInfo = {
url: string
}
export type FormIntegrationInfo = {
id: string
sessionID: string
title: string
metadata?: FormMetadata
mode: "integration"
integrationID: string
}
export type FormValue =
| string
| number
@ -5723,10 +5723,6 @@ export type IntegrationEnvMethod = {
names: Array<string>
}
export type IntegrationWebSearch = {
connection: "optional" | "required"
}
export type ConnectionCredentialInfo = {
type: "credential"
id: string
@ -5744,7 +5740,6 @@ export type IntegrationInfo = {
id: string
name: string
methods: Array<IntegrationMethod>
websearch?: IntegrationWebSearch
connections: Array<ConnectionInfo>
}
@ -6573,7 +6568,7 @@ export type FormCreated = {
type: "form.created"
location?: LocationRef
data: {
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
form: FormFormInfo | FormUrlInfo
}
}
@ -6608,6 +6603,19 @@ export type FormCancelled = {
}
}
export type WebsearchUpdated = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "websearch.updated"
location?: LocationRef
data: {
[key: string]: unknown
}
}
export type LspUpdated = {
id: string
created: number
@ -7023,6 +7031,11 @@ export type ProjectCopyCopy = {
export type VcsMode = "working" | "branch"
export type WebSearchProvider = {
id: string
name: string
}
export type WebSearchResult = {
providerID: string
text: string
@ -7851,7 +7864,7 @@ export type EventFormCreated = {
id: string
type: "form.created"
properties: {
form: FormFormInfo | FormUrlInfo | FormIntegrationInfo
form: FormFormInfo | FormUrlInfo
}
}
@ -7874,6 +7887,14 @@ export type EventFormCancelled = {
}
}
export type EventWebsearchUpdated = {
id: string
type: "websearch.updated"
properties: {
[key: string]: unknown
}
}
export type EventLspUpdated = {
id: string
type: "lsp.updated"
@ -8701,6 +8722,7 @@ export type V2EventV2 =
| FormCreatedV2
| FormRepliedV2
| FormCancelledV2
| WebsearchUpdatedV2
| SessionStatusV22
| SessionIdleV2
| TuiPromptAppendV2
@ -9930,15 +9952,6 @@ export type FormUrlInfoV2 = {
url: string
}
export type FormIntegrationInfoV2 = {
id: string
sessionID: string
title: string
metadata?: FormMetadata
mode: "integration"
integrationID: string
}
export type FormCreatePayloadV2 = {
id?: string | null
title: string
@ -10674,15 +10687,6 @@ export type FormUrlInfo1 = {
url: string
}
export type FormIntegrationInfo1 = {
id: string
sessionID: string
title: string
metadata?: FormMetadata1
mode: "integration"
integrationID: string
}
export type FormCreatedV2 = {
id: string
created: number
@ -10692,7 +10696,7 @@ export type FormCreatedV2 = {
type: "form.created"
location?: LocationRefV2
data: {
form: FormFormInfo1 | FormUrlInfo1 | FormIntegrationInfo1
form: FormFormInfo1 | FormUrlInfo1
}
}
@ -10729,6 +10733,21 @@ export type FormCancelledV2 = {
}
}
export type WebsearchUpdatedV2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "websearch.updated"
location?: LocationRefV2
data:
| {
[key: string]: unknown
}
| Array<unknown>
}
export type SessionIdleV2 = {
id: string
created: number
@ -17245,7 +17264,7 @@ export type V2FormRequestListResponses = {
*/
200: {
location: LocationInfoV2
data: Array<FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2>
data: Array<FormFormInfoV2 | FormUrlInfoV2>
}
}
@ -17282,7 +17301,7 @@ export type V2SessionFormListResponses = {
* Success
*/
200: {
data: Array<FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2>
data: Array<FormFormInfoV2 | FormUrlInfoV2>
}
}
@ -17323,7 +17342,7 @@ export type V2SessionFormCreateResponses = {
* Success
*/
200: {
data: FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2
data: FormFormInfoV2 | FormUrlInfoV2
}
}
@ -17361,7 +17380,7 @@ export type V2SessionFormGetResponses = {
* Success
*/
200: {
data: FormFormInfoV2 | FormUrlInfoV2 | FormIntegrationInfoV2
data: FormFormInfoV2 | FormUrlInfoV2
}
}
@ -18968,7 +18987,7 @@ export type V2DebugLocationListResponses = {
export type V2DebugLocationListResponse = V2DebugLocationListResponses[keyof V2DebugLocationListResponses]
export type V2WebsearchProviderGetData = {
export type V2WebsearchProviderListData = {
body?: never
path?: never
query?: {
@ -18980,7 +18999,48 @@ export type V2WebsearchProviderGetData = {
url: "/api/websearch/provider"
}
export type V2WebsearchProviderGetErrors = {
export type V2WebsearchProviderListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* ServiceUnavailableError
*/
503: ServiceUnavailableErrorV2
}
export type V2WebsearchProviderListError = V2WebsearchProviderListErrors[keyof V2WebsearchProviderListErrors]
export type V2WebsearchProviderListResponses = {
/**
* Success
*/
200: {
location: LocationInfoV2
data: Array<WebSearchProvider>
}
}
export type V2WebsearchProviderListResponse = V2WebsearchProviderListResponses[keyof V2WebsearchProviderListResponses]
export type V2WebsearchProviderSelectedData = {
body?: never
path?: never
query?: {
location?: {
directory?: string | null
workspace?: string | null
} | null
}
url: "/api/websearch/provider/selected"
}
export type V2WebsearchProviderSelectedErrors = {
/**
* InvalidRequestError
*/
@ -18991,9 +19051,10 @@ export type V2WebsearchProviderGetErrors = {
401: UnauthorizedError
}
export type V2WebsearchProviderGetError = V2WebsearchProviderGetErrors[keyof V2WebsearchProviderGetErrors]
export type V2WebsearchProviderSelectedError =
V2WebsearchProviderSelectedErrors[keyof V2WebsearchProviderSelectedErrors]
export type V2WebsearchProviderGetResponses = {
export type V2WebsearchProviderSelectedResponses = {
/**
* Success
*/
@ -19003,7 +19064,8 @@ export type V2WebsearchProviderGetResponses = {
}
}
export type V2WebsearchProviderGetResponse = V2WebsearchProviderGetResponses[keyof V2WebsearchProviderGetResponses]
export type V2WebsearchProviderSelectedResponse =
V2WebsearchProviderSelectedResponses[keyof V2WebsearchProviderSelectedResponses]
export type V2WebsearchProviderSelectData = {
body: {
@ -19016,7 +19078,7 @@ export type V2WebsearchProviderSelectData = {
workspace?: string | null
} | null
}
url: "/api/websearch/provider"
url: "/api/websearch/provider/selected"
}
export type V2WebsearchProviderSelectErrors = {

View file

@ -25,6 +25,7 @@ const INTEGRATION_PRIORITY: Record<string, number> = {
type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
type IntegrationAttempt = IntegrationConnectOauthOutput["data"]
type OnIntegrationConnected = (providerID?: string) => void
type WebSearchProvider = { id: string; name: string }
export function integrationOptions(list: IntegrationInfo[]) {
return list.toSorted(
@ -59,27 +60,30 @@ export function DialogIntegration(
const data = useData()
const dialog = useDialog()
const { theme } = useTheme()
const options = createMemo(() =>
integrationOptions(data.location.integration.list() ?? [])
.filter((integration) => props.integrationID === undefined || integration.id === props.integrationID)
.map((integration) => {
const options = createMemo(() => {
const providers = data.location.websearch.list() ?? []
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
const integrations = integrationOptions(data.location.integration.list() ?? []).filter(
(integration) => props.integrationID === undefined || integration.id === props.integrationID,
)
const integrationIDs = new Set(integrations.map((integration) => integration.id))
return [
...integrations.map((integration) => {
const methods = connectMethods(integration)
const connected = integration.connections.length > 0
const websearch = integration.websearch
const selected = data.location.websearch.provider() === integration.id
const provider = providersByID.get(integration.id)
const selected = data.location.websearch.provider() === provider?.id
const credentials = credentialConnections(integration)
const description = websearch?.connection === "optional" ? "API key optional" : undefined
const category = websearch ? "Web search" : undefined
return {
title: integration.name,
value: integration.id,
description: description ?? (methods.length === 0 ? "Environment only" : undefined),
description: methods.length === 0 ? "Environment only" : undefined,
footer:
[connectionSummary(integration), selected ? "Web search default" : undefined]
.filter((value) => value !== undefined && value.length > 0)
.join(" · ") || undefined,
category: category ?? (integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services"),
disabled: methods.length === 0 && !websearch,
category: provider ? "Web search" : integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
disabled: methods.length === 0 && !provider,
gutter: connected ? () => <text fg={theme.success}></text> : undefined,
onSelect: () => {
if (props.connectionOnly) {
@ -87,14 +91,24 @@ export function DialogIntegration(
? manageConnections(integration, methods, dialog, props.onConnected)
: selectMethod(integration, methods, dialog, props.onConnected)
}
if (websearch) return manageIntegration(integration, methods, websearch, dialog)
if (provider) return manageWebSearch(provider, dialog, integration, methods)
return credentials.length
? manageConnections(integration, methods, dialog, props.onConnected)
: selectMethod(integration, methods, dialog, props.onConnected)
},
}
}),
)
...providers
.filter((provider) => props.integrationID === undefined && !integrationIDs.has(provider.id))
.map((provider) => ({
title: provider.name,
value: provider.id,
footer: data.location.websearch.provider() === provider.id ? "Web search default" : undefined,
category: "Web search",
onSelect: () => manageWebSearch(provider, dialog),
})),
]
})
return (
<DialogSelect
@ -109,28 +123,30 @@ export function DialogIntegration(
)
}
function manageIntegration(
integration: IntegrationInfo,
methods: ConnectMethod[],
websearch: NonNullable<IntegrationInfo["websearch"]>,
function manageWebSearch(
provider: WebSearchProvider,
dialog: ReturnType<typeof useDialog>,
integration?: IntegrationInfo,
methods: ConnectMethod[] = [],
) {
const connected = integration.connections.length > 0
dialog.replace(() => {
const data = useData()
const sdk = useSDK()
const toast = useToast()
const credentials = credentialConnections(integration)
const selected = createMemo(() => data.location.websearch.provider() === integration.id)
const credentials = integration ? credentialConnections(integration) : []
const selected = createMemo(() => data.location.websearch.provider() === provider.id)
const selectWebSearch = () => {
void sdk.api.websearch.provider
.select({
providerID: integration.id,
providerID: provider.id,
location: location(data),
})
.then(async () => {
await Promise.all([data.location.integration.refresh(), data.location.websearch.refresh()])
toast.show({ variant: "success", message: `${integration.name} is now the web search default` })
await Promise.all([
...(integration ? [data.location.integration.refresh()] : []),
data.location.websearch.refresh(),
])
toast.show({ variant: "success", message: `${provider.name} is now the web search default` })
dialog.clear()
})
.catch(toast.error)
@ -138,18 +154,15 @@ function manageIntegration(
return (
<DialogSelect
title={integration.name}
title={provider.name}
options={[
{
title: selected() ? "Web search default" : "Use for web search",
value: "websearch",
disabled: selected(),
onSelect:
websearch.connection === "required" && !connected
? () => selectMethod(integration, methods, dialog, selectWebSearch)
: selectWebSearch,
onSelect: selectWebSearch,
},
...(methods.length
...(integration && methods.length
? [
{
title: credentials.length ? "Manage connections" : "Connect",

View file

@ -7,7 +7,6 @@ import type {
AgentInfo,
CommandInfo,
FormFormInfo,
FormIntegrationInfo,
FormUrlInfo,
IntegrationInfo,
LocationRef,
@ -39,7 +38,7 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
// server cannot recover their Location when settling them. Preserve the event Location
// until MCP elicitations carry session ownership.
export type FormInfo = (FormFormInfo | FormUrlInfo | FormIntegrationInfo) & { readonly location?: LocationRef }
export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef }
type LocationData = {
agent?: AgentInfo[]
@ -49,7 +48,8 @@ type LocationData = {
model?: ModelInfo[]
provider?: ProviderV2Info[]
reference?: ReferenceInfo[]
websearchProvider?: string | null
websearch?: { id: string; name: string }[]
websearchSelected?: string | null
// Currently running shell commands for this location, keyed by shell id. Entries are removed
// once the command exits or is deleted, so this only ever holds in-flight shells.
shell?: Record<string, Shell>
@ -797,6 +797,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "config.updated":
void result.location.websearch.refresh(event.location)
break
case "websearch.updated":
void result.location.websearch.refresh(event.location)
break
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
// so the mcp list refreshes here rather than off integration.updated.
case "mcp.status.changed":
@ -1006,13 +1009,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
},
websearch: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.websearch
},
provider(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.websearchProvider ?? undefined
return store.location[locationKey(location ?? defaultLocation())]?.websearchSelected ?? undefined
},
async refresh(ref?: LocationRef) {
const result = await sdk.api.websearch.provider.get({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(result.location)
setStore("location", key, { ...store.location[key], websearchProvider: result.data ?? null })
const location = { location: locationQuery(ref ?? defaultLocation()) }
const [providers, selected] = await Promise.all([
sdk.api.websearch.provider.list(location),
sdk.api.websearch.provider.selected(location),
])
const key = locationKey(providers.location)
setStore("location", key, {
...store.location[key],
websearch: mutable(providers.data),
websearchSelected: selected.data ?? null,
})
},
},
skill: {

View file

@ -10,8 +10,6 @@ import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../ui/border"
import { useTuiConfig } from "../../config"
import { useBindings, useOpencodeModeStack } from "../../keymap"
import { DialogIntegration } from "../../component/dialog-integration"
import { useDialog } from "../../ui/dialog"
const FORM_MODE = "form"
@ -144,51 +142,9 @@ function requestOptions(form: FormInfo) {
export function FormPrompt(props: { form: FormInfo }) {
if (props.form.mode === "url") return <UrlPrompt form={props.form} />
if (props.form.mode === "integration") return <IntegrationPrompt form={props.form} />
return <FieldsPrompt form={props.form} />
}
function IntegrationPrompt(props: { form: FormInfo & { mode: "integration" } }) {
const sdk = useSDK()
const dialog = useDialog()
const { theme } = useTheme()
let settled = false
onMount(() => {
dialog.replace(
() => (
<DialogIntegration
integrationID={props.form.integrationID}
connectionOnly
onConnected={() => {
settled = true
dialog.clear()
void sdk.api.form.reply({ sessionID: props.form.sessionID, formID: props.form.id, answer: {} })
}}
/>
),
() => {
if (settled) return
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id })
},
)
})
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.accent}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
<text fg={theme.text}>{props.form.title ?? "Connect integration"}</text>
<text fg={theme.textMuted}>Complete the connection dialog to continue.</text>
</box>
</box>
)
}
function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
const sdk = useSDK()
const { theme } = useTheme()

View file

@ -66,6 +66,9 @@ test("refreshes resources into reactive getters", async () => {
location,
data: [{ id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] }],
})
if (url.pathname === "/api/websearch/provider")
return json({ location, data: [{ id: "standalone", name: "Standalone" }] })
if (url.pathname === "/api/websearch/provider/selected") return json({ location, data: "standalone" })
return undefined
}, events)
let data!: ReturnType<typeof useData>
@ -101,6 +104,7 @@ test("refreshes resources into reactive getters", async () => {
await data.session.refresh("ses_test")
await data.session.message.refresh("ses_test")
await data.location.agent.refresh()
await data.location.websearch.refresh()
expect(data.session.get("ses_test")?.title).toBe("Test session")
expect(data.session.message.ids("ses_test")).toEqual(["msg_first", "msg_second"])
@ -109,6 +113,8 @@ test("refreshes resources into reactive getters", async () => {
expect(app.captureCharFrame()).toContain("msg_second")
expect(data.location.default()).toEqual({ directory, workspaceID: undefined })
expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"])
expect(data.location.websearch.list(location)).toEqual([{ id: "standalone", name: "Standalone" }])
expect(data.location.websearch.provider(location)).toBe("standalone")
} finally {
app.renderer.destroy()
}

View file

@ -121,6 +121,9 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/api/reference")
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
if (url.pathname === "/api/websearch/provider") {
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
}
if (url.pathname === "/api/websearch/provider/selected") {
if (request.method === "POST") return new Response(null, { status: 204 })
return json({ location: { directory, project: { id: "proj_test", directory } }, data: null })
}