Archived
fix: make workspace navigation bars scrollable
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Make workspace navigation bars horizontally scrollable on desktop and mobile, with side shadows showing when more items are available.
|
||||
@@ -43,6 +43,7 @@ export class PiWebApp extends LitElement {
|
||||
@state() private state: AppState = initialAppState();
|
||||
@query("chat-view") private chatView?: ChatView;
|
||||
@query("prompt-editor") private promptEditor?: PromptEditor;
|
||||
@query(".mobile-tabs") private mobileTabs?: HTMLElement;
|
||||
|
||||
private readonly sessions = new SessionController(
|
||||
() => this.state,
|
||||
@@ -79,6 +80,8 @@ export class PiWebApp extends LitElement {
|
||||
private readonly realtime = new RealtimeSocket();
|
||||
private readonly activeTerminalIds = new Set<string>();
|
||||
private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined;
|
||||
private observedMobileTabs: HTMLElement | undefined;
|
||||
private mobileTabsResizeObserver: ResizeObserver | undefined;
|
||||
private terminalAutoStartWorkspaceId: string | undefined;
|
||||
private piWebStatusTimer: number | undefined;
|
||||
private readonly plugins = createPluginRegistry();
|
||||
@@ -86,6 +89,8 @@ export class PiWebApp extends LitElement {
|
||||
@state() private activeThemeId: QualifiedContributionId = DEFAULT_THEME_ID;
|
||||
@state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false;
|
||||
@state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined;
|
||||
@state() private mobileTabsCanScrollLeft = false;
|
||||
@state() private mobileTabsCanScrollRight = false;
|
||||
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
|
||||
private readonly onFocus = () => {
|
||||
void this.sessions.refreshSelectedSession();
|
||||
@@ -99,6 +104,10 @@ export class PiWebApp extends LitElement {
|
||||
};
|
||||
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
|
||||
this.isMobileNavigationLayout = event.matches;
|
||||
this.updateMobileTabsScrollState();
|
||||
};
|
||||
private readonly onMobileTabsScroll = () => {
|
||||
this.updateMobileTabsScrollState();
|
||||
};
|
||||
private readonly onKeyDown = (event: KeyboardEvent) => {
|
||||
if (this.keyboard.handle(event, this.getActions())) {
|
||||
@@ -135,9 +144,22 @@ export class PiWebApp extends LitElement {
|
||||
this.git.dispose();
|
||||
if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer);
|
||||
this.piWebStatusTimer = undefined;
|
||||
this.mobileTabsResizeObserver?.disconnect();
|
||||
this.mobileTabsResizeObserver = undefined;
|
||||
this.observedMobileTabs = undefined;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override firstUpdated(): void {
|
||||
this.observeMobileTabs();
|
||||
this.updateMobileTabsScrollState();
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
this.observeMobileTabs();
|
||||
this.updateMobileTabsScrollState();
|
||||
}
|
||||
|
||||
private setState(patch: Partial<AppState>) {
|
||||
if (!patchChangesState(this.state, patch)) return;
|
||||
const previous = this.state;
|
||||
@@ -527,18 +549,46 @@ export class PiWebApp extends LitElement {
|
||||
void this.sessions.send(text, streamingBehavior);
|
||||
}
|
||||
|
||||
private mobileTabsFrameClass(): string {
|
||||
return `mobile-tabs-frame${this.mobileTabsCanScrollLeft ? " can-scroll-left" : ""}${this.mobileTabsCanScrollRight ? " can-scroll-right" : ""}`;
|
||||
}
|
||||
|
||||
private observeMobileTabs(): void {
|
||||
const mobileTabs = this.mobileTabs;
|
||||
if (this.observedMobileTabs === mobileTabs) return;
|
||||
this.mobileTabsResizeObserver?.disconnect();
|
||||
this.observedMobileTabs = mobileTabs;
|
||||
this.mobileTabsResizeObserver = undefined;
|
||||
if (mobileTabs === undefined || typeof ResizeObserver === "undefined") return;
|
||||
this.mobileTabsResizeObserver = new ResizeObserver(() => {
|
||||
this.updateMobileTabsScrollState();
|
||||
});
|
||||
this.mobileTabsResizeObserver.observe(mobileTabs);
|
||||
}
|
||||
|
||||
private updateMobileTabsScrollState(): void {
|
||||
const mobileTabs = this.mobileTabs;
|
||||
const maxScrollLeft = mobileTabs === undefined ? 0 : Math.max(0, mobileTabs.scrollWidth - mobileTabs.clientWidth);
|
||||
const canScrollLeft = mobileTabs !== undefined && mobileTabs.scrollLeft > 1;
|
||||
const canScrollRight = mobileTabs !== undefined && maxScrollLeft - mobileTabs.scrollLeft > 1;
|
||||
if (this.mobileTabsCanScrollLeft !== canScrollLeft) this.mobileTabsCanScrollLeft = canScrollLeft;
|
||||
if (this.mobileTabsCanScrollRight !== canScrollRight) this.mobileTabsCanScrollRight = canScrollRight;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const state = this.state;
|
||||
return html`
|
||||
<div class=${`shell ${state.mainView === "navigation" ? "navigation-view" : state.mainView === "chat" ? "chat-view" : "workspace-view"}`}>
|
||||
<aside>${this.isMobileNavigationLayout ? null : this.renderNavigationPanel(false)}</aside>
|
||||
<main class=${state.mainView === "chat" ? "chat-view" : state.mainView === "navigation" ? "navigation-view" : "workspace-view"}>
|
||||
<div class="mobile-tabs">
|
||||
<button class=${state.mainView === "navigation" ? "mobile-navigation-tab selected" : "mobile-navigation-tab"} @click=${() => { this.selectMainView("navigation"); }}>Sessions</button>
|
||||
<button class=${state.mainView === "chat" ? "selected" : ""} @click=${() => { this.selectMainView("chat"); }}>Chat</button>
|
||||
${this.visibleWorkspacePanels().map((panel) => html`
|
||||
<button class=${state.mainView === panel.id ? "selected" : ""} @click=${() => { this.openWorkspaceTool(panel.id); }}>${this.renderMobilePanelTitle(panel)}</button>
|
||||
`)}
|
||||
<div class=${this.mobileTabsFrameClass()}>
|
||||
<div class="mobile-tabs" @scroll=${this.onMobileTabsScroll}>
|
||||
<button class=${state.mainView === "navigation" ? "mobile-navigation-tab selected" : "mobile-navigation-tab"} @click=${() => { this.selectMainView("navigation"); }}>Sessions</button>
|
||||
<button class=${state.mainView === "chat" ? "selected" : ""} @click=${() => { this.selectMainView("chat"); }}>Chat</button>
|
||||
${this.visibleWorkspacePanels().map((panel) => html`
|
||||
<button class=${state.mainView === panel.id ? "selected" : ""} @click=${() => { this.openWorkspaceTool(panel.id); }}>${this.renderMobilePanelTitle(panel)}</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||
<div class="mobile-navigation-panel">${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LitElement, html, type TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api";
|
||||
import type { AppState } from "../appState";
|
||||
import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
||||
@@ -32,6 +32,32 @@ export class WorkspacePanel extends LitElement {
|
||||
@property({ attribute: false }) onSelectDiff: (path: string) => void = () => undefined;
|
||||
@property({ type: Number }) activeTerminalCount = 0;
|
||||
@property({ type: Boolean }) terminalAutoStart = false;
|
||||
@query(".workspace-header-strip") private workspaceHeaderStrip?: HTMLElement;
|
||||
@state() private workspaceHeaderCanScrollLeft = false;
|
||||
@state() private workspaceHeaderCanScrollRight = false;
|
||||
|
||||
private observedWorkspaceHeaderStrip: HTMLElement | undefined;
|
||||
private workspaceHeaderResizeObserver: ResizeObserver | undefined;
|
||||
private readonly onWorkspaceHeaderScroll = () => {
|
||||
this.updateWorkspaceHeaderScrollState();
|
||||
};
|
||||
|
||||
override firstUpdated(): void {
|
||||
this.observeWorkspaceHeaderStrip();
|
||||
this.updateWorkspaceHeaderScrollState();
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
this.observeWorkspaceHeaderStrip();
|
||||
this.updateWorkspaceHeaderScrollState();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.workspaceHeaderResizeObserver?.disconnect();
|
||||
this.workspaceHeaderResizeObserver = undefined;
|
||||
this.observedWorkspaceHeaderStrip = undefined;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const workspace = this.workspace;
|
||||
@@ -41,14 +67,18 @@ export class WorkspacePanel extends LitElement {
|
||||
const context = this.createPanelContext(workspace);
|
||||
return html`
|
||||
<header>
|
||||
${this.hideToolTabs ? null : html`
|
||||
<div class="tabs">
|
||||
${visiblePanels.map((panel) => html`
|
||||
<button class=${selectedPanel?.id === panel.id ? "selected" : ""} @click=${() => { this.onSelectTool(panel.id); }}>${this.renderPanelTitle(panel, context)}</button>
|
||||
`)}
|
||||
<div class=${this.workspaceHeaderFrameClass()}>
|
||||
<div class="workspace-header-strip" @scroll=${this.onWorkspaceHeaderScroll}>
|
||||
${this.hideToolTabs ? null : html`
|
||||
<div class="tabs">
|
||||
${visiblePanels.map((panel) => html`
|
||||
<button class=${selectedPanel?.id === panel.id ? "selected" : ""} @click=${() => { this.onSelectTool(panel.id); }}>${this.renderPanelTitle(panel, context)}</button>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
<small>${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)}</small>
|
||||
</div>
|
||||
`}
|
||||
<small>${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)}</small>
|
||||
</div>
|
||||
</header>
|
||||
${selectedPanel === undefined ? html`<section class="empty">No workspace panels registered.</section>` : html`
|
||||
<div class="panel-content">
|
||||
@@ -64,6 +94,32 @@ export class WorkspacePanel extends LitElement {
|
||||
return html`${panel.title} <span class="tab-badge">${badge}</span>`;
|
||||
}
|
||||
|
||||
private workspaceHeaderFrameClass(): string {
|
||||
return `workspace-header-scroll-frame${this.workspaceHeaderCanScrollLeft ? " can-scroll-left" : ""}${this.workspaceHeaderCanScrollRight ? " can-scroll-right" : ""}`;
|
||||
}
|
||||
|
||||
private observeWorkspaceHeaderStrip(): void {
|
||||
const strip = this.workspaceHeaderStrip;
|
||||
if (this.observedWorkspaceHeaderStrip === strip) return;
|
||||
this.workspaceHeaderResizeObserver?.disconnect();
|
||||
this.observedWorkspaceHeaderStrip = strip;
|
||||
this.workspaceHeaderResizeObserver = undefined;
|
||||
if (strip === undefined || typeof ResizeObserver === "undefined") return;
|
||||
this.workspaceHeaderResizeObserver = new ResizeObserver(() => {
|
||||
this.updateWorkspaceHeaderScrollState();
|
||||
});
|
||||
this.workspaceHeaderResizeObserver.observe(strip);
|
||||
}
|
||||
|
||||
private updateWorkspaceHeaderScrollState(): void {
|
||||
const strip = this.workspaceHeaderStrip;
|
||||
const maxScrollLeft = strip === undefined ? 0 : Math.max(0, strip.scrollWidth - strip.clientWidth);
|
||||
const canScrollLeft = strip !== undefined && strip.scrollLeft > 1;
|
||||
const canScrollRight = strip !== undefined && maxScrollLeft - strip.scrollLeft > 1;
|
||||
if (this.workspaceHeaderCanScrollLeft !== canScrollLeft) this.workspaceHeaderCanScrollLeft = canScrollLeft;
|
||||
if (this.workspaceHeaderCanScrollRight !== canScrollRight) this.workspaceHeaderCanScrollRight = canScrollRight;
|
||||
}
|
||||
|
||||
private createPanelContext(workspace: Workspace): WorkspacePanelContext {
|
||||
return {
|
||||
workspace,
|
||||
|
||||
@@ -57,7 +57,13 @@ export const appStyles = css`
|
||||
project-list, workspace-list { flex: 0 0 auto; max-height: 26%; overflow: auto; border-bottom: 1px solid var(--pi-border-muted); }
|
||||
session-list { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
||||
main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||
.mobile-tabs { display: none; flex: 0 0 auto; gap: 6px; padding: 8px; border-bottom: 1px solid var(--pi-border); overflow-x: auto; }
|
||||
.mobile-tabs-frame { position: relative; display: none; flex: 0 0 auto; min-width: 0; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg); }
|
||||
.mobile-tabs-frame::before, .mobile-tabs-frame::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
|
||||
.mobile-tabs-frame::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
|
||||
.mobile-tabs-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
|
||||
.mobile-tabs-frame.can-scroll-left::before, .mobile-tabs-frame.can-scroll-right::after { opacity: 1; }
|
||||
.mobile-tabs { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; -webkit-overflow-scrolling: touch; }
|
||||
.mobile-tabs button { flex: 0 0 auto; white-space: nowrap; }
|
||||
.mobile-navigation-tab, .mobile-navigation-panel { display: none; }
|
||||
.mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
.tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
|
||||
@@ -66,7 +72,7 @@ export const appStyles = css`
|
||||
.shell { grid-template-columns: 340px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
|
||||
aside { grid-row: 1 / 3; }
|
||||
main { grid-column: 2; grid-row: 1 / 3; }
|
||||
.mobile-tabs { display: flex; }
|
||||
.mobile-tabs-frame { display: flex; }
|
||||
.shell.workspace-view main { grid-row: 1; min-height: auto; }
|
||||
.shell.workspace-view > workspace-panel { grid-column: 2; grid-row: 2; display: flex; border-left: 0; }
|
||||
.shell:not(.workspace-view) > workspace-panel { display: none; }
|
||||
@@ -99,14 +105,23 @@ export const appStyles = css`
|
||||
|
||||
export const workspacePanelStyles = css`
|
||||
:host { display: flex; flex-direction: column; min-height: 0; color: var(--pi-text); background: var(--pi-bg); font: 13px system-ui, sans-serif; }
|
||||
header { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid var(--pi-border); }
|
||||
.tabs { display: flex; gap: 6px; }
|
||||
header { flex: 0 0 auto; min-width: 0; border-bottom: 1px solid var(--pi-border); }
|
||||
.workspace-header-scroll-frame { position: relative; min-width: 0; background: var(--pi-bg); }
|
||||
.workspace-header-scroll-frame::before, .workspace-header-scroll-frame::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 18px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
|
||||
.workspace-header-scroll-frame::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
|
||||
.workspace-header-scroll-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
|
||||
.workspace-header-scroll-frame.can-scroll-left::before, .workspace-header-scroll-frame.can-scroll-right::after { opacity: 1; }
|
||||
.workspace-header-strip { display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; -webkit-overflow-scrolling: touch; }
|
||||
.tabs { flex: 0 0 auto; display: flex; gap: 6px; }
|
||||
.tabs button { flex: 0 0 auto; white-space: nowrap; }
|
||||
button { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; }
|
||||
button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
.tab-badge { display: inline-block; min-width: 14px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
|
||||
.panel-content { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
small, .muted { color: var(--pi-muted); }
|
||||
header small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
header small { flex: 0 0 auto; min-width: max-content; overflow: visible; text-overflow: clip; white-space: nowrap; }
|
||||
header .workspace-label { width: max-content; max-width: none; overflow: visible; }
|
||||
header .workspace-label-base, header .workspace-label-item, header .workspace-label-render { overflow: visible; text-overflow: clip; }
|
||||
@media (max-width: 1180px) { .tabs { display: none; } }
|
||||
.workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; }
|
||||
.workspace-label-base, .workspace-label-item, .workspace-label-render { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
Reference in New Issue
Block a user