diff --git a/apps/x/apps/renderer/src/components/onboarding/index.tsx b/apps/x/apps/renderer/src/components/onboarding/index.tsx index d37cdf0f..2fb7426e 100644 --- a/apps/x/apps/renderer/src/components/onboarding/index.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/index.tsx @@ -14,6 +14,7 @@ import { StepIndicator } from "./step-indicator" import { WelcomeStep } from "./steps/welcome-step" import { LlmSetupStep } from "./steps/llm-setup-step" import { ConnectAccountsStep } from "./steps/connect-accounts-step" +import { CodeModeStep } from "./steps/code-mode-step" import { CompletionStep } from "./steps/completion-step" interface OnboardingModalProps { @@ -33,6 +34,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { case 2: return case 3: + return + case 4: return } }, [state.currentStep, state]) diff --git a/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx b/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx index d0b41991..661bdf8b 100644 --- a/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/step-indicator.tsx @@ -6,14 +6,16 @@ import type { Step, OnboardingPath } from "./use-onboarding-state" const ROWBOAT_STEPS = [ { step: 0 as Step, label: "Welcome" }, { step: 2 as Step, label: "Connect" }, - { step: 3 as Step, label: "Done" }, + { step: 3 as Step, label: "Code" }, + { step: 4 as Step, label: "Done" }, ] const BYOK_STEPS = [ { step: 0 as Step, label: "Welcome" }, { step: 1 as Step, label: "Model" }, { step: 2 as Step, label: "Connect" }, - { step: 3 as Step, label: "Done" }, + { step: 3 as Step, label: "Code" }, + { step: 4 as Step, label: "Done" }, ] interface StepIndicatorProps { diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/code-mode-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/code-mode-step.tsx new file mode 100644 index 00000000..28b1f6a2 --- /dev/null +++ b/apps/x/apps/renderer/src/components/onboarding/steps/code-mode-step.tsx @@ -0,0 +1,147 @@ +import { useState, useEffect, useCallback } from "react" +import { Loader2, ArrowLeft, Terminal, CheckCircle2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { startProvisioning, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning" +import type { OnboardingState } from "../use-onboarding-state" + +interface CodeModeStepProps { + state: OnboardingState +} + +const AGENTS = [ + { key: "claude" as const, name: "Claude Code" }, + { key: "codex" as const, name: "Codex" }, +] + +export function CodeModeStep({ state }: CodeModeStepProps) { + const { handleNext, handleBack } = state + + const [enabled, setEnabled] = useState(false) + const [selected, setSelected] = useState>({ claude: false, codex: false }) + const [status, setStatus] = useState(null) + const [statusLoading, setStatusLoading] = useState(true) + const [saving, setSaving] = useState(false) + + // Reflect what's already set up: pre-select installed agents and turn the master + // switch on if any agent is already there, so returning users don't start from off. + useEffect(() => { + let cancelled = false + ;(async () => { + setStatusLoading(true) + try { + const result = await window.ipc.invoke("codeMode:checkAgentStatus", null) + if (cancelled) return + setStatus(result) + const claudeInstalled = result.claude.installed + const codexInstalled = result.codex.installed + if (claudeInstalled || codexInstalled) { + setEnabled(true) + setSelected({ claude: claudeInstalled, codex: codexInstalled }) + } + } catch { + if (!cancelled) setStatus(null) + } finally { + if (!cancelled) setStatusLoading(false) + } + })() + return () => { cancelled = true } + }, []) + + const onContinue = useCallback(async () => { + if (enabled) { + setSaving(true) + try { + await window.ipc.invoke("codeMode:setConfig", { enabled: true, approvalPolicy: "ask" }) + window.dispatchEvent(new Event("code-mode-config-changed")) + } catch { + // Non-fatal — the user can still enable code mode later from Settings. + } + setSaving(false) + // Kick off engine downloads in the BACKGROUND for selected agents that aren't + // installed yet. We deliberately don't block onboarding on the ~200 MB download — + // it keeps running in the main process and its progress shows in Settings → Code Mode. + for (const a of AGENTS) { + if (selected[a.key] && !status?.[a.key].installed) { + startProvisioning(a.key, () => {}) + } + } + } + handleNext() + }, [enabled, selected, status, handleNext]) + + return ( +
+ {/* Title */} +

+ Set Up Code Mode +

+

+ Use your existing Claude Code or Codex subscription inside Rowboat to tackle coding + tasks and unlock far more workflows, all without leaving the app. Make sure Claude Code + and Codex are signed in locally with{" "} + claude login or{" "} + codex login in your terminal. +

+ + {statusLoading ? ( +
+ +
+ ) : ( +
+ {/* Master enable */} +
+
+
Enable code mode
+
+ Shows the code mode chip in the composer and lets the assistant delegate to your agents. +
+
+ +
+ + {/* Per-agent selection (revealed when enabled) */} + {enabled && ( +
+ + Agents to set up + + {AGENTS.map((a) => { + const st = status?.[a.key] + const ready = (st?.installed ?? false) && (st?.signedIn ?? false) + return ( +
+ +
{a.name}
+ {ready && } + setSelected((prev) => ({ ...prev, [a.key]: v }))} + /> +
+ ) + })} +
+ )} +
+ )} + + {/* Footer */} +
+ +
+ + +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index 17832f4b..8e01aae8 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -8,7 +8,7 @@ export interface ProviderState { isConnecting: boolean } -export type Step = 0 | 1 | 2 | 3 +export type Step = 0 | 1 | 2 | 3 | 4 export type OnboardingPath = 'rowboat' | 'byok' | null @@ -377,8 +377,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { }, [startGoogleCalendarConnect]) // New step flow: - // Rowboat path: 0 (welcome) → 2 (connect) → 3 (done) - // BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (done) + // Rowboat path: 0 (welcome) → 2 (connect) → 3 (code mode) → 4 (done) + // BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (code mode) → 4 (done) const handleNext = useCallback(() => { if (currentStep === 0) { if (onboardingPath === 'byok') { @@ -390,6 +390,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { setCurrentStep(2) } else if (currentStep === 2) { setCurrentStep(3) + } else if (currentStep === 3) { + setCurrentStep(4) } }, [currentStep, onboardingPath]) @@ -403,6 +405,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { } else { setCurrentStep(1) } + } else if (currentStep === 3) { + setCurrentStep(2) } }, [currentStep, onboardingPath]) diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index ef249836..6200cef9 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -26,6 +26,7 @@ import { toast } from "sonner" import { AccountSettings } from "@/components/settings/account-settings" import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings" import type { ApprovalPolicy } from "@x/shared/src/code-mode.js" +import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning" type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help" @@ -1705,66 +1706,6 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) { // --- Code Mode Settings --- -type AgentStatus = { installed: boolean; signedIn: boolean } -type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus } - -// Engine provisioning runs in the main process and keeps going even if the Settings -// dialog is closed. Track its state at MODULE level (not in the row component, which -// unmounts on close) so reopening Settings still shows the live % instead of the Enable -// button. A single persistent listener on the progress channel feeds this store. -type ProvState = { pct: number | null; error?: string } -const provStore: Record = {} -// Agents we provisioned this session — used to show "Ready" immediately on success -// without waiting for the async status refresh to round-trip (which caused the row to -// briefly flash the Enable button again). -const enabledOptimistic = new Set() -const provListeners = new Set<() => void>() -let provChannelHooked = false - -function notifyProv() { provListeners.forEach((l) => l()) } - -function startProvisioning(agent: 'claude' | 'codex', onDone: () => void | Promise): void { - if (provStore[agent] && !provStore[agent]!.error) return // already in flight - provStore[agent] = { pct: null } - notifyProv() - if (!provChannelHooked) { - provChannelHooked = true - window.ipc.on('codeMode:engineProgress', (p) => { - const cur = provStore[p.agent] - if (!cur) return - const pct = p.totalBytes ? Math.floor(((p.receivedBytes ?? 0) / p.totalBytes) * 100) : cur.pct - provStore[p.agent] = { pct } - notifyProv() - }) - } - window.ipc.invoke('codeMode:provisionEngine', { agent }) - .then((res) => { - if (res.success) { - // Mark installed optimistically so the row shows "Ready" the instant the flag - // clears — don't depend on the async status refresh (which re-renders the parent - // separately and left a window showing the Enable button). loadStatus still runs - // in the background to sync the real status. - enabledOptimistic.add(agent) - provStore[agent] = undefined - void onDone() - } else { - provStore[agent] = { pct: null, error: res.error ?? 'Failed to enable' } - } - }) - .catch((e) => { provStore[agent] = { pct: null, error: e instanceof Error ? e.message : 'Failed to enable' } }) - .finally(notifyProv) -} - -function useProvisioning(agent: string): ProvState | undefined { - const [, force] = useState(0) - useEffect(() => { - const l = () => force((n) => n + 1) - provListeners.add(l) - return () => { provListeners.delete(l) } - }, []) - return provStore[agent] -} - function AgentStatusRow({ name, agent, diff --git a/apps/x/apps/renderer/src/lib/code-mode-provisioning.ts b/apps/x/apps/renderer/src/lib/code-mode-provisioning.ts new file mode 100644 index 00000000..cbc3b922 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/code-mode-provisioning.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react" + +export type AgentStatus = { installed: boolean; signedIn: boolean } +export type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus } + +// Engine provisioning runs in the main process and keeps going even if the UI that +// started it (the Settings dialog OR the onboarding step) unmounts. Track its state at +// MODULE level — shared across both — so whichever view is mounted shows the live % +// instead of restarting. This is what lets a download kicked off from onboarding show +// its progress later in Settings → Code Mode. A single persistent listener on the +// progress channel feeds this store. +export type ProvState = { pct: number | null; error?: string } +const provStore: Record = {} +// Agents we provisioned this session — used to show "Ready" immediately on success +// without waiting for the async status refresh to round-trip (which caused the row to +// briefly flash the Enable button again). +export const enabledOptimistic = new Set() +const provListeners = new Set<() => void>() +let provChannelHooked = false + +function notifyProv() { provListeners.forEach((l) => l()) } + +export function startProvisioning(agent: 'claude' | 'codex', onDone: () => void | Promise): void { + if (provStore[agent] && !provStore[agent]!.error) return // already in flight + provStore[agent] = { pct: null } + notifyProv() + if (!provChannelHooked) { + provChannelHooked = true + window.ipc.on('codeMode:engineProgress', (p) => { + const cur = provStore[p.agent] + if (!cur) return + const pct = p.totalBytes ? Math.floor(((p.receivedBytes ?? 0) / p.totalBytes) * 100) : cur.pct + provStore[p.agent] = { pct } + notifyProv() + }) + } + window.ipc.invoke('codeMode:provisionEngine', { agent }) + .then((res) => { + if (res.success) { + // Mark installed optimistically so the row shows "Ready" the instant the flag + // clears — don't depend on the async status refresh (which re-renders the parent + // separately and left a window showing the Enable button). loadStatus still runs + // in the background to sync the real status. + enabledOptimistic.add(agent) + provStore[agent] = undefined + void onDone() + } else { + provStore[agent] = { pct: null, error: res.error ?? 'Failed to enable' } + } + }) + .catch((e) => { provStore[agent] = { pct: null, error: e instanceof Error ? e.message : 'Failed to enable' } }) + .finally(notifyProv) +} + +export function useProvisioning(agent: string): ProvState | undefined { + const [, force] = useState(0) + useEffect(() => { + const l = () => force((n) => n + 1) + provListeners.add(l) + return () => { provListeners.delete(l) } + }, []) + return provStore[agent] +}