Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ jobs:
test/project
test/file
test/github
test/settings
test/settings.test.ts
report_path: packages/opencode/.artifacts/unit/junit-windows-config-project.xml
# Server tools carries many smaller and faster directories, which is
Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ const SessionRoute = () => (

const SessionIndexRoute = () => <Navigate href="session" />

type WebSearchStatus = {
source: "saved" | "env" | "anonymous"
configured: boolean
needsAttention: boolean
quotaExceeded: boolean
}

function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
Expand All @@ -83,6 +90,10 @@ declare global {
getAboutInfo?: () => Promise<AboutInfo>
onAboutOpen?: (handler: () => void) => () => void
setLspEnabled?: (value: boolean) => Promise<void>
setWebSearchEnabled?: (value: boolean) => Promise<void>
webSearchStatus?: () => Promise<WebSearchStatus>
saveExaApiKey?: (key: string) => Promise<WebSearchStatus>
removeExaApiKey?: () => Promise<WebSearchStatus>
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"

const source = readFileSync(new URL("./dialog-connect-websearch.tsx", import.meta.url), "utf8")

describe("dialog-connect-websearch source contract", () => {
test("exports DialogConnectWebSearch", () => {
expect(source).toContain("export function DialogConnectWebSearch")
})

test("renders all four state branches", () => {
// anonymous default state
expect(source).toContain('source === "anonymous"')
expect(source).toContain("quotaExceeded")
// env read-only state
expect(source).toContain('source === "env"')
// saved states (healthy and needsAttention both match on source === "saved")
expect(source).toContain('source === "saved"')
// needsAttention branch
expect(source).toContain("needsAttention")
})

test("does not fabricate anonymous status before status loads", () => {
expect(source).not.toContain('source: "anonymous" as const')
expect(source).toContain("webSearchStatusResource.error")
expect(source).toContain("dialog.websearch.status.loading")
expect(source).toContain("dialog.websearch.status.error")
})

test("calls window.api saveExaApiKey and removeExaApiKey", () => {
expect(source).toMatch(/window\.api[\s\S]{0,40}saveExaApiKey/)
expect(source).toMatch(/window\.api[\s\S]{0,40}removeExaApiKey/)
})

test("imports Dialog from @opencode-ai/ui/dialog (visual pattern reuse)", () => {
expect(source).toContain('from "@opencode-ai/ui/dialog"')
})

test("does NOT import useProviders or globalSDK (Exa is not an LLM provider)", () => {
expect(source).not.toContain("useProviders")
expect(source).not.toContain("globalSDK")
})
})
276 changes: 276 additions & 0 deletions packages/app/src/components/dialog-connect-websearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { createMemo, createResource, createSignal, Match, Show, Switch } from "solid-js"
import { useLanguage } from "@/context/language"
import { Link } from "./link"

export function DialogConnectWebSearch() {
const dialog = useDialog()
const language = useLanguage()

const [webSearchStatusResource, webSearchStatusActions] = createResource(() => {
const load = window.api?.webSearchStatus
if (!load) throw new Error("Web Search settings are unavailable.")
return load()
})
const status = createMemo(() => webSearchStatusResource.latest)
const statusError = createMemo(() => webSearchStatusResource.error)

const [apiKeyInput, setApiKeyInput] = createSignal("")
const [saving, setSaving] = createSignal(false)
const [removing, setRemoving] = createSignal(false)
// Validation error clears on input change (addresses PR #271 review P3).
const [validationError, setValidationError] = createSignal("")

const title = createMemo(() => {
const s = status()
if (statusError()) return language.t("common.requestFailed")
if (!s) return language.t("common.loading")
if (s.source === "saved" && s.needsAttention) return language.t("dialog.websearch.title.failed")
if (s.source === "saved") return language.t("dialog.websearch.title.saved")
if (s.source === "anonymous" && s.quotaExceeded) return language.t("dialog.websearch.title.exhausted")
return language.t("dialog.websearch.title.default")
})

const handleSave = () => {
if (saving() || removing()) return
const key = apiKeyInput().trim()
if (!key) {
setValidationError(language.t("provider.connect.apiKey.required"))
return
}
if (!window.api?.saveExaApiKey) return
setSaving(true)
setValidationError("")
void window.api
.saveExaApiKey(key)
.then(() => {
setApiKeyInput("")
void webSearchStatusActions.refetch()
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("toast.websearch.saved.title"),
})
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err || language.t("common.requestFailed"))
// Surface inline for key-shaped errors; toast for other errors.
setValidationError(msg)
})
.finally(() => setSaving(false))
}

const handleRemove = () => {
if (saving() || removing()) return
if (!window.api?.removeExaApiKey) return
setRemoving(true)
setValidationError("")
void window.api
.removeExaApiKey()
.then(() => {
setApiKeyInput("")
void webSearchStatusActions.refetch()
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("toast.websearch.removed.title"),
})
})
.catch((err: unknown) => {
showToast({
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
variant: "error",
})
})
.finally(() => setRemoving(false))
}

