mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
8b76c54295
commit
6dbaa618e7
6 changed files with 225 additions and 65 deletions
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 login</code> or{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[13px] text-foreground">codex 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
63
apps/x/apps/renderer/src/lib/code-mode-provisioning.ts
Normal file
63
apps/x/apps/renderer/src/lib/code-mode-provisioning.ts
Normal 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]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue