diff --git a/packages/app/src/components/dialog-connect-provider-harmoniqs.test.ts b/packages/app/src/components/dialog-connect-provider-harmoniqs.test.ts new file mode 100644 index 000000000..95b281ce7 --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider-harmoniqs.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { + HARMONIQS_PROVIDER_ID, + HARMONIQS_PROVIDER_NAME, + CONNECT_HARMONIQS_PROVIDER_KIND, + CONNECT_HARMONIQS_PROVIDER_ACK_KIND, + shouldShowHarmoniqsEntry, + requestHarmoniqsProviderConnect, + isHarmoniqsProviderConnectAck, +} from "./dialog-connect-provider-harmoniqs" + +// `inAmicode()` gates on `window.self !== window.top` — these tests fake an +// iframe by overriding `window.top` to a distinct object, and restore it +// afterward so other test files' `window` state is untouched. +function frame(): () => void { + const realTop = window.top + Object.defineProperty(window, "top", { value: {}, configurable: true }) + return () => Object.defineProperty(window, "top", { value: realTop, configurable: true }) +} + +describe("shouldShowHarmoniqsEntry", () => { + test("hidden when unframed (no extension host to relay to)", () => { + expect(shouldShowHarmoniqsEntry(new Set())).toBe(false) + }) + + test("shown when framed and the real provider hasn't landed in the catalog", () => { + const unframe = frame() + expect(shouldShowHarmoniqsEntry(new Set(["anthropic", "openai"]))).toBe(true) + unframe() + }) + + test('hidden when a real "harmoniqs" catalog entry already exists — the stub never shadows it', () => { + const unframe = frame() + expect(shouldShowHarmoniqsEntry(new Set([HARMONIQS_PROVIDER_ID]))).toBe(false) + unframe() + }) +}) + +describe("requestHarmoniqsProviderConnect", () => { + let posted: unknown[] = [] + let unframe: () => void + let restorePost: () => void + + beforeEach(() => { + posted = [] + unframe = frame() + const original = window.parent.postMessage.bind(window.parent) + window.parent.postMessage = ((msg: unknown) => posted.push(msg)) as typeof window.parent.postMessage + restorePost = () => { + window.parent.postMessage = original + } + }) + + afterEach(() => { + restorePost() + unframe() + }) + + test("posts the connect envelope, not a generic provider-connect message", () => { + requestHarmoniqsProviderConnect() + expect(posted).toEqual([{ source: "amicode", kind: CONNECT_HARMONIQS_PROVIDER_KIND }]) + }) + + test("never carries a provider id/config payload the generic ProviderConnection flow would expect", () => { + requestHarmoniqsProviderConnect() + const msg = posted[0] as Record + expect(msg).not.toHaveProperty("provider") + expect(msg).not.toHaveProperty("apiKey") + expect(msg).not.toHaveProperty("baseURL") + }) +}) + +describe("isHarmoniqsProviderConnectAck", () => { + test("recognizes the extension's ack envelope", () => { + expect(isHarmoniqsProviderConnectAck({ source: "amicode", kind: CONNECT_HARMONIQS_PROVIDER_ACK_KIND })).toBe(true) + }) + + test("rejects foreign or malformed messages", () => { + expect(isHarmoniqsProviderConnectAck(undefined)).toBe(false) + expect(isHarmoniqsProviderConnectAck(null)).toBe(false) + expect(isHarmoniqsProviderConnectAck("connect-harmoniqs-provider-ack")).toBe(false) + expect(isHarmoniqsProviderConnectAck({ source: "amicode", kind: "dev-tools-status" })).toBe(false) + expect(isHarmoniqsProviderConnectAck({ source: "other", kind: CONNECT_HARMONIQS_PROVIDER_ACK_KIND })).toBe(false) + }) +}) + +describe("constants", () => { + test("HARMONIQS_PROVIDER_NAME is the branded display name", () => { + expect(HARMONIQS_PROVIDER_NAME).toBe("Harmoniqs AI") + }) +}) diff --git a/packages/app/src/components/dialog-connect-provider-harmoniqs.ts b/packages/app/src/components/dialog-connect-provider-harmoniqs.ts new file mode 100644 index 000000000..eb6a5a227 --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider-harmoniqs.ts @@ -0,0 +1,53 @@ +// Harmoniqs AI — a branded, always-present entry in the Connect Provider +// picker (amicode#962). It is deliberately NOT sourced from providers().all(): +// Harmoniqs is a preset (fixed base URL/model, key routed straight to +// opencode's own auth store) that the generic key-entry flow +// (ProviderConnection in dialog-connect-provider.tsx) cannot express without +// duplicating that logic — see packages/extension/src/onboarding_panel.ts +// (amicode repo) for where writeOnboardingConfig/writeAuthApiKey/ +// testConnection/classifyHarmoniqsError actually live. Clicking this entry +// hands off to that logic's own connection UI instead of rendering here. +// +// Extracted from dialog-connect-provider.tsx (mirrors dialog-custom-provider's +// split into dialog-custom-provider-form.ts) so the gating/messaging logic is +// unit-testable without rendering the picker. + +import { inAmicode } from "@/utils/amicode-bridge" + +export const HARMONIQS_PROVIDER_ID = "harmoniqs" +export const HARMONIQS_PROVIDER_NAME = "Harmoniqs AI" + +/** The envelope this module posts when the branded row is clicked. */ +export const CONNECT_HARMONIQS_PROVIDER_KIND = "connect-harmoniqs-provider" +/** The envelope the extension host acks with — the dialog closes on receipt + * (see useHarmoniqsProviderConnectAck). The extension's own handoff panel + * (Stage-0's onboarding webview, focused to just this provider) runs + * independently after that; this dialog has no further role. */ +export const CONNECT_HARMONIQS_PROVIDER_ACK_KIND = "connect-harmoniqs-provider-ack" + +type KnownProviderIds = ReadonlySet | { has(id: string): boolean } + +/** Whether the branded Harmoniqs row belongs in the picker right now: + * - only inside the extension (`inAmicode()`) — outside it there is no host + * to relay `connect-harmoniqs-provider` to, so a visible-but-dead entry + * would just confuse; and + * - only while the real provider hasn't already landed in the catalog some + * other way (a future `provider.harmoniqs` entry in opencode.json, or the + * generic seed mechanism tracked separately as amicode#326) — the stub + * must never shadow a real catalog entry. */ +export function shouldShowHarmoniqsEntry(knownProviderIds: KnownProviderIds): boolean { + return inAmicode() && !knownProviderIds.has(HARMONIQS_PROVIDER_ID) +} + +/** Ask the extension host to open its own Harmoniqs connection UI. */ +export function requestHarmoniqsProviderConnect(): void { + if (typeof window === "undefined") return + window.parent?.postMessage({ source: "amicode", kind: CONNECT_HARMONIQS_PROVIDER_KIND }, "*") +} + +/** True for the extension's ack envelope — the dialog's cue to close. */ +export function isHarmoniqsProviderConnectAck(data: unknown): boolean { + if (!data || typeof data !== "object") return false + const d = data as { source?: unknown; kind?: unknown } + return d.source === "amicode" && d.kind === CONNECT_HARMONIQS_PROVIDER_ACK_KIND +} diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 84267fd31..c6294d23c 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -38,6 +38,13 @@ import { useSettings } from "@/context/settings" import { popularProviders, useProviders } from "@/hooks/use-providers" import { CustomProviderForm } from "./dialog-custom-provider" import { decode64 } from "@/utils/base64" +import { + HARMONIQS_PROVIDER_ID, + HARMONIQS_PROVIDER_NAME, + requestHarmoniqsProviderConnect, + shouldShowHarmoniqsEntry, + isHarmoniqsProviderConnectAck, +} from "./dialog-connect-provider-harmoniqs" const CUSTOM_ID = "_custom" type ConnectMethod = Extract @@ -62,6 +69,7 @@ export const DialogConnectProvider: Component<{ const language = useLanguage() const settings = useSettings() const newLayout = settings.general.newLayoutDesigns + const dialog = useDialog() const reset = controller.back const back = { current: reset } let focusHost: HTMLDivElement | undefined @@ -71,6 +79,17 @@ export const DialogConnectProvider: Component<{ controller.select(provider) } + // amicode#962: the extension's Harmoniqs handoff panel (Stage-0's onboarding + // webview, focused to just this provider) runs independently of this + // dialog once opened — its ack is this dialog's cue to get out of the way. + onMount(() => { + const onMessage = (event: MessageEvent) => { + if (isHarmoniqsProviderConnectAck(event.data)) dialog.close() + } + window.addEventListener("message", onMessage) + onCleanup(() => window.removeEventListener("message", onMessage)) + }) + function Content() { return ( @@ -180,13 +199,20 @@ function ProviderPicker(props: { key={(x) => x?.id} items={() => { language.locale() - return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] + const all = providers.all() + return [ + { id: CUSTOM_ID, name: customLabel() }, + ...(shouldShowHarmoniqsEntry(all) ? [{ id: HARMONIQS_PROVIDER_ID, name: HARMONIQS_PROVIDER_NAME }] : []), + ...all.values(), + ] }} filterKeys={["id", "name"]} groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} sortBy={(a, b) => { if (a.id === CUSTOM_ID) return -1 if (b.id === CUSTOM_ID) return 1 + if (a.id === HARMONIQS_PROVIDER_ID) return -1 + if (b.id === HARMONIQS_PROVIDER_ID) return 1 if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) return a.name.localeCompare(b.name) @@ -199,6 +225,10 @@ function ProviderPicker(props: { }} onSelect={(x) => { if (!x) return + if (x.id === HARMONIQS_PROVIDER_ID) { + requestHarmoniqsProviderConnect() + return + } props.onSelect(x.id) }} > @@ -237,12 +267,23 @@ function ProviderPickerV2(props: { active: undefined as string | undefined, connecting: undefined as string | undefined, }) - const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"] + const featured = [ + "opencode", + "opencode-go", + "anthropic", + "openai", + "google", + "openrouter", + "vercel", + HARMONIQS_PROVIDER_ID, + ] const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") }) + const harmoniqs = () => ({ id: HARMONIQS_PROVIDER_ID, name: HARMONIQS_PROVIDER_NAME }) const all = createMemo(() => { language.locale() const query = store.filter.trim().toLowerCase() - const values = [custom(), ...providers.all().values()] + const providerMap = providers.all() + const values = [custom(), ...(shouldShowHarmoniqsEntry(providerMap) ? [harmoniqs()] : []), ...providerMap.values()] if (!query) return values return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query)) }) @@ -267,6 +308,10 @@ function ProviderPickerV2(props: { onMount(() => search?.focus({ preventScroll: true })) const connect = (provider: string) => { + if (provider === HARMONIQS_PROVIDER_ID) { + requestHarmoniqsProviderConnect() + return + } props.onPrepare?.() props.onSelect(provider) }