return (
<Dialog
title={
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => dialog.close()}
aria-label={language.t("common.goBack")}
/>
}
>
<div class="flex flex-col gap-6 px-2.5 pb-3">
{/* Header: service icon + title */}
<div class="px-2.5 flex gap-4 items-center">
<Icon name="link" class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">{title()}</div>
</div>

<div class="px-2.5 pb-10 flex flex-col gap-6">
<Switch>
{/* loading/error state: avoid showing anonymous setup before status is known */}
<Match when={statusError()}>
<div class="flex flex-col gap-4">
<div class="text-14-regular text-text-base">{language.t("dialog.websearch.status.error")}</div>
<Button size="large" variant="primary" onClick={() => void webSearchStatusActions.refetch()}>
{language.t("dialog.websearch.action.retry")}
</Button>
</div>
</Match>

<Match when={!status()}>
<div class="text-14-regular text-text-base">{language.t("dialog.websearch.status.loading")}</div>
</Match>

{/* env state: read-only, no input, no save/remove */}
<Match when={status()?.source === "env"}>
<div class="text-14-regular text-text-base">{language.t("dialog.websearch.body.env")}</div>
</Match>

{/* saved + healthy state */}
<Match when={status()?.source === "saved" && !status()?.needsAttention}>
<div class="flex flex-col gap-4">
<div class="text-14-regular text-text-base">{language.t("dialog.websearch.status.active")}</div>
<div class="flex gap-2">
<Button size="large" variant="ghost" disabled={removing() || saving()} onClick={handleRemove}>
{language.t("dialog.websearch.action.remove")}
</Button>
<Button
size="large"
variant="primary"
disabled={saving() || removing() || apiKeyInput().trim() === ""}
onClick={handleSave}
>
{language.t("dialog.websearch.action.update")}
</Button>
</div>
<TextField
autofocus
type="password"
label={language.t("dialog.websearch.placeholder")}
hideLabel
placeholder={language.t("dialog.websearch.placeholder")}
value={apiKeyInput()}
onChange={(v) => {
setApiKeyInput(v)
// Clear validation error on input change (PR #271 review P3).
if (validationError()) setValidationError("")
}}
validationState={validationError() ? "invalid" : undefined}
error={validationError()}
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck={false}
/>
</div>
</Match>

{/* saved + needsAttention state */}
<Match when={status()?.source === "saved" && status()?.needsAttention}>
<div class="flex flex-col gap-4">
<div class="text-14-regular text-text-base">{language.t("dialog.websearch.status.failed")}</div>
<TextField
autofocus
type="password"
label={language.t("dialog.websearch.placeholder")}
hideLabel
placeholder={language.t("dialog.websearch.placeholder")}
value={apiKeyInput()}
onChange={(v) => {
setApiKeyInput(v)
if (validationError()) setValidationError("")
}}
validationState={validationError() ? "invalid" : undefined}
error={validationError()}
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck={false}
/>
<div class="flex gap-2">
<Button size="large" variant="ghost" disabled={removing() || saving()} onClick={handleRemove}>
{language.t("dialog.websearch.action.removeShort")}
</Button>
<Button
size="large"
variant="primary"
disabled={saving() || removing() || apiKeyInput().trim() === ""}
onClick={handleSave}
>
{language.t("dialog.websearch.action.saveShort")}
</Button>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
</Match>

{/* anonymous (default) state */}
<Match when={status()?.source === "anonymous"}>
<div class="flex flex-col gap-4">
<div class="text-14-regular text-text-base">
{language.t(
status()?.quotaExceeded
? "dialog.websearch.body.exhausted.line1"
: "dialog.websearch.body.default.line1",
)}
</div>
<div class="text-14-regular text-text-base">
{language.t(
status()?.quotaExceeded
? "dialog.websearch.body.exhausted.line2"
: "dialog.websearch.body.default.line2",
)}
</div>
<TextField
autofocus
type="password"
label={language.t("dialog.websearch.placeholder")}
hideLabel
placeholder={language.t("dialog.websearch.placeholder")}
value={apiKeyInput()}
onChange={(v) => {
setApiKeyInput(v)
if (validationError()) setValidationError("")
}}
validationState={validationError() ? "invalid" : undefined}
error={validationError()}
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck={false}
/>
<div class="text-12-regular text-text-weak">
{language.t(
status()?.quotaExceeded ? "dialog.websearch.status.exhausted" : "dialog.websearch.status.bundled",
)}
</div>
<div class="flex flex-col gap-2">
<Button
size="large"
variant="primary"
disabled={saving() || removing() || apiKeyInput().trim() === ""}
onClick={handleSave}
>
{language.t("dialog.websearch.action.save")}
</Button>
<Show when={!saving()}>
<div class="text-12-regular text-text-weak">
<Link href="https://exa.ai">{language.t("dialog.websearch.help.getKey")}</Link>
</div>
</Show>
</div>
</div>
</Match>
</Switch>
</div>
</div>
</Dialog>
)
}
Loading
Loading