From e89441fa7efd2206dde1edb0994054937a72db4b Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 18 May 2026 19:25:51 +0200 Subject: [PATCH] feat: add collapsible mobile navigation sections --- .changeset/mobile-navigation-accordion.md | 5 ++ src/client/src/components/PiWebApp.ts | 70 +++++++++++++++++++++- src/client/src/components/ProjectList.ts | 13 +++- src/client/src/components/SessionList.ts | 22 +++++-- src/client/src/components/WorkspaceList.ts | 14 ++++- src/client/src/components/shared.ts | 12 +++- 6 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 .changeset/mobile-navigation-accordion.md diff --git a/.changeset/mobile-navigation-accordion.md b/.changeset/mobile-navigation-accordion.md new file mode 100644 index 0000000..0bedf0c --- /dev/null +++ b/.changeset/mobile-navigation-accordion.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Make the mobile navigation panel sections collapsible so projects, workspaces, and sessions can each use more screen space. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 1bf2d77..a06f47c 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -32,6 +32,8 @@ import "./ProjectDialog"; import "./WorkspacePanel"; import { appStyles } from "./shared"; +type NavigationSection = "projects" | "workspaces" | "sessions"; + @customElement("pi-web-app") export class PiWebApp extends LitElement { @state() private state: AppState = initialAppState(); @@ -76,6 +78,7 @@ export class PiWebApp extends LitElement { private terminalAutoStartWorkspaceId: string | undefined; private readonly plugins = createPluginRegistry(); @state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false; + @state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined; private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); private readonly onFocus = () => { void this.sessions.refreshSelectedSession(); }; private readonly onVisibilityChange = () => { @@ -274,12 +277,73 @@ export class PiWebApp extends LitElement { Pi Web - this.withChatScrollTransition(() => this.workspaces.selectProject(project))} .onClose=${(project: Project) => this.projects.closeProject(project.id)}> - this.plugins.getWorkspaceLabelItems(this.state, workspace)} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}> - openChatAfter(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))} .onDelete=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)} .onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)}> + { this.toggleNavigationSection("projects"); }} + .onSelect=${(project: Project) => this.withChatScrollTransition(async () => { + this.expandNavigationSection("workspaces"); + await this.workspaces.selectProject(project); + })} + .onClose=${(project: Project) => this.projects.closeProject(project.id)} + > + this.plugins.getWorkspaceLabelItems(this.state, workspace)} + .onToggleCollapsed=${() => { this.toggleNavigationSection("workspaces"); }} + .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(async () => { + this.expandNavigationSection("sessions"); + await this.workspaces.selectWorkspace(workspace); + })} + > + { this.toggleNavigationSection("sessions"); }} + .onStart=${() => openChatAfter(() => this.sessions.startSession())} + .onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} + .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} + .onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))} + .onDelete=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)} + .onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)} + > `; } + private expandedNavigationSection(): NavigationSection | undefined { + if (this.expandedMobileNavigationSection === "none") return undefined; + return this.expandedMobileNavigationSection ?? this.defaultNavigationSection(); + } + + private defaultNavigationSection(): NavigationSection { + if (this.state.selectedProject === undefined) return "projects"; + if (this.state.selectedWorkspace === undefined) return "workspaces"; + return "sessions"; + } + + private isNavigationSectionCollapsed(section: NavigationSection): boolean { + return this.isMobileNavigationLayout && this.expandedNavigationSection() !== section; + } + + private toggleNavigationSection(section: NavigationSection): void { + if (!this.isMobileNavigationLayout) return; + this.expandedMobileNavigationSection = this.expandedNavigationSection() === section ? "none" : section; + } + + private expandNavigationSection(section: NavigationSection): void { + if (this.isMobileNavigationLayout) this.expandedMobileNavigationSection = section; + } + private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] { const workspace = this.state.selectedWorkspace; return this.plugins.getWorkspacePanels().filter((panel) => workspace === undefined || (panel.visible?.({ workspace, state: this.state }) ?? true)); diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index 80a7d7e..c3ead23 100644 --- a/src/client/src/components/ProjectList.ts +++ b/src/client/src/components/ProjectList.ts @@ -8,8 +8,11 @@ import { listStyles } from "./shared"; export class ProjectList extends LitElement { @property({ attribute: false }) projects: Project[] = []; @property({ attribute: false }) selected?: Project; + @property({ type: Boolean, reflect: true }) collapsible = false; + @property({ type: Boolean, reflect: true }) collapsed = false; @property({ attribute: false }) onSelect?: (project: Project) => void; @property({ attribute: false }) onClose?: (project: Project) => void; + @property({ attribute: false }) onToggleCollapsed?: () => void; @state() private openMenuProjectId: string | undefined; @state() private menuStyle = ""; private readonly onDocumentClick = (event: MouseEvent) => { @@ -29,13 +32,14 @@ export class ProjectList extends LitElement { protected override updated(changed: PropertyValues): void { if (changed.has("projects") && this.openMenuProjectId !== undefined && !this.projects.some((project) => project.id === this.openMenuProjectId)) this.openMenuProjectId = undefined; + if (changed.has("collapsed") && this.collapsed) this.openMenuProjectId = undefined; } override render() { return html`
-

Projects

