diff --git a/src/client/src/appShell/appShellController.ts b/src/client/src/appShell/appShellController.ts new file mode 100644 index 0000000..b726684 --- /dev/null +++ b/src/client/src/appShell/appShellController.ts @@ -0,0 +1,82 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; +import type { AppState } from "../appState"; +import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMode"; +import { ViewportPositionRepairer } from "./viewportPositionRepair"; + +export const MOBILE_NAVIGATION_MEDIA_QUERY = "(max-width: 760px)"; + +export interface AppShellControllerOptions { + mobileNavigationMedia?: MediaQueryList | undefined; + pwaDisplayModeMedia?: MediaQueryList[] | undefined; + viewportPositionRepairer?: ViewportPositionRepairer | undefined; +} + +export class AppShellController implements ReactiveController { + private readonly mobileNavigationMedia: MediaQueryList | undefined; + private readonly pwaDisplayModeMedia: MediaQueryList[]; + private readonly viewportPositionRepairer: ViewportPositionRepairer; + isMobileNavigationLayout: boolean; + isPwaDisplayMode: boolean; + + constructor(private readonly host: ReactiveControllerHost, options: AppShellControllerOptions = {}) { + host.addController(this); + this.mobileNavigationMedia = options.mobileNavigationMedia ?? createMobileNavigationMedia(); + this.pwaDisplayModeMedia = options.pwaDisplayModeMedia ?? createPwaDisplayModeMedia(); + this.viewportPositionRepairer = options.viewportPositionRepairer ?? new ViewportPositionRepairer(); + this.isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false; + this.isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia); + } + + hostConnected(): void { + this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange); + for (const media of this.pwaDisplayModeMedia) media.addEventListener("change", this.onPwaDisplayModeChange); + } + + hostDisconnected(): void { + this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange); + for (const media of this.pwaDisplayModeMedia) media.removeEventListener("change", this.onPwaDisplayModeChange); + this.viewportPositionRepairer.clear(); + } + + shouldAutoFocusPrompt(): boolean { + return !this.isMobileNavigationLayout && !this.isPwaDisplayMode; + } + + shouldShowAppRefreshInHeader(): boolean { + return this.isPwaDisplayMode && !this.isMobileNavigationLayout; + } + + shouldShowAppRefreshInContextBar(): boolean { + return this.isPwaDisplayMode && this.isMobileNavigationLayout; + } + + defaultRouteView(): AppState["mainView"] { + return this.isMobileNavigationLayout ? "navigation" : "chat"; + } + + repairViewportPosition(): void { + this.viewportPositionRepairer.repair(this.shouldRepairViewportPosition()); + } + + private shouldRepairViewportPosition(): boolean { + return this.isMobileNavigationLayout || this.isPwaDisplayMode; + } + + private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => { + if (this.isMobileNavigationLayout === event.matches) return; + this.isMobileNavigationLayout = event.matches; + this.host.requestUpdate(); + }; + + private readonly onPwaDisplayModeChange = () => { + const isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia); + if (this.isPwaDisplayMode === isPwaDisplayMode) return; + this.isPwaDisplayMode = isPwaDisplayMode; + this.host.requestUpdate(); + }; +} + +function createMobileNavigationMedia(): MediaQueryList | undefined { + if (typeof window === "undefined" || !("matchMedia" in window)) return undefined; + return window.matchMedia(MOBILE_NAVIGATION_MEDIA_QUERY); +} diff --git a/src/client/src/appShell/navigationState.test.ts b/src/client/src/appShell/navigationState.test.ts new file mode 100644 index 0000000..d90669e --- /dev/null +++ b/src/client/src/appShell/navigationState.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { defaultNavigationSection, expandedNavigationSection, isNavigationSectionCollapsed, toggleNavigationSection } from "./navigationState"; + +describe("navigationState", () => { + it("defaults to the first incomplete selection section", () => { + expect(defaultNavigationSection({ selectedProject: undefined, selectedWorkspace: undefined })).toBe("projects"); + expect(defaultNavigationSection({ selectedProject: {}, selectedWorkspace: undefined })).toBe("workspaces"); + expect(defaultNavigationSection({ selectedProject: {}, selectedWorkspace: {} })).toBe("sessions"); + }); + + it("expands the default section until the user explicitly toggles a section", () => { + const state = { selectedProject: {}, selectedWorkspace: undefined }; + + expect(expandedNavigationSection(undefined, state)).toBe("workspaces"); + expect(expandedNavigationSection("sessions", state)).toBe("sessions"); + expect(expandedNavigationSection("none", state)).toBeUndefined(); + }); + + it("only collapses sections in mobile navigation layouts", () => { + const state = { selectedProject: {}, selectedWorkspace: {} }; + + expect(isNavigationSectionCollapsed("projects", { isMobileLayout: false, expanded: "sessions", state })).toBe(false); + expect(isNavigationSectionCollapsed("projects", { isMobileLayout: true, expanded: "sessions", state })).toBe(true); + expect(isNavigationSectionCollapsed("sessions", { isMobileLayout: true, expanded: "sessions", state })).toBe(false); + }); + + it("toggles the effective section, including the implicit default section", () => { + const state = { selectedProject: undefined, selectedWorkspace: undefined }; + + expect(toggleNavigationSection(undefined, "projects", { isMobileLayout: true, state })).toBe("none"); + expect(toggleNavigationSection("none", "projects", { isMobileLayout: true, state })).toBe("projects"); + expect(toggleNavigationSection("projects", "workspaces", { isMobileLayout: true, state })).toBe("workspaces"); + }); + + it("does not mutate expanded section on desktop layouts", () => { + const state = { selectedProject: undefined, selectedWorkspace: undefined }; + + expect(toggleNavigationSection("projects", "projects", { isMobileLayout: false, state })).toBe("projects"); + }); +}); diff --git a/src/client/src/appShell/navigationState.ts b/src/client/src/appShell/navigationState.ts new file mode 100644 index 0000000..6eaff83 --- /dev/null +++ b/src/client/src/appShell/navigationState.ts @@ -0,0 +1,81 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; + +export type NavigationSection = "projects" | "workspaces" | "sessions"; +export type ExpandedNavigationSection = NavigationSection | "none" | undefined; + +export interface NavigationSelectionState { + selectedProject: object | undefined; + selectedWorkspace: object | undefined; +} + +export function defaultNavigationSection(state: NavigationSelectionState): NavigationSection { + if (state.selectedProject === undefined) return "projects"; + if (state.selectedWorkspace === undefined) return "workspaces"; + return "sessions"; +} + +export function expandedNavigationSection(expanded: ExpandedNavigationSection, state: NavigationSelectionState): NavigationSection | undefined { + if (expanded === "none") return undefined; + return expanded ?? defaultNavigationSection(state); +} + +export function isNavigationSectionCollapsed(section: NavigationSection, options: { isMobileLayout: boolean; expanded: ExpandedNavigationSection; state: NavigationSelectionState }): boolean { + return options.isMobileLayout && expandedNavigationSection(options.expanded, options.state) !== section; +} + +export function toggleNavigationSection(expanded: ExpandedNavigationSection, section: NavigationSection, options: { isMobileLayout: boolean; state: NavigationSelectionState }): ExpandedNavigationSection { + if (!options.isMobileLayout) return expanded; + return expandedNavigationSection(expanded, options.state) === section ? "none" : section; +} + +export function expandNavigationSection(expanded: ExpandedNavigationSection, section: NavigationSection, isMobileLayout: boolean): ExpandedNavigationSection { + return isMobileLayout ? section : expanded; +} + +export class MobileNavigationController implements ReactiveController { + private expanded: ExpandedNavigationSection; + + hostConnected(): void { + return; + } + + constructor( + private readonly host: ReactiveControllerHost, + private readonly getState: () => NavigationSelectionState, + private readonly isMobileLayout: () => boolean, + ) { + host.addController(this); + } + + expandedSection(): NavigationSection | undefined { + return expandedNavigationSection(this.expanded, this.getState()); + } + + isCollapsed(section: NavigationSection): boolean { + return isNavigationSectionCollapsed(section, { + isMobileLayout: this.isMobileLayout(), + expanded: this.expanded, + state: this.getState(), + }); + } + + toggle(section: NavigationSection): void { + this.setExpanded(toggleNavigationSection(this.expanded, section, { isMobileLayout: this.isMobileLayout(), state: this.getState() })); + } + + expand(section: NavigationSection): void { + this.setExpanded(expandNavigationSection(this.expanded, section, this.isMobileLayout())); + } + + open(section: NavigationSection, openNavigationView: () => void): void { + if (!this.isMobileLayout()) return; + this.expand(section); + openNavigationView(); + } + + private setExpanded(expanded: ExpandedNavigationSection): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + this.host.requestUpdate(); + } +} diff --git a/src/client/src/appShell/panelCollapseController.ts b/src/client/src/appShell/panelCollapseController.ts new file mode 100644 index 0000000..378563d --- /dev/null +++ b/src/client/src/appShell/panelCollapseController.ts @@ -0,0 +1,40 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; +import type { AppState } from "../appState"; + +export class PanelCollapseController implements ReactiveController { + navigationPanelCollapsed = false; + workspacePanelCollapsed = false; + + hostConnected(): void { + return; + } + + constructor(private readonly host: ReactiveControllerHost) { + host.addController(this); + } + + toggleNavigationPanel(): void { + this.navigationPanelCollapsed = !this.navigationPanelCollapsed; + this.host.requestUpdate(); + } + + toggleWorkspacePanel(): void { + this.workspacePanelCollapsed = !this.workspacePanelCollapsed; + this.host.requestUpdate(); + } + + shellClass(mainView: AppState["mainView"]): string { + return [ + "shell", + mainViewClass(mainView), + ...(this.navigationPanelCollapsed ? ["navigation-panel-collapsed"] : []), + ...(this.workspacePanelCollapsed ? ["workspace-panel-collapsed"] : []), + ].join(" "); + } +} + +export function mainViewClass(mainView: AppState["mainView"]): "navigation-view" | "chat-view" | "workspace-view" { + if (mainView === "navigation") return "navigation-view"; + if (mainView === "chat") return "chat-view"; + return "workspace-view"; +} diff --git a/src/client/src/appShell/viewportPositionRepair.test.ts b/src/client/src/appShell/viewportPositionRepair.test.ts new file mode 100644 index 0000000..588ad69 --- /dev/null +++ b/src/client/src/appShell/viewportPositionRepair.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { VIEWPORT_POSITION_REPAIR_DELAY_MS, ViewportPositionRepairer, type ViewportPositionRepairScheduler } from "./viewportPositionRepair"; + +class FakeViewportScheduler implements ViewportPositionRepairScheduler { + documentElement = { scrollTop: 12 }; + body = { scrollTop: 34 }; + scrollCalls: [number, number][] = []; + animationFrames = new Map void>(); + timers = new Map void; delayMs: number }>(); + canceledAnimationFrames: number[] = []; + clearedTimers: number[] = []; + private nextId = 1; + + requestAnimationFrame(callback: () => void): number { + const id = this.nextId; + this.nextId += 1; + this.animationFrames.set(id, callback); + return id; + } + + cancelAnimationFrame(id: number): void { + this.canceledAnimationFrames.push(id); + this.animationFrames.delete(id); + } + + setTimeout(callback: () => void, delayMs: number): number { + const id = this.nextId; + this.nextId += 1; + this.timers.set(id, { callback, delayMs }); + return id; + } + + clearTimeout(id: number): void { + this.clearedTimers.push(id); + this.timers.delete(id); + } + + scrollTo(x: number, y: number): void { + this.scrollCalls.push([x, y]); + } + + runAnimationFrame(id: number): void { + const callback = this.animationFrames.get(id); + if (callback === undefined) throw new Error(`Animation frame ${String(id)} not scheduled`); + this.animationFrames.delete(id); + callback(); + } + + runTimer(id: number): void { + const timer = this.timers.get(id); + if (timer === undefined) throw new Error(`Timer ${String(id)} not scheduled`); + this.timers.delete(id); + timer.callback(); + } +} + +function firstMapKey(map: Map): K { + const key = map.keys().next().value; + if (key === undefined) throw new Error("Expected map to have a key"); + return key; +} + +function firstMapEntry(map: Map): [K, V] { + const entry = map.entries().next().value; + if (entry === undefined) throw new Error("Expected map to have an entry"); + return entry; +} + +describe("ViewportPositionRepairer", () => { + it("resets viewport position immediately, across two animation frames, and on a delayed timer", () => { + const scheduler = new FakeViewportScheduler(); + const repairer = new ViewportPositionRepairer(scheduler); + + repairer.repair(true); + + expect(scheduler.scrollCalls).toEqual([[0, 0]]); + expect(scheduler.documentElement.scrollTop).toBe(0); + expect(scheduler.body.scrollTop).toBe(0); + const firstFrame = firstMapKey(scheduler.animationFrames); + const timer = firstMapEntry(scheduler.timers); + expect(timer[1].delayMs).toBe(VIEWPORT_POSITION_REPAIR_DELAY_MS); + + scheduler.runAnimationFrame(firstFrame); + expect(scheduler.scrollCalls).toHaveLength(2); + const secondFrame = firstMapKey(scheduler.animationFrames); + + scheduler.runAnimationFrame(secondFrame); + expect(scheduler.scrollCalls).toHaveLength(3); + + scheduler.runTimer(timer[0]); + expect(scheduler.scrollCalls).toHaveLength(4); + }); + + it("replaces pending scheduled repairs", () => { + const scheduler = new FakeViewportScheduler(); + const repairer = new ViewportPositionRepairer(scheduler); + + repairer.repair(true); + const firstFrame = firstMapKey(scheduler.animationFrames); + const firstTimer = firstMapKey(scheduler.timers); + repairer.repair(true); + + expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]); + expect(scheduler.clearedTimers).toEqual([firstTimer]); + }); + + it("clears pending work when repair is no longer needed", () => { + const scheduler = new FakeViewportScheduler(); + const repairer = new ViewportPositionRepairer(scheduler); + + repairer.repair(true); + const firstFrame = firstMapKey(scheduler.animationFrames); + const firstTimer = firstMapKey(scheduler.timers); + repairer.repair(false); + + expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]); + expect(scheduler.clearedTimers).toEqual([firstTimer]); + }); +}); diff --git a/src/client/src/appShell/viewportPositionRepair.ts b/src/client/src/appShell/viewportPositionRepair.ts new file mode 100644 index 0000000..d098c22 --- /dev/null +++ b/src/client/src/appShell/viewportPositionRepair.ts @@ -0,0 +1,87 @@ +export const VIEWPORT_POSITION_REPAIR_DELAY_MS = 250; + +export interface ViewportPositionRepairScheduler { + requestAnimationFrame(callback: () => void): number; + cancelAnimationFrame(id: number): void; + setTimeout(callback: () => void, delayMs: number): number; + clearTimeout(id: number): void; + scrollTo(x: number, y: number): void; + readonly documentElement: { scrollTop: number } | undefined; + readonly body: { scrollTop: number } | undefined; +} + +export class ViewportPositionRepairer { + private repairFrame: number | undefined; + private repairTimer: number | undefined; + + constructor(private readonly scheduler: ViewportPositionRepairScheduler = createBrowserViewportPositionRepairScheduler()) {} + + repair(shouldRepair: boolean): void { + if (!shouldRepair) { + this.clear(); + return; + } + + this.resetViewportScroll(); + if (this.repairFrame !== undefined) this.scheduler.cancelAnimationFrame(this.repairFrame); + this.repairFrame = this.scheduler.requestAnimationFrame(() => { + this.repairFrame = undefined; + this.resetViewportScroll(); + this.repairFrame = this.scheduler.requestAnimationFrame(() => { + this.repairFrame = undefined; + this.resetViewportScroll(); + }); + }); + + if (this.repairTimer !== undefined) this.scheduler.clearTimeout(this.repairTimer); + this.repairTimer = this.scheduler.setTimeout(() => { + this.repairTimer = undefined; + this.resetViewportScroll(); + }, VIEWPORT_POSITION_REPAIR_DELAY_MS); + } + + clear(): void { + if (this.repairFrame !== undefined) { + this.scheduler.cancelAnimationFrame(this.repairFrame); + this.repairFrame = undefined; + } + if (this.repairTimer !== undefined) { + this.scheduler.clearTimeout(this.repairTimer); + this.repairTimer = undefined; + } + } + + private resetViewportScroll(): void { + this.scheduler.scrollTo(0, 0); + const documentElement = this.scheduler.documentElement; + if (documentElement !== undefined) documentElement.scrollTop = 0; + const body = this.scheduler.body; + if (body !== undefined) body.scrollTop = 0; + } +} + +export function createBrowserViewportPositionRepairScheduler(): ViewportPositionRepairScheduler { + return { + requestAnimationFrame(callback: () => void): number { + return window.requestAnimationFrame(callback); + }, + cancelAnimationFrame(id: number): void { + window.cancelAnimationFrame(id); + }, + setTimeout(callback: () => void, delayMs: number): number { + return window.setTimeout(callback, delayMs); + }, + clearTimeout(id: number): void { + window.clearTimeout(id); + }, + scrollTo(x: number, y: number): void { + window.scrollTo(x, y); + }, + get documentElement(): { scrollTop: number } | undefined { + return typeof document === "undefined" ? undefined : document.documentElement; + }, + get body(): { scrollTop: number } | undefined { + return typeof document === "undefined" ? undefined : document.body; + }, + }; +} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index be43dd9..f456464 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -21,7 +21,9 @@ import { themePackPlugin } from "../plugins/themes"; import { loadExternalPlugins } from "../plugins/external"; import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry"; import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs"; -import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMode"; +import { AppShellController } from "../appShell/appShellController"; +import { MobileNavigationController, type NavigationSection } from "../appShell/navigationState"; +import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController"; import { readRoute, writeRoute, type AppRoute } from "../route"; import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime"; import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion"; @@ -39,30 +41,26 @@ import "./AuthDialog"; import "./ProjectDialog"; import "./WorkspacePanel"; import type { WorkspacePanelEmptyState } from "./WorkspacePanel"; -import { actionMenuPanelStyle } from "./actionMenu"; +import "./appShell/AppContextBar"; +import "./appShell/AppMobileMainTabs"; +import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs"; +import "./appShell/AppNavigationPanel"; +import "./appShell/AppPanelEdgeControl"; +import "./appShell/AppRefreshControl"; import { appStyles } from "./shared"; -type NavigationSection = "projects" | "workspaces" | "sessions"; - const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000; const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const; const THEME_AUTO_ON_VALUE = "auto:on"; const THEME_AUTO_OFF_VALUE = "auto:off"; const THEME_OPTION_PREFIX = "theme:"; const TERMINAL_ROUTE_NAMESPACE = queryNamespace("core:workspace.terminal"); -const REFRESH_LONG_PRESS_MS = 550; -const VIEWPORT_POSITION_REPAIR_DELAY_MS = 250; @customElement("pi-web-app") export class PiWebApp extends LitElement { @state() private state: AppState = initialAppState(); - @state() private navigationPanelCollapsed = false; - @state() private workspacePanelCollapsed = false; @query("chat-view") private chatView?: ChatView; @query("prompt-editor") private promptEditor?: PromptEditor; - @query(".context-items") private contextItems?: HTMLElement | null; - @query(".mobile-tabs") private mobileTabs?: HTMLElement | null; - @query(".app-refresh") private appRefresh?: HTMLElement | null; private readonly sessions = new SessionController( () => this.state, @@ -103,13 +101,14 @@ export class PiWebApp extends LitElement { private readonly realtime = new RealtimeSocket(); private readonly activeTerminalIds = new Set(); private readonly terminalSelection = new InMemoryTerminalSelectionMemory(); - private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined; + private readonly appShell = new AppShellController(this); + private readonly panelCollapse = new PanelCollapseController(this); + private readonly mobileNavigation = new MobileNavigationController( + this, + () => this.state, + () => this.appShell.isMobileNavigationLayout, + ); private readonly systemLightThemeMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(prefers-color-scheme: light)") : undefined; - private readonly pwaDisplayModeMedia = createPwaDisplayModeMedia(); - private observedContextItems: HTMLElement | undefined; - private observedMobileTabs: HTMLElement | undefined; - private contextItemsResizeObserver: ResizeObserver | undefined; - private mobileTabsResizeObserver: ResizeObserver | undefined; private terminalAutoStartWorkspaceId: string | undefined; private piWebStatusTimer: number | undefined; private workspaceDeletionPollTimer: number | undefined; @@ -120,27 +119,14 @@ export class PiWebApp extends LitElement { private restoringRouteTerminalId: string | undefined; private readonly plugins = createPluginRegistry(); private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE; - private refreshLongPressTimer: number | undefined; - private suppressNextRefreshClick = false; - private viewportPositionRepairFrame: number | undefined; - private viewportPositionRepairTimer: number | undefined; @state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID; - @state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false; - @state() private isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia); @state() private isRefreshingApp = false; - @state() private refreshMenuOpen = false; - @state() private refreshMenuStyle = ""; - @state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined; - @state() private contextCanScrollLeft = false; - @state() private contextCanScrollRight = false; - @state() private mobileTabsCanScrollLeft = false; - @state() private mobileTabsCanScrollRight = false; private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); private readonly onPageShow = () => { - this.repairViewportPosition(); + this.appShell.repairViewportPosition(); }; private readonly onFocus = () => { - this.repairViewportPosition(); + this.appShell.repairViewportPosition(); void this.sessions.refreshSelectedSession(); void this.refreshPiWebStatus(); void this.refreshWorkspaceActivity(); @@ -148,44 +134,17 @@ export class PiWebApp extends LitElement { }; private readonly onVisibilityChange = () => { if (document.visibilityState === "visible") { - this.repairViewportPosition(); + this.appShell.repairViewportPosition(); void this.sessions.refreshSelectedSession(); void this.refreshPiWebStatus(); void this.refreshWorkspaceActivity(); void this.refreshWorkspaceDeletionRuns(); } }; - private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => { - this.isMobileNavigationLayout = event.matches; - this.updateContextScrollState(); - this.updateMobileTabsScrollState(); - }; private readonly onSystemLightThemeChange = () => { if (this.themePreference.auto) this.applyPreferredTheme(false); }; - private readonly onPwaDisplayModeChange = () => { - this.isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia); - }; - private readonly onContextScroll = () => { - this.updateContextScrollState(); - }; - private readonly onMobileTabsScroll = () => { - this.updateMobileTabsScrollState(); - }; - private readonly onDocumentClick = (event: MouseEvent) => { - const refresh = this.appRefreshElement(); - if (refresh !== undefined && event.composedPath().includes(refresh)) return; - this.refreshMenuOpen = false; - this.suppressNextRefreshClick = false; - }; private readonly onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && this.refreshMenuOpen) { - event.preventDefault(); - event.stopPropagation(); - this.refreshMenuOpen = false; - this.suppressNextRefreshClick = false; - return; - } if (this.keyboard.handle(event, this.getActions())) { event.preventDefault(); event.stopPropagation(); @@ -197,12 +156,9 @@ export class PiWebApp extends LitElement { window.addEventListener("popstate", this.onPopState); window.addEventListener("pageshow", this.onPageShow); window.addEventListener("focus", this.onFocus); - document.addEventListener("click", this.onDocumentClick); document.addEventListener("visibilitychange", this.onVisibilityChange); window.addEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS); - this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange); this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange); - for (const media of this.pwaDisplayModeMedia) media.addEventListener("change", this.onPwaDisplayModeChange); this.applyPreferredTheme(false); this.connectRealtime(); this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS); @@ -216,12 +172,9 @@ export class PiWebApp extends LitElement { window.removeEventListener("popstate", this.onPopState); window.removeEventListener("pageshow", this.onPageShow); window.removeEventListener("focus", this.onFocus); - document.removeEventListener("click", this.onDocumentClick); document.removeEventListener("visibilitychange", this.onVisibilityChange); window.removeEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS); - this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange); this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange); - for (const media of this.pwaDisplayModeMedia) media.removeEventListener("change", this.onPwaDisplayModeChange); this.keyboard.reset(); this.auth.dispose(); this.sessions.dispose(); @@ -231,31 +184,9 @@ export class PiWebApp extends LitElement { this.piWebStatusTimer = undefined; if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer); this.workspaceDeletionPollTimer = undefined; - this.contextItemsResizeObserver?.disconnect(); - this.contextItemsResizeObserver = undefined; - this.observedContextItems = undefined; - this.mobileTabsResizeObserver?.disconnect(); - this.mobileTabsResizeObserver = undefined; - this.observedMobileTabs = undefined; - this.clearRefreshLongPressTimer(); - this.clearViewportPositionRepair(); super.disconnectedCallback(); } - override firstUpdated(): void { - this.observeContextItems(); - this.observeMobileTabs(); - this.updateContextScrollState(); - this.updateMobileTabsScrollState(); - } - - override updated(): void { - this.observeContextItems(); - this.observeMobileTabs(); - this.updateContextScrollState(); - this.updateMobileTabsScrollState(); - } - private setState(patch: Partial) { if (!patchChangesState(this.state, patch)) return; const previous = this.state; @@ -288,8 +219,6 @@ export class PiWebApp extends LitElement { private async refreshAppData(): Promise { if (this.isRefreshingApp) return; - this.refreshMenuOpen = false; - this.suppressNextRefreshClick = false; this.isRefreshingApp = true; try { await Promise.all([ @@ -370,47 +299,7 @@ export class PiWebApp extends LitElement { } private shouldAutoFocusPrompt(): boolean { - return !this.isMobileNavigationLayout && !this.isPwaDisplayMode; - } - - private repairViewportPosition(): void { - if (!this.shouldRepairViewportPosition()) return; - this.resetViewportScroll(); - if (this.viewportPositionRepairFrame !== undefined) window.cancelAnimationFrame(this.viewportPositionRepairFrame); - this.viewportPositionRepairFrame = window.requestAnimationFrame(() => { - this.viewportPositionRepairFrame = undefined; - this.resetViewportScroll(); - this.viewportPositionRepairFrame = window.requestAnimationFrame(() => { - this.viewportPositionRepairFrame = undefined; - this.resetViewportScroll(); - }); - }); - if (this.viewportPositionRepairTimer !== undefined) window.clearTimeout(this.viewportPositionRepairTimer); - this.viewportPositionRepairTimer = window.setTimeout(() => { - this.viewportPositionRepairTimer = undefined; - this.resetViewportScroll(); - }, VIEWPORT_POSITION_REPAIR_DELAY_MS); - } - - private shouldRepairViewportPosition(): boolean { - return this.isMobileNavigationLayout || this.isPwaDisplayMode; - } - - private resetViewportScroll(): void { - window.scrollTo(0, 0); - document.documentElement.scrollTop = 0; - document.body.scrollTop = 0; - } - - private clearViewportPositionRepair(): void { - if (this.viewportPositionRepairFrame !== undefined) { - window.cancelAnimationFrame(this.viewportPositionRepairFrame); - this.viewportPositionRepairFrame = undefined; - } - if (this.viewportPositionRepairTimer !== undefined) { - window.clearTimeout(this.viewportPositionRepairTimer); - this.viewportPositionRepairTimer = undefined; - } + return this.appShell.shouldAutoFocusPrompt(); } private async withChatPrependTransition(action: () => Promise) { @@ -420,7 +309,7 @@ export class PiWebApp extends LitElement { } private defaultRouteView(): AppState["mainView"] { - return this.isMobileNavigationLayout ? "navigation" : "chat"; + return this.appShell.defaultRouteView(); } private updateUrl(options?: { replace?: boolean | undefined }) { @@ -582,63 +471,32 @@ export class PiWebApp extends LitElement { `; } - private toggleNavigationPanelCollapse(): void { - this.navigationPanelCollapsed = !this.navigationPanelCollapsed; - } - private renderNavigationPanelEdgeControl() { - const collapsed = this.navigationPanelCollapsed; - const label = collapsed ? "Expand navigation panel" : "Collapse navigation panel"; return html` - + { this.panelCollapse.toggleNavigationPanel(); }} + > `; } - private renderNavigationPanelEdgeIcon(collapsed: boolean) { - return this.renderPanelEdgeIcon(collapsed ? "right" : "left", "navigation-panel-edge-icon"); - } - - private toggleWorkspacePanelCollapse(): void { - this.workspacePanelCollapsed = !this.workspacePanelCollapsed; - } - private renderWorkspacePanelEdgeControl() { - const collapsed = this.workspacePanelCollapsed; - const label = collapsed ? "Expand workspace panel" : "Collapse workspace panel"; return html` -
- -
+ { this.panelCollapse.toggleWorkspacePanel(); }} + > `; } - private renderWorkspacePanelEdgeIcon(collapsed: boolean) { - return this.renderPanelEdgeIcon(collapsed ? "left" : "right", "workspace-panel-edge-icon"); - } - - private renderPanelEdgeIcon(direction: "left" | "right", className: string) { - const path = direction === "left" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6"; - return html``; - } - private renderNavigationPanel(autoSwitchToChat: boolean) { const openChatAfter = (action: () => Promise) => this.withChatScrollTransition(async () => { await action(); @@ -646,91 +504,53 @@ export class PiWebApp extends LitElement { if (autoSwitchToChat) this.updateUrl(); }); return html` -
- PI WEB -
- ${this.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : null} - -
-
- { this.toggleNavigationSection("projects"); }} - .onSelect=${(project: Project) => this.withChatScrollTransition(async () => { - this.expandNavigationSection("workspaces"); + .workspaces=${this.state.workspaces} + .selectedWorkspace=${this.state.selectedWorkspace} + .deletingWorkspaceIds=${pendingWorkspaceDeletionIds(this.state.workspaceDeletionRuns)} + .sessions=${this.state.sessions} + .sessionStatuses=${this.state.sessionStatuses} + .sessionActivities=${this.state.sessionActivities} + .selectedSession=${this.state.selectedSession} + .canStartSession=${!!this.state.selectedWorkspace} + .collapsible=${this.appShell.isMobileNavigationLayout} + .projectsCollapsed=${this.mobileNavigation.isCollapsed("projects")} + .workspacesCollapsed=${this.mobileNavigation.isCollapsed("workspaces")} + .sessionsCollapsed=${this.mobileNavigation.isCollapsed("sessions")} + .workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)} + .refreshControl=${this.appShell.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : undefined} + .onShowActions=${() => { this.setState({ actionPaletteOpen: true }); }} + .onToggleProjects=${() => { this.mobileNavigation.toggle("projects"); }} + .onToggleWorkspaces=${() => { this.mobileNavigation.toggle("workspaces"); }} + .onToggleSessions=${() => { this.mobileNavigation.toggle("sessions"); }} + .onSelectProject=${(project: Project) => this.withChatScrollTransition(async () => { + this.mobileNavigation.expand("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"); + .onCloseProject=${(project: Project) => this.projects.closeProject(project.id)} + .onSelectWorkspace=${(workspace: Workspace) => this.withChatScrollTransition(async () => { + this.mobileNavigation.expand("sessions"); await this.workspaces.selectWorkspace(workspace); })} - .onDelete=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }} - > - { this.toggleNavigationSection("sessions"); }} + .onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }} .onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }} - .onStart=${() => openChatAfter(() => this.sessions.startSession())} - .onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} - .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} - .onArchiveWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)} - .onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))} - .onDelete=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)} - .onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)} - > + .onStartSession=${() => openChatAfter(() => this.sessions.startSession())} + .onSelectSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} + .onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)} + .onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)} + .onRestoreSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))} + .onDeleteCachedNewSession=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)} + .onDetachParentSession=${(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 openNavigationSection(section: NavigationSection): void { - if (!this.isMobileNavigationLayout) return; - this.expandNavigationSection(section); - this.selectMainView("navigation"); + this.mobileNavigation.open(section, () => { this.selectMainView("navigation"); }); } private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] { @@ -1117,223 +937,51 @@ export class PiWebApp extends LitElement { } private renderContextBar() { - const project = this.state.selectedProject; - const workspace = this.state.selectedWorkspace; - const session = this.state.selectedSession; - const projectLabel = projectContextLabel(project); - const showRefresh = this.shouldShowAppRefreshInContextBar(); - const workspaceLabel = workspaceContextLabel(workspace); - const sessionLabel = sessionContextLabel(session); + if (!this.appShell.isMobileNavigationLayout) return null; return html` - + { this.openNavigationSection(section); }} + > `; } - private contextBarClass(): string { - const classes = ["context-bar"]; - if (this.shouldShowAppRefreshInContextBar()) classes.push("has-context-actions"); - if (this.contextCanScrollLeft) classes.push("can-scroll-left"); - if (this.contextCanScrollRight) classes.push("can-scroll-right"); - return classes.join(" "); + private renderMobileMainTabs() { + return html` + { this.selectMainView(view); }} + > + `; } - private shouldShowAppRefreshInHeader(): boolean { - return this.isPwaDisplayMode && !this.isMobileNavigationLayout; - } - - private shouldShowAppRefreshInContextBar(): boolean { - return this.isPwaDisplayMode && this.isMobileNavigationLayout; - } - - private mobileTabsFrameClass(): string { - return `mobile-tabs-frame${this.mobileTabsCanScrollLeft ? " can-scroll-left" : ""}${this.mobileTabsCanScrollRight ? " can-scroll-right" : ""}`; - } - - private observeContextItems(): void { - const contextItems = this.contextItemsElement(); - if (this.observedContextItems === contextItems) return; - this.contextItemsResizeObserver?.disconnect(); - this.observedContextItems = contextItems; - this.contextItemsResizeObserver = undefined; - if (contextItems === undefined || typeof ResizeObserver === "undefined") return; - this.contextItemsResizeObserver = new ResizeObserver(() => { - this.updateContextScrollState(); - }); - this.contextItemsResizeObserver.observe(contextItems); - } - - private updateContextScrollState(): void { - const contextItems = this.contextItemsElement(); - const maxScrollLeft = contextItems === undefined ? 0 : Math.max(0, contextItems.scrollWidth - contextItems.clientWidth); - const canScrollLeft = contextItems !== undefined && contextItems.scrollLeft > 1; - const canScrollRight = contextItems !== undefined && maxScrollLeft - contextItems.scrollLeft > 1; - if (this.contextCanScrollLeft !== canScrollLeft) this.contextCanScrollLeft = canScrollLeft; - if (this.contextCanScrollRight !== canScrollRight) this.contextCanScrollRight = canScrollRight; - } - - private contextItemsElement(): HTMLElement | undefined { - const contextItems = this.contextItems; - return contextItems instanceof HTMLElement ? contextItems : undefined; - } - - private observeMobileTabs(): void { - const mobileTabs = this.mobileTabsElement(); - 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.mobileTabsElement(); - 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; - } - - private mobileTabsElement(): HTMLElement | undefined { - const mobileTabs = this.mobileTabs; - return mobileTabs instanceof HTMLElement ? mobileTabs : undefined; - } - - private appRefreshElement(): HTMLElement | undefined { - const appRefresh = this.appRefresh; - return appRefresh instanceof HTMLElement ? appRefresh : undefined; + private mobileMainTabs(): AppMobileMainTab[] { + return [ + { id: "navigation", label: "Sessions", className: "navigation-tab" }, + { id: "chat", label: "Chat" }, + ...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => ({ id: panel.id, label: this.renderMobilePanelTitle(panel) })), + ]; } private renderAppRefresh() { - const label = this.isRefreshingApp ? "Refreshing app data. Long-press for reload options." : "Refresh app data. Long-press for reload options."; - return html` -
- -
- `; - } - - private renderRefreshMenu() { - if (!this.refreshMenuOpen) return null; - return html` - - `; - } - - private renderRefreshIcon() { - return html` - - `; - } - - private onRefreshClick(event: MouseEvent): void { - event.stopPropagation(); - if (this.suppressNextRefreshClick) { - this.suppressNextRefreshClick = false; - return; - } - void this.refreshAppData(); - } - - private onRefreshPointerDown(event: PointerEvent): void { - if (!event.isPrimary || event.button !== 0) return; - const target = event.currentTarget; - if (!(target instanceof HTMLElement)) return; - this.clearRefreshLongPressTimer(); - this.suppressNextRefreshClick = false; - this.refreshLongPressTimer = window.setTimeout(() => { - this.refreshLongPressTimer = undefined; - this.suppressNextRefreshClick = true; - this.openRefreshMenu(target); - }, REFRESH_LONG_PRESS_MS); - } - - private onRefreshContextMenu(event: MouseEvent): void { - event.preventDefault(); - event.stopPropagation(); - this.clearRefreshLongPressTimer(); - this.suppressNextRefreshClick = true; - this.openRefreshMenu(event.currentTarget); - } - - private openRefreshMenu(target: EventTarget | null): void { - this.refreshMenuStyle = actionMenuPanelStyle(target); - this.refreshMenuOpen = true; - } - - private clearRefreshLongPressTimer(): void { - if (this.refreshLongPressTimer === undefined) return; - window.clearTimeout(this.refreshLongPressTimer); - this.refreshLongPressTimer = undefined; + return html` this.refreshAppData()} .onReload=${() => { this.hardReloadApp(); }}>`; } override render() { const state = this.state; return html` -
- +
+ ${this.renderNavigationPanelEdgeControl()} -
+
${this.renderContextBar()} -
-
- - - ${this.visibleWorkspacePanels().map((panel) => html` - - `)} -
-
+ ${this.renderMobileMainTabs()} ${state.error ? html`
${state.error}
` : null} -
${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}
+
${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}
${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> @@ -1349,7 +997,6 @@ export class PiWebApp extends LitElement { ${state.actionPaletteOpen ? html` { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}>` : null} ${state.projectDialogOpen ? html` this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}>` : null} ${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} - ${this.renderRefreshMenu()}
`; } @@ -1364,32 +1011,6 @@ function createPluginRegistry(): PluginRegistry { return registry; } -function projectContextLabel(project: Project | undefined): string { - return project?.name ?? "No project"; -} - -function projectContextTitle(project: Project | undefined): string { - return project === undefined ? "No project selected" : `${project.name} — ${project.path}`; -} - -function workspaceContextLabel(workspace: Workspace | undefined): string { - return workspace === undefined ? "No workspace" : `${workspace.label}${workspace.isMain ? " · main" : ""} · ${workspace.path}`; -} - -function workspaceContextTitle(workspace: Workspace | undefined): string { - return workspace === undefined ? "No workspace selected" : `${workspace.label}${workspace.isMain ? " · main" : ""} — ${workspace.path}`; -} - -function sessionContextLabel(session: SessionInfo | undefined): string { - const name = session?.name?.trim(); - const firstMessage = session?.firstMessage.trim(); - return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session?.id.slice(0, 8) ?? "No session"; -} - -function sessionContextTitle(session: SessionInfo | undefined): string { - return session === undefined ? "No session selected" : session.path; -} - function patchChangesState(state: AppState, patch: Partial): boolean { return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value); } diff --git a/src/client/src/components/appShell/AppContextBar.ts b/src/client/src/components/appShell/AppContextBar.ts new file mode 100644 index 0000000..45ce6ad --- /dev/null +++ b/src/client/src/components/appShell/AppContextBar.ts @@ -0,0 +1,155 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import type { Project, SessionInfo, Workspace } from "../../api"; +import type { NavigationSection } from "../../appShell/navigationState"; + +@customElement("app-context-bar") +export class AppContextBar extends LitElement { + @property({ attribute: false }) project?: Project; + @property({ attribute: false }) workspace?: Workspace; + @property({ attribute: false }) session?: SessionInfo; + @property({ attribute: false }) refreshControl: unknown; + @property({ attribute: false }) onOpenSection?: (section: NavigationSection) => void; + @query(".context-items") private contextItems?: HTMLElement | null; + @state() private canScrollLeft = false; + @state() private canScrollRight = false; + private observedContextItems: HTMLElement | undefined; + private contextItemsResizeObserver: ResizeObserver | undefined; + + override disconnectedCallback(): void { + this.contextItemsResizeObserver?.disconnect(); + this.contextItemsResizeObserver = undefined; + this.observedContextItems = undefined; + super.disconnectedCallback(); + } + + override firstUpdated(): void { + this.observeContextItems(); + this.updateScrollState(); + } + + override updated(): void { + this.observeContextItems(); + this.updateScrollState(); + } + + override render() { + const projectLabel = projectContextLabel(this.project); + const workspaceLabel = workspaceContextLabel(this.workspace); + const sessionLabel = sessionContextLabel(this.session); + return html` + + `; + } + + private contextBarClass(): string { + const classes = ["context-bar"]; + if (this.refreshControl !== undefined) classes.push("has-context-actions"); + if (this.canScrollLeft) classes.push("can-scroll-left"); + if (this.canScrollRight) classes.push("can-scroll-right"); + return classes.join(" "); + } + + private observeContextItems(): void { + const contextItems = this.contextItemsElement(); + if (this.observedContextItems === contextItems) return; + this.contextItemsResizeObserver?.disconnect(); + this.observedContextItems = contextItems; + this.contextItemsResizeObserver = undefined; + if (contextItems === undefined || typeof ResizeObserver === "undefined") return; + this.contextItemsResizeObserver = new ResizeObserver(() => { + this.updateScrollState(); + }); + this.contextItemsResizeObserver.observe(contextItems); + } + + private updateScrollState(): void { + const contextItems = this.contextItemsElement(); + const maxScrollLeft = contextItems === undefined ? 0 : Math.max(0, contextItems.scrollWidth - contextItems.clientWidth); + const canScrollLeft = contextItems !== undefined && contextItems.scrollLeft > 1; + const canScrollRight = contextItems !== undefined && maxScrollLeft - contextItems.scrollLeft > 1; + if (this.canScrollLeft !== canScrollLeft) this.canScrollLeft = canScrollLeft; + if (this.canScrollRight !== canScrollRight) this.canScrollRight = canScrollRight; + } + + private contextItemsElement(): HTMLElement | undefined { + const contextItems = this.contextItems; + return contextItems instanceof HTMLElement ? contextItems : undefined; + } + + private readonly onContextScroll = () => { + this.updateScrollState(); + }; + + static override styles = css` + :host { flex: 0 0 auto; min-width: 0; } + .context-bar { position: relative; flex: 0 0 auto; min-width: 0; display: flex; align-items: center; gap: 0; padding: 6px 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); } + .context-bar::before, .context-bar::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; } + .context-bar::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } + .context-bar::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } + .context-bar.can-scroll-left::before, .context-bar.can-scroll-right::after { opacity: 1; } + .context-bar-label { display: none; } + .context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 5px; margin: 0; padding: 0 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px; scrollbar-width: thin; } + .context-bar.has-context-actions .context-items { padding-right: 52px; scroll-padding-inline: 8px 52px; } + .context-item { flex: 0 0 auto; min-width: 0; display: flex; } + .context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; padding: 0 8px 0 0; pointer-events: none; } + .context-actions::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; z-index: 0; width: 26px; background: var(--pi-bg); pointer-events: none; } + app-refresh-control { pointer-events: auto; } + .context-chip { flex: 0 0 auto; min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 4px 8px; font: inherit; text-align: left; } + .context-chip:hover { background: var(--pi-surface-hover); } + .context-chip:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; } + .context-chip.empty { border-style: dashed; color: var(--pi-muted); } + .context-kind { display: none; } + .context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; } + button { cursor: pointer; } + `; +} + +function projectContextLabel(project: Project | undefined): string { + return project?.name ?? "No project"; +} + +function projectContextTitle(project: Project | undefined): string { + return project === undefined ? "No project selected" : `${project.name} — ${project.path}`; +} + +function workspaceContextLabel(workspace: Workspace | undefined): string { + return workspace === undefined ? "No workspace" : `${workspace.label}${workspace.isMain ? " · main" : ""} · ${workspace.path}`; +} + +function workspaceContextTitle(workspace: Workspace | undefined): string { + return workspace === undefined ? "No workspace selected" : `${workspace.label}${workspace.isMain ? " · main" : ""} — ${workspace.path}`; +} + +function sessionContextLabel(session: SessionInfo | undefined): string { + const name = session?.name?.trim(); + const firstMessage = session?.firstMessage.trim(); + return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session?.id.slice(0, 8) ?? "No session"; +} + +function sessionContextTitle(session: SessionInfo | undefined): string { + return session === undefined ? "No session selected" : session.path; +} diff --git a/src/client/src/components/appShell/AppMobileMainTabs.ts b/src/client/src/components/appShell/AppMobileMainTabs.ts new file mode 100644 index 0000000..4eaf5fa --- /dev/null +++ b/src/client/src/components/appShell/AppMobileMainTabs.ts @@ -0,0 +1,110 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import type { AppState } from "../../appState"; + +export interface AppMobileMainTab { + id: AppState["mainView"]; + label: unknown; + className?: string | undefined; +} + +@customElement("app-mobile-main-tabs") +export class AppMobileMainTabs extends LitElement { + @property({ attribute: false }) tabs: AppMobileMainTab[] = []; + @property({ attribute: false }) selectedView: AppState["mainView"] = "chat"; + @property({ attribute: false }) onSelect?: (view: AppState["mainView"]) => void; + @query(".mobile-tabs") private mobileTabs?: HTMLElement | null; + @state() private canScrollLeft = false; + @state() private canScrollRight = false; + private observedMobileTabs: HTMLElement | undefined; + private mobileTabsResizeObserver: ResizeObserver | undefined; + + override disconnectedCallback(): void { + this.mobileTabsResizeObserver?.disconnect(); + this.mobileTabsResizeObserver = undefined; + this.observedMobileTabs = undefined; + super.disconnectedCallback(); + } + + override firstUpdated(): void { + this.observeMobileTabs(); + this.updateScrollState(); + } + + override updated(): void { + this.observeMobileTabs(); + this.updateScrollState(); + } + + override render() { + return html` +
+
+ ${this.tabs.map((tab) => html` + + `)} +
+
+ `; + } + + private frameClass(): string { + return `mobile-tabs-frame${this.canScrollLeft ? " can-scroll-left" : ""}${this.canScrollRight ? " can-scroll-right" : ""}`; + } + + private tabClass(tab: AppMobileMainTab): string { + return [ + ...(tab.className === undefined ? [] : [tab.className]), + ...(this.selectedView === tab.id ? ["selected"] : []), + ].join(" "); + } + + private observeMobileTabs(): void { + const mobileTabs = this.mobileTabsElement(); + 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.updateScrollState(); + }); + this.mobileTabsResizeObserver.observe(mobileTabs); + } + + private updateScrollState(): void { + const mobileTabs = this.mobileTabsElement(); + 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.canScrollLeft !== canScrollLeft) this.canScrollLeft = canScrollLeft; + if (this.canScrollRight !== canScrollRight) this.canScrollRight = canScrollRight; + } + + private mobileTabsElement(): HTMLElement | undefined { + const mobileTabs = this.mobileTabs; + return mobileTabs instanceof HTMLElement ? mobileTabs : undefined; + } + + private readonly onMobileTabsScroll = () => { + this.updateScrollState(); + }; + + static override styles = css` + :host { flex: 0 0 auto; min-width: 0; } + .mobile-tabs-frame { position: relative; display: flex; 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; } + .mobile-tabs button { flex: 0 0 auto; white-space: nowrap; } + .navigation-tab { 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; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } + @media (max-width: 760px) { + .navigation-tab { display: block; } + } + `; +} diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts new file mode 100644 index 0000000..7ac7982 --- /dev/null +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -0,0 +1,114 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api"; +import type { WorkspaceLabelItem } from "../../plugins/types"; +import "../ProjectList"; +import "../WorkspaceList"; +import "../SessionList"; + +@customElement("app-navigation-panel") +export class AppNavigationPanel extends LitElement { + @property({ attribute: false }) projects: Project[] = []; + @property({ attribute: false }) selectedProject?: Project; + @property({ attribute: false }) workspaces: Workspace[] = []; + @property({ attribute: false }) selectedWorkspace?: Workspace; + @property({ attribute: false }) sessions: SessionInfo[] = []; + @property({ attribute: false }) selectedSession?: SessionInfo; + @property({ attribute: false }) workspaceActivities: Record = {}; + @property({ attribute: false }) sessionActivities: Record = {}; + @property({ attribute: false }) sessionStatuses: Record = {}; + @property({ attribute: false }) workspacesByProjectId: Record = {}; + @property({ attribute: false }) deletingWorkspaceIds: string[] = []; + @property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => []; + @property({ attribute: false }) refreshControl: unknown; + @property({ type: Boolean, reflect: true }) collapsible = false; + @property({ type: Boolean }) projectsCollapsed = false; + @property({ type: Boolean }) workspacesCollapsed = false; + @property({ type: Boolean }) sessionsCollapsed = false; + @property({ type: Boolean }) canStartSession = false; + @property({ attribute: false }) onShowActions?: () => void; + @property({ attribute: false }) onToggleProjects?: () => void; + @property({ attribute: false }) onToggleWorkspaces?: () => void; + @property({ attribute: false }) onToggleSessions?: () => void; + @property({ attribute: false }) onSelectProject?: (project: Project) => void | Promise; + @property({ attribute: false }) onCloseProject?: (project: Project) => void | Promise; + @property({ attribute: false }) onSelectWorkspace?: (workspace: Workspace) => void | Promise; + @property({ attribute: false }) onDeleteWorkspace?: (workspace: Workspace) => void | Promise; + @property({ attribute: false }) onStartSession?: () => void | Promise; + @property({ attribute: false }) onSelectSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onArchiveSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onArchiveSessionWithDescendants?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onRestoreSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onDeleteCachedNewSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise; + + override render() { + return html` +
+ PI WEB +
+ ${this.refreshControl} + +
+
+ { this.onToggleProjects?.(); }} + .onSelect=${(project: Project) => this.onSelectProject?.(project)} + .onClose=${(project: Project) => this.onCloseProject?.(project)} + > + { this.onToggleWorkspaces?.(); }} + .onSelect=${(workspace: Workspace) => this.onSelectWorkspace?.(workspace)} + .onDelete=${(workspace: Workspace) => this.onDeleteWorkspace?.(workspace)} + > + { this.onToggleSessions?.(); }} + .onArchivedCollapsed=${() => this.onArchivedCollapsed?.()} + .onStart=${() => this.onStartSession?.()} + .onSelect=${(session: SessionInfo) => this.onSelectSession?.(session)} + .onArchive=${(session: SessionInfo) => this.onArchiveSession?.(session)} + .onArchiveWithDescendants=${(session: SessionInfo) => this.onArchiveSessionWithDescendants?.(session)} + .onRestore=${(session: SessionInfo) => this.onRestoreSession?.(session)} + .onDelete=${(session: SessionInfo) => this.onDeleteCachedNewSession?.(session)} + .onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)} + > + `; + } + + static override styles = css` + :host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; } + :host([collapsible]) { 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-actions { display: flex; align-items: center; gap: 8px; } + 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; } + :host([collapsible]) project-list, + :host([collapsible]) workspace-list, + :host([collapsible]) session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; } + :host([collapsible]) project-list[collapsed], + :host([collapsible]) workspace-list[collapsed], + :host([collapsible]) session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } + `; +} diff --git a/src/client/src/components/appShell/AppPanelEdgeControl.ts b/src/client/src/components/appShell/AppPanelEdgeControl.ts new file mode 100644 index 0000000..2f9594f --- /dev/null +++ b/src/client/src/components/appShell/AppPanelEdgeControl.ts @@ -0,0 +1,58 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +export type PanelEdgeSide = "navigation" | "workspace"; + +@customElement("app-panel-edge-control") +export class AppPanelEdgeControl extends LitElement { + @property({ reflect: true }) side: PanelEdgeSide = "navigation"; + @property({ type: Boolean, reflect: true }) collapsed = false; + @property() controls = ""; + @property() expandLabel = "Expand panel"; + @property() collapseLabel = "Collapse panel"; + @property({ attribute: false }) onToggle?: () => void; + + override render() { + const label = this.collapsed ? this.expandLabel : this.collapseLabel; + return html` + + `; + } + + private renderIcon() { + const direction = this.iconDirection(); + const path = direction === "left" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6"; + return html``; + } + + private iconDirection(): "left" | "right" { + if (this.side === "navigation") return this.collapsed ? "right" : "left"; + return this.collapsed ? "left" : "right"; + } + + static override styles = css` + :host { min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; } + :host([side="navigation"]) { grid-column: 2; } + :host([side="workspace"]) { grid-column: 4; } + .edge-button { position: relative; z-index: 1; box-sizing: border-box; display: grid; place-items: center; width: 18px; height: 48px; padding: 0; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-bg); color: var(--pi-muted); opacity: .75; cursor: pointer; } + .edge-button:hover, .edge-button:focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); opacity: 1; } + :host([side="navigation"][collapsed]) .edge-button { transform: translateX(calc(50% - .5px)); } + :host([side="workspace"][collapsed]) .edge-button { transform: translateX(calc(-50% + .5px)); } + .edge-icon { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } + @media (max-width: 1180px) { + :host([side="navigation"]) { grid-row: 1 / 3; } + :host([side="workspace"]) { display: none; } + } + @media (max-width: 760px) { + :host([side="navigation"]) { display: none; } + } + `; +} diff --git a/src/client/src/components/appShell/AppRefreshControl.ts b/src/client/src/components/appShell/AppRefreshControl.ts new file mode 100644 index 0000000..8bda17a --- /dev/null +++ b/src/client/src/components/appShell/AppRefreshControl.ts @@ -0,0 +1,151 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { actionMenuPanelStyle } from "../actionMenu"; + +const REFRESH_LONG_PRESS_MS = 550; + +@customElement("app-refresh-control") +export class AppRefreshControl extends LitElement { + @property({ type: Boolean }) isRefreshing = false; + @property({ attribute: false }) onRefresh?: () => void | Promise; + @property({ attribute: false }) onReload?: () => void; + @state() private menuOpen = false; + @state() private menuStyle = ""; + private longPressTimer: number | undefined; + private suppressNextClick = false; + + override connectedCallback(): void { + super.connectedCallback(); + document.addEventListener("click", this.onDocumentClick); + document.addEventListener("keydown", this.onDocumentKeyDown); + } + + override disconnectedCallback(): void { + document.removeEventListener("click", this.onDocumentClick); + document.removeEventListener("keydown", this.onDocumentKeyDown); + this.clearLongPressTimer(); + super.disconnectedCallback(); + } + + override render() { + const label = this.isRefreshing ? "Refreshing app data. Long-press for reload options." : "Refresh app data. Long-press for reload options."; + return html` + + ${this.renderMenu()} + `; + } + + private renderMenu() { + if (!this.menuOpen) return null; + return html` + + `; + } + + private renderRefreshIcon() { + return html` + + `; + } + + private readonly onRefreshClick = (event: MouseEvent): void => { + event.stopPropagation(); + if (this.suppressNextClick) { + this.suppressNextClick = false; + return; + } + this.refresh(); + }; + + private readonly onRefreshPointerDown = (event: PointerEvent): void => { + if (!event.isPrimary || event.button !== 0) return; + const target = event.currentTarget; + if (!(target instanceof HTMLElement)) return; + this.clearLongPressTimer(); + this.suppressNextClick = false; + this.longPressTimer = window.setTimeout(() => { + this.longPressTimer = undefined; + this.suppressNextClick = true; + this.openMenu(target); + }, REFRESH_LONG_PRESS_MS); + }; + + private readonly onRefreshContextMenu = (event: MouseEvent): void => { + event.preventDefault(); + event.stopPropagation(); + this.clearLongPressTimer(); + this.suppressNextClick = true; + this.openMenu(event.currentTarget); + }; + + private readonly onDocumentClick = (event: MouseEvent): void => { + if (event.composedPath().includes(this)) return; + this.closeMenu(); + }; + + private readonly onDocumentKeyDown = (event: KeyboardEvent): void => { + if (event.key !== "Escape" || !this.menuOpen) return; + event.preventDefault(); + event.stopPropagation(); + this.closeMenu(); + }; + + private openMenu(target: EventTarget | null): void { + this.menuStyle = actionMenuPanelStyle(target); + this.menuOpen = true; + } + + private closeMenu(): void { + this.menuOpen = false; + this.suppressNextClick = false; + } + + private refresh(): void { + this.closeMenu(); + void this.onRefresh?.(); + } + + private reload(): void { + this.closeMenu(); + this.onReload?.(); + } + + private clearLongPressTimer(): void { + if (this.longPressTimer === undefined) return; + window.clearTimeout(this.longPressTimer); + this.longPressTimer = undefined; + } + + static override styles = css` + :host { position: relative; z-index: 1; display: flex; align-items: center; pointer-events: auto; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; } + :host, :host * { -webkit-user-select: none; user-select: none; } + .app-refresh-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 0; line-height: 1; cursor: pointer; touch-action: manipulation; -webkit-touch-callout: none; } + .app-refresh-icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } + .app-refresh-button.refreshing .app-refresh-icon { animation: app-refresh-spin .8s linear infinite; } + .app-refresh-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(170px, 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); overflow-wrap: anywhere; } + .app-refresh-menu button { display: block; width: 100%; border: 0; border-radius: 8px; background: transparent; color: var(--pi-text); padding: 7px 9px; text-align: left; white-space: normal; overflow-wrap: anywhere; cursor: pointer; } + .app-refresh-menu button:hover, .app-refresh-menu button:focus { background: var(--pi-selection-bg); } + @keyframes app-refresh-spin { to { transform: rotate(360deg); } } + `; +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 5122423..b3fc6cb 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -53,6 +53,7 @@ export const appStyles = css` :host { position: fixed; top: 0; right: 0; left: 0; display: block; height: 100dvh; box-sizing: border-box; overflow: hidden; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); color: var(--pi-text); background: var(--pi-bg); font: 14px system-ui, sans-serif; } .shell { --navigation-panel-width: 340px; --workspace-panel-width: minmax(360px, 42vw); display: grid; grid-template-columns: var(--navigation-panel-width) 1px minmax(420px, 1fr) 1px var(--workspace-panel-width); height: 100%; min-height: 0; } aside { grid-column: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; } + aside app-navigation-panel { flex: 1 1 auto; min-height: 0; } 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-actions { display: flex; align-items: center; gap: 8px; } project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); } @@ -83,6 +84,7 @@ export const appStyles = css` .context-chip.empty { border-style: dashed; color: var(--pi-muted); } .context-kind { display: none; } .context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; } + app-mobile-main-tabs { display: none; } .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%); } @@ -115,6 +117,7 @@ export const appStyles = css` aside { grid-row: 1 / 3; } .navigation-panel-edge { grid-row: 1 / 3; } main { grid-column: 3; grid-row: 1 / 3; } + app-mobile-main-tabs { display: block; flex: 0 0 auto; min-width: 0; } .mobile-tabs-frame { display: flex; } .shell.workspace-view main { grid-row: 1; min-height: auto; } .shell.workspace-view > workspace-panel { grid-column: 3; grid-row: 2; display: flex; border-left: 0; } @@ -133,6 +136,7 @@ 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 app-navigation-panel { flex: 1 1 auto; min-height: 0; } 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: hidden; }