From 9a3f2ce64f7f6998fd52c4eee8f6eab7adf43736 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 5 Jun 2026 21:45:23 +0200 Subject: [PATCH] feat: make desktop navigation sections collapsible --- .changeset/collapsible-desktop-navigation.md | 5 ++ .../src/appShell/navigationState.test.ts | 28 ++++++-- src/client/src/appShell/navigationState.ts | 71 +++++++++++++++++-- src/client/src/components/MachineList.ts | 2 +- src/client/src/components/PiWebApp.ts | 46 +++++++----- src/client/src/components/ProjectList.ts | 2 +- src/client/src/components/SessionList.ts | 2 +- src/client/src/components/WorkspaceList.ts | 2 +- .../components/appShell/AppNavigationPanel.ts | 25 ++++--- 9 files changed, 141 insertions(+), 42 deletions(-) create mode 100644 .changeset/collapsible-desktop-navigation.md diff --git a/.changeset/collapsible-desktop-navigation.md b/.changeset/collapsible-desktop-navigation.md new file mode 100644 index 0000000..3f7a660 --- /dev/null +++ b/.changeset/collapsible-desktop-navigation.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Make navigation sections collapsible on desktop and auto-collapse completed context sections after selections. diff --git a/src/client/src/appShell/navigationState.test.ts b/src/client/src/appShell/navigationState.test.ts index d90669e..399d0c0 100644 --- a/src/client/src/appShell/navigationState.test.ts +++ b/src/client/src/appShell/navigationState.test.ts @@ -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"]); + }); }); diff --git a/src/client/src/appShell/navigationState.ts b/src/client/src/appShell/navigationState.ts index 6036a3d..71a98b1 100644 --- a/src/client/src/appShell/navigationState.ts +++ b/src/client/src/appShell/navigationState.ts @@ -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[] { + 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]); } diff --git a/src/client/src/components/MachineList.ts b/src/client/src/components/MachineList.ts index cfaf4cf..2363813 100644 --- a/src/client/src/components/MachineList.ts +++ b/src/client/src/components/MachineList.ts @@ -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``; + return html``; } private toggleMenu(machineId: string, target: EventTarget | null): void { diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 093b59c..19646c9 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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)} > @@ -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[] { diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index f857404..b494d2a 100644 --- a/src/client/src/components/ProjectList.ts +++ b/src/client/src/components/ProjectList.ts @@ -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``; + return html``; } private renderActivity(project: Project) { diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index 0793781..33adeb7 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -95,7 +95,7 @@ export class SessionList extends LitElement { const selectedTitle = this.selected?.path ?? selectedSummary; return html`

- +

`; diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index b130dbb..838d18f 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -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``; + return html``; } private renderActivity(workspace: Workspace): TemplateResult | undefined { diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 56a8dee..99305b6 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -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; } `; }