From ec3ae96f85a89009b429bf38c59ecff8bf0b0a37 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Thu, 10 Sep 2026 05:52:26 -0400 Subject: [PATCH] feat(onboarding): focused Harmoniqs AI connect entry point for the generic Connect Provider dialog Companion to harmoniqs/opencode#327: the app's Connect Provider dialog gets a branded "Harmoniqs AI" row that isn't sourced from the provider catalog -- clicking it posts {kind: "connect-harmoniqs-provider"} instead of opening the generic key-entry flow, since Harmoniqs is a preset (fixed base URL and model, key routed straight to the auth store) that flow can't express. - chat_bridge.ts: new handler acks immediately (so the dialog can close) and executes amicode.connectHarmoniqsProvider. - chat_panel.ts: relay allowlists (both lanes, both HTML copies) admit the new kind pair. - onboarding_panel.ts: extracted the amicode.onboarding.open command body into openOnboardingPanel(ctx, options), parameterized by focusProvider (restrict + skip animation) and bootstrap (Stage-0 side effects -- queue greeting, restart server -- default true, unchanged). The new amicode.connectHarmoniqsProvider command opens the SAME webview/message handling with {focusProvider: "harmoniqs", bootstrap: false}: no restart, no greeting, since the chat panel and server are already live when this fires. Zero duplication of writeOnboardingConfig/writeAuthApiKey/ testConnection/classifyHarmoniqsError. - onboarding_webview.ts: reads window.__FOCUS_PROVIDER__ -- skips the intro animation, restricts+locks the provider select to one option, hides the import-credentials section (not reachable in a single-provider handoff). - extension.ts, package.json: registers the new command. Tests: chat_bridge.test.ts covers the ack + command dispatch; onboarding_panel.test.ts covers same-webview reuse (view type, injected PROVIDER_MODELS/PROVIDER_DISPLAY_NAMES data, __FOCUS_PROVIDER__), singleton behavior, and the bootstrap:false branch (via cancel, not config-success -- config-success's write path defaults to the real ~/.config/opencode path with no configPath override, and os/fs builtins aren't spyable in this vitest setup, so cancel is the safe proxy for the same branching). --- packages/extension/package.json | 4 + packages/extension/src/chat_bridge.ts | 14 + packages/extension/src/chat_panel.ts | 8 +- packages/extension/src/extension.ts | 9 +- packages/extension/src/onboarding_panel.ts | 416 +++++++++++------- packages/extension/src/onboarding_webview.ts | 51 ++- packages/extension/test/chat_bridge.test.ts | 12 + .../extension/test/onboarding_panel.test.ts | 101 +++++ 8 files changed, 432 insertions(+), 183 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index 8f76078a6..9eb6d4bcd 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -70,6 +70,10 @@ "command": "amicode.onboarding.open", "title": "Amicode: Open Onboarding" }, + { + "command": "amicode.connectHarmoniqsProvider", + "title": "Amicode: Connect Harmoniqs AI Provider" + }, { "command": "amicode.openChat", "title": "Amicode: Open Chat", diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index 313fa2305..40bdfdc28 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -324,6 +324,20 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean return true; } + // Connect Provider dialog → "Harmoniqs AI" branded entry: the app's + // generic picker never renders the generic key-entry flow for Harmoniqs — + // it hands off here instead, because Harmoniqs is a branded preset (fixed + // base URL/model, key routed straight to the auth store) the generic flow + // cannot express without duplicating onboarding_panel.ts's logic. Ack + // immediately so the dialog can close; the handoff panel it opens + // (amicode.connectHarmoniqsProvider, registered in onboarding_panel.ts) + // runs independently — see openOnboardingPanel's `bootstrap: false`. + if (msg.kind === "connect-harmoniqs-provider") { + io.postToWebview({ source: "amicode", kind: "connect-harmoniqs-provider-ack", tab: msg.tab }); + void vscode.commands.executeCommand("amicode.connectHarmoniqsProvider"); + return true; + } + // Developer Tools settings: validate paths, swap the opencode binary + // restart its server as appropriate. The app posts on blur and on toggle. // Committing the amicode path is validate-only — no build, no reload; see diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 4ee0dfec6..bb9dda326 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -457,7 +457,7 @@ export class ChatPanel { vscode.postMessage({ source: "amicode", kind: "clipboard-image-read", nonce: d.nonce }); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files")) { vscode.postMessage(d); } return; @@ -467,7 +467,7 @@ export class ChatPanel { // our own envelopes, pinned to the opencode origin. #351 adds // run:*/device:* envelopes for the Work Column inspector tabs. // #934: preview-file — sidebar/chat file routing to the Preview companion tab. - if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle" || d.kind === "preview-file")) { + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "connect-harmoniqs-provider-ack" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle" || d.kind === "preview-file")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, ${origin}); } @@ -619,12 +619,12 @@ export class ChatPanel { vscode.postMessage({ source: "amicode", kind: "clipboard-image-read", nonce: d.nonce }); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files")) { vscode.postMessage(d); } return; } - if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle" || d.kind === "preview-file")) { + if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "connect-harmoniqs-provider-ack" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify" || d.kind === "fs-diff-invalidate" || d.kind === "agent-cycle" || d.kind === "preview-file")) { var f = document.querySelector("iframe"); if (f && f.contentWindow) f.contentWindow.postMessage(d, origin); } diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 6bdaaca8b..5ef661ff1 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -38,7 +38,13 @@ import { writeStopFile, stopPlan, forceStop, runLogMtime } from "./run_controls" import { watchSolverMode, applyEntitlementForMode, readSolverModeState } from "./solver_mode"; import { runSetCloudKeyCommand } from "./cloud_key"; import { amicodeOpsDir } from "./substrate/vault_store"; -import { registerOnboardingPanel, onOnboardingCancelled, getOnboardingPanel, releaseOnboardingPanel } from "./onboarding_panel"; +import { + registerOnboardingPanel, + registerHarmoniqsConnectCommand, + onOnboardingCancelled, + getOnboardingPanel, + releaseOnboardingPanel, +} from "./onboarding_panel"; import { registerFleetPanel } from "./fleet_panel"; import { isModelConfigured } from "./onboarding_routing"; import { getWorkspaceProjects, type WorkspaceProjectDeps } from "./workspace_projects"; @@ -422,6 +428,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // behavior: "reset" for explicit selection, "expand" for session/tab switch. ChatPanel.onProjectSelected((path, mode) => sidebarProvider.setActiveProject(path, mode)); registerOnboardingPanel(ctx); // #433 — Stage 0 model-setup webview + registerHarmoniqsConnectCommand(ctx); // Connect Provider dialog's branded Harmoniqs row registerFleetPanel(ctx); // #527 — Fleet & Versions: the view over doctor's JSON statusBar = new StatusBarManager(); ctx.subscriptions.push({ dispose: () => statusBar?.dispose() }); diff --git a/packages/extension/src/onboarding_panel.ts b/packages/extension/src/onboarding_panel.ts index 1c5cd4907..8ffc1869a 100644 --- a/packages/extension/src/onboarding_panel.ts +++ b/packages/extension/src/onboarding_panel.ts @@ -755,198 +755,271 @@ ${fontFace} `; } -/** Register the onboarding panel command. Call from extension.ts activate(). */ -export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { - ctx.subscriptions.push( - vscode.commands.registerCommand("amicode.onboarding.open", () => { - if (currentPanel) { - currentPanel.reveal(vscode.ViewColumn.One); - return; - } +/** Options for {@link openOnboardingPanel}. */ +export interface OpenOnboardingPanelOptions { + /** Restrict the provider picker to this one provider and skip the welcome + * animation — the focused connect entry point: the generic Connect + * Provider dialog's branded Harmoniqs row hands off here instead of + * duplicating the connect UI. `undefined` (the Stage-0 wizard's own + * `amicode.onboarding.open` command) shows the full picker, unchanged. */ + focusProvider?: string; + /** Whether a successful connection runs the Stage-0 bootstrap side effects + * (queue the post-onboarding greeting, restart the opencode server so a + * not-yet-running server picks up the very first provider). Defaults to + * true, so `amicode.onboarding.open`'s existing behavior is unchanged. + * The focused-connect flow passes false: the chat panel and server are + * already live when this fires, so there's no "get chat ready" splash to + * show and no reason to bounce the running server. */ + bootstrap?: boolean; +} - const panel = vscode.window.createWebviewPanel( - "amicode.onboarding", - "Welcome to Amicode", - vscode.ViewColumn.One, - { - enableScripts: true, - localResourceRoots: [ - vscode.Uri.joinPath(ctx.extensionUri, "dist"), - vscode.Uri.joinPath(ctx.extensionUri, "media"), - ], - }, - ); - currentPanel = panel; - - // Handle messages from the webview - let heldCredentials: DetectedCredential[] = []; - const testResults = new Map(); // provider -> passed - const validatedModels = new Map(); // provider -> validated model ID - let scanAborted = false; - - panel.webview.onDidReceiveMessage( - async (msg: { type: string; payload?: unknown }) => { - if (msg.type === "test-connection") { - const payload = msg.payload as OnboardingConfig; - const result = await testConnection(payload); - panel.webview.postMessage({ type: "test-result", payload: result }); - } else if (msg.type === "config-success") { - const payload = msg.payload as OnboardingConfig; - writeOnboardingConfig(payload); - // Clear stale model pin — the old provider may no longer be connected. - // The server will resolve the new provider's default on its own. - void vscode.workspace.getConfiguration("amicode").update("defaultModel", undefined, vscode.ConfigurationTarget.Global); - // Swap the panel HTML directly to the splash (same as confirm-import) - panel.webview.html = splashHtml( - panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, "media", "ui", "atoms", "DMSans-Variable.woff2")), - panel.webview.cspSource, - ); - // Signal that the next chat panel open should auto-send the onboarding greeting - ChatPanel.setPendingOnboardingGreeting(true); - fireOnboardingComplete(); - // Restart server so it picks up the new provider config. - // Chat opens via the onReady-gated listener in extension.ts. - void vscode.commands.executeCommand("amicode.restartServer"); - } else if (msg.type === "cancel") { - // User cancelled onboarding — close panel, re-open chat - panel.dispose(); - fireOnboardingCancelled(); - // Also directly open chat as fallback (in case no listener is wired) - void vscode.commands.executeCommand("amicode.openChat"); - } else if (msg.type === "scan-credentials") { - // Auto-import: scan for existing credentials - scanAborted = false; - heldCredentials = []; +/** Open (or reveal) the onboarding webview panel. Extracted from the + * `amicode.onboarding.open` command body so the focused single-provider + * connect flow ({@link registerHarmoniqsConnectCommand}) can reuse the + * exact same webview, message handling, and config-writing logic instead + * of a second UI surface. */ +export function openOnboardingPanel( + ctx: vscode.ExtensionContext, + options: OpenOnboardingPanelOptions = {}, +): vscode.WebviewPanel { + const { focusProvider, bootstrap = true } = options; + + if (currentPanel) { + currentPanel.reveal(vscode.ViewColumn.One); + return currentPanel; + } + + const panel = vscode.window.createWebviewPanel( + "amicode.onboarding", + "Welcome to Amicode", + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(ctx.extensionUri, "dist"), + vscode.Uri.joinPath(ctx.extensionUri, "media"), + ], + }, + ); + currentPanel = panel; + + // Handle messages from the webview + let heldCredentials: DetectedCredential[] = []; + const testResults = new Map(); // provider -> passed + const validatedModels = new Map(); // provider -> validated model ID + let scanAborted = false; + + panel.webview.onDidReceiveMessage( + async (msg: { type: string; payload?: unknown }) => { + if (msg.type === "test-connection") { + const payload = msg.payload as OnboardingConfig; + const result = await testConnection(payload); + panel.webview.postMessage({ type: "test-result", payload: result }); + } else if (msg.type === "config-success") { + const payload = msg.payload as OnboardingConfig; + writeOnboardingConfig(payload); + // Clear stale model pin — the old provider may no longer be connected. + // The server will resolve the new provider's default on its own. + void vscode.workspace.getConfiguration("amicode").update("defaultModel", undefined, vscode.ConfigurationTarget.Global); + if (!bootstrap) { + // Focused connect: the chat panel + server are already live — just + // close the handoff panel. No restart, no greeting, no splash; + // those are Stage-0-only (see OpenOnboardingPanelOptions above). + panel.dispose(); + fireOnboardingComplete(); + return; + } + // Swap the panel HTML directly to the splash (same as confirm-import) + panel.webview.html = splashHtml( + panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, "media", "ui", "atoms", "DMSans-Variable.woff2")), + panel.webview.cspSource, + ); + // Signal that the next chat panel open should auto-send the onboarding greeting + ChatPanel.setPendingOnboardingGreeting(true); + fireOnboardingComplete(); + // Restart server so it picks up the new provider config. + // Chat opens via the onReady-gated listener in extension.ts. + void vscode.commands.executeCommand("amicode.restartServer"); + } else if (msg.type === "cancel") { + // User cancelled onboarding — close panel, re-open chat + panel.dispose(); + fireOnboardingCancelled(); + if (bootstrap) { + // Also directly open chat as fallback (in case no listener is wired). + // Focused connect: the chat panel this was opened alongside never + // went anywhere — nothing to re-open. + void vscode.commands.executeCommand("amicode.openChat"); + } + } else if (msg.type === "scan-credentials") { + // Auto-import: scan for existing credentials + scanAborted = false; + heldCredentials = []; + panel.webview.postMessage({ + type: "scan-status", + payload: { state: "searching" }, + }); + + try { + const scanResult = await scanCredentials(defaultScanOptions()); + if (scanAborted) return; // Panel was closed mid-scan + heldCredentials = scanResult.credentials; + + if (heldCredentials.length === 0) { panel.webview.postMessage({ type: "scan-status", - payload: { state: "searching" }, + payload: { state: "empty" }, + }); + } else { + panel.webview.postMessage({ + type: "scan-status", + payload: { state: "found", count: heldCredentials.length }, + }); + // Send webview-safe results (no key material) + panel.webview.postMessage({ + type: "scan-results", + payload: { providers: webviewSafeResults(heldCredentials) }, }); - try { - const scanResult = await scanCredentials(defaultScanOptions()); - if (scanAborted) return; // Panel was closed mid-scan - heldCredentials = scanResult.credentials; - - if (heldCredentials.length === 0) { - panel.webview.postMessage({ - type: "scan-status", - payload: { state: "empty" }, - }); - } else { - panel.webview.postMessage({ - type: "scan-status", - payload: { state: "found", count: heldCredentials.length }, - }); - // Send webview-safe results (no key material) - panel.webview.postMessage({ - type: "scan-results", - payload: { providers: webviewSafeResults(heldCredentials) }, - }); - - // Run connection tests with model probing in parallel (AC12) - // For each provider, probe models in order to find the first accessible one - const testPromises = heldCredentials.map(async (cred) => { - const validModel = await probeModels(cred.provider, cred.key); - const ok = validModel !== undefined; - testResults.set(cred.provider, ok); - if (validModel) { - validatedModels.set(cred.provider, validModel.id); - } - if (!scanAborted) { - panel.webview.postMessage({ - type: "test-status-update", - payload: { - provider: cred.provider, - ok, - error: ok ? undefined : "No accessible model found for this provider", - ...(validModel ? { model: validModel.id } : {}), - }, - }); - } - }); - // Fire all tests in parallel, don't await sequentially - void Promise.allSettled(testPromises); + // Run connection tests with model probing in parallel (AC12) + // For each provider, probe models in order to find the first accessible one + const testPromises = heldCredentials.map(async (cred) => { + const validModel = await probeModels(cred.provider, cred.key); + const ok = validModel !== undefined; + testResults.set(cred.provider, ok); + if (validModel) { + validatedModels.set(cred.provider, validModel.id); } - } catch { if (!scanAborted) { panel.webview.postMessage({ - type: "scan-status", - payload: { state: "failed", error: "Scan failed unexpectedly" }, + type: "test-status-update", + payload: { + provider: cred.provider, + ok, + error: ok ? undefined : "No accessible model found for this provider", + ...(validModel ? { model: validModel.id } : {}), + }, }); } - } - } else if (msg.type === "confirm-import") { - // User confirmed the import — write only explicitly selected providers that passed (#455) - // Opt-in: if includedProviders is missing or empty, nothing is imported except bedrock infra. - const payload = msg.payload as { activeProvider: string; includedProviders?: string[] }; - const included = payload.includedProviders ? new Set(payload.includedProviders) : new Set(); - const passedCredentials = heldCredentials.filter( - (c) => included.has(c.provider) && testResults.get(c.provider) !== false, - ); - // Use the validated model from probing (if available) instead of the static first entry - const modelOverride = validatedModels.get(payload.activeProvider); - // Always write batch config — even with zero user providers, bedrock infra is provisioned - writeBatchConfig(passedCredentials, payload.activeProvider, undefined, modelOverride); - // If user excluded 'opencode', disconnect it from the auth store. - // This is the only provider that needs file-level removal (it's a - // built-in integration, not in the connections seam). - if (!included.has("opencode") && heldCredentials.some((c) => c.provider === "opencode")) { - disconnectProviders(["opencode"]); - } - heldCredentials = []; - testResults.clear(); - validatedModels.clear(); - // Clear stale model pin — the old provider may no longer be connected. - void vscode.workspace.getConfiguration("amicode").update("defaultModel", undefined, vscode.ConfigurationTarget.Global); - // Swap the panel HTML directly to the splash — no webview-side - // DOM manipulation, so there's no flash when adopt() fires later - // (adopt's overlay uses the exact same SVG + CSS). - panel.webview.html = splashHtml( - panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, "media", "ui", "atoms", "DMSans-Variable.woff2")), - panel.webview.cspSource, - ); - // Signal that the next chat panel open should auto-send the onboarding greeting - ChatPanel.setPendingOnboardingGreeting(true); - fireOnboardingComplete(); - // Restart server so it picks up the new provider config. - // Chat opens via the onReady-gated listener in extension.ts. - void vscode.commands.executeCommand("amicode.restartServer"); - } else if (msg.type === "transition-complete") { - // The extension signals that the chat panel is ready — dispose the - // splash now. This is posted by the extension host after app-ready. - panel.dispose(); + }); + // Fire all tests in parallel, don't await sequentially + void Promise.allSettled(testPromises); } - }, - null, - ctx.subscriptions, - ); - - // On panel close, abort scan and drop credentials (AC13, AC14) - panel.onDidDispose( - () => { - scanAborted = true; - heldCredentials = []; - currentPanel = undefined; - }, - null, - ctx.subscriptions, - ); + } catch { + if (!scanAborted) { + panel.webview.postMessage({ + type: "scan-status", + payload: { state: "failed", error: "Scan failed unexpectedly" }, + }); + } + } + } else if (msg.type === "confirm-import") { + // User confirmed the import — write only explicitly selected providers that passed (#455) + // Opt-in: if includedProviders is missing or empty, nothing is imported except bedrock infra. + const payload = msg.payload as { activeProvider: string; includedProviders?: string[] }; + const included = payload.includedProviders ? new Set(payload.includedProviders) : new Set(); + const passedCredentials = heldCredentials.filter( + (c) => included.has(c.provider) && testResults.get(c.provider) !== false, + ); + // Use the validated model from probing (if available) instead of the static first entry + const modelOverride = validatedModels.get(payload.activeProvider); + // Always write batch config — even with zero user providers, bedrock infra is provisioned + writeBatchConfig(passedCredentials, payload.activeProvider, undefined, modelOverride); + // If user excluded 'opencode', disconnect it from the auth store. + // This is the only provider that needs file-level removal (it's a + // built-in integration, not in the connections seam). + if (!included.has("opencode") && heldCredentials.some((c) => c.provider === "opencode")) { + disconnectProviders(["opencode"]); + } + heldCredentials = []; + testResults.clear(); + validatedModels.clear(); + // Clear stale model pin — the old provider may no longer be connected. + void vscode.workspace.getConfiguration("amicode").update("defaultModel", undefined, vscode.ConfigurationTarget.Global); + // Swap the panel HTML directly to the splash — no webview-side + // DOM manipulation, so there's no flash when adopt() fires later + // (adopt's overlay uses the exact same SVG + CSS). + panel.webview.html = splashHtml( + panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, "media", "ui", "atoms", "DMSans-Variable.woff2")), + panel.webview.cspSource, + ); + if (!bootstrap) { + // Focused connect: same reasoning as config-success above — no + // live server/chat to bootstrap, just close. + panel.dispose(); + fireOnboardingComplete(); + return; + } + // Signal that the next chat panel open should auto-send the onboarding greeting + ChatPanel.setPendingOnboardingGreeting(true); + fireOnboardingComplete(); + // Restart server so it picks up the new provider config. + // Chat opens via the onReady-gated listener in extension.ts. + void vscode.commands.executeCommand("amicode.restartServer"); + } else if (msg.type === "transition-complete") { + // The extension signals that the chat panel is ready — dispose the + // splash now. This is posted by the extension host after app-ready. + panel.dispose(); + } + }, + null, + ctx.subscriptions, + ); - // Render the webview HTML - const uri = (...p: string[]) => - panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, ...p)); - const nonce = Math.random().toString(36).slice(2); + // On panel close, abort scan and drop credentials (AC13, AC14) + panel.onDidDispose( + () => { + scanAborted = true; + heldCredentials = []; + currentPanel = undefined; + }, + null, + ctx.subscriptions, + ); - panel.webview.html = buildWebviewHtml(panel.webview, uri, nonce); + // Render the webview HTML + const uri = (...p: string[]) => + panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, ...p)); + const nonce = Math.random().toString(36).slice(2); + + panel.webview.html = buildWebviewHtml(panel.webview, uri, nonce, focusProvider); + return panel; +} + +/** Register the onboarding panel command. Call from extension.ts activate(). */ +export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { + ctx.subscriptions.push( + vscode.commands.registerCommand("amicode.onboarding.open", () => { + openOnboardingPanel(ctx); + }), + ); +} + +/** Register the focused Harmoniqs-only connect command: the generic Connect + * Provider dialog's branded "Harmoniqs AI" row hands off to this instead of + * the dialog's own generic key-entry flow, because Harmoniqs is a branded + * preset (fixed base URL/model, key routed to the auth store — see + * writeOnboardingConfig/buildProviderConfigEntry/writeAuthApiKey above) + * that the generic flow cannot express without duplicating that logic. + * Reuses the exact same webview/message-handling as Stage-0 — see + * openOnboardingPanel's `bootstrap: false` for the one behavioral + * difference (no restart, no greeting: the chat panel and server are + * already live). Call from extension.ts activate(). */ +export function registerHarmoniqsConnectCommand(ctx: vscode.ExtensionContext): void { + ctx.subscriptions.push( + vscode.commands.registerCommand("amicode.connectHarmoniqsProvider", () => { + openOnboardingPanel(ctx, { focusProvider: HARMONIQS_PROVIDER_ID, bootstrap: false }); }), ); } -/** Build the webview HTML with CSP, brand CSS, animation container, and injected data. */ +/** Build the webview HTML with CSP, brand CSS, animation container, and injected data. + * `focusProvider`, when set, tells the webview script to skip the welcome + * animation and restrict the picker to that one provider. */ function buildWebviewHtml( webview: vscode.Webview, uri: (...p: string[]) => vscode.Uri, nonce: string, + focusProvider?: string, ): string { return ` @@ -991,6 +1064,7 @@ function buildWebviewHtml( `; diff --git a/packages/extension/src/onboarding_webview.ts b/packages/extension/src/onboarding_webview.ts index 0380b6367..ca548596b 100644 --- a/packages/extension/src/onboarding_webview.ts +++ b/packages/extension/src/onboarding_webview.ts @@ -18,12 +18,18 @@ declare global { interface Window { __PROVIDERS__: Record; __PROVIDER_NAMES__: Record; + /** Set when this panel was opened via the focused single-provider connect + * entry point (the generic Connect Provider dialog's branded Harmoniqs + * row) — skips the welcome animation and restricts the form to this one + * provider instead of the full Stage-0 picker. */ + __FOCUS_PROVIDER__: string | null; } } const vscodeApi = acquireVsCodeApi(); const providers = window.__PROVIDERS__; const providerNames = window.__PROVIDER_NAMES__ ?? {}; +const focusProvider = window.__FOCUS_PROVIDER__ ?? undefined; // ─── Animation ─────────────────────────────────────────────────────────────── @@ -383,7 +389,11 @@ function revealForm(): void { } function buildForm(): void { - const providerOptions = Object.keys(providers) + // Focused connect restricts the picker to exactly one provider — the + // branded row the user clicked in the app's Connect Provider dialog asked + // for, not the full Stage-0 lineup. + const providerIds = focusProvider ? [focusProvider] : Object.keys(providers); + const providerOptions = providerIds .map((p) => ``) .join(""); @@ -394,14 +404,16 @@ function buildForm(): void { formEl.innerHTML = `
-

