mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-10 08:25:05 +00:00
feat(desktop): add third-party web search consent
This commit is contained in:
parent
cf212a4235
commit
33ef66746b
15 changed files with 912 additions and 26 deletions
79
packages/app/component-tests/websearch.spec.ts
Normal file
79
packages/app/component-tests/websearch.spec.ts
Normal 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: "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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
58
packages/app/src/session/requests/session-websearch-dock.css
Normal file
58
packages/app/src/session/requests/session-websearch-dock.css
Normal 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;
|
||||
}
|
||||
}
|
||||
86
packages/app/src/session/requests/session-websearch-dock.tsx
Normal file
86
packages/app/src/session/requests/session-websearch-dock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
packages/app/src/session/requests/websearch.test.ts
Normal file
171
packages/app/src/session/requests/websearch.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
154
packages/app/src/session/requests/websearch.ts
Normal file
154
packages/app/src/session/requests/websearch.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
209
packages/app/test-browser/websearch-request.test.ts
Normal file
209
packages/app/test-browser/websearch-request.test.ts
Normal 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" }])
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue