Archived
feat: add collapsible mobile navigation sections
This commit is contained in:
@@ -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.
|
||||
@@ -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 {
|
||||
<strong>Pi Web</strong>
|
||||
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.setState({ actionPaletteOpen: true }); }}>Actions</button>
|
||||
</header>
|
||||
<project-list .projects=${this.state.projects} .selected=${this.state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))} .onClose=${(project: Project) => this.projects.closeProject(project.id)}></project-list>
|
||||
<workspace-list .workspaces=${this.state.workspaces} .selected=${this.state.selectedWorkspace} .workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
|
||||
<session-list .sessions=${this.state.sessions} .statuses=${this.state.sessionStatuses} .activities=${this.state.sessionActivities} .selected=${this.state.selectedSession} .canStart=${!!this.state.selectedWorkspace} .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)}></session-list>
|
||||
<project-list
|
||||
.projects=${this.state.projects}
|
||||
.selected=${this.state.selectedProject}
|
||||
.collapsible=${this.isMobileNavigationLayout}
|
||||
.collapsed=${this.isNavigationSectionCollapsed("projects")}
|
||||
.onToggleCollapsed=${() => { 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)}
|
||||
></project-list>
|
||||
<workspace-list
|
||||
.workspaces=${this.state.workspaces}
|
||||
.selected=${this.state.selectedWorkspace}
|
||||
.collapsible=${this.isMobileNavigationLayout}
|
||||
.collapsed=${this.isNavigationSectionCollapsed("workspaces")}
|
||||
.workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)}
|
||||
.onToggleCollapsed=${() => { this.toggleNavigationSection("workspaces"); }}
|
||||
.onSelect=${(workspace: Workspace) => this.withChatScrollTransition(async () => {
|
||||
this.expandNavigationSection("sessions");
|
||||
await this.workspaces.selectWorkspace(workspace);
|
||||
})}
|
||||
></workspace-list>
|
||||
<session-list
|
||||
.sessions=${this.state.sessions}
|
||||
.statuses=${this.state.sessionStatuses}
|
||||
.activities=${this.state.sessionActivities}
|
||||
.selected=${this.state.selectedSession}
|
||||
.canStart=${!!this.state.selectedWorkspace}
|
||||
.collapsible=${this.isMobileNavigationLayout}
|
||||
.collapsed=${this.isNavigationSectionCollapsed("sessions")}
|
||||
.onToggleCollapsed=${() => { 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)}
|
||||
></session-list>
|
||||
`;
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
@@ -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<this>): 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`
|
||||
<section>
|
||||
<h2>Projects</h2>
|
||||
${this.projects.map((project) => html`
|
||||
<h2>${this.renderHeading()}</h2>
|
||||
${this.collapsed ? null : this.projects.map((project) => html`
|
||||
<div
|
||||
class=${`action-row ${this.selected?.id === project.id ? "selected" : ""}`}
|
||||
tabindex="0"
|
||||
@@ -60,6 +64,11 @@ export class ProjectList extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderHeading() {
|
||||
if (!this.collapsible) return "Projects";
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span>${this.collapsed ? "▸" : "▾"} Projects</span><small>${this.projects.length}</small></button>`;
|
||||
}
|
||||
|
||||
private toggleMenu(projectId: string, target: EventTarget | null) {
|
||||
if (this.openMenuProjectId === projectId) {
|
||||
this.openMenuProjectId = undefined;
|
||||
|
||||
@@ -23,8 +23,11 @@ export class SessionList extends LitElement {
|
||||
@property({ attribute: false }) activities: Record<string, SessionActivity> = {};
|
||||
@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<this>): 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`
|
||||
<section>
|
||||
<h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2>
|
||||
${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`
|
||||
<h2 class="subheading"><button class="section-toggle" aria-expanded=${String(this.archivedExpanded)} @click=${() => { this.toggleArchived(); }}><span>${this.archivedExpanded ? "▾" : "▸"} Archived</span><small>${archivedRows.length}</small></button></h2>
|
||||
${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`<h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2>`;
|
||||
return html`
|
||||
<h2>
|
||||
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span>${this.collapsed ? "▸" : "▾"} Sessions</span><small>${sessionCount}</small></button>
|
||||
<button ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>
|
||||
</h2>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSession(row: SessionRow) {
|
||||
const { session } = row;
|
||||
const cappedDepth = Math.min(row.depth, 2);
|
||||
|
||||
@@ -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<this>): 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`
|
||||
<section>
|
||||
<h2>Workspaces</h2>
|
||||
${this.workspaces.map((workspace) => {
|
||||
<h2>${this.renderHeading()}</h2>
|
||||
${this.collapsed ? null : this.workspaces.map((workspace) => {
|
||||
const label = `${workspace.label}${workspace.isMain ? " · main" : ""}`;
|
||||
return html`
|
||||
<div
|
||||
@@ -45,6 +48,11 @@ export class WorkspaceList extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderHeading() {
|
||||
if (!this.collapsible) return "Workspaces";
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span>${this.collapsed ? "▸" : "▾"} Workspaces</span><small>${this.workspaces.length}</small></button>`;
|
||||
}
|
||||
|
||||
private scrollSelectedIntoView(): void {
|
||||
this.renderRoot.querySelector<HTMLElement>(".action-row.selected")?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user