Configure your model

+

${ + focusProvider ? `Connect ${providerNames[focusProvider] ?? focusProvider}` : "Configure your model" + }

- Choose a provider and enter your API key to get started. + ${focusProvider ? "Enter your API key to connect." : "Choose a provider and enter your API key to get started."}

@@ -443,13 +455,17 @@ function buildForm(): void {
-
+ ${ + focusProvider + ? "" + : ` +
` + }
`; @@ -562,6 +578,17 @@ function buildForm(): void { testBtn.textContent = getButtonLabel(providerSelect.value); }); + // Focused connect — the select has exactly one option (already selected + // by the browser), but nothing has fired its "change" listeners yet, so + // the hint/API-key row/model dropdown would stay in their empty initial + // state. Dispatch the SAME event a manual pick would fire, then lock the + // control — there's nothing else to switch to. + if (focusProvider) { + providerSelect.value = focusProvider; + providerSelect.dispatchEvent(new Event("change")); + providerSelect.disabled = true; + } + testBtn.addEventListener("click", () => { if (testBtn.disabled) return; const selected = providerSelect.value; @@ -632,6 +659,9 @@ function buildForm(): void { }); // ─── Import existing credentials UI ────────────────────────────────────── + // Not rendered when focused on a single provider (see the import-section + // ternary above) — nothing here to wire up. + if (focusProvider) return; const importLink = document.getElementById("import-link") as HTMLAnchorElement; const importStatus = document.getElementById("import-status") as HTMLDivElement; @@ -995,4 +1025,11 @@ if (cancelBtn) { }); } -playWelcomeAnimation(); +// Focused connect skips the brand animation entirely — the user just clicked +// a specific "Connect X" entry point elsewhere in the product; the welcome +// beat belongs to first-run Stage-0 only. +if (focusProvider) { + revealForm(); +} else { + playWelcomeAnimation(); +} diff --git a/packages/extension/test/chat_bridge.test.ts b/packages/extension/test/chat_bridge.test.ts index 4aec3c7c1..f4d609de4 100644 --- a/packages/extension/test/chat_bridge.test.ts +++ b/packages/extension/test/chat_bridge.test.ts @@ -179,6 +179,18 @@ describe("amicode bridge — clipboard", () => { }); }); +describe("amicode bridge — connect-harmoniqs-provider (Connect Provider dialog handoff)", () => { + it("acks immediately and dispatches amicode.connectHarmoniqsProvider", async () => { + const host = io(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "connect-harmoniqs-provider", tab: "tab-1" }, host)).toBe(true); + await flush(); + const ack = host.posted.find((m: any) => m.kind === "connect-harmoniqs-provider-ack") as any; + expect(ack).toEqual({ source: "amicode", kind: "connect-harmoniqs-provider-ack", tab: "tab-1" }); + const ran = (vscode.commands as unknown as { executed: string[] }).executed ?? []; + expect(ran).toContain("amicode.connectHarmoniqsProvider"); + }); +}); + describe("amicode bridge — commands & settings", () => { it("runs allowlisted commands only", async () => { const host = io(); diff --git a/packages/extension/test/onboarding_panel.test.ts b/packages/extension/test/onboarding_panel.test.ts index bae24041c..0a6a03499 100644 --- a/packages/extension/test/onboarding_panel.test.ts +++ b/packages/extension/test/onboarding_panel.test.ts @@ -12,6 +12,7 @@ import * as vscode from "vscode"; import { registerOnboardingPanel, + registerHarmoniqsConnectCommand, PROVIDER_MODELS, PROVIDER_DISPLAY_NAMES, HARMONIQS_PROVIDER_ID, @@ -24,6 +25,7 @@ import { testConnection, probeModels, onOnboardingComplete, + onOnboardingCancelled, dismissOnboardingPanel, getOnboardingPanel, releaseOnboardingPanel, @@ -1036,6 +1038,105 @@ describe("Webview HTML generation (AC2, AC9)", () => { }); }); +describe("registerHarmoniqsConnectCommand — focused connect entry point (Connect Provider dialog handoff)", () => { + let ctx: { subscriptions: unknown[]; extensionUri: unknown }; + + beforeEach(() => { + _resetForTesting(); + ctx = { subscriptions: [], extensionUri: vscode.Uri.file("/ext") } as never; + registerOnboardingPanel(ctx as never); + registerHarmoniqsConnectCommand(ctx as never); + }); + + it("opens the SAME onboarding webview panel type as the Stage-0 command", async () => { + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.connectHarmoniqsProvider"); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + "amicode.onboarding", + expect.any(String), + expect.anything(), + expect.objectContaining({ enableScripts: true }), + ); + spy.mockRestore(); + }); + + it("is a singleton with amicode.onboarding.open — reveals the existing panel rather than opening a second one", async () => { + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.onboarding.open"); + await vscode.commands.executeCommand("amicode.connectHarmoniqsProvider"); + expect(spy).toHaveBeenCalledTimes(1); + const panel = spy.mock.results[0].value as { revealCount: number }; + expect(panel.revealCount).toBe(1); + spy.mockRestore(); + }); + + it("injects window.__FOCUS_PROVIDER__ = 'harmoniqs', restricting the picker", async () => { + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.connectHarmoniqsProvider"); + const panel = spy.mock.results[0].value as { webview: { html: string } }; + expect(panel.webview.html).toContain("__FOCUS_PROVIDER__"); + expect(panel.webview.html).toContain(JSON.stringify(HARMONIQS_PROVIDER_ID)); + spy.mockRestore(); + }); + + it("amicode.onboarding.open still gets a null focusProvider (Stage-0 behavior unchanged)", async () => { + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.onboarding.open"); + const panel = spy.mock.results[0].value as { webview: { html: string } }; + expect(panel.webview.html).toContain("window.__FOCUS_PROVIDER__ = null"); + spy.mockRestore(); + }); + + // bootstrap:false's one behavioral difference is "no restart, no greeting, + // no fallback chat-open" — config-success's write path defaults to the + // real ~/.config/opencode path with no configPath override, and os/fs + // builtins aren't spyable in this vitest setup for that path, so "cancel" + // is the safe proxy: it hits the identical bootstrap branch with zero + // filesystem writes (see `cancel` in openOnboardingPanel). + it("bootstrap:false — cancel does NOT fall back to amicode.openChat (chat panel is already live)", async () => { + (vscode.commands as { executed: string[] }).executed = []; + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.connectHarmoniqsProvider"); + const panel = spy.mock.results[0].value as { + webview: { _simulateMessage: (msg: unknown) => void }; + disposed?: boolean; + }; + + let cancelled = false; + const disposable = onOnboardingCancelled(() => { + cancelled = true; + }); + + panel.webview._simulateMessage({ type: "cancel" }); + await new Promise((r) => setTimeout(r, 10)); + + expect(cancelled).toBe(true); // fireOnboardingCancelled still fires either way + const executed = (vscode.commands as { executed: string[] }).executed; + expect(executed).not.toContain("amicode.openChat"); // the focused-connect difference + + disposable.dispose(); + spy.mockRestore(); + }); + + it("bootstrap:true (Stage-0 default) — cancel DOES fall back to amicode.openChat, unchanged", async () => { + (vscode.commands as { executed: string[] }).executed = []; + const spy = vi.spyOn(vscode.window, "createWebviewPanel"); + await vscode.commands.executeCommand("amicode.onboarding.open"); + const panel = spy.mock.results[0].value as { + webview: { _simulateMessage: (msg: unknown) => void }; + }; + + panel.webview._simulateMessage({ type: "cancel" }); + await new Promise((r) => setTimeout(r, 10)); + + const executed = (vscode.commands as { executed: string[] }).executed; + expect(executed).toContain("amicode.openChat"); + + spy.mockRestore(); + }); +}); + describe("testConnection — Bedrock model probe (model-access validation)", () => { it("makes an HTTP call to Bedrock converse endpoint with bearer auth", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true });