feat: simplify desktop navigation

This commit is contained in:
Federico Jaramillo Martinez
2026-06-06 00:12:38 +02:00
parent 9a3f2ce64f
commit a73bcebbd6
6 changed files with 239 additions and 29 deletions
+5
View File
@@ -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.
@@ -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"]);
});
});
+3 -18
View File
@@ -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 {
@@ -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<string, MachineHealth> = {};
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
@property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
@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<this>): 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`
<div class="machine-switcher">
<button
type="button"
class="machine-switcher-button"
title=${machineTitle(selected)}
aria-label=${`Machine: ${label}. Switch machine.`}
aria-expanded=${String(this.open)}
@click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }}
>
${this.renderActivity(selected)}
<span class="machine-switcher-text">
<span class="machine-switcher-kicker">Machine</span>
<span class="machine-switcher-label">${label}</span>
</span>
<span class=${`machine-status ${status}`}>${machineStatusLabel(status)}</span>
<span class="machine-chevron" aria-hidden="true">▾</span>
</button>
${this.open ? html`
<div class="machine-switcher-menu" style=${this.menuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
${this.machines.map((machine) => this.renderMachineOption(machine))}
</div>
` : null}
</div>
`;
}
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`
<div class=${`machine-option ${selected ? "selected" : ""} ${hasActions ? "" : "no-actions"}`}>
<button
type="button"
class="machine-option-main"
title=${machineTitle(machine)}
@click=${() => { this.select(machine); }}
>
<span class="machine-option-name">${this.renderActivity(machine)}<span>${machine.name}</span></span>
<small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)}</small>
</button>
${hasActions ? html`
<div class="machine-option-actions">
<button
type="button"
class="machine-option-actions-toggle"
title="Machine actions"
aria-label=${`Actions for ${machine.name}`}
aria-expanded=${String(actionsOpen)}
@click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleActionsMenu(machine.id, event.currentTarget); }}
>⋯</button>
${actionsOpen ? html`
<div class="machine-option-actions-panel" style=${this.actionMenuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
<button class="danger" title=${`Remove ${machine.name}`} @click=${() => { this.removeMachine(machine); }}>Remove</button>
</div>
` : null}
</div>
` : null}
</div>
`;
}
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<string, MachineHealth>): 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`;
}
@@ -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);
});
});
@@ -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`
<header>
<strong>PI WEB</strong>
${shouldShowMachinesSection(this.machines) ? html`
<machine-switcher
.machines=${this.machines}
.selected=${this.selectedMachine}
.statuses=${this.machineStatuses}
.activities=${this.machineActivities}
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
></machine-switcher>
` : null}
<div class="header-actions">
${this.refreshControl}
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.onShowActions?.(); }}>Actions</button>
</div>
</header>
${shouldShowMachinesSection(this.machines) ? html`
${this.compact && shouldShowMachinesSection(this.machines) ? html`
<machine-list
.machines=${this.machines}
.selected=${this.selectedMachine}
@@ -123,8 +134,10 @@ export class AppNavigationPanel extends LitElement {
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
: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); }
header strong { flex: 0 0 auto; }
machine-switcher { flex: 1 1 auto; min-width: 0; }
:host([compact]) header { display: none; }
.header-actions { display: flex; align-items: center; gap: 8px; }
.header-actions { flex: 0 0 auto; 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; }
machine-list[collapsed],