feat(onboarding): add Code Mode setup step (#643)

* feat(onboarding): add Code Mode setup step

Adds a new onboarding step (3, between Connect and Done; Completion moves
to 4) shown in both Rowboat and BYOK paths. It informs the user up front
that Code Mode needs Claude Code and/or Codex installed and signed in
locally (claude login / codex login), then offers a master 'Enable code
mode' toggle that reveals per-agent toggles.

Downloads do NOT block onboarding: on Continue, selected-but-not-yet-
installed engines are provisioned fire-and-forget in the main process,
and their live % surfaces in Settings -> Code Mode. Approval policy is
left at the default ('ask'); users adjust it later in Settings. The step
is skippable like Connect Accounts.

To make an onboarding-started download show its progress in Settings, the
module-level provisioning store (startProvisioning/useProvisioning) is
lifted out of settings-dialog.tsx into lib/code-mode-provisioning.ts and
shared by both.

* feat(onboarding): simplify Code Mode agent rows and refine copy

- Show just the agent name (Claude Code / Codex) with a toggle and a
  green check when ready; drop the per-row download/sign-in clutter
- Value-led description: use your existing Claude Code or Codex
  subscription inside Rowboat, signed in locally via the terminal
This commit is contained in:
gagan 2026-06-24 17:30:34 +05:30 committed by GitHub
parent 8b76c54295
commit 6dbaa618e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 225 additions and 65 deletions

View file

@ -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 <ConnectAccountsStep state={state} />
case 3:
return <CodeModeStep state={state} />
case 4:
return <CompletionStep state={state} />
}
}, [state.currentStep, state])

View file

@ -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 {

View file

@ -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<Record<"claude" | "codex", boolean>>({ claude: false, codex: false })
const [status, setStatus] = useState<CodeModeAgentStatus | null>(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 (
<div className="flex flex-col flex-1">
{/* Title */}
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
Set Up Code Mode
</h2>
<p className="text-base text-muted-foreground text-center leading-relaxed mb-6 max-w-md mx-auto">
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{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[13px] text-foreground">claude&nbsp;login</code> or{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[13px] text-foreground">codex&nbsp;login</code> in your terminal.
</p>
{statusLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4">
{/* Master enable */}
<div className="rounded-xl border px-4 py-3.5 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">Enable code mode</div>
<div className="text-xs text-muted-foreground mt-0.5">
Shows the code mode chip in the composer and lets the assistant delegate to your agents.
</div>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} disabled={saving} />
</div>
{/* Per-agent selection (revealed when enabled) */}
{enabled && (
<div className="space-y-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Agents to set up
</span>
{AGENTS.map((a) => {
const st = status?.[a.key]
const ready = (st?.installed ?? false) && (st?.signedIn ?? false)
return (
<div key={a.key} className="rounded-xl border px-4 py-3 flex items-center gap-3">
<Terminal className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0 text-sm font-medium">{a.name}</div>
{ready && <CheckCircle2 className="size-4 text-green-600 shrink-0" />}
<Switch
checked={selected[a.key]}
onCheckedChange={(v) => setSelected((prev) => ({ ...prev, [a.key]: v }))}
/>
</div>
)
})}
</div>
)}
</div>
)}
{/* Footer */}
<div className="flex flex-col gap-3 mt-8 pt-4 border-t">
<Button onClick={onContinue} size="lg" className="h-12 text-base font-medium" disabled={saving}>
{saving ? <Loader2 className="size-5 animate-spin" /> : "Continue"}
</Button>
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={handleBack} className="gap-1">
<ArrowLeft className="size-4" />
Back
</Button>
<Button variant="ghost" onClick={handleNext} className="text-muted-foreground">
Skip for now
</Button>
</div>
</div>
</div>
)
}

View file

@ -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])

View file

@ -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<string, ProvState | undefined> = {}
// 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<string>()
const provListeners = new Set<() => void>()
let provChannelHooked = false
function notifyProv() { provListeners.forEach((l) => l()) }
function startProvisioning(agent: 'claude' | 'codex', onDone: () => void | Promise<void>): 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,

View file

@ -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<string, ProvState | undefined> = {}
// 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<string>()
const provListeners = new Set<() => void>()
let provChannelHooked = false
function notifyProv() { provListeners.forEach((l) => l()) }
export function startProvisioning(agent: 'claude' | 'codex', onDone: () => void | Promise<void>): 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]
}