diff --git a/ui/src/components/layout/AppShell.module.css b/ui/src/components/layout/AppShell.module.css
index d66608c0..2c2cc0b4 100644
--- a/ui/src/components/layout/AppShell.module.css
+++ b/ui/src/components/layout/AppShell.module.css
@@ -1,7 +1,12 @@
-.main {
+.layout {
+ display: flex;
position: relative;
z-index: 1;
- max-width: 1440px;
- margin: 0 auto;
- padding: 24px;
+}
+
+.main {
+ flex: 1;
+ min-width: 0;
+ padding: 24px;
+ min-height: calc(100vh - 56px);
}
diff --git a/ui/src/components/layout/AppShell.tsx b/ui/src/components/layout/AppShell.tsx
index 2847c9d8..5c6de3bc 100644
--- a/ui/src/components/layout/AppShell.tsx
+++ b/ui/src/components/layout/AppShell.tsx
@@ -1,15 +1,47 @@
+import { useState, useEffect } from 'react';
import { Outlet } from 'react-router';
import { TopNav } from './TopNav';
+import { AppSidebar } from './AppSidebar';
import { CommandPalette } from '../command-palette/CommandPalette';
import styles from './AppShell.module.css';
+const COLLAPSED_KEY = 'cameleer-sidebar-collapsed';
+
export function AppShell() {
+ const [collapsed, setCollapsed] = useState(() => {
+ try { return localStorage.getItem(COLLAPSED_KEY) === 'true'; }
+ catch { return false; }
+ });
+
+ // Auto-collapse on small screens
+ useEffect(() => {
+ const mq = window.matchMedia('(max-width: 1024px)');
+ function handleChange(e: MediaQueryListEvent | MediaQueryList) {
+ if (e.matches) setCollapsed(true);
+ }
+ handleChange(mq);
+ mq.addEventListener('change', handleChange);
+ return () => mq.removeEventListener('change', handleChange);
+ }, []);
+
+ function toggleSidebar() {
+ setCollapsed((prev) => {
+ const next = !prev;
+ try { localStorage.setItem(COLLAPSED_KEY, String(next)); }
+ catch { /* ignore */ }
+ return next;
+ });
+ }
+
return (
<>
-
-
-
-
+
+
>
);
diff --git a/ui/src/components/layout/AppSidebar.module.css b/ui/src/components/layout/AppSidebar.module.css
new file mode 100644
index 00000000..eba3e514
--- /dev/null
+++ b/ui/src/components/layout/AppSidebar.module.css
@@ -0,0 +1,245 @@
+/* ─── Sidebar Container ─── */
+.sidebar {
+ width: 240px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-surface);
+ border-right: 1px solid var(--border-subtle);
+ height: calc(100vh - 56px);
+ position: sticky;
+ top: 56px;
+ overflow: hidden;
+ transition: width 0.2s ease;
+}
+
+.sidebarCollapsed {
+ width: 48px;
+}
+
+/* ─── Search ─── */
+.search {
+ padding: 12px;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.sidebarCollapsed .search {
+ display: none;
+}
+
+.searchInput {
+ width: 100%;
+ padding: 6px 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-raised);
+ color: var(--text-primary);
+ font-size: 12px;
+ font-family: var(--font-body);
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.searchInput::placeholder {
+ color: var(--text-muted);
+}
+
+.searchInput:focus {
+ border-color: var(--amber);
+}
+
+/* ─── App List ─── */
+.appList {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px 0;
+}
+
+/* ─── Section Divider ─── */
+.divider {
+ height: 1px;
+ background: var(--border-subtle);
+ margin: 4px 12px;
+}
+
+.sidebarCollapsed .divider {
+ margin: 4px 8px;
+}
+
+/* ─── App Item ─── */
+.appItem {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 8px 16px;
+ border: none;
+ background: none;
+ color: var(--text-secondary);
+ font-size: 13px;
+ font-family: var(--font-body);
+ cursor: pointer;
+ transition: all 0.1s;
+ text-align: left;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+.appItem:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.appItemActive {
+ background: var(--amber-glow);
+ color: var(--amber);
+}
+
+.sidebarCollapsed .appItem {
+ padding: 8px 0;
+ justify-content: center;
+ gap: 0;
+}
+
+/* ─── Health Dot ─── */
+.healthDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.dotLive { background: var(--green); }
+.dotStale { background: var(--amber); }
+.dotDead { background: var(--text-muted); }
+
+/* ─── App Info (hidden when collapsed) ─── */
+.appInfo {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.sidebarCollapsed .appInfo {
+ display: none;
+}
+
+.appName {
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.appMeta {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+/* ─── All Item icon ─── */
+.allIcon {
+ width: 8px;
+ height: 8px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-muted);
+ line-height: 1;
+}
+
+.appItemActive .allIcon {
+ color: var(--amber);
+}
+
+/* ─── Bottom Section ─── */
+.bottom {
+ border-top: 1px solid var(--border-subtle);
+ padding: 8px 0;
+}
+
+.bottomItem {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 8px 16px;
+ border: none;
+ background: none;
+ color: var(--text-muted);
+ font-size: 12px;
+ font-family: var(--font-body);
+ cursor: pointer;
+ transition: all 0.1s;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.bottomItem:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.bottomItemActive {
+ color: var(--amber);
+ background: var(--amber-glow);
+}
+
+.sidebarCollapsed .bottomItem {
+ padding: 8px 0;
+ justify-content: center;
+ gap: 0;
+}
+
+.bottomLabel {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sidebarCollapsed .bottomLabel {
+ display: none;
+}
+
+.bottomIcon {
+ font-size: 14px;
+ flex-shrink: 0;
+ width: 16px;
+ text-align: center;
+}
+
+/* ─── Responsive ─── */
+@media (max-width: 1024px) {
+ .sidebar {
+ width: 48px;
+ }
+
+ .sidebar .search {
+ display: none;
+ }
+
+ .sidebar .appInfo {
+ display: none;
+ }
+
+ .sidebar .appItem {
+ padding: 8px 0;
+ justify-content: center;
+ gap: 0;
+ }
+
+ .sidebar .divider {
+ margin: 4px 8px;
+ }
+
+ .sidebar .bottomItem {
+ padding: 8px 0;
+ justify-content: center;
+ gap: 0;
+ }
+
+ .sidebar .bottomLabel {
+ display: none;
+ }
+}
diff --git a/ui/src/components/layout/AppSidebar.tsx b/ui/src/components/layout/AppSidebar.tsx
new file mode 100644
index 00000000..1ebfa9f8
--- /dev/null
+++ b/ui/src/components/layout/AppSidebar.tsx
@@ -0,0 +1,129 @@
+import { useMemo, useState } from 'react';
+import { NavLink, useParams } from 'react-router';
+import { useAgents } from '../../api/queries/agents';
+import { useAuthStore } from '../../auth/auth-store';
+import type { AgentInstance } from '../../api/types';
+import styles from './AppSidebar.module.css';
+
+interface GroupInfo {
+ group: string;
+ agents: AgentInstance[];
+ liveCount: number;
+ staleCount: number;
+ deadCount: number;
+}
+
+function healthStatus(g: GroupInfo): 'live' | 'stale' | 'dead' {
+ if (g.liveCount > 0) return 'live';
+ if (g.staleCount > 0) return 'stale';
+ return 'dead';
+}
+
+interface AppSidebarProps {
+ collapsed: boolean;
+}
+
+export function AppSidebar({ collapsed }: AppSidebarProps) {
+ const { group: activeGroup } = useParams<{ group: string }>();
+ const { data: agents } = useAgents();
+ const { roles } = useAuthStore();
+ const [filter, setFilter] = useState('');
+
+ const groups = useMemo(() => {
+ if (!agents) return [];
+ const map = new Map();
+ for (const agent of agents) {
+ const key = agent.group ?? 'default';
+ let entry = map.get(key);
+ if (!entry) {
+ entry = { group: key, agents: [], liveCount: 0, staleCount: 0, deadCount: 0 };
+ map.set(key, entry);
+ }
+ entry.agents.push(agent);
+ if (agent.status === 'LIVE') entry.liveCount++;
+ else if (agent.status === 'STALE') entry.staleCount++;
+ else entry.deadCount++;
+ }
+ return Array.from(map.values()).sort((a, b) => a.group.localeCompare(b.group));
+ }, [agents]);
+
+ const filtered = useMemo(() => {
+ if (!filter) return groups;
+ const lower = filter.toLowerCase();
+ return groups.filter((g) => g.group.toLowerCase().includes(lower));
+ }, [groups, filter]);
+
+ const sidebarClass = `${styles.sidebar} ${collapsed ? styles.sidebarCollapsed : ''}`;
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/layout/TopNav.module.css b/ui/src/components/layout/TopNav.module.css
index d87c9cb0..9db6a912 100644
--- a/ui/src/components/layout/TopNav.module.css
+++ b/ui/src/components/layout/TopNav.module.css
@@ -5,11 +5,32 @@
background: var(--topnav-bg);
backdrop-filter: blur(20px) saturate(1.2);
border-bottom: 1px solid var(--border-subtle);
- padding: 0 24px;
+ padding: 0 16px;
display: flex;
align-items: center;
height: 56px;
- gap: 32px;
+ gap: 16px;
+}
+
+.hamburger {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: all 0.15s;
+ flex-shrink: 0;
+}
+
+.hamburger:hover {
+ color: var(--text-primary);
+ border-color: var(--text-muted);
+ background: var(--bg-raised);
}
.logo {
@@ -27,38 +48,57 @@
.logo:hover { color: var(--amber); }
-.navLinks {
+/* ─── Search Bar ─── */
+.searchBar {
+ flex: 1;
+ max-width: 480px;
display: flex;
- gap: 4px;
- list-style: none;
-}
-
-.navLink {
- padding: 8px 16px;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ border: 1px solid var(--border);
border-radius: var(--radius-sm);
- font-size: 13px;
- font-weight: 500;
- color: var(--text-secondary);
- transition: all 0.15s;
- text-decoration: none;
-}
-
-.navLink:hover {
- color: var(--text-primary);
background: var(--bg-raised);
+ cursor: pointer;
+ transition: all 0.15s;
}
-.navLinkActive {
- composes: navLink;
- color: var(--amber);
- background: var(--amber-glow);
+.searchBar:hover {
+ border-color: var(--text-muted);
}
+.searchIcon {
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+
+.searchPlaceholder {
+ flex: 1;
+ font-size: 13px;
+ color: var(--text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.searchKbd {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-muted);
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ padding: 1px 5px;
+ flex-shrink: 0;
+}
+
+/* ─── Right Section ─── */
.navRight {
margin-left: auto;
display: flex;
align-items: center;
gap: 16px;
+ flex-shrink: 0;
}
.utilLink {
@@ -136,3 +176,10 @@
.logoutBtn:hover {
color: var(--rose);
}
+
+/* ─── Responsive ─── */
+@media (max-width: 768px) {
+ .searchBar {
+ display: none;
+ }
+}
diff --git a/ui/src/components/layout/TopNav.tsx b/ui/src/components/layout/TopNav.tsx
index 89f27dfa..a2ecad6a 100644
--- a/ui/src/components/layout/TopNav.tsx
+++ b/ui/src/components/layout/TopNav.tsx
@@ -1,14 +1,26 @@
import { NavLink } from 'react-router';
import { useThemeStore } from '../../theme/theme-store';
import { useAuthStore } from '../../auth/auth-store';
+import { useCommandPalette } from '../command-palette/use-command-palette';
import styles from './TopNav.module.css';
-export function TopNav() {
+interface TopNavProps {
+ onToggleSidebar: () => void;
+}
+
+export function TopNav({ onToggleSidebar }: TopNavProps) {
const { theme, toggle } = useThemeStore();
- const { username, roles, logout } = useAuthStore();
+ const { username, logout } = useAuthStore();
+ const openPalette = useCommandPalette((s) => s.open);
return (