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) && ( -
-
- - Home -
-
- )} - - {isElectron && ( -
- - {APP_DISPLAY_NAME} - - Home -
- )} - -
-
- {!hasReadyProvider && providers.length > 0 ? ( -
- -
- ) : ( -
-
- )} -
-
-
- ); -} 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) && ( +
+
+ + Home +
+
+ )} + + {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 ( +
+ + + +
+ ); +} 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) => ( + + )) + ) : ( +

+ 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",