Skip to content
Open
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
63 changes: 50 additions & 13 deletions packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ import { stageDemoRun } from "./demo_replay";
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 { amicodeOpsDir, onboardingDir, hasOnboardingCompleted } from "./substrate/vault_store";
import { registerOnboardingPanel, onOnboardingCancelled, getOnboardingPanel, releaseOnboardingPanel } from "./onboarding_panel";
import { registerFleetPanel } from "./fleet_panel";
import { isModelConfigured } from "./onboarding_routing";
import { isModelConfigured, hasProviderEnvVar, resolveOnboardingAction } from "./onboarding_routing";
import { stagePasqalConnector } from "./pasqal_assets";
import { stageModCards } from "./mode_cards";
import { needsProvision, pasqalVenvDir, provisionPasqalPython } from "./pasqal_python";
Expand Down Expand Up @@ -95,6 +95,11 @@ let statusBar: StatusBarManager | undefined;
let sseClient: OpencodeEventClient | undefined;
let runsManager: RunsManager | undefined;
let opencodeReadyUrl: URL | undefined;
// Post-ready routing (onboarding gate + provider signal). Assigned at boot, and
// re-invoked by every path that REPLACES serverManager — a replacement registers
// its own onReady, so without this the boot handler is orphaned and the first
// run silently loses its Stage 0 surface.
let routePostReady: ((url: URL) => void) | undefined;
/** Set once the binary + vault are known; the watcher's onRunFinished closure
* and the distillNow command read it lazily (undefined = distiller disabled). */
let distillerSetup: DistillerSetup | undefined;
Expand Down Expand Up @@ -276,10 +281,21 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
amicoPython = r.pythonPath;
if (r.provisioned) {
if (currentSpawnEnv) currentSpawnEnv.AMICO_PYTHON = r.pythonPath;
opencodeChannel.appendLine(
`[pasqal] python provisioned: ${r.pythonPath} — restarting server to pick it up`,
);
void vscode.commands.executeCommand("amicode.restartServer");
// Never restart out from under a live Stage 0 panel. On a first run
// this provisioning lands WHILE the onboarding webview is waiting on
// the server, and the restart strands it on the splash. The panel
// issues its own `amicode.restartServer` when the user submits the
// form, so the fresh AMICO_PYTHON is picked up there instead.
if (getOnboardingPanel()) {
opencodeChannel.appendLine(
`[pasqal] python provisioned: ${r.pythonPath} — restart deferred (onboarding in progress)`,
);
} else {
opencodeChannel.appendLine(
`[pasqal] python provisioned: ${r.pythonPath} — restarting server to pick it up`,
);
void vscode.commands.executeCommand("amicode.restartServer");
}
}
} else {
opencodeChannel.appendLine(`[pasqal] ${r.message}`);
Expand Down Expand Up @@ -833,15 +849,28 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
});
ctx.subscriptions.push(sseClient);

serverManager.onReady((url) => {
opencodeReadyUrl = url;
statusBar?.setServerReady(true);
sseClient?.connect(url);
routePostReady = (url) => {
// Onboarding gate: if no model is configured, open the Stage 0 webview
// instead of chat. The webview will fire onOnboardingComplete when done,
// which then opens chat.
if (!isModelConfigured() && vscode.workspace.getConfiguration("amicode").get<boolean>("chat.autoOpen", true)) {
void vscode.commands.executeCommand("amicode.onboarding.open");
const autoOpen = vscode.workspace.getConfiguration("amicode").get<boolean>("chat.autoOpen", true);
const onboardingAction = resolveOnboardingAction({
modelConfigured: isModelConfigured() || hasProviderEnvVar(),
onboardingCompleted: hasOnboardingCompleted(onboardingDir()),
partialStage: undefined,
});
// First-run routing is otherwise invisible: every branch here is silent,
// so a first run that lands on an empty chat leaves nothing to explain
// why. Log the inputs AND the decision.
opencodeChannel.appendLine(
`[onboarding] action=${onboardingAction} modelConfigured=${isModelConfigured()} ` +
`providerEnv=${hasProviderEnvVar()} completed=${hasOnboardingCompleted(onboardingDir())} autoOpen=${autoOpen}`,
);
if (onboardingAction === "show-webview" && autoOpen) {
void vscode.commands.executeCommand("amicode.onboarding.open").then(
() => opencodeChannel.appendLine("[onboarding] Stage 0 webview opened"),
(e) => opencodeChannel.appendLine(`[onboarding] Stage 0 webview FAILED to open: ${e}`),
);
// Wire: when onboarding completes, the server restarts and the
// onReady handler (else-if branch below) opens the chat panel.
// We do NOT open chat here — that would race the server restart
Expand All @@ -850,7 +879,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
onOnboardingCancelled(() => {
ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir);
});
} else if (vscode.workspace.getConfiguration("amicode").get<boolean>("chat.autoOpen", true)) {
} else if (autoOpen) {
// Normal path: model configured → open chat directly
// Post-onboarding: adopt the onboarding panel as the chat panel (zero
// tab switching — the splash overlay fades out revealing the chat).
Expand Down Expand Up @@ -879,6 +908,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
: `[boot] LLM provider: ${sig.reason} → ${sig.fix}`,
);
});
};

