Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ describe("AppSettingsSchema", () => {
claudeBinaryPath: "",
codexBinaryPath: "/usr/local/bin/codex",
codexHomePath: "",
defaultThreadEnvMode: "local",
defaultThreadEnvMode: "worktree",
confirmThreadDelete: false,
enableAssistantStreaming: false,
sidebarProjectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const AppSettingsSchema = Schema.Struct({
claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)),
defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "worktree" as const satisfies EnvMode)),
confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),
diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)),
enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),
Expand Down
110 changes: 110 additions & 0 deletions apps/web/src/components/onboarding/OnboardingDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useState } from "react";
import {
FileDiffIcon,
GitBranchIcon,
ListChecksIcon,
MessageSquareIcon,
RocketIcon,
ShieldCheckIcon,
SparklesIcon,
TerminalSquareIcon,
} from "lucide-react";
import { Dialog, DialogFooter, DialogHeader, DialogPopup } from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { OnboardingStep } from "./OnboardingStep";
import { ONBOARDING_STEPS } from "./onboardingSteps";
import { useOnboardingState } from "./useOnboardingState";

const STEP_ICONS = [
<SparklesIcon key="sparkles" className="size-7" />,
<MessageSquareIcon key="message" className="size-7" />,
<GitBranchIcon key="git" className="size-7" />,
<FileDiffIcon key="diff" className="size-7" />,
<TerminalSquareIcon key="terminal" className="size-7" />,
<ListChecksIcon key="plan" className="size-7" />,
<ShieldCheckIcon key="shield" className="size-7" />,
<RocketIcon key="rocket" className="size-7" />,
];

export function OnboardingDialog() {
const { open, complete, skip } = useOnboardingState();
const [step, setStep] = useState(0);

if (!open) return null;

const totalSteps = ONBOARDING_STEPS.length;
const isFirst = step === 0;
const isLast = step === totalSteps - 1;
const currentStep = ONBOARDING_STEPS[step]!;

const handleNext = () => {
if (isLast) {
complete();
} else {
setStep((s) => s + 1);
}
};

const handleBack = () => {
setStep((s) => Math.max(0, s - 1));
};

return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) skip();
}}
>
<DialogPopup showCloseButton={false} className="max-w-xl">
<DialogHeader className="px-8 pt-8 pb-2">
<div key={step} className="animate-in fade-in duration-300">
<OnboardingStep step={currentStep} icon={STEP_ICONS[step]} />
</div>
</DialogHeader>

<DialogFooter
variant="bare"
className="flex-row items-center justify-between px-8 pt-4 pb-8"
>
{/* Step indicator dots */}
<div className="flex items-center gap-1.5" role="group" aria-label="Onboarding progress">
{ONBOARDING_STEPS.map((s, i) => (
<button
key={s.id}
type="button"
onClick={() => setStep(i)}
aria-label={`Go to step ${i + 1} of ${totalSteps}: ${s.title}`}
aria-current={i === step ? "step" : undefined}
className={`size-2 rounded-full transition-all duration-200 ${
i === step
? "scale-125 bg-primary"
: i < step
? "bg-primary/40 hover:bg-primary/60"
: "bg-muted-foreground/20 hover:bg-muted-foreground/40"
}`}
/>
))}
</div>

{/* Navigation buttons */}
<div className="flex items-center gap-2">
{!isLast && (
<Button variant="ghost" size="sm" onClick={skip}>
Skip
</Button>
)}
{!isFirst && (
<Button variant="outline" size="sm" onClick={handleBack}>
Back
</Button>
)}
<Button size="sm" onClick={handleNext}>
{isLast ? "Get Started" : "Next"}
</Button>
</div>
</DialogFooter>
</DialogPopup>
</Dialog>
);
}
64 changes: 64 additions & 0 deletions apps/web/src/components/onboarding/OnboardingStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { ReactNode } from "react";
import { DialogDescription, DialogTitle } from "~/components/ui/dialog";
import type { OnboardingStepConfig } from "./onboardingSteps";

const ACCENT_STYLES: Record<string, { icon: string; dot: string }> = {
primary: {
icon: "bg-primary/10 text-primary",
dot: "bg-primary/60",
},
sky: {
icon: "bg-sky-500/10 text-sky-500",
dot: "bg-sky-500/60",
},
emerald: {
icon: "bg-emerald-500/10 text-emerald-500",
dot: "bg-emerald-500/60",
},
amber: {
icon: "bg-amber-500/10 text-amber-500",
dot: "bg-amber-500/60",
},
violet: {
icon: "bg-violet-500/10 text-violet-500",
dot: "bg-violet-500/60",
},
rose: {
icon: "bg-rose-500/10 text-rose-500",
dot: "bg-rose-500/60",
},
orange: {
icon: "bg-orange-500/10 text-orange-500",
dot: "bg-orange-500/60",
},
};

