Archived
refactor(client): extract app shell chrome
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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<number, () => void>();
|
||||||
|
timers = new Map<number, { callback: () => 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<K, V>(map: Map<K, V>): K {
|
||||||
|
const key = map.keys().next().value;
|
||||||
|
if (key === undefined) throw new Error("Expected map to have a key");
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstMapEntry<K, V>(map: Map<K, V>): [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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,7 +21,9 @@ import { themePackPlugin } from "../plugins/themes";
|
|||||||
import { loadExternalPlugins } from "../plugins/external";
|
import { loadExternalPlugins } from "../plugins/external";
|
||||||
import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry";
|
import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry";
|
||||||
import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
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 { readRoute, writeRoute, type AppRoute } from "../route";
|
||||||
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
|
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
|
||||||
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
|
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
|
||||||
@@ -39,30 +41,26 @@ import "./AuthDialog";
|
|||||||
import "./ProjectDialog";
|
import "./ProjectDialog";
|
||||||
import "./WorkspacePanel";
|
import "./WorkspacePanel";
|
||||||
import type { WorkspacePanelEmptyState } from "./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";
|
import { appStyles } from "./shared";
|
||||||
|
|
||||||
type NavigationSection = "projects" | "workspaces" | "sessions";
|
|
||||||
|
|
||||||
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
|
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
|
||||||
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
|
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
|
||||||
const THEME_AUTO_ON_VALUE = "auto:on";
|
const THEME_AUTO_ON_VALUE = "auto:on";
|
||||||
const THEME_AUTO_OFF_VALUE = "auto:off";
|
const THEME_AUTO_OFF_VALUE = "auto:off";
|
||||||
const THEME_OPTION_PREFIX = "theme:";
|
const THEME_OPTION_PREFIX = "theme:";
|
||||||
const TERMINAL_ROUTE_NAMESPACE = queryNamespace("core:workspace.terminal");
|
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")
|
@customElement("pi-web-app")
|
||||||
export class PiWebApp extends LitElement {
|
export class PiWebApp extends LitElement {
|
||||||
@state() private state: AppState = initialAppState();
|
@state() private state: AppState = initialAppState();
|
||||||
@state() private navigationPanelCollapsed = false;
|
|
||||||
@state() private workspacePanelCollapsed = false;
|
|
||||||
@query("chat-view") private chatView?: ChatView;
|
@query("chat-view") private chatView?: ChatView;
|
||||||
@query("prompt-editor") private promptEditor?: PromptEditor;
|
@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(
|
private readonly sessions = new SessionController(
|
||||||
() => this.state,
|
() => this.state,
|
||||||
@@ -103,13 +101,14 @@ export class PiWebApp extends LitElement {
|
|||||||
private readonly realtime = new RealtimeSocket();
|
private readonly realtime = new RealtimeSocket();
|
||||||
private readonly activeTerminalIds = new Set<string>();
|
private readonly activeTerminalIds = new Set<string>();
|
||||||
private readonly terminalSelection = new InMemoryTerminalSelectionMemory();
|
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 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 terminalAutoStartWorkspaceId: string | undefined;
|
||||||
private piWebStatusTimer: number | undefined;
|
private piWebStatusTimer: number | undefined;
|
||||||
private workspaceDeletionPollTimer: number | undefined;
|
private workspaceDeletionPollTimer: number | undefined;
|
||||||
@@ -120,27 +119,14 @@ export class PiWebApp extends LitElement {
|
|||||||
private restoringRouteTerminalId: string | undefined;
|
private restoringRouteTerminalId: string | undefined;
|
||||||
private readonly plugins = createPluginRegistry();
|
private readonly plugins = createPluginRegistry();
|
||||||
private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE;
|
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 activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID;
|
||||||
@state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false;
|
|
||||||
@state() private isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia);
|
|
||||||
@state() private isRefreshingApp = false;
|
@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 onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
|
||||||
private readonly onPageShow = () => {
|
private readonly onPageShow = () => {
|
||||||
this.repairViewportPosition();
|
this.appShell.repairViewportPosition();
|
||||||
};
|
};
|
||||||
private readonly onFocus = () => {
|
private readonly onFocus = () => {
|
||||||
this.repairViewportPosition();
|
this.appShell.repairViewportPosition();
|
||||||
void this.sessions.refreshSelectedSession();
|
void this.sessions.refreshSelectedSession();
|
||||||
void this.refreshPiWebStatus();
|
void this.refreshPiWebStatus();
|
||||||
void this.refreshWorkspaceActivity();
|
void this.refreshWorkspaceActivity();
|
||||||
@@ -148,44 +134,17 @@ export class PiWebApp extends LitElement {
|
|||||||
};
|
};
|
||||||
private readonly onVisibilityChange = () => {
|
private readonly onVisibilityChange = () => {
|
||||||
if (document.visibilityState === "visible") {
|
if (document.visibilityState === "visible") {
|
||||||
this.repairViewportPosition();
|
this.appShell.repairViewportPosition();
|
||||||
void this.sessions.refreshSelectedSession();
|
void this.sessions.refreshSelectedSession();
|
||||||
void this.refreshPiWebStatus();
|
void this.refreshPiWebStatus();
|
||||||
void this.refreshWorkspaceActivity();
|
void this.refreshWorkspaceActivity();
|
||||||
void this.refreshWorkspaceDeletionRuns();
|
void this.refreshWorkspaceDeletionRuns();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
|
|
||||||
this.isMobileNavigationLayout = event.matches;
|
|
||||||
this.updateContextScrollState();
|
|
||||||
this.updateMobileTabsScrollState();
|
|
||||||
};
|
|
||||||
private readonly onSystemLightThemeChange = () => {
|
private readonly onSystemLightThemeChange = () => {
|
||||||
if (this.themePreference.auto) this.applyPreferredTheme(false);
|
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) => {
|
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())) {
|
if (this.keyboard.handle(event, this.getActions())) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -197,12 +156,9 @@ export class PiWebApp extends LitElement {
|
|||||||
window.addEventListener("popstate", this.onPopState);
|
window.addEventListener("popstate", this.onPopState);
|
||||||
window.addEventListener("pageshow", this.onPageShow);
|
window.addEventListener("pageshow", this.onPageShow);
|
||||||
window.addEventListener("focus", this.onFocus);
|
window.addEventListener("focus", this.onFocus);
|
||||||
document.addEventListener("click", this.onDocumentClick);
|
|
||||||
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
||||||
window.addEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
|
window.addEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
|
||||||
this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange);
|
|
||||||
this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange);
|
this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange);
|
||||||
for (const media of this.pwaDisplayModeMedia) media.addEventListener("change", this.onPwaDisplayModeChange);
|
|
||||||
this.applyPreferredTheme(false);
|
this.applyPreferredTheme(false);
|
||||||
this.connectRealtime();
|
this.connectRealtime();
|
||||||
this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS);
|
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("popstate", this.onPopState);
|
||||||
window.removeEventListener("pageshow", this.onPageShow);
|
window.removeEventListener("pageshow", this.onPageShow);
|
||||||
window.removeEventListener("focus", this.onFocus);
|
window.removeEventListener("focus", this.onFocus);
|
||||||
document.removeEventListener("click", this.onDocumentClick);
|
|
||||||
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
||||||
window.removeEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
|
window.removeEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
|
||||||
this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange);
|
|
||||||
this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange);
|
this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange);
|
||||||
for (const media of this.pwaDisplayModeMedia) media.removeEventListener("change", this.onPwaDisplayModeChange);
|
|
||||||
this.keyboard.reset();
|
this.keyboard.reset();
|
||||||
this.auth.dispose();
|
this.auth.dispose();
|
||||||
this.sessions.dispose();
|
this.sessions.dispose();
|
||||||
@@ -231,31 +184,9 @@ export class PiWebApp extends LitElement {
|
|||||||
this.piWebStatusTimer = undefined;
|
this.piWebStatusTimer = undefined;
|
||||||
if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer);
|
if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer);
|
||||||
this.workspaceDeletionPollTimer = undefined;
|
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();
|
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<AppState>) {
|
private setState(patch: Partial<AppState>) {
|
||||||
if (!patchChangesState(this.state, patch)) return;
|
if (!patchChangesState(this.state, patch)) return;
|
||||||
const previous = this.state;
|
const previous = this.state;
|
||||||
@@ -288,8 +219,6 @@ export class PiWebApp extends LitElement {
|
|||||||
|
|
||||||
private async refreshAppData(): Promise<void> {
|
private async refreshAppData(): Promise<void> {
|
||||||
if (this.isRefreshingApp) return;
|
if (this.isRefreshingApp) return;
|
||||||
this.refreshMenuOpen = false;
|
|
||||||
this.suppressNextRefreshClick = false;
|
|
||||||
this.isRefreshingApp = true;
|
this.isRefreshingApp = true;
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -370,47 +299,7 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private shouldAutoFocusPrompt(): boolean {
|
private shouldAutoFocusPrompt(): boolean {
|
||||||
return !this.isMobileNavigationLayout && !this.isPwaDisplayMode;
|
return this.appShell.shouldAutoFocusPrompt();
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async withChatPrependTransition(action: () => Promise<void>) {
|
private async withChatPrependTransition(action: () => Promise<void>) {
|
||||||
@@ -420,7 +309,7 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private defaultRouteView(): AppState["mainView"] {
|
private defaultRouteView(): AppState["mainView"] {
|
||||||
return this.isMobileNavigationLayout ? "navigation" : "chat";
|
return this.appShell.defaultRouteView();
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateUrl(options?: { replace?: boolean | undefined }) {
|
private updateUrl(options?: { replace?: boolean | undefined }) {
|
||||||
@@ -582,63 +471,32 @@ export class PiWebApp extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleNavigationPanelCollapse(): void {
|
|
||||||
this.navigationPanelCollapsed = !this.navigationPanelCollapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderNavigationPanelEdgeControl() {
|
private renderNavigationPanelEdgeControl() {
|
||||||
const collapsed = this.navigationPanelCollapsed;
|
|
||||||
const label = collapsed ? "Expand navigation panel" : "Collapse navigation panel";
|
|
||||||
return html`
|
return html`
|
||||||
<div class="navigation-panel-edge">
|
<app-panel-edge-control
|
||||||
<button
|
side="navigation"
|
||||||
type="button"
|
controls="navigation-panel"
|
||||||
class="navigation-panel-edge-button"
|
expandLabel="Expand navigation panel"
|
||||||
title=${label}
|
collapseLabel="Collapse navigation panel"
|
||||||
aria-label=${label}
|
.collapsed=${this.panelCollapse.navigationPanelCollapsed}
|
||||||
aria-controls="navigation-panel"
|
.onToggle=${() => { this.panelCollapse.toggleNavigationPanel(); }}
|
||||||
aria-expanded=${String(!collapsed)}
|
></app-panel-edge-control>
|
||||||
@click=${() => { this.toggleNavigationPanelCollapse(); }}
|
|
||||||
>${this.renderNavigationPanelEdgeIcon(collapsed)}</button>
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderNavigationPanelEdgeIcon(collapsed: boolean) {
|
|
||||||
return this.renderPanelEdgeIcon(collapsed ? "right" : "left", "navigation-panel-edge-icon");
|
|
||||||
}
|
|
||||||
|
|
||||||
private toggleWorkspacePanelCollapse(): void {
|
|
||||||
this.workspacePanelCollapsed = !this.workspacePanelCollapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderWorkspacePanelEdgeControl() {
|
private renderWorkspacePanelEdgeControl() {
|
||||||
const collapsed = this.workspacePanelCollapsed;
|
|
||||||
const label = collapsed ? "Expand workspace panel" : "Collapse workspace panel";
|
|
||||||
return html`
|
return html`
|
||||||
<div class="workspace-panel-edge">
|
<app-panel-edge-control
|
||||||
<button
|
side="workspace"
|
||||||
type="button"
|
controls="workspace-panel"
|
||||||
class="workspace-panel-edge-button"
|
expandLabel="Expand workspace panel"
|
||||||
title=${label}
|
collapseLabel="Collapse workspace panel"
|
||||||
aria-label=${label}
|
.collapsed=${this.panelCollapse.workspacePanelCollapsed}
|
||||||
aria-controls="workspace-panel"
|
.onToggle=${() => { this.panelCollapse.toggleWorkspacePanel(); }}
|
||||||
aria-expanded=${String(!collapsed)}
|
></app-panel-edge-control>
|
||||||
@click=${() => { this.toggleWorkspacePanelCollapse(); }}
|
|
||||||
>${this.renderWorkspacePanelEdgeIcon(collapsed)}</button>
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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`<svg class=${className} viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d=${path}/></svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderNavigationPanel(autoSwitchToChat: boolean) {
|
private renderNavigationPanel(autoSwitchToChat: boolean) {
|
||||||
const openChatAfter = (action: () => Promise<void>) => this.withChatScrollTransition(async () => {
|
const openChatAfter = (action: () => Promise<void>) => this.withChatScrollTransition(async () => {
|
||||||
await action();
|
await action();
|
||||||
@@ -646,91 +504,53 @@ export class PiWebApp extends LitElement {
|
|||||||
if (autoSwitchToChat) this.updateUrl();
|
if (autoSwitchToChat) this.updateUrl();
|
||||||
});
|
});
|
||||||
return html`
|
return html`
|
||||||
<header>
|
<app-navigation-panel
|
||||||
<strong>PI WEB</strong>
|
|
||||||
<div class="header-actions">
|
|
||||||
${this.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : null}
|
|
||||||
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.setState({ actionPaletteOpen: true }); }}>Actions</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<project-list
|
|
||||||
.projects=${this.state.projects}
|
.projects=${this.state.projects}
|
||||||
.selected=${this.state.selectedProject}
|
.selectedProject=${this.state.selectedProject}
|
||||||
.activities=${this.state.workspaceActivities}
|
.workspaceActivities=${this.state.workspaceActivities}
|
||||||
.workspacesByProjectId=${this.state.workspacesByProjectId}
|
.workspacesByProjectId=${this.state.workspacesByProjectId}
|
||||||
.collapsible=${this.isMobileNavigationLayout}
|
.workspaces=${this.state.workspaces}
|
||||||
.collapsed=${this.isNavigationSectionCollapsed("projects")}
|
.selectedWorkspace=${this.state.selectedWorkspace}
|
||||||
.onToggleCollapsed=${() => { this.toggleNavigationSection("projects"); }}
|
.deletingWorkspaceIds=${pendingWorkspaceDeletionIds(this.state.workspaceDeletionRuns)}
|
||||||
.onSelect=${(project: Project) => this.withChatScrollTransition(async () => {
|
.sessions=${this.state.sessions}
|
||||||
this.expandNavigationSection("workspaces");
|
.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);
|
await this.workspaces.selectProject(project);
|
||||||
})}
|
})}
|
||||||
.onClose=${(project: Project) => this.projects.closeProject(project.id)}
|
.onCloseProject=${(project: Project) => this.projects.closeProject(project.id)}
|
||||||
></project-list>
|
.onSelectWorkspace=${(workspace: Workspace) => this.withChatScrollTransition(async () => {
|
||||||
<workspace-list
|
this.mobileNavigation.expand("sessions");
|
||||||
.workspaces=${this.state.workspaces}
|
|
||||||
.selected=${this.state.selectedWorkspace}
|
|
||||||
.activities=${this.state.workspaceActivities}
|
|
||||||
.deletingWorkspaceIds=${pendingWorkspaceDeletionIds(this.state.workspaceDeletionRuns)}
|
|
||||||
.collapsible=${this.isMobileNavigationLayout}
|
|
||||||
.collapsed=${this.isNavigationSectionCollapsed("workspaces")}
|
|
||||||
.workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)}
|
|
||||||
.onToggleCollapsed=${() => { this.toggleNavigationSection("workspaces"); }}
|
|
||||||
.onSelect=${(workspace: Workspace) => this.withChatScrollTransition(async () => {
|
|
||||||
this.expandNavigationSection("sessions");
|
|
||||||
await this.workspaces.selectWorkspace(workspace);
|
await this.workspaces.selectWorkspace(workspace);
|
||||||
})}
|
})}
|
||||||
.onDelete=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
|
.onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
|
||||||
></workspace-list>
|
|
||||||
<session-list
|
|
||||||
.sessions=${this.state.sessions}
|
|
||||||
.statuses=${this.state.sessionStatuses}
|
|
||||||
.activities=${this.state.sessionActivities}
|
|
||||||
.selected=${this.state.selectedSession}
|
|
||||||
.canStart=${!!this.state.selectedWorkspace}
|
|
||||||
.collapsible=${this.isMobileNavigationLayout}
|
|
||||||
.collapsed=${this.isNavigationSectionCollapsed("sessions")}
|
|
||||||
.onToggleCollapsed=${() => { this.toggleNavigationSection("sessions"); }}
|
|
||||||
.onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }}
|
.onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }}
|
||||||
.onStart=${() => openChatAfter(() => this.sessions.startSession())}
|
.onStartSession=${() => openChatAfter(() => this.sessions.startSession())}
|
||||||
.onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))}
|
.onSelectSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))}
|
||||||
.onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)}
|
.onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)}
|
||||||
.onArchiveWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
|
.onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
|
||||||
.onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))}
|
.onRestoreSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))}
|
||||||
.onDelete=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)}
|
.onDeleteCachedNewSession=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)}
|
||||||
.onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)}
|
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
|
||||||
></session-list>
|
></app-navigation-panel>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
private openNavigationSection(section: NavigationSection): void {
|
||||||
if (!this.isMobileNavigationLayout) return;
|
this.mobileNavigation.open(section, () => { this.selectMainView("navigation"); });
|
||||||
this.expandNavigationSection(section);
|
|
||||||
this.selectMainView("navigation");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
|
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
|
||||||
@@ -1117,223 +937,51 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderContextBar() {
|
private renderContextBar() {
|
||||||
const project = this.state.selectedProject;
|
if (!this.appShell.isMobileNavigationLayout) return null;
|
||||||
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);
|
|
||||||
return html`
|
return html`
|
||||||
<nav class=${this.contextBarClass()} aria-label="Current location">
|
<app-context-bar
|
||||||
<span class="context-bar-label">Location</span>
|
.project=${this.state.selectedProject}
|
||||||
<ol class="context-items" @scroll=${this.onContextScroll}>
|
.workspace=${this.state.selectedWorkspace}
|
||||||
<li class="context-item">
|
.session=${this.state.selectedSession}
|
||||||
<button type="button" class=${project === undefined ? "context-chip empty" : "context-chip"} title=${projectContextTitle(project)} aria-label=${`Project: ${projectLabel}. Open project selection.`} @click=${() => { this.openNavigationSection("projects"); }}>
|
.refreshControl=${this.appShell.shouldShowAppRefreshInContextBar() ? this.renderAppRefresh() : undefined}
|
||||||
<span class="context-kind">Project</span>
|
.onOpenSection=${(section: NavigationSection) => { this.openNavigationSection(section); }}
|
||||||
<span class="context-value">${projectLabel}</span>
|
></app-context-bar>
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="context-item">
|
|
||||||
<button type="button" class=${workspace === undefined ? "context-chip empty" : "context-chip"} title=${workspaceContextTitle(workspace)} aria-label=${`Workspace: ${workspaceLabel}. Open workspace selection.`} @click=${() => { this.openNavigationSection("workspaces"); }}>
|
|
||||||
<span class="context-kind">Workspace</span>
|
|
||||||
<span class="context-value">${workspaceLabel}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="context-item">
|
|
||||||
<button type="button" class=${session === undefined ? "context-chip empty" : "context-chip"} title=${sessionContextTitle(session)} aria-label=${`Session: ${sessionLabel}. Open session selection.`} @click=${() => { this.openNavigationSection("sessions"); }}>
|
|
||||||
<span class="context-kind">Session</span>
|
|
||||||
<span class="context-value">${sessionLabel}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
${showRefresh ? html`<div class="context-actions">${this.renderAppRefresh()}</div>` : null}
|
|
||||||
</nav>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private contextBarClass(): string {
|
private renderMobileMainTabs() {
|
||||||
const classes = ["context-bar"];
|
return html`
|
||||||
if (this.shouldShowAppRefreshInContextBar()) classes.push("has-context-actions");
|
<app-mobile-main-tabs
|
||||||
if (this.contextCanScrollLeft) classes.push("can-scroll-left");
|
.tabs=${this.mobileMainTabs()}
|
||||||
if (this.contextCanScrollRight) classes.push("can-scroll-right");
|
.selectedView=${this.state.mainView}
|
||||||
return classes.join(" ");
|
.onSelect=${(view: AppState["mainView"]) => { this.selectMainView(view); }}
|
||||||
|
></app-mobile-main-tabs>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldShowAppRefreshInHeader(): boolean {
|
private mobileMainTabs(): AppMobileMainTab[] {
|
||||||
return this.isPwaDisplayMode && !this.isMobileNavigationLayout;
|
return [
|
||||||
}
|
{ id: "navigation", label: "Sessions", className: "navigation-tab" },
|
||||||
|
{ id: "chat", label: "Chat" },
|
||||||
private shouldShowAppRefreshInContextBar(): boolean {
|
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => ({ id: panel.id, label: this.renderMobilePanelTitle(panel) })),
|
||||||
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 renderAppRefresh() {
|
private renderAppRefresh() {
|
||||||
const label = this.isRefreshingApp ? "Refreshing app data. Long-press for reload options." : "Refresh app data. Long-press for reload options.";
|
return html`<app-refresh-control .isRefreshing=${this.isRefreshingApp} .onRefresh=${() => this.refreshAppData()} .onReload=${() => { this.hardReloadApp(); }}></app-refresh-control>`;
|
||||||
return html`
|
|
||||||
<div class="app-refresh">
|
|
||||||
<button
|
|
||||||
class=${`app-refresh-button${this.isRefreshingApp ? " refreshing" : ""}`}
|
|
||||||
title=${label}
|
|
||||||
aria-label=${label}
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded=${String(this.refreshMenuOpen)}
|
|
||||||
aria-busy=${String(this.isRefreshingApp)}
|
|
||||||
@click=${(event: MouseEvent) => { this.onRefreshClick(event); }}
|
|
||||||
@contextmenu=${(event: MouseEvent) => { this.onRefreshContextMenu(event); }}
|
|
||||||
@pointerdown=${(event: PointerEvent) => { this.onRefreshPointerDown(event); }}
|
|
||||||
@pointerup=${() => { this.clearRefreshLongPressTimer(); }}
|
|
||||||
@pointercancel=${() => { this.clearRefreshLongPressTimer(); }}
|
|
||||||
@pointerleave=${() => { this.clearRefreshLongPressTimer(); }}
|
|
||||||
>${this.renderRefreshIcon()}</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderRefreshMenu() {
|
|
||||||
if (!this.refreshMenuOpen) return null;
|
|
||||||
return html`
|
|
||||||
<div class="app-refresh-menu" role="menu" style=${this.refreshMenuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
|
|
||||||
<button role="menuitem" @click=${() => { void this.refreshAppData(); }}>Refresh app data</button>
|
|
||||||
<button role="menuitem" @click=${() => { this.refreshMenuOpen = false; this.suppressNextRefreshClick = false; this.hardReloadApp(); }}>Full page reload</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderRefreshIcon() {
|
|
||||||
return html`
|
|
||||||
<svg class="app-refresh-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
||||||
<path d="M20 6v5h-5"></path>
|
|
||||||
<path d="M4 18v-5h5"></path>
|
|
||||||
<path d="M18.2 9A7 7 0 0 0 6.1 6.8L4 9"></path>
|
|
||||||
<path d="M5.8 15a7 7 0 0 0 12.1 2.2L20 15"></path>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const state = this.state;
|
const state = this.state;
|
||||||
return html`
|
return html`
|
||||||
<div class=${`shell ${state.mainView === "navigation" ? "navigation-view" : state.mainView === "chat" ? "chat-view" : "workspace-view"}${this.navigationPanelCollapsed ? " navigation-panel-collapsed" : ""}${this.workspacePanelCollapsed ? " workspace-panel-collapsed" : ""}`}>
|
<div class=${this.panelCollapse.shellClass(state.mainView)}>
|
||||||
<aside id="navigation-panel">${this.isMobileNavigationLayout ? null : this.renderNavigationPanel(false)}</aside>
|
<aside id="navigation-panel">${this.appShell.isMobileNavigationLayout ? null : this.renderNavigationPanel(false)}</aside>
|
||||||
${this.renderNavigationPanelEdgeControl()}
|
${this.renderNavigationPanelEdgeControl()}
|
||||||
<main class=${state.mainView === "chat" ? "chat-view" : state.mainView === "navigation" ? "navigation-view" : "workspace-view"}>
|
<main class=${mainViewClass(state.mainView)}>
|
||||||
${this.renderContextBar()}
|
${this.renderContextBar()}
|
||||||
<div class=${this.mobileTabsFrameClass()}>
|
${this.renderMobileMainTabs()}
|
||||||
<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}
|
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||||
<div class="mobile-navigation-panel">${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
|
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
|
||||||
${state.selectedSession ? html`
|
${state.selectedSession ? html`
|
||||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 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())}></chat-view>
|
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 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())}></chat-view>
|
||||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 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(); }}></prompt-editor>
|
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 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(); }}></prompt-editor>
|
||||||
@@ -1349,7 +997,6 @@ export class PiWebApp extends LitElement {
|
|||||||
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
|
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
|
||||||
${state.projectDialogOpen ? html`<project-dialog .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
|
${state.projectDialogOpen ? html`<project-dialog .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
|
||||||
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
|
||||||
${this.renderRefreshMenu()}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1364,32 +1011,6 @@ function createPluginRegistry(): PluginRegistry {
|
|||||||
return registry;
|
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<AppState>): boolean {
|
function patchChangesState(state: AppState, patch: Partial<AppState>): boolean {
|
||||||
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value);
|
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
<nav class=${this.contextBarClass()} aria-label="Current location">
|
||||||
|
<span class="context-bar-label">Location</span>
|
||||||
|
<ol class="context-items" @scroll=${this.onContextScroll}>
|
||||||
|
<li class="context-item">
|
||||||
|
<button type="button" class=${this.project === undefined ? "context-chip empty" : "context-chip"} title=${projectContextTitle(this.project)} aria-label=${`Project: ${projectLabel}. Open project selection.`} @click=${() => { this.onOpenSection?.("projects"); }}>
|
||||||
|
<span class="context-kind">Project</span>
|
||||||
|
<span class="context-value">${projectLabel}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="context-item">
|
||||||
|
<button type="button" class=${this.workspace === undefined ? "context-chip empty" : "context-chip"} title=${workspaceContextTitle(this.workspace)} aria-label=${`Workspace: ${workspaceLabel}. Open workspace selection.`} @click=${() => { this.onOpenSection?.("workspaces"); }}>
|
||||||
|
<span class="context-kind">Workspace</span>
|
||||||
|
<span class="context-value">${workspaceLabel}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="context-item">
|
||||||
|
<button type="button" class=${this.session === undefined ? "context-chip empty" : "context-chip"} title=${sessionContextTitle(this.session)} aria-label=${`Session: ${sessionLabel}. Open session selection.`} @click=${() => { this.onOpenSection?.("sessions"); }}>
|
||||||
|
<span class="context-kind">Session</span>
|
||||||
|
<span class="context-value">${sessionLabel}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
${this.refreshControl === undefined ? null : html`<div class="context-actions">${this.refreshControl}</div>`}
|
||||||
|
</nav>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
|
<div class=${this.frameClass()}>
|
||||||
|
<div class="mobile-tabs" @scroll=${this.onMobileTabsScroll}>
|
||||||
|
${this.tabs.map((tab) => html`
|
||||||
|
<button class=${this.tabClass(tab)} @click=${() => { this.onSelect?.(tab.id); }}>${tab.label}</button>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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<string, WorkspaceActivity> = {};
|
||||||
|
@property({ attribute: false }) sessionActivities: Record<string, SessionActivity> = {};
|
||||||
|
@property({ attribute: false }) sessionStatuses: Record<string, SessionStatus> = {};
|
||||||
|
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
|
||||||
|
@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<void>;
|
||||||
|
@property({ attribute: false }) onCloseProject?: (project: Project) => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) onSelectWorkspace?: (workspace: Workspace) => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) onDeleteWorkspace?: (workspace: Workspace) => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) onStartSession?: () => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) onSelectSession?: (session: SessionInfo) => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) onArchiveSession?: (session: SessionInfo) => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) onArchiveSessionWithDescendants?: (session: SessionInfo) => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) onRestoreSession?: (session: SessionInfo) => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) onDeleteCachedNewSession?: (session: SessionInfo) => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<header>
|
||||||
|
<strong>PI WEB</strong>
|
||||||
|
<div class="header-actions">
|
||||||
|
${this.refreshControl}
|
||||||
|
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.onShowActions?.(); }}>Actions</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<project-list
|
||||||
|
.projects=${this.projects}
|
||||||
|
.selected=${this.selectedProject}
|
||||||
|
.activities=${this.workspaceActivities}
|
||||||
|
.workspacesByProjectId=${this.workspacesByProjectId}
|
||||||
|
.collapsible=${this.collapsible}
|
||||||
|
.collapsed=${this.projectsCollapsed}
|
||||||
|
.onToggleCollapsed=${() => { this.onToggleProjects?.(); }}
|
||||||
|
.onSelect=${(project: Project) => this.onSelectProject?.(project)}
|
||||||
|
.onClose=${(project: Project) => this.onCloseProject?.(project)}
|
||||||
|
></project-list>
|
||||||
|
<workspace-list
|
||||||
|
.workspaces=${this.workspaces}
|
||||||
|
.selected=${this.selectedWorkspace}
|
||||||
|
.activities=${this.workspaceActivities}
|
||||||
|
.deletingWorkspaceIds=${this.deletingWorkspaceIds}
|
||||||
|
.collapsible=${this.collapsible}
|
||||||
|
.collapsed=${this.workspacesCollapsed}
|
||||||
|
.workspaceLabelItems=${this.workspaceLabelItems}
|
||||||
|
.onToggleCollapsed=${() => { this.onToggleWorkspaces?.(); }}
|
||||||
|
.onSelect=${(workspace: Workspace) => this.onSelectWorkspace?.(workspace)}
|
||||||
|
.onDelete=${(workspace: Workspace) => this.onDeleteWorkspace?.(workspace)}
|
||||||
|
></workspace-list>
|
||||||
|
<session-list
|
||||||
|
.sessions=${this.sessions}
|
||||||
|
.statuses=${this.sessionStatuses}
|
||||||
|
.activities=${this.sessionActivities}
|
||||||
|
.selected=${this.selectedSession}
|
||||||
|
.canStart=${this.canStartSession}
|
||||||
|
.collapsible=${this.collapsible}
|
||||||
|
.collapsed=${this.sessionsCollapsed}
|
||||||
|
.onToggleCollapsed=${() => { 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)}
|
||||||
|
></session-list>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="edge-button"
|
||||||
|
title=${label}
|
||||||
|
aria-label=${label}
|
||||||
|
aria-controls=${this.controls}
|
||||||
|
aria-expanded=${String(!this.collapsed)}
|
||||||
|
@click=${() => { this.onToggle?.(); }}
|
||||||
|
>${this.renderIcon()}</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderIcon() {
|
||||||
|
const direction = this.iconDirection();
|
||||||
|
const path = direction === "left" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6";
|
||||||
|
return html`<svg class="edge-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d=${path}/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
@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`
|
||||||
|
<button
|
||||||
|
class=${`app-refresh-button${this.isRefreshing ? " refreshing" : ""}`}
|
||||||
|
title=${label}
|
||||||
|
aria-label=${label}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded=${String(this.menuOpen)}
|
||||||
|
aria-busy=${String(this.isRefreshing)}
|
||||||
|
@click=${this.onRefreshClick}
|
||||||
|
@contextmenu=${this.onRefreshContextMenu}
|
||||||
|
@pointerdown=${this.onRefreshPointerDown}
|
||||||
|
@pointerup=${() => { this.clearLongPressTimer(); }}
|
||||||
|
@pointercancel=${() => { this.clearLongPressTimer(); }}
|
||||||
|
@pointerleave=${() => { this.clearLongPressTimer(); }}
|
||||||
|
>${this.renderRefreshIcon()}</button>
|
||||||
|
${this.renderMenu()}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderMenu() {
|
||||||
|
if (!this.menuOpen) return null;
|
||||||
|
return html`
|
||||||
|
<div class="app-refresh-menu" role="menu" style=${this.menuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
|
||||||
|
<button role="menuitem" @click=${() => { this.refresh(); }}>Refresh app data</button>
|
||||||
|
<button role="menuitem" @click=${() => { this.reload(); }}>Full page reload</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRefreshIcon() {
|
||||||
|
return html`
|
||||||
|
<svg class="app-refresh-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M20 6v5h-5"></path>
|
||||||
|
<path d="M4 18v-5h5"></path>
|
||||||
|
<path d="M18.2 9A7 7 0 0 0 6.1 6.8L4 9"></path>
|
||||||
|
<path d="M5.8 15a7 7 0 0 0 12.1 2.2L20 15"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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); } }
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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; }
|
: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; }
|
.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 { 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 { 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; }
|
.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); }
|
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-chip.empty { border-style: dashed; color: var(--pi-muted); }
|
||||||
.context-kind { display: none; }
|
.context-kind { display: none; }
|
||||||
.context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; }
|
.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 { 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, .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::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; }
|
aside { grid-row: 1 / 3; }
|
||||||
.navigation-panel-edge { grid-row: 1 / 3; }
|
.navigation-panel-edge { grid-row: 1 / 3; }
|
||||||
main { grid-column: 3; 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; }
|
.mobile-tabs-frame { display: flex; }
|
||||||
.shell.workspace-view main { grid-row: 1; min-height: auto; }
|
.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; }
|
.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 chat-view, main.navigation-view prompt-editor, main.navigation-view status-bar,
|
||||||
main.navigation-view .empty { display: none; }
|
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 { 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 project-list,
|
||||||
main.navigation-view .mobile-navigation-panel workspace-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; }
|
main.navigation-view .mobile-navigation-panel session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; }
|
||||||
|
|||||||
Reference in New Issue
Block a user