Skip to content

Commit 0385650

Browse files
Implement issue #96a: Navigation sidebar + breadcrumbs
Sidebar with project/session tree, collapsible with localStorage persistence, session status dots, active page highlighting. Breadcrumb trail on project and session pages. 17 new tests.
1 parent aba3147 commit 0385650

8 files changed

Lines changed: 641 additions & 40 deletions

File tree

web/src/components/Breadcrumb.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Link } from "react-router-dom";
2+
3+
export interface BreadcrumbSegment {
4+
label: string;
5+
to: string;
6+
}
7+
8+
interface BreadcrumbProps {
9+
segments: BreadcrumbSegment[];
10+
}
11+
12+
export default function Breadcrumb({ segments }: BreadcrumbProps) {
13+
if (segments.length === 0) return null;
14+
15+
return (
16+
<nav aria-label="Breadcrumb" className="mb-4 text-sm text-gray-500">
17+
<ol className="flex items-center gap-1">
18+
{segments.map((segment, index) => {
19+
const isLast = index === segments.length - 1;
20+
return (
21+
<li key={segment.to} className="flex items-center gap-1">
22+
{index > 0 && <span aria-hidden="true">/</span>}
23+
{isLast ? (
24+
<span className="text-gray-700 font-medium" aria-current="page">
25+
{segment.label}
26+
</span>
27+
) : (
28+
<Link
29+
to={segment.to}
30+
className="text-gray-500 hover:text-gray-700 hover:underline"
31+
>
32+
{segment.label}
33+
</Link>
34+
)}
35+
</li>
36+
);
37+
})}
38+
</ol>
39+
</nav>
40+
);
41+
}

