Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions web/src/components/SessionItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ export function SessionItem({
e.stopPropagation();
onArchive(e, s.id);
}}
className="absolute right-7 top-1/2 -translate-y-1/2 p-1 rounded-md opacity-0 pointer-events-none sm:group-hover:opacity-100 sm:group-hover:pointer-events-auto hover:bg-cc-border text-cc-muted hover:text-cc-fg transition-all cursor-pointer"
className="absolute right-7 top-1/2 -translate-y-1/2 p-1 rounded-md opacity-0 pointer-events-none can-hover:group-hover:opacity-100 can-hover:group-hover:pointer-events-auto hover:bg-cc-border text-cc-muted hover:text-cc-fg transition-all cursor-pointer"
title="Archive"
aria-label="Archive session"
>
Expand All @@ -280,7 +280,7 @@ export function SessionItem({
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
className="absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-md opacity-100 pointer-events-auto sm:opacity-0 sm:pointer-events-none sm:group-hover:opacity-100 sm:group-hover:pointer-events-auto hover:bg-cc-border text-cc-muted hover:text-cc-fg transition-all cursor-pointer"
className="absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-md opacity-100 pointer-events-auto can-hover:opacity-0 can-hover:pointer-events-none can-hover:group-hover:opacity-100 can-hover:group-hover:pointer-events-auto hover:bg-cc-border text-cc-muted hover:text-cc-fg transition-all cursor-pointer"
title="Session actions"
aria-label="Session actions"
aria-haspopup="true"
Expand Down
62 changes: 60 additions & 2 deletions web/src/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ beforeEach(() => {
vi.clearAllMocks();
mockState = createMockState();
window.location.hash = "";
localStorage.removeItem("companion-nav-expanded");
});

describe("Sidebar", () => {
Expand Down Expand Up @@ -373,8 +374,8 @@ describe("Sidebar", () => {
const menuButton = screen.getByTitle("Session actions");

expect(menuButton).toHaveClass("opacity-100");
expect(menuButton).toHaveClass("sm:opacity-0");
expect(menuButton).toHaveClass("sm:group-hover:opacity-100");
expect(menuButton).toHaveClass("can-hover:opacity-0");
expect(menuButton).toHaveClass("can-hover:group-hover:opacity-100");
});

it("pending permissions render a yellow awaiting status dot", () => {
Expand Down Expand Up @@ -1802,4 +1803,61 @@ describe("Sidebar", () => {

expect(screen.getByText(/3 archived sessions/)).toBeInTheDocument();
});

// ── Collapsible footer navigation ───────────────────────────────────────────

it("sidebar footer nav is expanded by default", () => {
// Default state should show all nav items — no regression from current behavior.
mockState = createMockState({});
render(<Sidebar />);

const toggle = screen.getByRole("button", { name: /collapse navigation/i });
expect(toggle).toHaveAttribute("aria-expanded", "true");

// Nav items should be visible
expect(screen.getByText("Prompts")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
});

it("clicking nav toggle collapses footer navigation and persists to localStorage", () => {
// Collapsing hides nav items and saves preference.
mockState = createMockState({});
render(<Sidebar />);

const toggle = screen.getByRole("button", { name: /collapse navigation/i });
fireEvent.click(toggle);

// Nav items should be hidden
expect(screen.queryByText("Prompts")).not.toBeInTheDocument();
expect(screen.queryByText("Settings")).not.toBeInTheDocument();

// Toggle label should update
expect(screen.getByRole("button", { name: /expand navigation/i })).toBeInTheDocument();

// localStorage should be persisted
expect(localStorage.getItem("companion-nav-expanded")).toBe("false");
});

it("clicking nav toggle again expands footer navigation", () => {
// Expanding restores nav items.
mockState = createMockState({});
render(<Sidebar />);

const toggle = screen.getByRole("button", { name: /collapse navigation/i });
fireEvent.click(toggle); // collapse
fireEvent.click(screen.getByRole("button", { name: /expand navigation/i })); // expand

expect(screen.getByText("Prompts")).toBeInTheDocument();
expect(localStorage.getItem("companion-nav-expanded")).toBe("true");
});

it("restores collapsed state from localStorage", () => {
// When localStorage has "false", footer should render collapsed.
localStorage.setItem("companion-nav-expanded", "false");
mockState = createMockState({});
render(<Sidebar />);

expect(screen.getByRole("button", { name: /expand navigation/i })).toBeInTheDocument();
expect(screen.queryByText("Prompts")).not.toBeInTheDocument();
});
});
183 changes: 112 additions & 71 deletions web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,26 @@ export function Sidebar() {
const logoSrc = currentSession?.backendType === "codex" ? "/logo-codex.svg" : "/logo.svg";
const [showCronSessions, setShowCronSessions] = useState(true);
const [showAgentSessions, setShowAgentSessions] = useState(true);
const [navExpanded, setNavExpanded] = useState(() => {
try {
const saved = localStorage.getItem("companion-nav-expanded");
return saved !== null ? saved !== "false" : true;
} catch {
return true;
}
});

const toggleNav = useCallback(() => {
setNavExpanded((prev) => {
const next = !prev;
try {
localStorage.setItem("companion-nav-expanded", String(next));
} catch {
// localStorage unavailable
}
return next;
});
}, []);

// Group active sessions by project
const projectGroups = useMemo(
Expand Down Expand Up @@ -684,81 +704,102 @@ export function Sidebar() {

{/* Footer */}
<div className="px-2 py-1.5 pb-safe bg-cc-sidebar-footer border-t border-cc-border/30">
<nav className="flex flex-col gap-1.5" aria-label="Navigation">
{NAV_SECTIONS.map((section) => (
<section key={section.id} className="rounded-lg border border-cc-border/30 bg-cc-card/20 p-0.5">
<span className="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-cc-muted/75 block">
{section.label}
</span>
<div className="flex flex-col">
{section.itemIds.map((itemId) => {
const item = NAV_ITEMS_BY_ID.get(itemId);
if (!item) return null;
const isActive = item.activePages
? item.activePages.some((p) => route.page === p)
: route.page === item.id;
return (
<button
key={item.id}
onClick={() => {
if (item.id !== "terminal") {
useStore.getState().closeTerminal();
}
window.location.hash = item.hash;
// Close sidebar on mobile so the navigated page is visible
if (window.innerWidth < 768) {
useStore.getState().setSidebarOpen(false);
}
}}
title={item.label}
aria-current={isActive ? "page" : undefined}
className={`group flex min-h-[44px] md:min-h-[34px] w-full items-center gap-2 rounded-md px-2 py-1 md:py-0.5 text-left transition-colors duration-150 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cc-primary/60 ${
isActive
? "bg-cc-active text-cc-fg"
: "text-cc-muted hover:text-cc-fg hover:bg-cc-hover"
}`}
{/* Navigation toggle */}
<button
onClick={toggleNav}
aria-expanded={navExpanded}
aria-label={navExpanded ? "Collapse navigation" : "Expand navigation"}
className="w-full flex items-center justify-between px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-cc-muted/75 hover:text-cc-fg hover:bg-cc-hover rounded-md transition-colors cursor-pointer"
>
<span>Navigation</span>
<svg
viewBox="0 0 16 16"
fill="currentColor"
className={`w-2.5 h-2.5 transition-transform duration-150 ${navExpanded ? "rotate-180" : ""}`}
>
<path d="M4 6l4 4 4-4" />
</svg>
</button>

{navExpanded && (
<>
<nav className="flex flex-col gap-1.5 mt-1" aria-label="Navigation">
{NAV_SECTIONS.map((section) => (
<section key={section.id} className="rounded-lg border border-cc-border/30 bg-cc-card/20 p-0.5">
<span className="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-cc-muted/75 block">
{section.label}
</span>
<div className="flex flex-col">
{section.itemIds.map((itemId) => {
const item = NAV_ITEMS_BY_ID.get(itemId);
if (!item) return null;
const isActive = item.activePages
? item.activePages.some((p) => route.page === p)
: route.page === item.id;
return (
<button
key={item.id}
onClick={() => {
if (item.id !== "terminal") {
useStore.getState().closeTerminal();
}
window.location.hash = item.hash;
// Close sidebar on mobile so the navigated page is visible
if (window.innerWidth < 768) {
useStore.getState().setSidebarOpen(false);
}
}}
title={item.label}
aria-current={isActive ? "page" : undefined}
className={`group flex min-h-[44px] md:min-h-[34px] w-full items-center gap-2 rounded-md px-2 py-1 md:py-0.5 text-left transition-colors duration-150 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cc-primary/60 ${
isActive
? "bg-cc-active text-cc-fg"
: "text-cc-muted hover:text-cc-fg hover:bg-cc-hover"
}`}
>
<span
aria-hidden
className={`h-4 w-0.5 shrink-0 rounded-full transition-colors ${
isActive ? "bg-cc-primary" : "bg-transparent group-hover:bg-cc-border"
}`}
/>
<svg viewBox={item.viewBox} fill="currentColor" className="w-3.5 h-3.5 shrink-0">
<path d={item.iconPath} fillRule={item.fillRule} clipRule={item.clipRule} />
</svg>
<span className="min-w-0 flex-1 text-[12px] font-medium leading-tight">{item.label}</span>
</button>
);
})}
</div>
</section>
))}
</nav>
<div className="mt-1.5 rounded-lg border border-cc-border/30 bg-cc-card/20 px-1.5 py-0.5">
<div className="flex items-center justify-between">
<span className="px-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-cc-muted/75">
Resources
</span>
<div className="flex items-center gap-0.5">
{EXTERNAL_LINKS.map((link) => (
<a
key={link.label}
href={link.url}
target="_blank"
rel="noopener noreferrer"
title={link.label}
aria-label={`Open ${link.label.toLowerCase()}`}
className="w-9 h-9 md:w-7 md:h-7 rounded-md flex items-center justify-center text-cc-muted hover:text-cc-fg hover:bg-cc-hover transition-colors"
>
<span
aria-hidden
className={`h-4 w-0.5 shrink-0 rounded-full transition-colors ${
isActive ? "bg-cc-primary" : "bg-transparent group-hover:bg-cc-border"
}`}
/>
<svg viewBox={item.viewBox} fill="currentColor" className="w-3.5 h-3.5 shrink-0">
<path d={item.iconPath} fillRule={item.fillRule} clipRule={item.clipRule} />
<svg viewBox={link.viewBox} fill="currentColor" className="w-3.5 h-3.5">
<path d={link.iconPath} />
</svg>
<span className="min-w-0 flex-1 text-[12px] font-medium leading-tight">{item.label}</span>
</button>
);
})}
</a>
))}
</div>
</div>
</section>
))}
</nav>
<div className="mt-1.5 rounded-lg border border-cc-border/30 bg-cc-card/20 px-1.5 py-0.5">
<div className="flex items-center justify-between">
<span className="px-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-cc-muted/75">
Resources
</span>
<div className="flex items-center gap-0.5">
{EXTERNAL_LINKS.map((link) => (
<a
key={link.label}
href={link.url}
target="_blank"
rel="noopener noreferrer"
title={link.label}
aria-label={`Open ${link.label.toLowerCase()}`}
className="w-9 h-9 md:w-7 md:h-7 rounded-md flex items-center justify-center text-cc-muted hover:text-cc-fg hover:bg-cc-hover transition-colors"
>
<svg viewBox={link.viewBox} fill="currentColor" className="w-3.5 h-3.5">
<path d={link.iconPath} />
</svg>
</a>
))}
</div>
</div>
</div>
</>
)}
</div>

{/* Delete confirmation modal */}
Expand Down
2 changes: 2 additions & 0 deletions web/src/index.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
@import "tailwindcss";

@custom-variant can-hover (@media (hover: hover));

/* Meslo LGS Nerd Font Mono — terminal font with powerline & devicon glyphs */
@font-face {
font-family: "MesloLGS Nerd Font Mono";
Expand Down
Loading