serverManager.onReady((url) => {
opencodeReadyUrl = url;
statusBar?.setServerReady(true);
sseClient?.connect(url);
routePostReady?.(url);
});

serverManager.start().catch((err) => {
Expand Down Expand Up @@ -944,6 +980,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
opencodeReadyUrl = url;
statusBar?.setServerReady(true);
sseClient?.connect(url);
routePostReady?.(url);
});
await serverManager.start();
if (project2.vaultDir) {
Expand Down
87 changes: 1 addition & 86 deletions packages/extension/src/onboarding_routing.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Onboarding routing — session auto-launch and routing logic (#434)
//
// Pure routing predicate + launcher with at-most-once guard.
// Given (modelConfigured, welcomeShown, onboardingCompleted, partialStage),
// Given (modelConfigured, onboardingCompleted, partialStage),
// determines the correct action for the session.

import * as fs from "node:fs";
Expand All @@ -13,8 +13,6 @@ import * as os from "node:os";
export interface OnboardingFlags {
/** True if the opencode config has at least one provider entry with credentials. */
modelConfigured: boolean;
/** True if the Stage 0 welcome animation has been played this install. */
welcomeShown: boolean;
/** True if the full onboarding flow (through Stage 8) has completed. */
onboardingCompleted: boolean;
/** If partially completed, the last finished stage number (1-based). undefined = none. */
Expand Down Expand Up @@ -95,89 +93,6 @@ export function hasProviderEnvVar(): boolean {
});
}

// ─── welcome_shown persistence ───────────────────────────────────────────────

const WELCOME_STATE_FILE = "onboarding_state.json";

/** Read whether the welcome animation has been shown (persisted across sessions). */
export function readWelcomeShown(
statePath: string = path.join(os.homedir(), ".amico", "amicode", WELCOME_STATE_FILE),
): boolean {
try {
const data = JSON.parse(fs.readFileSync(statePath, "utf8")) as Record<string, unknown>;
return data.welcome_shown === true;
} catch {
return false;
}
}

/** Mark the welcome animation as shown. */
export function writeWelcomeShown(
statePath: string = path.join(os.homedir(), ".amico", "amicode", WELCOME_STATE_FILE),
): void {
try {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(fs.readFileSync(statePath, "utf8"));
} catch { /* fresh file */ }
fs.writeFileSync(statePath, JSON.stringify({ ...existing, welcome_shown: true }, null, 2) + "\n");
} catch {
// Non-critical — don't crash the extension
}
}

// ─── Launcher (at-most-once guard) ──────────────────────────────────────────

export interface LauncherCallbacks {
resolveFlags: () => OnboardingFlags;
showWebview: () => void;
openChat: () => void;
openChatAtStage: (stage: number) => void;
}

/** Encapsulates the at-most-once launch logic for a VS Code window.
* Calling tryLaunch() multiple times fires the action only once.
* After webview success, onWebviewSuccess() opens chat. */
export class OnboardingLauncher {
private launched = false;
private callbacks: LauncherCallbacks;

constructor(callbacks: LauncherCallbacks) {
this.callbacks = callbacks;
}

/** Attempt to launch the onboarding flow. Fires at most once per instance. */
tryLaunch(): void {
if (this.launched) return;

const flags = this.callbacks.resolveFlags();
const action = resolveOnboardingAction(flags);

if (action === "normal-session") return; // nothing to do

this.launched = true;

switch (action) {
case "show-webview":
this.callbacks.showWebview();
break;
case "open-chat":
this.callbacks.openChat();
break;
case "resume-chat-at-stage":
this.callbacks.openChatAtStage(flags.partialStage ?? 1);
break;
}
}

/** Called when the Stage 0 webview completes successfully.
* Transitions to the chat panel. */
onWebviewSuccess(): void {
this.callbacks.openChat();
}
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

// (defaultConfigPath removed — isModelConfigured checks both .json and .jsonc)
Loading
Loading