From 71ce2fb09d325b5f0c3b47a8c9f0c25e57398c5c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 19 May 2026 10:13:39 +0200 Subject: [PATCH] fix: make workspace navigation bars scrollable --- .changeset/horizontal-workspace-tabs.md | 5 ++ src/client/src/components/PiWebApp.ts | 62 ++++++++++++++++-- src/client/src/components/WorkspacePanel.ts | 72 ++++++++++++++++++--- src/client/src/components/shared.ts | 25 +++++-- 4 files changed, 145 insertions(+), 19 deletions(-) create mode 100644 .changeset/horizontal-workspace-tabs.md diff --git a/.changeset/horizontal-workspace-tabs.md b/.changeset/horizontal-workspace-tabs.md new file mode 100644 index 0000000..65c8bc7 --- /dev/null +++ b/.changeset/horizontal-workspace-tabs.md @@ -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. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 90deec0..1cb57fd 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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(); 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) { 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`
-
- - - ${this.visibleWorkspacePanels().map((panel) => html` - - `)} +
+
+ + + ${this.visibleWorkspacePanels().map((panel) => html` + + `)} +
${state.error ? html`
${state.error}
` : null}
${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}
diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 3d17b68..8403a8f 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -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`
- ${this.hideToolTabs ? null : html` -
- ${visiblePanels.map((panel) => html` - - `)} +
+
+ ${this.hideToolTabs ? null : html` +
+ ${visiblePanels.map((panel) => html` + + `)} +
+ `} + ${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)}
- `} - ${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)} +
${selectedPanel === undefined ? html`
No workspace panels registered.
` : html`
@@ -64,6 +94,32 @@ export class WorkspacePanel extends LitElement { return html`${panel.title} ${badge}`; } + 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, diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index d60987f..6f69368 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; }