Skip to content

Commit c24f0f8

Browse files
committed
feat(web): refactor HomePage to manage project and workspace views, update ProjectSwitcher to support navigation
1 parent 56031ae commit c24f0f8

4 files changed

Lines changed: 782 additions & 3 deletions

File tree

web/src/app/page.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ChatTabsBar, type ChatTabDescriptor } from "@/components/chat-tabs-bar"
88
import { SettingsDialog } from "@/components/settings-dialog";
99
import { WorkflowsPanel } from "@/components/workflows-panel";
1010
import { ProjectSwitcher } from "@/components/project-switcher";
11+
import { ProjectView } from "@/components/project-view";
1112
import { SessionCostPill } from "@/components/session-cost-pill";
1213
import { ResourceMonitor } from "@/components/resource-monitor";
1314
import { useSessionCost } from "@/lib/use-session-cost";
@@ -72,7 +73,21 @@ function ResizeHandle({ onMouseDown }: { onMouseDown: (e: React.MouseEvent) => v
7273
);
7374
}
7475

75-
export default function ChatPage() {
76+
export default function HomePage() {
77+
const [screen, setScreen] = useState<"projects" | "workspace">("projects");
78+
79+
if (screen === "projects") {
80+
return <ProjectView onOpenProject={() => setScreen("workspace")} />;
81+
}
82+
83+
return <WorkspacePage onOpenProjectView={() => setScreen("projects")} />;
84+
}
85+
86+
function WorkspacePage({
87+
onOpenProjectView,
88+
}: {
89+
onOpenProjectView: () => void;
90+
}) {
7691
const sandbox = useSandbox(false);
7792
const { updateAvailable } = useUpdateCheck();
7893
const { skills: allSkills } = useSkills();
@@ -483,7 +498,7 @@ export default function ChatPage() {
483498
</InfoTooltip>
484499
)}
485500
<span className="mx-1 h-4 w-px bg-border/60" aria-hidden />
486-
<ProjectSwitcher />
501+
<ProjectSwitcher onOpenProjectView={onOpenProjectView} />
487502
</div>
488503
<p className="absolute left-1/2 -translate-x-1/2 text-[11px] text-muted-foreground/60 tracking-wide select-none">
489504
Brought to you by K-Dense, Inc.

web/src/components/project-switcher.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
CheckIcon,
77
ChevronsUpDownIcon,
88
FolderIcon,
9+
LayoutGridIcon,
910
PencilIcon,
1011
PlusIcon,
1112
Trash2Icon,
@@ -71,7 +72,11 @@ function formatProjectId(id: string): string {
7172
.join(" ");
7273
}
7374

74-
export function ProjectSwitcher() {
75+
interface ProjectSwitcherProps {
76+
onOpenProjectView?: () => void;
77+
}
78+
79+
export function ProjectSwitcher({ onOpenProjectView }: ProjectSwitcherProps) {
7580
const {
7681
projects,
7782
activeProjectId,
@@ -284,6 +289,19 @@ export function ProjectSwitcher() {
284289
)}
285290
<CommandSeparator />
286291
<CommandGroup>
292+
{onOpenProjectView && (
293+
<CommandItem
294+
value="__view_all_projects__"
295+
onSelect={() => {
296+
setPopoverOpen(false);
297+
onOpenProjectView();
298+
}}
299+
className="gap-2 text-foreground"
300+
>
301+
<LayoutGridIcon className="size-4" />
302+
View all projects
303+
</CommandItem>
304+
)}
287305
<CommandItem
288306
// Value tracks the live query so cmdk never filters this row
289307
// out — typing a brand-new name keeps "Create …" reachable
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { render, screen, waitFor, within } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
import type { Project } from "@/lib/projects";
6+
7+
const setActive = vi.fn();
8+
const create = vi.fn();
9+
const update = vi.fn();
10+
const remove = vi.fn();
11+
const refresh = vi.fn();
12+
13+
const PROJECTS: Project[] = [
14+
{
15+
id: "rna-pilot",
16+
name: "RNA pilot",
17+
description: "Compare treatment and control cohorts.",
18+
tags: ["genomics"],
19+
createdAt: "2026-07-01T12:00:00.000Z",
20+
updatedAt: "2026-07-08T12:00:00.000Z",
21+
archived: false,
22+
spendLimitUsd: 25,
23+
},
24+
{
25+
id: "old-study",
26+
name: "Old study",
27+
description: "",
28+
tags: [],
29+
createdAt: "2026-06-01T12:00:00.000Z",
30+
updatedAt: "2026-06-02T12:00:00.000Z",
31+
archived: true,
32+
spendLimitUsd: null,
33+
},
34+
];
35+
36+
vi.mock("@/lib/use-projects", () => ({
37+
useProjects: () => ({
38+
projects: PROJECTS,
39+
activeProjectId: "rna-pilot",
40+
activeProject: PROJECTS[0],
41+
loading: false,
42+
error: null,
43+
setActive,
44+
refresh,
45+
create,
46+
update,
47+
remove,
48+
}),
49+
}));
50+
51+
vi.mock("next-themes", () => ({
52+
useTheme: () => ({
53+
resolvedTheme: "light",
54+
setTheme: vi.fn(),
55+
}),
56+
}));
57+
58+
vi.mock("@/components/settings-dialog", () => ({
59+
SettingsDialog: () => null,
60+
}));
61+
62+
import { ProjectView } from "@/components/project-view";
63+
64+
describe("ProjectView", () => {
65+
beforeEach(() => {
66+
create.mockResolvedValue({
67+
...PROJECTS[0],
68+
id: "new-project",
69+
name: "New project",
70+
});
71+
});
72+
73+
it("shows active and archived projects and opens a selected project", async () => {
74+
const user = userEvent.setup();
75+
const onOpenProject = vi.fn();
76+
render(<ProjectView onOpenProject={onOpenProject} />);
77+
78+
expect(screen.getByRole("heading", { name: "Choose a project" })).toBeInTheDocument();
79+
expect(screen.getByText("RNA pilot")).toBeInTheDocument();
80+
expect(screen.getByText("Old study")).toBeInTheDocument();
81+
expect(screen.getByText("Current")).toBeInTheDocument();
82+
83+
const card = screen.getByText("RNA pilot").closest('[data-slot="card"]');
84+
expect(card).not.toBeNull();
85+
await user.click(within(card as HTMLElement).getByRole("button", { name: "Open" }));
86+
87+
expect(setActive).toHaveBeenCalledWith("rna-pilot");
88+
expect(onOpenProject).toHaveBeenCalledWith("rna-pilot");
89+
});
90+
91+
it("creates a project and opens it immediately", async () => {
92+
const user = userEvent.setup();
93+
const onOpenProject = vi.fn();
94+
render(<ProjectView onOpenProject={onOpenProject} />);
95+
96+
await user.click(screen.getByRole("button", { name: "New project" }));
97+
await user.type(screen.getByLabelText("Name"), "Protein screen");
98+
await user.type(screen.getByLabelText(/Tags/), "proteomics, pilot");
99+
await user.click(screen.getByRole("button", { name: "Create project" }));
100+
101+
await waitFor(() =>
102+
expect(create).toHaveBeenCalledWith({
103+
name: "Protein screen",
104+
description: "",
105+
tags: ["proteomics", "pilot"],
106+
spendLimitUsd: null,
107+
}),
108+
);
109+
expect(setActive).toHaveBeenCalledWith("new-project");
110+
expect(onOpenProject).toHaveBeenCalledWith("new-project");
111+
});
112+
});

0 commit comments

Comments
 (0)