export function OnboardingStep({ step, icon }: { step: OnboardingStepConfig; icon: ReactNode }) {
const accent = ACCENT_STYLES[step.accentColor] ?? ACCENT_STYLES.primary;

return (
<div className="flex flex-col items-center text-center">
<div className={`mb-4 flex size-14 items-center justify-center rounded-2xl ${accent.icon}`}>
{icon}
</div>

<DialogTitle className="text-xl font-semibold tracking-tight">{step.title}</DialogTitle>

<DialogDescription className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">
{step.description}
</DialogDescription>

<ul className="mt-5 w-full max-w-sm space-y-2.5 text-left">
{step.details.map((detail) => (
<li
key={detail}
className="flex items-start gap-2.5 text-[13px] leading-snug text-foreground/80"
>
<span className={`mt-1.5 size-1.5 shrink-0 rounded-full ${accent.dot}`} />
{detail}
</li>
))}
</ul>
</div>
);
}
105 changes: 105 additions & 0 deletions apps/web/src/components/onboarding/onboardingSteps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
export interface OnboardingStepConfig {
id: string;
title: string;
description: string;
details: string[];
accentColor: string;
}

export const ONBOARDING_STEPS: OnboardingStepConfig[] = [
{
id: "welcome",
title: "Welcome to OK Code",
description:
"Your AI-powered coding companion. Let's take a quick tour of the features that will supercharge your workflow.",
details: [
"Work alongside AI agents that read, write, and reason about your code",
"Every conversation runs in an isolated git worktree by default",
"This tour takes about a minute — you can skip at any time",
],
accentColor: "primary",
},
{
id: "chat",
title: "AI-Powered Conversations",
description:
"Chat with AI coding agents in real time. Ask questions, request changes, or let the agent drive entire features.",
details: [
"Choose between multiple providers — Codex and Claude",
"Stream responses in real time as the agent works",
"Attach images and terminal context directly in your prompts",
],
accentColor: "sky",
},
{
id: "git",
title: "Built-in Git Workflows",
description:
"Every thread can run in its own git worktree, keeping your main branch safe while the agent experiments freely.",
details: [
"New threads automatically create isolated worktrees",
"Switch branches, create PRs, and manage worktrees from the toolbar",
"Link threads to existing pull requests for focused code review",
],
accentColor: "emerald",
},
{
id: "diff",
title: "Review Changes Side-by-Side",
description:
"Inspect every code change the agent makes with a built-in diff viewer before accepting anything.",
details: [
"Inline and side-by-side diff views with syntax highlighting",
"Accept or reject changes per-file with a single click",
"Word-level highlighting shows exactly what changed",
],
accentColor: "amber",
},
{
id: "terminal",
title: "Integrated Terminal",
description:
"A full terminal lives inside every thread — run commands, see output, and feed context back to the agent.",
details: [
"Up to four terminal tabs per thread for parallel workflows",
"Select terminal output and add it directly to your prompt",
"Track running subprocesses with live activity indicators",
],
accentColor: "violet",
},
{
id: "plan",
title: "AI-Generated Plans",
description:
"Switch to Plan mode and let the agent outline a structured implementation strategy before writing a single line of code.",
details: [
"Step-by-step plans with status tracking as work progresses",
"Review, copy, or export plans as Markdown",
'Click "Implement Plan" to kick off execution in a new thread',
],
accentColor: "rose",
},
{
id: "approvals",
title: "Stay in Control",
description:
"You decide what gets executed. The agent asks for your approval before making changes, so nothing happens without your say-so.",
details: [
"Approve, request changes, or cancel any proposed action",
"Switch between full-access and approval-required modes per thread",
"Review pending file changes before they're applied",
],
accentColor: "orange",
},
{
id: "getStarted",
title: "You're All Set!",
description: "You're ready to start building. Here are a few shortcuts to help you move fast.",
details: [
"Press Cmd+N (or Ctrl+N) to create a new thread instantly",
"Use the sidebar to switch between projects and threads",
"Open Settings to customize models, themes, and keybindings",
],
accentColor: "primary",
},
];
24 changes: 24 additions & 0 deletions apps/web/src/components/onboarding/useOnboardingState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useCallback, useState } from "react";

const STORAGE_KEY = "okcode:onboarding-completed:v1";

export function useOnboardingState() {
const [open, setOpen] = useState(() => {
try {
return localStorage.getItem(STORAGE_KEY) !== "true";
} catch {
return false;
}
});

const complete = useCallback(() => {
try {
localStorage.setItem(STORAGE_KEY, "true");
} catch {
// Ignore storage errors
}
setOpen(false);
}, []);

return { open, complete, skip: complete };
}
2 changes: 1 addition & 1 deletion apps/web/src/hooks/useHandleNewThread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function useHandleNewThread() {
createdAt,
branch: options?.branch ?? null,
worktreePath: options?.worktreePath ?? null,
envMode: options?.envMode ?? "local",
envMode: options?.envMode ?? "worktree",
runtimeMode: DEFAULT_RUNTIME_MODE,
});
if (stickyModel) {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
import { providerQueryKeys } from "../lib/providerReactQuery";
import { projectQueryKeys } from "../lib/projectReactQuery";
import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";
import { OnboardingDialog } from "../components/onboarding/OnboardingDialog";

export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
Expand Down Expand Up @@ -54,6 +55,7 @@ function RootRouteView() {
<EventRouter />
<DesktopProjectBootstrap />
<Outlet />
<OnboardingDialog />
</AnchoredToastProvider>
</ToastProvider>
);
Expand Down
Loading