From a73bcebbd67a938909ec363baaf5386b398f91a5 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 5 Jun 2026 23:11:23 +0200 Subject: [PATCH] feat: simplify desktop navigation --- .changeset/calm-navigation-panels.md | 5 + .../src/appShell/navigationState.test.ts | 8 +- src/client/src/appShell/navigationState.ts | 21 +- src/client/src/components/MachineSwitcher.ts | 213 ++++++++++++++++++ .../appShell/AppNavigationPanel.test.ts | 4 +- .../components/appShell/AppNavigationPanel.ts | 17 +- 6 files changed, 239 insertions(+), 29 deletions(-) create mode 100644 .changeset/calm-navigation-panels.md create mode 100644 src/client/src/components/MachineSwitcher.ts diff --git a/.changeset/calm-navigation-panels.md b/.changeset/calm-navigation-panels.md new file mode 100644 index 0000000..821aa1d --- /dev/null +++ b/.changeset/calm-navigation-panels.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Reduce desktop navigation crowding by moving machine switching into a compact header control and removing automatic desktop section collapse. diff --git a/src/client/src/appShell/navigationState.test.ts b/src/client/src/appShell/navigationState.test.ts index 399d0c0..17aa244 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 { collapsedNavigationSectionsAfterSelection, defaultNavigationSection, expandedNavigationSection, isNavigationSectionCollapsed, toggleCollapsedNavigationSection, toggleNavigationSection } from "./navigationState"; +import { defaultNavigationSection, expandedNavigationSection, isNavigationSectionCollapsed, toggleCollapsedNavigationSection, toggleNavigationSection } from "./navigationState"; describe("navigationState", () => { it("defaults to the first incomplete selection section", () => { @@ -51,10 +51,4 @@ describe("navigationState", () => { 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 71a98b1..0981d03 100644 --- a/src/client/src/appShell/navigationState.ts +++ b/src/client/src/appShell/navigationState.ts @@ -41,18 +41,6 @@ export function toggleCollapsedNavigationSection(collapsedSections: readonly Nav 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]; } @@ -103,12 +91,9 @@ export class NavigationSectionsController implements ReactiveController { } advanceAfterSelection(section: NavigationSection): void { - if (this.isMobileLayout()) { - const next = nextNavigationSection(section); - if (next !== undefined) this.expand(next); - return; - } - this.setCollapsedSections(collapsedNavigationSectionsAfterSelection(this.collapsedSections, section)); + if (!this.isMobileLayout()) return; + const next = nextNavigationSection(section); + if (next !== undefined) this.expand(next); } open(section: NavigationSection, openNavigationView: () => void): void { diff --git a/src/client/src/components/MachineSwitcher.ts b/src/client/src/components/MachineSwitcher.ts new file mode 100644 index 0000000..dad943a --- /dev/null +++ b/src/client/src/components/MachineSwitcher.ts @@ -0,0 +1,213 @@ +import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import type { Machine, MachineHealth, MachineStatus, WorkspaceActivity } from "../api"; +import { machineActivityIndicator } from "../workspaceActivity"; +import { actionMenuPanelStyle } from "./actionMenu"; +import { renderActivityIndicator } from "./activityBadge"; +import { canRemoveMachine } from "./MachineList"; + +@customElement("machine-switcher") +export class MachineSwitcher extends LitElement { + @property({ attribute: false }) machines: Machine[] = []; + @property({ attribute: false }) selected?: Machine; + @property({ attribute: false }) statuses: Record = {}; + @property({ attribute: false }) activities: Record> = {}; + @property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise; + @property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise; + @state() private open = false; + @state() private menuStyle = ""; + @state() private openActionsMachineId: string | undefined; + @state() private actionMenuStyle = ""; + + private readonly onDocumentClick = (event: MouseEvent) => { + if (event.composedPath().includes(this)) return; + this.open = false; + this.openActionsMachineId = undefined; + }; + + override connectedCallback(): void { + super.connectedCallback(); + document.addEventListener("click", this.onDocumentClick); + } + + override disconnectedCallback(): void { + document.removeEventListener("click", this.onDocumentClick); + super.disconnectedCallback(); + } + + protected override updated(changed: PropertyValues): void { + if (changed.has("machines") && this.open && this.selectedMachine() === undefined) this.open = false; + if (changed.has("machines") && this.openActionsMachineId !== undefined && !this.machines.some((machine) => machine.id === this.openActionsMachineId)) this.openActionsMachineId = undefined; + } + + override render() { + const selected = this.selectedMachine(); + if (selected === undefined) return null; + const status = machineStatus(selected, this.statuses); + const label = selected.name; + return html` +
+ + ${this.open ? html` +
{ event.stopPropagation(); }}> + ${this.machines.map((machine) => this.renderMachineOption(machine))} +
+ ` : null} +
+ `; + } + + private renderMachineOption(machine: Machine): TemplateResult { + const selected = this.selected?.id === machine.id; + const status = machineStatus(machine, this.statuses); + const hasActions = canRemoveMachine(machine) && this.onRemove !== undefined; + const actionsOpen = this.openActionsMachineId === machine.id; + return html` +
+ + ${hasActions ? html` +
+ + ${actionsOpen ? html` +
{ event.stopPropagation(); }}> + +
+ ` : null} +
+ ` : null} +
+ `; + } + + private renderActivity(machine: Machine): TemplateResult | undefined { + const status = machineStatus(machine, this.statuses); + if (status === "offline" || status === "error") return undefined; + const kind = machineActivityIndicator(this.activities[machine.id]); + return renderActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active"); + } + + private selectedMachine(): Machine | undefined { + return this.selected ?? this.machines.find((machine) => machine.id === "local") ?? this.machines[0]; + } + + private toggleMenu(target: EventTarget | null): void { + this.menuStyle = machineSwitcherMenuStyle(target); + this.open = !this.open; + this.openActionsMachineId = undefined; + } + + private toggleActionsMenu(machineId: string, target: EventTarget | null): void { + if (this.openActionsMachineId === machineId) { + this.openActionsMachineId = undefined; + return; + } + this.actionMenuStyle = actionMenuPanelStyle(target, { constrainTo: "viewport" }); + this.openActionsMachineId = machineId; + } + + private select(machine: Machine): void { + this.open = false; + this.openActionsMachineId = undefined; + void this.onSelect?.(machine); + } + + private removeMachine(machine: Machine): void { + this.open = false; + this.openActionsMachineId = undefined; + void this.onRemove?.(machine); + } + + static override styles = css` + :host { min-width: 0; display: block; } + .machine-switcher { min-width: 0; } + .machine-switcher-button { box-sizing: border-box; width: 100%; min-width: 0; display: flex; align-items: center; gap: 6px; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 8px; cursor: pointer; text-align: left; } + .machine-switcher-button:hover, .machine-switcher-button:focus-visible { border-color: var(--pi-accent); background: var(--pi-selection-bg); } + .machine-switcher-text { min-width: 0; display: grid; gap: 1px; } + .machine-switcher-kicker { color: var(--pi-muted); font-size: 10px; line-height: 1; text-transform: uppercase; letter-spacing: .02em; } + .machine-switcher-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; line-height: 1.2; } + .machine-status { flex: 0 0 auto; color: var(--pi-muted); font-size: 11px; } + .machine-status.online { color: var(--pi-success); } + .machine-status.offline, .machine-status.error { color: var(--pi-danger); } + .machine-chevron { flex: 0 0 auto; color: var(--pi-muted); font-size: 11px; } + .activity-indicator { flex: 0 0 auto; display: inline-block; width: 7px; height: 7px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; } + .activity-indicator.session { border-radius: 50%; background: var(--pi-success); } + .activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); } + .machine-switcher-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(280px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); } + .machine-option { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px; align-items: stretch; margin: 2px 0; } + .machine-option.no-actions { grid-template-columns: minmax(0, 1fr); } + .machine-option-main, .machine-option-actions-toggle, .machine-option-actions-panel button { border: 0; border-radius: 7px; background: transparent; color: var(--pi-text); cursor: pointer; } + .machine-option-main { min-width: 0; display: grid; gap: 2px; padding: 7px 8px; text-align: left; } + .machine-option-name { min-width: 0; display: flex; align-items: baseline; gap: 6px; } + .machine-option-name span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .machine-option-main small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-muted); } + .machine-option-actions { position: relative; align-self: stretch; } + .machine-option-actions-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: var(--pi-muted); } + .machine-option-actions-panel { position: fixed; z-index: 10001; box-sizing: border-box; min-width: min(120px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); } + .machine-option-actions-panel button { display: block; width: 100%; padding: 7px 9px; text-align: left; white-space: nowrap; } + .machine-option-actions-panel button.danger { color: var(--pi-danger); } + .machine-option-main:hover, .machine-option-main:focus-visible, .machine-option-actions-toggle:hover, .machine-option-actions-toggle:focus-visible, .machine-option.selected .machine-option-main { background: var(--pi-selection-bg); } + .machine-option-actions-panel button:hover, .machine-option-actions-panel button:focus-visible { background: var(--pi-selection-bg); } + .machine-option-actions-panel button.danger:hover, .machine-option-actions-panel button.danger:focus-visible { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); } + @keyframes pulse { 0%, 100% { opacity: .55; } 50% { opacity: 1; } } + `; +} + +export function shouldShowMachineSwitcher(machines: readonly Machine[]): boolean { + return machines.length > 1; +} + +function machineStatus(machine: Machine, statuses: Record): MachineStatus { + return statuses[machine.id]?.status ?? machine.status ?? "unknown"; +} + +function machineStatusLabel(status: MachineStatus): string { + return status === "online" ? "online" : status === "offline" ? "offline" : status === "error" ? "error" : "unknown"; +} + +function machineTitle(machine: Machine): string { + return machine.baseUrl ?? machine.name; +} + +function machineSwitcherMenuStyle(target: EventTarget | null): string { + if (typeof HTMLElement === "undefined" || typeof window === "undefined" || !(target instanceof HTMLElement)) return ""; + const trigger = target.getBoundingClientRect(); + const viewportPadding = 8; + const menuWidth = Math.min(280, Math.max(0, window.innerWidth - viewportPadding * 2)); + const left = Math.min(Math.max(viewportPadding, trigger.left), Math.max(viewportPadding, window.innerWidth - viewportPadding - menuWidth)); + const availableBelow = Math.max(0, window.innerHeight - trigger.bottom - viewportPadding); + return [`top: ${px(trigger.bottom)};`, `left: ${px(left)};`, `width: ${px(menuWidth)};`, `max-height: ${px(availableBelow)};`].join(" "); +} + +function px(value: number): string { + return `${String(Math.round(value))}px`; +} diff --git a/src/client/src/components/appShell/AppNavigationPanel.test.ts b/src/client/src/components/appShell/AppNavigationPanel.test.ts index 57757c8..51defa1 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.test.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.test.ts @@ -3,12 +3,12 @@ import type { Machine } from "../../api"; import { shouldShowMachinesSection } from "./AppNavigationPanel"; describe("shouldShowMachinesSection", () => { - it("hides the machines section when there is no machine choice", () => { + it("hides machine navigation when there is no machine choice", () => { expect(shouldShowMachinesSection([])).toBe(false); expect(shouldShowMachinesSection([machine("local")])).toBe(false); }); - it("shows the machines section when there are multiple machines", () => { + it("shows machine navigation when there are multiple machines", () => { expect(shouldShowMachinesSection([machine("local"), machine("remote-a")])).toBe(true); }); }); diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index 99305b6..cd5aa2e 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -3,6 +3,7 @@ import { customElement, property } from "lit/decorators.js"; import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api"; import type { WorkspaceLabelItem } from "../../plugins/types"; import "../MachineList"; +import "../MachineSwitcher"; import "../ProjectList"; import "../WorkspaceList"; import "../SessionList"; @@ -57,12 +58,22 @@ export class AppNavigationPanel extends LitElement { return html`
PI WEB + ${shouldShowMachinesSection(this.machines) ? html` + this.onSelectMachine?.(machine)} + .onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)} + > + ` : null}
${this.refreshControl}
- ${shouldShowMachinesSection(this.machines) ? html` + ${this.compact && shouldShowMachinesSection(this.machines) ? html`