diff --git a/apps/web/src/components/ChatHomeEmptyState.tsx b/apps/web/src/components/ChatHomeEmptyState.tsx
deleted file mode 100644
index ccbcba1b2..000000000
--- a/apps/web/src/components/ChatHomeEmptyState.tsx
+++ /dev/null
@@ -1,716 +0,0 @@
-import { DEFAULT_MODEL_BY_PROVIDER, type ServerProviderStatus } from "@okcode/contracts";
-import { useQuery } from "@tanstack/react-query";
-import { useNavigate } from "@tanstack/react-router";
-import {
- ArrowRightIcon,
- CheckCircle2Icon,
- FolderGit2Icon,
- FolderOpenIcon,
- SettingsIcon,
- ShieldCheckIcon,
- SparklesIcon,
- TerminalSquareIcon,
- WorkflowIcon,
-} from "lucide-react";
-import { useCallback, useMemo, useState } from "react";
-
-import { useAppSettings } from "../appSettings";
-import { APP_BASE_NAME, APP_DISPLAY_NAME } from "../branding";
-import { isElectron } from "../env";
-import { useHandleNewThread } from "../hooks/useHandleNewThread";
-import { serverConfigQueryOptions } from "../lib/serverReactQuery";
-import { newCommandId, newProjectId } from "../lib/utils";
-import { readNativeApi } from "../nativeApi";
-import { useStore } from "../store";
-import { sortProjectsForSidebar } from "./Sidebar.logic";
-import { OkCodeMark } from "./OkCodeMark";
-import { ProviderSetupCard } from "./chat/ProviderSetupCard";
-import { Button } from "./ui/button";
-import { SidebarTrigger, useSidebar } from "./ui/sidebar";
-import { toastManager } from "./ui/toast";
-
-const capabilityHighlights = [
- {
- title: "Project-scoped threads",
- description: "Keep work attached to a repo instead of losing context in a global chat.",
- icon: FolderGit2Icon,
- },
- {
- title: "Predictable execution",
- description: "Launch new work in local or worktree mode with the same defaults every time.",
- icon: WorkflowIcon,
- },
- {
- title: "Built for long sessions",
- description: "Reconnect cleanly and keep provider state legible under restarts or failures.",
- icon: ShieldCheckIcon,
- },
-] as const;
-
-const commandPreview = [
- "> Open repository",
- "> Start provider session",
- "> Keep thread context attached to the workspace",
-] as const;
-
-const shellStatusItems = ["Agent-ready", "Desktop-native", "Workspace-first"] as const;
-const heroGraphicNodes = [
- { className: "left-[8%] top-[14%] h-12 w-24", delay: "0ms" },
- { className: "left-[34%] top-[8%] h-16 w-32", delay: "120ms" },
- { className: "right-[12%] top-[18%] h-12 w-28", delay: "220ms" },
- { className: "left-[18%] bottom-[22%] h-14 w-36", delay: "160ms" },
- { className: "left-[50%] bottom-[12%] h-12 w-24", delay: "280ms" },
- { className: "right-[10%] bottom-[24%] h-16 w-32", delay: "340ms" },
-] as const;
-
-function formatEnvModeLabel(envMode: "local" | "worktree") {
- return envMode === "worktree" ? "New worktree" : "Local mode";
-}
-
-function getProviderLabel(provider: ServerProviderStatus["provider"]) {
- switch (provider) {
- case "claudeAgent":
- return "Claude";
- case "codex":
- return "Codex";
- }
-}
-
-function getProviderStatusLabel(provider: ServerProviderStatus) {
- if (!provider.available) {
- return "Unavailable";
- }
- if (provider.status === "ready") {
- return provider.authStatus === "authenticated" ? "Ready" : "Needs sign-in";
- }
- if (provider.status === "warning") {
- return "Needs attention";
- }
- return "Error";
-}
-
-function getProviderStatusClasses(provider: ServerProviderStatus) {
- if (!provider.available || provider.status === "error") {
- return "border-red-500/20 bg-red-500/8 text-red-700 dark:text-red-300";
- }
- if (provider.status === "warning" || provider.authStatus !== "authenticated") {
- return "border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300";
- }
- return "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
-}
-
-function formatRelativeTimeCompact(value: string | undefined) {
- if (!value) {
- return "Just now";
- }
-
- const timestamp = Date.parse(value);
- if (Number.isNaN(timestamp)) {
- return "Recently";
- }
-
- const diffMs = Date.now() - timestamp;
- const minute = 60_000;
- const hour = 60 * minute;
- const day = 24 * hour;
-
- if (diffMs < minute) {
- return "Just now";
- }
- if (diffMs < hour) {
- return `${Math.max(1, Math.round(diffMs / minute))}m ago`;
- }
- if (diffMs < day) {
- return `${Math.max(1, Math.round(diffMs / hour))}h ago`;
- }
- return `${Math.max(1, Math.round(diffMs / day))}d ago`;
-}
-
-function getThreadActivityLabel(input: {
- updatedAt: string | undefined;
- sessionStatus: string | null | undefined;
- hasLatestTurn: boolean;
-}) {
- if (input.sessionStatus === "running") {
- return "Running";
- }
- if (input.sessionStatus === "connecting") {
- return "Connecting";
- }
- if (input.sessionStatus === "error") {
- return "Needs attention";
- }
- if (input.hasLatestTurn) {
- return "Ready to resume";
- }
- if (input.updatedAt) {
- return "Recently updated";
- }
- return "New thread";
-}
-
-export function ChatHomeEmptyState() {
- const navigate = useNavigate();
- const serverConfigQuery = useQuery(serverConfigQueryOptions());
- const providers = serverConfigQuery.data?.providers ?? [];
- const hasReadyProvider = providers.some((provider) => provider.status === "ready");
- const readyProviders = providers.filter((provider) => provider.status === "ready");
- const { settings: appSettings } = useAppSettings();
- const projects = useStore((store) => store.projects);
- const threads = useStore((store) => store.threads);
- const { handleNewThread } = useHandleNewThread();
- const [isOpeningProject, setIsOpeningProject] = useState(false);
- const { open: sidebarOpen, isMobile: sidebarIsMobile } = useSidebar();
-
- const latestProject = useMemo(
- () => sortProjectsForSidebar(projects, threads, appSettings.sidebarProjectSortOrder)[0] ?? null,
- [appSettings.sidebarProjectSortOrder, projects, threads],
- );
-
- const latestProjectThreadCount = useMemo(
- () =>
- latestProject ? threads.filter((thread) => thread.projectId === latestProject.id).length : 0,
- [latestProject, threads],
- );
-
- const recentThreads = useMemo(() => {
- const projectsById = new Map(projects.map((project) => [project.id, project] as const));
-
- return threads
- .toSorted((a, b) => {
- const aTime = Date.parse(a.updatedAt ?? a.createdAt);
- const bTime = Date.parse(b.updatedAt ?? b.createdAt);
- return bTime - aTime;
- })
- .slice(0, 4)
- .map((thread) => ({
- id: thread.id,
- title: thread.title,
- projectName: projectsById.get(thread.projectId)?.name ?? "Unknown project",
- updatedLabel: formatRelativeTimeCompact(thread.updatedAt ?? thread.createdAt),
- statusLabel: getThreadActivityLabel({
- updatedAt: thread.updatedAt,
- sessionStatus: thread.session?.status ?? null,
- hasLatestTurn: thread.latestTurn !== null,
- }),
- }));
- }, [projects, threads]);
-
- const openProjectFolder = useCallback(async () => {
- const api = readNativeApi();
- if (!api || isOpeningProject) {
- return;
- }
-
- setIsOpeningProject(true);
- let pickedPath: string | null = null;
- try {
- pickedPath = await api.dialogs.pickFolder();
- } catch (error) {
- toastManager.add({
- type: "error",
- title: "Could not open folder picker",
- description: error instanceof Error ? error.message : "An unexpected error occurred.",
- });
- setIsOpeningProject(false);
- return;
- }
-
- if (!pickedPath) {
- setIsOpeningProject(false);
- return;
- }
-
- const existingProject = projects.find((project) => project.cwd === pickedPath);
- if (existingProject) {
- await handleNewThread(existingProject.id, {
- envMode: appSettings.defaultThreadEnvMode,
- }).catch(() => undefined);
- setIsOpeningProject(false);
- return;
- }
-
- const title = pickedPath.split(/[/\\]/).findLast((segment) => segment.length > 0) ?? pickedPath;
- try {
- const projectId = newProjectId();
- await api.orchestration.dispatchCommand({
- type: "project.create",
- commandId: newCommandId(),
- projectId,
- title,
- workspaceRoot: pickedPath,
- defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex,
- createdAt: new Date().toISOString(),
- });
- await handleNewThread(projectId, {
- envMode: appSettings.defaultThreadEnvMode,
- }).catch(() => undefined);
- } catch (error) {
- toastManager.add({
- type: "error",
- title: "Failed to add project",
- description:
- error instanceof Error
- ? error.message
- : "An unexpected error occurred while adding the project.",
- });
- }
-
- setIsOpeningProject(false);
- }, [appSettings.defaultThreadEnvMode, handleNewThread, isOpeningProject, projects]);
-
- const startLatestThread = useCallback(async () => {
- if (!latestProject) {
- await openProjectFolder();
- return;
- }
-
- await handleNewThread(latestProject.id, {
- envMode: appSettings.defaultThreadEnvMode,
- }).catch(() => undefined);
- }, [appSettings.defaultThreadEnvMode, handleNewThread, latestProject, openProjectFolder]);
-
- return (
-
- {!isElectron && (sidebarIsMobile || !sidebarOpen) && (
-
- )}
-
- {isElectron && (
-
-
- {APP_DISPLAY_NAME}
-
- Home
-
- )}
-
-
-
- {!hasReadyProvider && providers.length > 0 ? (
-
- ) : (
-
-
-
-
-
-
-
- OK
-
-
-
-
-
-
- {shellStatusItems.map((item, index) => (
-
-
- {item}
-
- ))}
-
-
-
-
-
-
-
-
- {APP_DISPLAY_NAME}
-
-
- A fast local workspace for coding agents
-
-
-
-
-
-
- Start from context, not from scratch.
-
-
- Launch a premium coding workspace with reliable agent sessions built in.
-
-
- {APP_BASE_NAME} keeps threads tied to real repositories, preserves provider
- state, and gives your desktop a calmer control surface for deep,
- multi-session work.
-
-
-
-
-
void startLatestThread()}>
-
- {latestProject
- ? `New thread in ${latestProject.name}`
- : "Open your first project"}
-
-
-
void openProjectFolder()}
- disabled={isOpeningProject}
- >
-
- {isOpeningProject ? "Opening..." : "Open folder"}
-
-
void navigate({ to: "/settings" })}
- >
-
- Settings
-
-
-
-
-
-
- Providers
-
-
- {providers.length > 0
- ? `${readyProviders.length}/${providers.length}`
- : "0/0"}
-
-
-
-
- Projects
-
-
- {projects.length}
-
-
-
-
- Threads
-
-
- {threads.length}
-
-
-
-
- New thread mode
-
-
- {formatEnvModeLabel(appSettings.defaultThreadEnvMode)}
-
-
-
-
-
-
-
-
-
-
-
-
- Session flow
-
-
-
- {commandPreview.map((line, index) => (
-
- {line}
-
- ))}
-
-
-
-
-
-
-
-
- {heroGraphicNodes.map((node) => (
-
- ))}
-
-
-
-
-
-
- Current default
-
-
- {formatEnvModeLabel(appSettings.defaultThreadEnvMode)}
-
-
- Designed to open work with context already attached instead of asking
- you to reorient each time.
-
-
-
-
-
-
- Ready for focused desktop sessions
-
-
-
-
-
-
-
- {capabilityHighlights.map((item) => {
- const Icon = item.icon;
- return (
-
-
-
- {item.description}
-
-
- );
- })}
-
-
-
-
-
-
-
- Workspace overview
-
-
-
-
-
-
- Latest project
-
-
- {latestProject ? latestProject.name : "No project selected"}
-
-
-
- {latestProject
- ? "Resume quickly in the most recently active workspace or open a different folder."
- : "Open a repository to create a project-scoped thread and start working with full context."}
-
-
-
-
- Threads in project
-
-
- {latestProject ? latestProjectThreadCount : 0}
-
-
-
-
- Workspace root
-
-
- {latestProject ? latestProject.cwd : "Choose a folder to begin"}
-
-
-
-
-
- {latestProject ? "Ready to resume" : "Awaiting repository"}
-
-
- Desktop workspace
-
-
-
-
-
-
-
-
- Provider status
-
-
- Keep both engines visible so handoff and recovery stay predictable.
-
-
-
-
- {readyProviders.length} ready
-
-
-
-
- {providers.length > 0 ? (
- providers.map((provider) => (
-
-
-
-
-
- {getProviderLabel(provider.provider)}
-
-
-
- {provider.message ??
- (provider.available
- ? "Available for new sessions."
- : "Install or configure this provider in Settings.")}
-
-
-
- {getProviderStatusLabel(provider)}
-
-
- ))
- ) : (
-
- Provider status will appear here after the app finishes loading
- configuration.
-
- )}
-
-
-
-
-
-
- Recent activity
-
-
void startLatestThread()}>
- Open recent thread
-
-
-
-
- {recentThreads.length > 0 ? (
- recentThreads.map((thread) => (
-
-
-
- {thread.title}
-
-
- {thread.projectName} · {thread.statusLabel}
-
-
-
- {thread.updatedLabel}
-
-
- ))
- ) : (
-
- Recent threads will appear here after you start working in a project.
-
- )}
-
-
-
-
-
- Recommended flow
-
-
-
-
- 1
-
-
Open the repository you want to work in.
-
-
-
- 2
-
-
- Start a thread using your default{" "}
- {formatEnvModeLabel(appSettings.defaultThreadEnvMode).toLowerCase()}{" "}
- setup.
-
-
-
-
- 3
-
-
- Adjust provider installs, models, and editor integrations from Settings
- when needed.
-
-
-
-
-
-
-
-
- )}
-
-
-
- );
-}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index ba2d09584..2730176f2 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -177,7 +177,7 @@ import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./Compose
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
import { MessagesTimeline } from "./chat/MessagesTimeline";
import { ChatHeader } from "./chat/ChatHeader";
-import { ChatHomeEmptyState } from "./ChatHomeEmptyState";
+import { ChatHomeEmptyState } from "./home/ChatHomeEmptyState";
import { useCodeViewerStore } from "~/codeViewerStore";
import { PreviewPanel } from "./PreviewPanel";
import { ContextWindowMeter } from "./chat/ContextWindowMeter";
diff --git a/apps/web/src/components/home/ChatHomeEmptyState.tsx b/apps/web/src/components/home/ChatHomeEmptyState.tsx
new file mode 100644
index 000000000..143a48690
--- /dev/null
+++ b/apps/web/src/components/home/ChatHomeEmptyState.tsx
@@ -0,0 +1,204 @@
+import { DEFAULT_MODEL_BY_PROVIDER, type ServerProviderStatus } from "@okcode/contracts";
+import { useQuery } from "@tanstack/react-query";
+import { useNavigate } from "@tanstack/react-router";
+import { useCallback, useMemo, useState } from "react";
+
+import { useAppSettings } from "../../appSettings";
+import { APP_DISPLAY_NAME } from "../../branding";
+import { isElectron } from "../../env";
+import { useHandleNewThread } from "../../hooks/useHandleNewThread";
+import { serverConfigQueryOptions } from "../../lib/serverReactQuery";
+import { newCommandId, newProjectId } from "../../lib/utils";
+import { readNativeApi } from "../../nativeApi";
+import { useStore } from "../../store";
+import { sortProjectsForSidebar } from "../Sidebar.logic";
+import { ProviderSetupCard } from "../chat/ProviderSetupCard";
+import { SidebarTrigger, useSidebar } from "../ui/sidebar";
+import { toastManager } from "../ui/toast";
+import { HomeActions } from "./HomeActions";
+import { HomeGreeting } from "./HomeGreeting";
+import { HomeProviderStatus } from "./HomeProviderStatus";
+import { HomeQuickStats } from "./HomeQuickStats";
+import { HomeRecentThreads } from "./HomeRecentThreads";
+import {
+ formatEnvModeLabel,
+ formatRelativeTimeCompact,
+ getThreadActivityLabel,
+} from "./home-utils";
+
+export function ChatHomeEmptyState() {
+ const navigate = useNavigate();
+ const serverConfigQuery = useQuery(serverConfigQueryOptions());
+ const providers = serverConfigQuery.data?.providers ?? [];
+ const hasReadyProvider = providers.some((provider) => provider.status === "ready");
+ const { settings: appSettings } = useAppSettings();
+ const projects = useStore((store) => store.projects);
+ const threads = useStore((store) => store.threads);
+ const { handleNewThread } = useHandleNewThread();
+ const [isOpeningProject, setIsOpeningProject] = useState(false);
+ const { open: sidebarOpen, isMobile: sidebarIsMobile } = useSidebar();
+
+ const latestProject = useMemo(
+ () => sortProjectsForSidebar(projects, threads, appSettings.sidebarProjectSortOrder)[0] ?? null,
+ [appSettings.sidebarProjectSortOrder, projects, threads],
+ );
+
+ const recentThreads = useMemo(() => {
+ const projectsById = new Map(projects.map((project) => [project.id, project] as const));
+
+ return threads
+ .toSorted((a, b) => {
+ const aTime = Date.parse(a.updatedAt ?? a.createdAt);
+ const bTime = Date.parse(b.updatedAt ?? b.createdAt);
+ return bTime - aTime;
+ })
+ .slice(0, 4)
+ .map((thread) => ({
+ id: thread.id,
+ title: thread.title,
+ projectName: projectsById.get(thread.projectId)?.name ?? "Unknown project",
+ updatedLabel: formatRelativeTimeCompact(thread.updatedAt ?? thread.createdAt),
+ statusLabel: getThreadActivityLabel({
+ updatedAt: thread.updatedAt,
+ sessionStatus: thread.session?.status ?? null,
+ hasLatestTurn: thread.latestTurn !== null,
+ }),
+ }));
+ }, [projects, threads]);
+
+ const openProjectFolder = useCallback(async () => {
+ const api = readNativeApi();
+ if (!api || isOpeningProject) {
+ return;
+ }
+
+ setIsOpeningProject(true);
+ let pickedPath: string | null = null;
+ try {
+ pickedPath = await api.dialogs.pickFolder();
+ } catch (error) {
+ toastManager.add({
+ type: "error",
+ title: "Could not open folder picker",
+ description: error instanceof Error ? error.message : "An unexpected error occurred.",
+ });
+ setIsOpeningProject(false);
+ return;
+ }
+
+ if (!pickedPath) {
+ setIsOpeningProject(false);
+ return;
+ }
+
+ const existingProject = projects.find((project) => project.cwd === pickedPath);
+ if (existingProject) {
+ await handleNewThread(existingProject.id, {
+ envMode: appSettings.defaultThreadEnvMode,
+ }).catch(() => undefined);
+ setIsOpeningProject(false);
+ return;
+ }
+
+ const title = pickedPath.split(/[/\\]/).findLast((segment) => segment.length > 0) ?? pickedPath;
+ try {
+ const projectId = newProjectId();
+ await api.orchestration.dispatchCommand({
+ type: "project.create",
+ commandId: newCommandId(),
+ projectId,
+ title,
+ workspaceRoot: pickedPath,
+ defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex,
+ createdAt: new Date().toISOString(),
+ });
+ await handleNewThread(projectId, {
+ envMode: appSettings.defaultThreadEnvMode,
+ }).catch(() => undefined);
+ } catch (error) {
+ toastManager.add({
+ type: "error",
+ title: "Failed to add project",
+ description:
+ error instanceof Error
+ ? error.message
+ : "An unexpected error occurred while adding the project.",
+ });
+ }
+
+ setIsOpeningProject(false);
+ }, [appSettings.defaultThreadEnvMode, handleNewThread, isOpeningProject, projects]);
+
+ const startLatestThread = useCallback(async () => {
+ if (!latestProject) {
+ await openProjectFolder();
+ return;
+ }
+
+ await handleNewThread(latestProject.id, {
+ envMode: appSettings.defaultThreadEnvMode,
+ }).catch(() => undefined);
+ }, [appSettings.defaultThreadEnvMode, handleNewThread, latestProject, openProjectFolder]);
+
+ return (
+
+ {!isElectron && (sidebarIsMobile || !sidebarOpen) && (
+
+ )}
+
+ {isElectron && (
+
+
+ {APP_DISPLAY_NAME}
+
+ Home
+
+ )}
+
+
+
+ {!hasReadyProvider && providers.length > 0 ? (
+
+ ) : (
+ <>
+
+
+
void startLatestThread()}
+ onOpenFolder={() => void openProjectFolder()}
+ onSettings={() => void navigate({ to: "/settings" })}
+ />
+
+ void navigate({ to: "/settings" })}
+ />
+
+
+ void navigate({ to: "/$threadId", params: { threadId: id } })
+ }
+ />
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/home/HomeActions.tsx b/apps/web/src/components/home/HomeActions.tsx
new file mode 100644
index 000000000..f64b142b1
--- /dev/null
+++ b/apps/web/src/components/home/HomeActions.tsx
@@ -0,0 +1,36 @@
+import { FolderOpenIcon, SettingsIcon, TerminalSquareIcon } from "lucide-react";
+
+import { Button } from "../ui/button";
+
+interface HomeActionsProps {
+ latestProjectName: string | null;
+ isOpeningProject: boolean;
+ onNewThread: () => void;
+ onOpenFolder: () => void;
+ onSettings: () => void;
+}
+
+export function HomeActions({
+ latestProjectName,
+ isOpeningProject,
+ onNewThread,
+ onOpenFolder,
+ onSettings,
+}: HomeActionsProps) {
+ return (
+
+
+
+ {latestProjectName ? `New thread in ${latestProjectName}` : "Open your first project"}
+
+
+
+ {isOpeningProject ? "Opening..." : "Open folder"}
+
+
+
+ Settings
+
+
+ );
+}
diff --git a/apps/web/src/components/home/HomeGreeting.tsx b/apps/web/src/components/home/HomeGreeting.tsx
new file mode 100644
index 000000000..00b3eba37
--- /dev/null
+++ b/apps/web/src/components/home/HomeGreeting.tsx
@@ -0,0 +1,23 @@
+import { OkCodeMark } from "../OkCodeMark";
+import { Kbd } from "../ui/kbd";
+
+const isMac = typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent);
+
+interface HomeGreetingProps {
+ projectName: string | null;
+}
+
+export function HomeGreeting({ projectName }: HomeGreetingProps) {
+ return (
+
+
+
+
+
{projectName ?? "Welcome back"}
+
+ {isMac ? "\u2318" : "Ctrl"}
+ K
+
+
+ );
+}
diff --git a/apps/web/src/components/home/HomeProviderStatus.tsx b/apps/web/src/components/home/HomeProviderStatus.tsx
new file mode 100644
index 000000000..6b0edfacb
--- /dev/null
+++ b/apps/web/src/components/home/HomeProviderStatus.tsx
@@ -0,0 +1,45 @@
+import type { ServerProviderStatus } from "@okcode/contracts";
+
+import { Badge } from "../ui/badge";
+import { getProviderBadgeVariant, getProviderLabel, getProviderStatusLabel } from "./home-utils";
+
+interface HomeProviderStatusProps {
+ providers: ServerProviderStatus[];
+ onSettingsClick: () => void;
+}
+
+export function HomeProviderStatus({ providers, onSettingsClick }: HomeProviderStatusProps) {
+ if (providers.length === 0) {
+ return (
+
+ Providers will appear after configuration loads.
+
+ );
+ }
+
+ return (
+
+ {providers.map((provider) => {
+ const variant = getProviderBadgeVariant(provider);
+ const dotColor =
+ variant === "success"
+ ? "bg-emerald-500"
+ : variant === "warning"
+ ? "bg-amber-500"
+ : "bg-red-500";
+
+ return (
+ }
+ className="cursor-pointer gap-1.5 px-2 py-0.5"
+ >
+
+ {getProviderLabel(provider.provider)}: {getProviderStatusLabel(provider)}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/home/HomeQuickStats.tsx b/apps/web/src/components/home/HomeQuickStats.tsx
new file mode 100644
index 000000000..091174ec1
--- /dev/null
+++ b/apps/web/src/components/home/HomeQuickStats.tsx
@@ -0,0 +1,23 @@
+import { Separator } from "../ui/separator";
+
+interface HomeQuickStatsProps {
+ projectCount: number;
+ threadCount: number;
+ envModeLabel: string;
+}
+
+export function HomeQuickStats({ projectCount, threadCount, envModeLabel }: HomeQuickStatsProps) {
+ return (
+
+
+ {projectCount} {projectCount === 1 ? "project" : "projects"}
+
+
+
+ {threadCount} {threadCount === 1 ? "thread" : "threads"}
+
+
+ {envModeLabel}
+
+ );
+}
diff --git a/apps/web/src/components/home/HomeRecentThreads.tsx b/apps/web/src/components/home/HomeRecentThreads.tsx
new file mode 100644
index 000000000..c5b236c74
--- /dev/null
+++ b/apps/web/src/components/home/HomeRecentThreads.tsx
@@ -0,0 +1,41 @@
+import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
+import type { RecentThread } from "./home-utils";
+
+interface HomeRecentThreadsProps {
+ threads: RecentThread[];
+ onThreadClick: (id: string) => void;
+}
+
+export function HomeRecentThreads({ threads, onThreadClick }: HomeRecentThreadsProps) {
+ return (
+
+
+ Recent threads
+
+
+ {threads.length > 0 ? (
+ threads.map((thread) => (
+ onThreadClick(thread.id)}
+ >
+
+
{thread.title}
+
+ {thread.projectName} · {thread.statusLabel}
+
+
+ {thread.updatedLabel}
+
+ ))
+ ) : (
+
+ No threads yet. Start one above.
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/home/home-utils.ts b/apps/web/src/components/home/home-utils.ts
new file mode 100644
index 000000000..64ffe30e5
--- /dev/null
+++ b/apps/web/src/components/home/home-utils.ts
@@ -0,0 +1,97 @@
+import type { ServerProviderStatus } from "@okcode/contracts";
+
+export function formatEnvModeLabel(envMode: "local" | "worktree") {
+ return envMode === "worktree" ? "New worktree" : "Local mode";
+}
+
+export function getProviderLabel(provider: ServerProviderStatus["provider"]) {
+ switch (provider) {
+ case "claudeAgent":
+ return "Claude";
+ case "codex":
+ return "Codex";
+ }
+}
+
+export function getProviderStatusLabel(provider: ServerProviderStatus) {
+ if (!provider.available) {
+ return "Unavailable";
+ }
+ if (provider.status === "ready") {
+ return provider.authStatus === "authenticated" ? "Ready" : "Needs sign-in";
+ }
+ if (provider.status === "warning") {
+ return "Needs attention";
+ }
+ return "Error";
+}
+
+export function getProviderBadgeVariant(
+ provider: ServerProviderStatus,
+): "success" | "warning" | "error" {
+ if (!provider.available || provider.status === "error") {
+ return "error";
+ }
+ if (provider.status === "warning" || provider.authStatus !== "authenticated") {
+ return "warning";
+ }
+ return "success";
+}
+
+export function formatRelativeTimeCompact(value: string | undefined) {
+ if (!value) {
+ return "Just now";
+ }
+
+ const timestamp = Date.parse(value);
+ if (Number.isNaN(timestamp)) {
+ return "Recently";
+ }
+
+ const diffMs = Date.now() - timestamp;
+ const minute = 60_000;
+ const hour = 60 * minute;
+ const day = 24 * hour;
+
+ if (diffMs < minute) {
+ return "Just now";
+ }
+ if (diffMs < hour) {
+ return `${Math.max(1, Math.round(diffMs / minute))}m ago`;
+ }
+ if (diffMs < day) {
+ return `${Math.max(1, Math.round(diffMs / hour))}h ago`;
+ }
+ return `${Math.max(1, Math.round(diffMs / day))}d ago`;
+}
+
+export function getThreadActivityLabel(input: {
+ updatedAt: string | undefined;
+ sessionStatus: string | null | undefined;
+ hasLatestTurn: boolean;
+}) {
+ if (input.sessionStatus === "running") {
+ return "Running";
+ }
+ if (input.sessionStatus === "connecting") {
+ return "Connecting";
+ }
+ if (input.sessionStatus === "error") {
+ return "Needs attention";
+ }
+ if (input.hasLatestTurn) {
+ return "Ready to resume";
+ }
+ if (input.updatedAt) {
+ return "Recently updated";
+ }
+ return "New thread";
+}
+
+export interface RecentThread {
+ id: string;
+ title: string;
+ projectName: string;
+ updatedLabel: string;
+ statusLabel: string;
+}
diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx
index 1a09a73c8..402364b7e 100644
--- a/apps/web/src/routes/_chat.index.tsx
+++ b/apps/web/src/routes/_chat.index.tsx
@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
-import { ChatHomeEmptyState } from "../components/ChatHomeEmptyState";
+import { ChatHomeEmptyState } from "../components/home/ChatHomeEmptyState";
function ChatIndexRouteView() {
return ;
diff --git a/bun.lock b/bun.lock
index e34a85189..6d70cd539 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
- "configVersion": 0,
"workspaces": {
"": {
"name": "@okcode/monorepo",