- ${this.projects.map((project) => html` +

${this.renderHeading()}

+ ${this.collapsed ? null : this.projects.map((project) => html`
{ this.onToggleCollapsed?.(); }}>${this.collapsed ? "▸" : "▾"} Projects${this.projects.length}`; + } + private toggleMenu(projectId: string, target: EventTarget | null) { if (this.openMenuProjectId === projectId) { this.openMenuProjectId = undefined; diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index 5c5f1ae..b203f4e 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -23,8 +23,11 @@ export class SessionList extends LitElement { @property({ attribute: false }) activities: Record = {}; @property({ attribute: false }) selected?: SessionInfo; @property({ type: Boolean }) canStart = false; + @property({ type: Boolean, reflect: true }) collapsible = false; + @property({ type: Boolean, reflect: true }) collapsed = false; @property({ attribute: false }) onSelect?: (session: SessionInfo) => void; @property({ attribute: false }) onStart?: () => void; + @property({ attribute: false }) onToggleCollapsed?: () => void; @state() private openMenuSessionId: string | undefined; @state() private menuStyle = ""; @state() private archivedExpanded = false; @@ -49,13 +52,14 @@ export class SessionList extends LitElement { protected override updated(changed: PropertyValues): void { if (changed.has("sessions") && this.openMenuSessionId !== undefined && !this.sessions.some((session) => session.id === this.openMenuSessionId)) this.openMenuSessionId = undefined; + if (changed.has("collapsed") && this.collapsed) this.openMenuSessionId = undefined; if (changed.has("sessions") && !this.sessions.some((session) => session.archived === true)) this.archivedExpanded = false; if (this.selected?.archived === true && !this.archivedExpanded) { this.archivedExpanded = true; void this.updateComplete.then(() => { this.scrollSelectedIntoView(); }); return; } - if (changed.has("selected") || changed.has("sessions")) this.scrollSelectedIntoView(); + if ((changed.has("selected") || changed.has("sessions") || changed.has("collapsed")) && !this.collapsed) this.scrollSelectedIntoView(); } override render() { @@ -64,9 +68,9 @@ export class SessionList extends LitElement { const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !activeIds.has(session.id))); return html`
-

Sessions

- ${activeRows.map((row) => this.renderSession(row))} - ${archivedRows.length > 0 ? html` + ${this.renderHeading(activeRows.length + archivedRows.length)} + ${this.collapsed ? null : activeRows.map((row) => this.renderSession(row))} + ${this.collapsed ? null : archivedRows.length > 0 ? html`

${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row)) : null} ` : null} @@ -74,6 +78,16 @@ export class SessionList extends LitElement { `; } + private renderHeading(sessionCount: number) { + if (!this.collapsible) return html`

Sessions

`; + return html` +

+ + +

+ `; + } + private renderSession(row: SessionRow) { const { session } = row; const cappedDepth = Math.min(row.depth, 2); diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index 5a5d0fe..2758c23 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -10,18 +10,21 @@ import { renderWorkspaceLabelItems } from "./workspaceLabel"; export class WorkspaceList extends LitElement { @property({ attribute: false }) workspaces: Workspace[] = []; @property({ attribute: false }) selected?: Workspace; + @property({ type: Boolean, reflect: true }) collapsible = false; + @property({ type: Boolean, reflect: true }) collapsed = false; @property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => []; @property({ attribute: false }) onSelect?: (workspace: Workspace) => void; + @property({ attribute: false }) onToggleCollapsed?: () => void; protected override updated(changed: PropertyValues): void { - if (changed.has("selected") || changed.has("workspaces")) this.scrollSelectedIntoView(); + if ((changed.has("selected") || changed.has("workspaces") || changed.has("collapsed")) && !this.collapsed) this.scrollSelectedIntoView(); } override render() { return html`
-

Workspaces

- ${this.workspaces.map((workspace) => { +

${this.renderHeading()}

+ ${this.collapsed ? null : this.workspaces.map((workspace) => { const label = `${workspace.label}${workspace.isMain ? " · main" : ""}`; return html`
{ this.onToggleCollapsed?.(); }}>${this.collapsed ? "▸" : "▾"} Workspaces${this.workspaces.length}`; + } + private scrollSelectedIntoView(): void { this.renderRoot.querySelector(".action-row.selected")?.scrollIntoView({ block: "nearest" }); } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index d4ca82c..9fb4b36 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -62,6 +62,12 @@ export const appStyles = css` main.navigation-view chat-view, main.navigation-view prompt-editor, main.navigation-view status-bar, main.navigation-view .empty { display: none; } main.navigation-view .mobile-navigation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } + main.navigation-view .mobile-navigation-panel project-list, + main.navigation-view .mobile-navigation-panel workspace-list, + main.navigation-view .mobile-navigation-panel session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: auto; } + main.navigation-view .mobile-navigation-panel project-list[collapsed], + main.navigation-view .mobile-navigation-panel workspace-list[collapsed], + main.navigation-view .mobile-navigation-panel session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; } } status-bar { flex: 0 0 auto; } chat-view { flex: 1 1 auto; min-height: 0; overflow: hidden; } @@ -110,12 +116,14 @@ export const workspacePanelStyles = css` export const listStyles = css` :host { display: block; color: #e6edf3; font: 14px system-ui, sans-serif; } + :host([collapsed]) { flex: 0 0 auto; min-height: auto; overflow: hidden; } section { padding: 10px; } - h2 { display: flex; justify-content: space-between; align-items: center; margin: 0 0 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; } + h2 { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 0 0 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; } button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; } section > button { display: block; width: 100%; text-align: left; margin: 6px 0; } .subheading { margin-top: 14px; } - .section-toggle { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; border: 0; background: transparent; color: inherit; padding: 0; font: inherit; text-transform: inherit; } + .section-toggle { display: flex; flex: 1 1 auto; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; width: 100%; border: 0; background: transparent; color: inherit; padding: 0; font: inherit; text-transform: inherit; } + .section-toggle span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .section-toggle small { display: inline; color: inherit; font-size: inherit; } .action-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; margin: 6px 0; cursor: pointer; } .action-row:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; border-radius: 8px; }