diff --git a/apps/web/src/components/ChatHomeEmptyState.tsx b/apps/web/src/components/ChatHomeEmptyState.tsx index 5a38a583c..a0fd6bc25 100644 --- a/apps/web/src/components/ChatHomeEmptyState.tsx +++ b/apps/web/src/components/ChatHomeEmptyState.tsx @@ -1,7 +1,17 @@ -import { DEFAULT_MODEL_BY_PROVIDER } from "@okcode/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, type ServerProviderStatus } from "@okcode/contracts"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { FolderOpenIcon, SettingsIcon, TerminalSquareIcon } from "lucide-react"; +import { + ArrowRightIcon, + CheckCircle2Icon, + FolderGit2Icon, + FolderOpenIcon, + SettingsIcon, + ShieldCheckIcon, + SparklesIcon, + TerminalSquareIcon, + WorkflowIcon, +} from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useAppSettings } from "../appSettings"; @@ -13,24 +23,138 @@ 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 { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "./ui/empty"; 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); @@ -43,6 +167,35 @@ export function ChatHomeEmptyState() { [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) { @@ -138,64 +291,422 @@ export function ChatHomeEmptyState() { )}
+ {APP_DISPLAY_NAME} +
++ A fast local workspace for coding agents +
++ Start from context, not from scratch. +
++ OK Code keeps threads tied to real repositories, preserves provider state, + and gives your desktop a calmer control surface for deep, multi-session + work. +
++ Providers +
++ {providers.length > 0 + ? `${readyProviders.length}/${providers.length}` + : "0/0"} +
++ Projects +
++ {projects.length} +
++ Threads +
++ {threads.length} +
++ New thread mode +
++ {formatEnvModeLabel(appSettings.defaultThreadEnvMode)} +
++ Current default +
++ {formatEnvModeLabel(appSettings.defaultThreadEnvMode)} +
++ Designed to open work with context already attached instead of asking + you to reorient each time. +
+{item.title}
++ {item.description} +
+- Save and refresh to replace this temporary home screen. -
- - -+ 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."} +
++ Provider status +
++ Keep both engines visible so handoff and recovery stay predictable. +
++ {getProviderLabel(provider.provider)} +
++ {provider.message ?? + (provider.available + ? "Available for new sessions." + : "Install or configure this provider in Settings.")} +
++ Recent activity +
+ ++ {thread.title} +
++ {thread.projectName} ยท {thread.statusLabel} +
++ Recommended flow +
+Open the repository you want to work in.
++ Start a thread using your default{" "} + {formatEnvModeLabel(appSettings.defaultThreadEnvMode).toLowerCase()}{" "} + setup. +
++ Adjust provider installs, models, and editor integrations from Settings + when needed. +
+