feat: make desktop navigation sections collapsible

This commit is contained in:
Federico Jaramillo Martinez
2026-06-06 00:12:38 +02:00
committed by Federico Jaramillo Martinez
parent f7eff88e00
commit 9a3f2ce64f
9 changed files with 141 additions and 42 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Make navigation sections collapsible on desktop and auto-collapse completed context sections after selections.
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { defaultNavigationSection, expandedNavigationSection, isNavigationSectionCollapsed, toggleNavigationSection } from "./navigationState";
import { collapsedNavigationSectionsAfterSelection, defaultNavigationSection, expandedNavigationSection, isNavigationSectionCollapsed, toggleCollapsedNavigationSection, toggleNavigationSection } from "./navigationState";
describe("navigationState", () => {
it("defaults to the first incomplete selection section", () => {
@@ -16,15 +16,22 @@ describe("navigationState", () => {
expect(expandedNavigationSection("none", state)).toBeUndefined();
});
it("only collapses sections in mobile navigation layouts", () => {
it("uses the mobile accordion state on mobile layouts", () => {
const state = { selectedProject: {}, selectedWorkspace: {} };
expect(isNavigationSectionCollapsed("projects", { isMobileLayout: false, expanded: "sessions", state })).toBe(false);
expect(isNavigationSectionCollapsed("projects", { isMobileLayout: true, expanded: "sessions", state })).toBe(true);
expect(isNavigationSectionCollapsed("sessions", { isMobileLayout: true, expanded: "sessions", state })).toBe(false);
});
it("toggles the effective section, including the implicit default section", () => {
it("uses independent collapsed sections on desktop layouts", () => {
const state = { selectedProject: {}, selectedWorkspace: {} };
expect(isNavigationSectionCollapsed("projects", { isMobileLayout: false, expanded: "sessions", state })).toBe(false);
expect(isNavigationSectionCollapsed("projects", { isMobileLayout: false, expanded: "sessions", state, collapsedSections: ["projects"] })).toBe(true);
expect(isNavigationSectionCollapsed("sessions", { isMobileLayout: false, expanded: "sessions", state, collapsedSections: ["projects"] })).toBe(false);
});
it("toggles the effective mobile section, including the implicit default section", () => {
const state = { selectedProject: undefined, selectedWorkspace: undefined };
expect(toggleNavigationSection(undefined, "projects", { isMobileLayout: true, state })).toBe("none");
@@ -37,4 +44,17 @@ describe("navigationState", () => {
expect(toggleNavigationSection("projects", "projects", { isMobileLayout: false, state })).toBe("projects");
});
it("toggles desktop sections independently", () => {
expect(toggleCollapsedNavigationSection([], "projects")).toEqual(["projects"]);
expect(toggleCollapsedNavigationSection(["machines", "projects"], "projects")).toEqual(["machines"]);
expect(toggleCollapsedNavigationSection(["sessions"], "machines")).toEqual(["machines", "sessions"]);
});
it("collapses completed desktop sections and expands the next section after selection", () => {
expect(collapsedNavigationSectionsAfterSelection([], "machines")).toEqual(["machines"]);
expect(collapsedNavigationSectionsAfterSelection(["workspaces"], "projects")).toEqual(["machines", "projects"]);
expect(collapsedNavigationSectionsAfterSelection([], "workspaces")).toEqual(["machines", "projects", "workspaces"]);
expect(collapsedNavigationSectionsAfterSelection(["sessions"], "sessions")).toEqual(["machines", "projects", "workspaces"]);
});
});
+65 -6
View File
@@ -1,6 +1,7 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
export type NavigationSection = "machines" | "projects" | "workspaces" | "sessions";
export const NAVIGATION_SECTION_ORDER = ["machines", "projects", "workspaces", "sessions"] as const;
export type NavigationSection = (typeof NAVIGATION_SECTION_ORDER)[number];
export type ExpandedNavigationSection = NavigationSection | "none" | undefined;
export interface NavigationSelectionState {
@@ -19,8 +20,9 @@ export function expandedNavigationSection(expanded: ExpandedNavigationSection, s
return expanded ?? defaultNavigationSection(state);
}
export function isNavigationSectionCollapsed(section: NavigationSection, options: { isMobileLayout: boolean; expanded: ExpandedNavigationSection; state: NavigationSelectionState }): boolean {
return options.isMobileLayout && expandedNavigationSection(options.expanded, options.state) !== section;
export function isNavigationSectionCollapsed(section: NavigationSection, options: { isMobileLayout: boolean; expanded: ExpandedNavigationSection; state: NavigationSelectionState; collapsedSections?: readonly NavigationSection[] | undefined }): boolean {
if (options.isMobileLayout) return expandedNavigationSection(options.expanded, options.state) !== section;
return options.collapsedSections?.includes(section) ?? false;
}
export function toggleNavigationSection(expanded: ExpandedNavigationSection, section: NavigationSection, options: { isMobileLayout: boolean; state: NavigationSelectionState }): ExpandedNavigationSection {
@@ -32,8 +34,32 @@ export function expandNavigationSection(expanded: ExpandedNavigationSection, sec
return isMobileLayout ? section : expanded;
}
export class MobileNavigationController implements ReactiveController {
export function toggleCollapsedNavigationSection(collapsedSections: readonly NavigationSection[], section: NavigationSection): NavigationSection[] {
const collapsed = new Set(collapsedSections);
if (collapsed.has(section)) collapsed.delete(section);
else collapsed.add(section);
return orderedNavigationSections(collapsed);
}
export function collapsedNavigationSectionsAfterSelection(collapsedSections: readonly NavigationSection[], selectedSection: NavigationSection): NavigationSection[] {
const selectedIndex = NAVIGATION_SECTION_ORDER.indexOf(selectedSection);
const collapsed = new Set(collapsedSections);
const collapseThroughIndex = selectedSection === "sessions" ? selectedIndex - 1 : selectedIndex;
for (const section of NAVIGATION_SECTION_ORDER.slice(0, collapseThroughIndex + 1)) collapsed.add(section);
const next = nextNavigationSection(selectedSection);
if (next !== undefined) collapsed.delete(next);
if (selectedSection === "sessions") collapsed.delete("sessions");
return orderedNavigationSections(collapsed);
}
export function nextNavigationSection(section: NavigationSection): NavigationSection | undefined {
return NAVIGATION_SECTION_ORDER[NAVIGATION_SECTION_ORDER.indexOf(section) + 1];
}
export class NavigationSectionsController implements ReactiveController {
private expanded: ExpandedNavigationSection;
private collapsedSections: readonly NavigationSection[] = [];
hostConnected(): void {
return;
@@ -56,15 +82,33 @@ export class MobileNavigationController implements ReactiveController {
isMobileLayout: this.isMobileLayout(),
expanded: this.expanded,
state: this.getState(),
collapsedSections: this.collapsedSections,
});
}
toggle(section: NavigationSection): void {
this.setExpanded(toggleNavigationSection(this.expanded, section, { isMobileLayout: this.isMobileLayout(), state: this.getState() }));
if (this.isMobileLayout()) {
this.setExpanded(toggleNavigationSection(this.expanded, section, { isMobileLayout: true, state: this.getState() }));
return;
}
this.setCollapsedSections(toggleCollapsedNavigationSection(this.collapsedSections, section));
}
expand(section: NavigationSection): void {
this.setExpanded(expandNavigationSection(this.expanded, section, this.isMobileLayout()));
if (this.isMobileLayout()) {
this.setExpanded(expandNavigationSection(this.expanded, section, true));
return;
}
this.setCollapsedSections(this.collapsedSections.filter((collapsedSection) => collapsedSection !== section));
}
advanceAfterSelection(section: NavigationSection): void {
if (this.isMobileLayout()) {
const next = nextNavigationSection(section);
if (next !== undefined) this.expand(next);
return;
}
this.setCollapsedSections(collapsedNavigationSectionsAfterSelection(this.collapsedSections, section));
}
open(section: NavigationSection, openNavigationView: () => void): void {
@@ -78,4 +122,19 @@ export class MobileNavigationController implements ReactiveController {
this.expanded = expanded;
this.host.requestUpdate();
}
private setCollapsedSections(collapsedSections: readonly NavigationSection[]): void {
if (navigationSectionListsEqual(this.collapsedSections, collapsedSections)) return;
this.collapsedSections = collapsedSections;
this.host.requestUpdate();
}
}
function orderedNavigationSections(sections: Iterable<NavigationSection>): NavigationSection[] {
const sectionSet = new Set(sections);
return NAVIGATION_SECTION_ORDER.filter((section) => sectionSet.has(section));
}
function navigationSectionListsEqual(first: readonly NavigationSection[], second: readonly NavigationSection[]): boolean {
return first.length === second.length && first.every((section, index) => section === second[index]);
}
+1 -1
View File
@@ -107,7 +107,7 @@ export class MachineList extends LitElement {
if (!this.collapsible) return "Machines";
const selectedSummary = this.selected?.name ?? "No machine selected";
const selectedTitle = this.selected?.baseUrl ?? selectedSummary;
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.machines.length}</small></button>`;
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span><small class="section-count">${this.machines.length}</small></button>`;
}
private toggleMenu(machineId: string, target: EventTarget | null): void {
+28 -18
View File
@@ -27,7 +27,7 @@ import { loadExternalPlugins } from "../plugins/external";
import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry";
import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs";
import { AppShellController } from "../appShell/appShellController";
import { MobileNavigationController, type NavigationSection } from "../appShell/navigationState";
import { NavigationSectionsController, type NavigationSection } from "../appShell/navigationState";
import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController";
import { readRoute, writeRoute, type AppRoute } from "../route";
import { readSettingsSection, writeSettingsSection, type SettingsSection } from "../settingsRoute";
@@ -127,7 +127,7 @@ export class PiWebApp extends LitElement {
private readonly terminalSelection = new SessionStorageTerminalSelectionMemory();
private readonly appShell = new AppShellController(this);
private readonly panelCollapse = new PanelCollapseController(this);
private readonly mobileNavigation = new MobileNavigationController(
private readonly navigationSections = new NavigationSectionsController(
this,
() => this.state,
() => this.appShell.isMobileNavigationLayout,
@@ -777,10 +777,10 @@ export class PiWebApp extends LitElement {
.selectedMachine=${this.state.selectedMachine}
.machineStatuses=${this.state.machineStatuses}
.machineActivities=${this.state.machineActivities}
.machinesCollapsed=${this.mobileNavigation.isCollapsed("machines")}
.onToggleMachines=${() => { this.mobileNavigation.toggle("machines"); }}
.machinesCollapsed=${this.navigationSections.isCollapsed("machines")}
.onToggleMachines=${() => { this.navigationSections.toggle("machines"); }}
.onSelectMachine=${(machine: Machine) => this.withChatScrollTransition(async () => {
this.mobileNavigation.expand("projects");
this.navigationSections.advanceAfterSelection("machines");
await this.selectMachineWithMemory(machine);
})}
.onRemoveMachine=${(machine: Machine) => { void this.removeMachine(machine); }}
@@ -796,32 +796,42 @@ export class PiWebApp extends LitElement {
.sessionActivities=${this.state.sessionActivities}
.selectedSession=${this.state.selectedSession}
.canStartSession=${!!this.state.selectedWorkspace}
.collapsible=${this.appShell.isMobileNavigationLayout}
.projectsCollapsed=${this.mobileNavigation.isCollapsed("projects")}
.workspacesCollapsed=${this.mobileNavigation.isCollapsed("workspaces")}
.sessionsCollapsed=${this.mobileNavigation.isCollapsed("sessions")}
.collapsible=${true}
.compact=${this.appShell.isMobileNavigationLayout}
.projectsCollapsed=${this.navigationSections.isCollapsed("projects")}
.workspacesCollapsed=${this.navigationSections.isCollapsed("workspaces")}
.sessionsCollapsed=${this.navigationSections.isCollapsed("sessions")}
.workspaceLabelItems=${(workspace: Workspace) => this.workspaceLabelItems(workspace)}
.refreshControl=${this.appShell.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : undefined}
.onShowActions=${() => { this.setState({ actionPaletteOpen: true }); }}
.onToggleProjects=${() => { this.mobileNavigation.toggle("projects"); }}
.onToggleWorkspaces=${() => { this.mobileNavigation.toggle("workspaces"); }}
.onToggleSessions=${() => { this.mobileNavigation.toggle("sessions"); }}
.onToggleProjects=${() => { this.navigationSections.toggle("projects"); }}
.onToggleWorkspaces=${() => { this.navigationSections.toggle("workspaces"); }}
.onToggleSessions=${() => { this.navigationSections.toggle("sessions"); }}
.onSelectProject=${(project: Project) => this.withChatScrollTransition(async () => {
this.mobileNavigation.expand("workspaces");
this.navigationSections.advanceAfterSelection("projects");
await this.workspaces.selectProject(project);
})}
.onCloseProject=${(project: Project) => this.projects.closeProject(project.id)}
.onSelectWorkspace=${(workspace: Workspace) => this.withChatScrollTransition(async () => {
this.mobileNavigation.expand("sessions");
this.navigationSections.advanceAfterSelection("workspaces");
await this.workspaces.selectWorkspace(workspace);
})}
.onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
.onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }}
.onStartSession=${() => openChatAfter(() => this.sessions.startSession())}
.onSelectSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))}
.onStartSession=${() => openChatAfter(() => {
this.navigationSections.advanceAfterSelection("sessions");
return this.sessions.startSession();
})}
.onSelectSession=${(session: SessionInfo) => openChatAfter(() => {
this.navigationSections.advanceAfterSelection("sessions");
return this.sessions.selectSession(session);
})}
.onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)}
.onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
.onRestoreSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))}
.onRestoreSession=${(session: SessionInfo) => openChatAfter(() => {
this.navigationSections.advanceAfterSelection("sessions");
return this.sessions.restoreSession(session);
})}
.onDeleteCachedNewSession=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)}
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
></app-navigation-panel>
@@ -829,7 +839,7 @@ export class PiWebApp extends LitElement {
}
private openNavigationSection(section: NavigationSection): void {
this.mobileNavigation.open(section, () => { this.selectMainView("navigation"); });
this.navigationSections.open(section, () => { this.selectMainView("navigation"); });
}
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
+1 -1
View File
@@ -77,7 +77,7 @@ export class ProjectList extends LitElement {
if (!this.collapsible) return "Projects";
const selectedSummary = this.selected?.name ?? "No project selected";
const selectedTitle = this.selected?.path ?? selectedSummary;
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Projects</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.projects.length}</small></button>`;
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Projects</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span><small class="section-count">${this.projects.length}</small></button>`;
}
private renderActivity(project: Project) {
+1 -1
View File
@@ -95,7 +95,7 @@ export class SessionList extends LitElement {
const selectedTitle = this.selected?.path ?? selectedSummary;
return html`
<h2>
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${sessionCount}</small></button>
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span><small class="section-count">${sessionCount}</small></button>
<button ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>
</h2>
`;
+1 -1
View File
@@ -79,7 +79,7 @@ export class WorkspaceList extends LitElement {
if (!this.collapsible) return "Workspaces";
const selectedSummary = this.selected === undefined ? "No workspace selected" : `${this.selected.label}${this.selected.isMain ? " · main" : ""} · ${this.selected.path}`;
const selectedTitle = this.selected?.path ?? selectedSummary;
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Workspaces</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.workspaces.length}</small></button>`;
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Workspaces</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span><small class="section-count">${this.workspaces.length}</small></button>`;
}
private renderActivity(workspace: Workspace): TemplateResult | undefined {
@@ -27,6 +27,7 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
@property({ attribute: false }) refreshControl: unknown;
@property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) compact = false;
@property({ type: Boolean }) machinesCollapsed = false;
@property({ type: Boolean }) projectsCollapsed = false;
@property({ type: Boolean }) workspacesCollapsed = false;
@@ -120,20 +121,24 @@ export class AppNavigationPanel extends LitElement {
static override styles = css`
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
:host([collapsible]) { flex: 1 1 auto; }
:host([compact]) { flex: 1 1 auto; }
header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }
:host([collapsible]) header { display: none; }
:host([compact]) header { display: none; }
.header-actions { display: flex; align-items: center; gap: 8px; }
machine-list, project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); }
session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; }
:host([collapsible]) machine-list,
:host([collapsible]) project-list,
:host([collapsible]) workspace-list,
:host([collapsible]) session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; }
:host([collapsible]) machine-list[collapsed],
:host([collapsible]) project-list[collapsed],
:host([collapsible]) workspace-list[collapsed],
:host([collapsible]) session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; }
machine-list[collapsed],
project-list[collapsed],
workspace-list[collapsed],
session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; }
:host([compact]) machine-list,
:host([compact]) project-list,
:host([compact]) workspace-list,
:host([compact]) session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; }
:host([compact]) machine-list[collapsed],
:host([compact]) project-list[collapsed],
:host([compact]) workspace-list[collapsed],
:host([compact]) session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
`;
}