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
716 changes: 0 additions & 716 deletions apps/web/src/components/ChatHomeEmptyState.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
204 changes: 204 additions & 0 deletions apps/web/src/components/home/ChatHomeEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
{!isElectron && (sidebarIsMobile || !sidebarOpen) && (
<header className="border-b border-border px-3 py-2">
<div className="flex items-center gap-2">
<SidebarTrigger className="size-7 shrink-0" />
<span className="text-sm font-medium text-foreground">Home</span>
</div>
</header>
)}

{isElectron && (
<div className="drag-region flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4 pl-[90px] sm:px-5 sm:pl-[90px]">
<span className="text-xs font-medium tracking-[0.14em] text-muted-foreground/70 uppercase">
{APP_DISPLAY_NAME}
</span>
<span className="text-[11px] text-muted-foreground/55">Home</span>
</div>
)}

<div className="flex flex-1 items-center overflow-y-auto">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8 px-6 py-12">
{!hasReadyProvider && providers.length > 0 ? (
<div className="mx-auto w-full max-w-xl">
<ProviderSetupCard providers={providers} />
</div>
) : (
<>
<HomeGreeting projectName={latestProject?.name ?? null} />

<HomeActions
latestProjectName={latestProject?.name ?? null}
isOpeningProject={isOpeningProject}
onNewThread={() => void startLatestThread()}
onOpenFolder={() => void openProjectFolder()}
onSettings={() => void navigate({ to: "/settings" })}
/>

<HomeProviderStatus
providers={providers}
onSettingsClick={() => void navigate({ to: "/settings" })}
/>

<HomeRecentThreads
threads={recentThreads}
onThreadClick={(id) =>
void navigate({ to: "/$threadId", params: { threadId: id } })
}
/>

<HomeQuickStats
projectCount={projects.length}
threadCount={threads.length}
envModeLabel={formatEnvModeLabel(appSettings.defaultThreadEnvMode)}
/>
</>
)}
</div>
</div>
</div>
);
}
36 changes: 36 additions & 0 deletions apps/web/src/components/home/HomeActions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-wrap items-center gap-2">
<Button onClick={onNewThread}>
<TerminalSquareIcon className="size-4" />
{latestProjectName ? `New thread in ${latestProjectName}` : "Open your first project"}
</Button>
<Button variant="outline" onClick={onOpenFolder} disabled={isOpeningProject}>
<FolderOpenIcon className="size-4" />
{isOpeningProject ? "Opening..." : "Open folder"}
</Button>
<Button variant="ghost" onClick={onSettings}>
<SettingsIcon className="size-4" />
Settings
</Button>
</div>
);
}
23 changes: 23 additions & 0 deletions apps/web/src/components/home/HomeGreeting.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-3">
<span className="flex size-8 items-center justify-center rounded-xl bg-primary/10 text-primary">
<OkCodeMark className="size-4" />
</span>
<h1 className="text-lg font-semibold text-foreground">{projectName ?? "Welcome back"}</h1>
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
<Kbd>{isMac ? "\u2318" : "Ctrl"}</Kbd>
<Kbd>K</Kbd>
</div>
</div>
);
}
45 changes: 45 additions & 0 deletions apps/web/src/components/home/HomeProviderStatus.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p className="text-sm text-muted-foreground">
Providers will appear after configuration loads.
</p>
);
}

return (
<div className="flex flex-wrap items-center gap-2">
{providers.map((provider) => {
const variant = getProviderBadgeVariant(provider);
const dotColor =
variant === "success"
? "bg-emerald-500"
: variant === "warning"
? "bg-amber-500"
: "bg-red-500";

return (
<Badge
key={provider.provider}
variant={variant}
render={<button type="button" onClick={onSettingsClick} />}
className="cursor-pointer gap-1.5 px-2 py-0.5"
>
<span className={`size-1.5 rounded-full ${dotColor}`} />
{getProviderLabel(provider.provider)}: {getProviderStatusLabel(provider)}
</Badge>
);
})}
</div>
);
}
23 changes: 23 additions & 0 deletions apps/web/src/components/home/HomeQuickStats.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>
{projectCount} {projectCount === 1 ? "project" : "projects"}
</span>
<Separator orientation="vertical" className="h-3" />
<span>
{threadCount} {threadCount === 1 ? "thread" : "threads"}
</span>
<Separator orientation="vertical" className="h-3" />
<span>{envModeLabel}</span>
</div>
);
}
41 changes: 41 additions & 0 deletions apps/web/src/components/home/HomeRecentThreads.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">Recent threads</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
{threads.length > 0 ? (
threads.map((thread) => (
<button
type="button"
key={thread.id}
className="flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-left transition-colors hover:bg-accent/50"
onClick={() => onThreadClick(thread.id)}
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{thread.title}</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{thread.projectName} · {thread.statusLabel}
</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{thread.updatedLabel}</span>
</button>
))
) : (
<p className="px-3 py-4 text-sm text-muted-foreground">
No threads yet. Start one above.
</p>
)}
</CardContent>
</Card>
);
}
Loading
Loading