web/src/components/Sidebar.tsx

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { useEffect, useState, useCallback } from "react";
2+
import { NavLink, useLocation } from "react-router-dom";
3+
import { fetchProjects, type ProjectRead } from "@/api/projects";
4+
import { fetchSessions, type SessionRead } from "@/api/sessions";
5+
import UserMenu from "@/components/UserMenu";
6+
7+
const SIDEBAR_COLLAPSED_KEY = "codehive-sidebar-collapsed";
8+
9+
const statusDotColors: Record<string, string> = {
10+
idle: "bg-gray-400",
11+
planning: "bg-yellow-400",
12+
executing: "bg-blue-400",
13+
waiting_input: "bg-purple-400",
14+
completed: "bg-green-400",
15+
failed: "bg-red-400",
16+
};
17+
18+
export default function Sidebar() {
19+
const location = useLocation();
20+
const [collapsed, setCollapsed] = useState(() => {
21+
try {
22+
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true";
23+
} catch {
24+
return false;
25+
}
26+
});
27+
const [projects, setProjects] = useState<ProjectRead[]>([]);
28+
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
29+
new Set(),
30+
);
31+
const [sessionsByProject, setSessionsByProject] = useState<
32+
Record<string, SessionRead[]>
33+
>({});
34+
const [loadingSessions, setLoadingSessions] = useState<Set<string>>(
35+
new Set(),
36+
);
37+
38+
useEffect(() => {
39+
let cancelled = false;
40+
async function load() {
41+
try {
42+
const data = await fetchProjects();
43+
if (!cancelled) setProjects(data);
44+
} catch {
45+
// Silently fail -- sidebar is supplementary navigation
46+
}
47+
}
48+
load();
49+
return () => {
50+
cancelled = true;
51+
};
52+
}, []);
53+
54+
const toggleCollapse = useCallback(() => {
55+
setCollapsed((prev) => {
56+
const next = !prev;
57+
try {
58+
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
59+
} catch {
60+
// localStorage unavailable
61+
}
62+
return next;
63+
});
64+
}, []);
65+
66+
const toggleProject = useCallback(
67+
async (projectId: string) => {
68+
setExpandedProjects((prev) => {
69+
const next = new Set(prev);
70+
if (next.has(projectId)) {
71+
next.delete(projectId);
72+
} else {
73+
next.add(projectId);
74+
}
75+
return next;
76+
});
77+
78+
// Fetch sessions if not already cached
79+
if (!sessionsByProject[projectId] && !loadingSessions.has(projectId)) {
80+
setLoadingSessions((prev) => new Set(prev).add(projectId));
81+
try {
82+
const sessions = await fetchSessions(projectId);
83+
setSessionsByProject((prev) => ({ ...prev, [projectId]: sessions }));
84+
} catch {
85+
// Keep empty on error
86+
} finally {
87+
setLoadingSessions((prev) => {
88+
const next = new Set(prev);
89+
next.delete(projectId);
90+
return next;
91+
});
92+
}
93+
}
94+
},
95+
[sessionsByProject, loadingSessions],
96+
);
97+
98+
// Determine active project/session from URL
99+
const activeProjectId = location.pathname.match(
100+
/^\/projects\/([^/]+)/,
101+
)?.[1];
102+
const activeSessionId = location.pathname.match(
103+
/^\/sessions\/([^/]+)/,
104+
)?.[1];
105+
106+
return (
107+
<aside
108+
data-testid="sidebar"
109+
className={`bg-gray-900 text-white flex-shrink-0 flex flex-col transition-all duration-200 ${
110+
collapsed ? "w-12" : "w-64"
111+
}`}
112+
>
113+
<div className="p-4 flex items-center justify-between">
114+
{!collapsed && <h2 className="text-lg font-semibold">Codehive</h2>}
115+
<button
116+
onClick={toggleCollapse}
117+
className="text-gray-400 hover:text-white p-1"
118+
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
119+
data-testid="sidebar-toggle"
120+
>
121+
{collapsed ? "\u25B6" : "\u25C0"}
122+
</button>
123+
</div>
124+
{!collapsed && (
125+
<div className="px-4 pb-2">
126+
<UserMenu />
127+
</div>
128+
)}
129+
<nav className="mt-2 flex-1 overflow-y-auto">
130+
<ul className="space-y-0.5">
131+
<li>
132+
<NavLink
133+
to="/"
134+
end
135+
className={({ isActive }) =>
136+
`block px-4 py-2 text-sm ${
137+
isActive
138+
? "bg-gray-800 text-white font-medium"
139+
: "text-gray-300 hover:bg-gray-800 hover:text-white"
140+
}`
141+
}
142+
>
143+
{collapsed ? "D" : "Dashboard"}
144+
</NavLink>
145+
</li>
146+
{projects.map((project) => {
147+
const isActiveProject = activeProjectId === project.id;
148+
const isExpanded = expandedProjects.has(project.id);
149+
const sessions = sessionsByProject[project.id];
150+
151+
return (
152+
<li key={project.id}>
153+
<div className="flex items-center">
154+
<button
155+
onClick={() => toggleProject(project.id)}
156+
className="px-2 py-2 text-gray-400 hover:text-white text-xs flex-shrink-0"
157+
aria-label={`Toggle ${project.name} sessions`}
158+
data-testid={`toggle-${project.id}`}
159+
>
160+
{isExpanded ? "\u25BC" : "\u25B6"}
161+
</button>
162+
<NavLink
163+
to={`/projects/${project.id}`}
164+
className={`flex-1 block py-2 pr-4 text-sm truncate ${
165+
isActiveProject
166+
? "text-white font-medium"
167+
: "text-gray-300 hover:text-white"
168+
}`}
169+
>
170+
{collapsed
171+
? project.name.charAt(0).toUpperCase()
172+
: project.name}
173+
</NavLink>
174+
</div>
175+
{isExpanded && !collapsed && (
176+
<ul className="ml-6 space-y-0.5" data-testid={`sessions-${project.id}`}>
177+
{loadingSessions.has(project.id) && (
178+
<li className="px-4 py-1 text-xs text-gray-500">
179+
Loading...
180+
</li>
181+
)}
182+
{sessions?.map((session) => {
183+
const isActiveSession = activeSessionId === session.id;
184+
const dotColor =
185+
statusDotColors[session.status] ?? "bg-gray-400";
186+
return (
187+
<li key={session.id}>
188+
<NavLink
189+
to={`/sessions/${session.id}`}
190+
className={`block px-4 py-1.5 text-xs truncate flex items-center gap-2 ${
191+
isActiveSession
192+
? "text-white font-medium bg-gray-800"
193+
: "text-gray-400 hover:text-white hover:bg-gray-800"
194+
}`}
195+
>
196+
<span
197+
className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${dotColor}`}
198+
aria-label={session.status}
199+
/>
200+
{session.name}
201+
</NavLink>
202+
</li>
203+
);
204+
})}
205+
{sessions?.length === 0 && (
206+
<li className="px-4 py-1 text-xs text-gray-500">
207+
No sessions
208+
</li>
209+
)}
210+
</ul>
211+
)}
212+
</li>
213+
);
214+
})}
215+
</ul>
216+
</nav>
217+
</aside>
218+
);
219+
}

web/src/layouts/MainLayout.tsx

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,11 @@
1-
import { NavLink, Outlet } from "react-router-dom";
1+
import { Outlet } from "react-router-dom";
22
import SearchBar from "@/components/SearchBar";
3-
import UserMenu from "@/components/UserMenu";
3+
import Sidebar from "@/components/Sidebar";
44

55
export default function MainLayout() {
66
return (
77
<div className="flex min-h-screen bg-gray-50">
8-
<aside className="w-64 bg-gray-900 text-white flex-shrink-0 flex flex-col">
9-
<div className="p-4 flex items-center justify-between">
10-
<h2 className="text-lg font-semibold">Codehive</h2>
11-
<UserMenu />
12-
</div>
13-
<nav className="mt-4">
14-
<ul className="space-y-1">
15-
<li>
16-
<NavLink
17-
to="/"
18-
end
19-
className={({ isActive }) =>
20-
`block px-4 py-2 text-sm ${
21-
isActive
22-
? "bg-gray-800 text-white"
23-
: "text-gray-300 hover:bg-gray-800 hover:text-white"
24-
}`
25-
}
26-
>
27-
Dashboard
28-
</NavLink>
29-
</li>
30-
</ul>
31-
</nav>
32-
</aside>
8+
<Sidebar />
339
<div className="flex-1 flex flex-col">
3410
<header className="flex items-center justify-end border-b border-gray-200 bg-white px-6 py-3">
3511
<SearchBar />

web/src/pages/ProjectPage.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "@/api/issues";
1111
import SessionList from "@/components/SessionList";
1212
import IssueList from "@/components/IssueList";
13+
import Breadcrumb from "@/components/Breadcrumb";
1314

1415
type Tab = "sessions" | "issues";
1516

@@ -163,10 +164,13 @@ export default function ProjectPage() {
163164

164165
return (
165166
<div>
166-
<Link to="/" className="text-sm text-blue-600 hover:underline">
167-
&larr; Back to Dashboard
168-
</Link>
169-
<div className="mt-4">
167+
<Breadcrumb
168+
segments={[
169+
{ label: "Dashboard", to: "/" },
170+
{ label: project.name, to: `/projects/${project.id}` },
171+
]}
172+
/>
173+
<div>
170174
<div className="flex items-center gap-3">
171175
<h1 className="text-2xl font-bold">{project.name}</h1>
172176
{project.archetype && (

web/src/pages/SessionPage.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { useParams } from "react-router-dom";
33
import { WebSocketProvider } from "@/context/WebSocketContext";
44
import { apiClient } from "@/api/client";
55
import type { SessionRead } from "@/api/sessions";
6+
import { fetchProject, type ProjectRead } from "@/api/projects";
7+
import Breadcrumb from "@/components/Breadcrumb";
68
import ChatPanel from "@/components/ChatPanel";
79
import SidebarTabs from "@/components/sidebar/SidebarTabs";
810
import SessionModeIndicator from "@/components/SessionModeIndicator";
@@ -15,6 +17,7 @@ export default function SessionPage() {
1517
const { sessionId } = useParams<{ sessionId: string }>();
1618
const { isMobile } = useResponsive();
1719
const [session, setSession] = useState<SessionRead | null>(null);
20+
const [project, setProject] = useState<ProjectRead | null>(null);
1821
const [loading, setLoading] = useState(true);
1922
const [error, setError] = useState<string | null>(null);
2023
const [modeLoading, setModeLoading] = useState(false);
@@ -33,6 +36,15 @@ export default function SessionPage() {
3336
const data = (await response.json()) as SessionRead;
3437
if (!cancelled) {
3538
setSession(data);
39+
// Fetch parent project for breadcrumb
40+
if (data.project_id) {
41+
try {
42+
const proj = await fetchProject(data.project_id);
43+
if (!cancelled) setProject(proj);
44+
} catch {
45+
// Project fetch failure is non-critical for breadcrumbs
46+
}
47+
}
3648
}
3749
} catch (err) {
3850
if (!cancelled) {
@@ -115,6 +127,17 @@ export default function SessionPage() {
115127
return (
116128
<WebSocketProvider sessionId={sessionId}>
117129
<div className="flex h-full flex-col">
130+
{project && (
131+
<div className="px-4 pt-3">
132+
<Breadcrumb
133+
segments={[
134+
{ label: "Dashboard", to: "/" },
135+
{ label: project.name, to: `/projects/${project.id}` },
136+
{ label: session.name, to: `/sessions/${session.id}` },
137+
]}
138+
/>
139+
</div>
140+
)}
118141
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
119142
<div className="flex items-center gap-3">
120143
<h1 className="text-xl font-bold text-gray-900">{session.name}</h1>

0 commit comments

Comments
 (0)