Archived
Merge remote-tracking branch 'origin/main' into review/pr-5-machine-federation-fixes
# Conflicts: # src/client/src/api/clients.ts # src/client/src/components/PiWebApp.ts # src/client/src/components/PromptEditor.ts # src/server/app.ts # src/server/terminalProxyRoutes.ts # src/server/workspaces/fileSuggestions.ts
This commit is contained in:
@@ -145,8 +145,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export interface FileSuggestionQueryOptions {
|
||||
kind?: FileSuggestion["kind"] | undefined;
|
||||
mode?: "file" | "path" | undefined;
|
||||
scope?: "tracked" | "all" | undefined;
|
||||
machineId?: string | undefined;
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path", machineId = "local") => request(`${machinePrefix(machineId)}/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)),
|
||||
files: (cwd: string, query: string, options: FileSuggestionQueryOptions = {}) => {
|
||||
const params = new URLSearchParams({ cwd, q: query });
|
||||
if (options.kind !== undefined) params.set("kind", options.kind);
|
||||
if (options.mode !== undefined) params.set("mode", options.mode);
|
||||
if (options.scope !== undefined) params.set("scope", options.scope);
|
||||
return request(`${machinePrefix(options.machineId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion));
|
||||
},
|
||||
};
|
||||
|
||||
export const gitApi = {
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(workspacesApi.workspaces("p 1", machineId)),
|
||||
ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)),
|
||||
ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)),
|
||||
ignoreParseFailure(filesApi.files("/repo", "README", "tracked", "file", machineId)),
|
||||
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })),
|
||||
ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)),
|
||||
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
|
||||
|
||||
@@ -469,7 +469,13 @@ function parsePiWebReleaseStatus(value: unknown): PiWebReleaseStatus {
|
||||
|
||||
function parsePiWebCommands(value: unknown): PiWebStatusResponse["commands"] {
|
||||
const record = requireRecord(value);
|
||||
return { update: requireString(record, "update"), restart: requireString(record, "restart"), restartSystemd: requireString(record, "restartSystemd"), restartDev: requireString(record, "restartDev") };
|
||||
return {
|
||||
...optionalField("update", optionalString(record, "update")),
|
||||
...optionalField("restart", optionalString(record, "restart")),
|
||||
...optionalField("restartWeb", optionalString(record, "restartWeb")),
|
||||
...optionalField("restartSessiond", optionalString(record, "restartSessiond")),
|
||||
...optionalField("status", optionalString(record, "status")),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiWebStatusMessage(value: unknown): PiWebStatusMessage {
|
||||
|
||||
@@ -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 = "machines" | "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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export interface AppState {
|
||||
sessions: SessionInfo[];
|
||||
messages: ChatLine[];
|
||||
messagePageStart: number;
|
||||
messagePageEnd: number;
|
||||
messagePageTotal: number;
|
||||
isLoadingEarlierMessages: boolean;
|
||||
isReceivingPartialStream: boolean;
|
||||
@@ -104,6 +105,7 @@ export function initialAppState(): AppState {
|
||||
sessions: [],
|
||||
messages: [],
|
||||
messagePageStart: 0,
|
||||
messagePageEnd: 0,
|
||||
messagePageTotal: 0,
|
||||
isLoadingEarlierMessages: false,
|
||||
isReceivingPartialStream: false,
|
||||
|
||||
@@ -29,10 +29,26 @@ describe("ChatTranscriptStore", () => {
|
||||
{ role: "assistant", parts: [{ type: "text", text: "hello" }] },
|
||||
],
|
||||
messagePageStart: 0,
|
||||
messagePageEnd: 2,
|
||||
messagePageTotal: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks the raw page end separately from normalized display messages", () => {
|
||||
const store = new ChatTranscriptStore(new MemoryChatHistoryCache());
|
||||
|
||||
const view = store.mergeHistory("s1", page(0, 3, [
|
||||
{ role: "user", content: "run the tool" },
|
||||
{ role: "assistant", content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "src/app.ts" } }] },
|
||||
{ role: "toolResult", toolCallId: "tool-1", toolName: "read", content: [{ type: "text", text: "ok" }] },
|
||||
]));
|
||||
|
||||
expect(view.messages).toHaveLength(2);
|
||||
expect(view.messagePageStart).toBe(0);
|
||||
expect(view.messagePageEnd).toBe(3);
|
||||
expect(view.messagePageTotal).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps live streamed transcript state out of the raw history cache", () => {
|
||||
const cache = new MemoryChatHistoryCache();
|
||||
const store = new ChatTranscriptStore(cache);
|
||||
@@ -51,6 +67,7 @@ describe("ChatTranscriptStore", () => {
|
||||
{ role: "user", parts: [{ type: "text", text: "next" }] },
|
||||
],
|
||||
messagePageStart: 0,
|
||||
messagePageEnd: 3,
|
||||
messagePageTotal: 3,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,9 @@ import type { SessionUiEvent } from "./sessionSocket";
|
||||
export interface ChatTranscriptView {
|
||||
messages: ChatLine[];
|
||||
messagePageStart: number;
|
||||
// End offset in the raw transcript. Normalization may coalesce multiple raw
|
||||
// entries into one displayed chat message, especially tool calls/results.
|
||||
messagePageEnd: number;
|
||||
messagePageTotal: number;
|
||||
}
|
||||
|
||||
@@ -48,9 +51,11 @@ export class ChatTranscriptStore {
|
||||
}
|
||||
|
||||
export function transcriptViewFromHistory(history: RawMessagePage | undefined): ChatTranscriptView {
|
||||
const start = history?.start ?? 0;
|
||||
return {
|
||||
messages: normalizeMessages(history?.messages ?? []),
|
||||
messagePageStart: history?.start ?? 0,
|
||||
messagePageStart: start,
|
||||
messagePageEnd: start + (history?.messages.length ?? 0),
|
||||
messagePageTotal: history?.total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export class ChatView extends LitElement {
|
||||
@property({ attribute: false }) messages: ChatLine[] = [];
|
||||
@property() sessionId = "";
|
||||
@property({ type: Number }) messageStart = 0;
|
||||
@property({ type: Number }) messageEnd = 0;
|
||||
@property({ type: Number }) messageTotal = 0;
|
||||
@property({ type: Boolean }) hasMore = false;
|
||||
@property({ type: Boolean }) loadingMore = false;
|
||||
@@ -307,8 +308,13 @@ export class ChatView extends LitElement {
|
||||
private historyRangeLabel() {
|
||||
if (!this.messages.length || this.messageTotal <= 0) return null;
|
||||
const from = this.messageStart + 1;
|
||||
const to = this.messageStart + this.messages.length;
|
||||
return html`<small>Showing messages ${from}–${to} of ${this.messageTotal}</small>`;
|
||||
const to = this.loadedRawMessageEnd();
|
||||
const total = Math.max(this.messageTotal, to);
|
||||
return html`<small>Showing messages ${from}–${to} of ${total}</small>`;
|
||||
}
|
||||
|
||||
private loadedRawMessageEnd(): number {
|
||||
return Math.max(this.messageEnd, this.messageStart + this.messages.length);
|
||||
}
|
||||
|
||||
private renderMessage(message: ChatLine, index: number) {
|
||||
|
||||
@@ -23,7 +23,9 @@ import { themePackPlugin } from "../plugins/themes";
|
||||
import { loadExternalPlugins } from "../plugins/external";
|
||||
import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry";
|
||||
import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
||||
import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMode";
|
||||
import { AppShellController } from "../appShell/appShellController";
|
||||
import { MobileNavigationController, type NavigationSection } from "../appShell/navigationState";
|
||||
import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController";
|
||||
import { readRoute, writeRoute, type AppRoute } from "../route";
|
||||
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
|
||||
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
|
||||
@@ -42,10 +44,14 @@ import "./AuthDialog";
|
||||
import "./ProjectDialog";
|
||||
import "./WorkspacePanel";
|
||||
import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import "./appShell/AppContextBar";
|
||||
import "./appShell/AppMobileMainTabs";
|
||||
import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs";
|
||||
import "./appShell/AppNavigationPanel";
|
||||
import "./appShell/AppPanelEdgeControl";
|
||||
import "./appShell/AppRefreshControl";
|
||||
import { appStyles } from "./shared";
|
||||
|
||||
type NavigationSection = "machines" | "projects" | "workspaces" | "sessions";
|
||||
|
||||
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
|
||||
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
|
||||
@@ -53,16 +59,12 @@ const THEME_AUTO_ON_VALUE = "auto:on";
|
||||
const THEME_AUTO_OFF_VALUE = "auto:off";
|
||||
const THEME_OPTION_PREFIX = "theme:";
|
||||
const TERMINAL_ROUTE_NAMESPACE = queryNamespace("core:workspace.terminal");
|
||||
const REFRESH_LONG_PRESS_MS = 550;
|
||||
|
||||
@customElement("pi-web-app")
|
||||
export class PiWebApp extends LitElement {
|
||||
@state() private state: AppState = initialAppState();
|
||||
@query("chat-view") private chatView?: ChatView;
|
||||
@query("prompt-editor") private promptEditor?: PromptEditor;
|
||||
@query(".context-items") private contextItems?: HTMLElement | null;
|
||||
@query(".mobile-tabs") private mobileTabs?: HTMLElement | null;
|
||||
@query(".app-refresh") private appRefresh?: HTMLElement | null;
|
||||
|
||||
private readonly sessions = new SessionController(
|
||||
() => this.state,
|
||||
@@ -109,13 +111,14 @@ export class PiWebApp extends LitElement {
|
||||
private readonly realtime = new RealtimeSocket();
|
||||
private readonly activeTerminalIds = new Set<string>();
|
||||
private readonly terminalSelection = new InMemoryTerminalSelectionMemory();
|
||||
private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined;
|
||||
private readonly appShell = new AppShellController(this);
|
||||
private readonly panelCollapse = new PanelCollapseController(this);
|
||||
private readonly mobileNavigation = new MobileNavigationController(
|
||||
this,
|
||||
() => this.state,
|
||||
() => this.appShell.isMobileNavigationLayout,
|
||||
);
|
||||
private readonly systemLightThemeMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(prefers-color-scheme: light)") : undefined;
|
||||
private readonly pwaDisplayModeMedia = createPwaDisplayModeMedia();
|
||||
private observedContextItems: HTMLElement | undefined;
|
||||
private observedMobileTabs: HTMLElement | undefined;
|
||||
private contextItemsResizeObserver: ResizeObserver | undefined;
|
||||
private mobileTabsResizeObserver: ResizeObserver | undefined;
|
||||
private terminalAutoStartWorkspaceId: string | undefined;
|
||||
private piWebStatusTimer: number | undefined;
|
||||
private workspaceDeletionPollTimer: number | undefined;
|
||||
@@ -126,21 +129,14 @@ export class PiWebApp extends LitElement {
|
||||
private restoringRouteTerminalId: string | undefined;
|
||||
private readonly plugins = createPluginRegistry();
|
||||
private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE;
|
||||
private refreshLongPressTimer: number | undefined;
|
||||
private suppressNextRefreshClick = false;
|
||||
@state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID;
|
||||
@state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false;
|
||||
@state() private isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia);
|
||||
@state() private isRefreshingApp = false;
|
||||
@state() private refreshMenuOpen = false;
|
||||
@state() private refreshMenuStyle = "";
|
||||
@state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined;
|
||||
@state() private contextCanScrollLeft = false;
|
||||
@state() private contextCanScrollRight = false;
|
||||
@state() private mobileTabsCanScrollLeft = false;
|
||||
@state() private mobileTabsCanScrollRight = false;
|
||||
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
|
||||
private readonly onPageShow = () => {
|
||||
this.appShell.repairViewportPosition();
|
||||
};
|
||||
private readonly onFocus = () => {
|
||||
this.appShell.repairViewportPosition();
|
||||
void this.sessions.refreshSelectedSession();
|
||||
void this.refreshPiWebStatus();
|
||||
void this.refreshWorkspaceActivity();
|
||||
@@ -148,59 +144,35 @@ export class PiWebApp extends LitElement {
|
||||
};
|
||||
private readonly onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
this.appShell.repairViewportPosition();
|
||||
void this.sessions.refreshSelectedSession();
|
||||
void this.refreshPiWebStatus();
|
||||
void this.refreshWorkspaceActivity();
|
||||
void this.refreshWorkspaceDeletionRuns();
|
||||
}
|
||||
};
|
||||
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
|
||||
this.isMobileNavigationLayout = event.matches;
|
||||
this.updateContextScrollState();
|
||||
this.updateMobileTabsScrollState();
|
||||
};
|
||||
private readonly onSystemLightThemeChange = () => {
|
||||
if (this.themePreference.auto) this.applyPreferredTheme(false);
|
||||
};
|
||||
private readonly onPwaDisplayModeChange = () => {
|
||||
this.isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia);
|
||||
};
|
||||
private readonly onContextScroll = () => {
|
||||
this.updateContextScrollState();
|
||||
};
|
||||
private readonly onMobileTabsScroll = () => {
|
||||
this.updateMobileTabsScrollState();
|
||||
};
|
||||
private readonly onDocumentClick = (event: MouseEvent) => {
|
||||
const refresh = this.appRefreshElement();
|
||||
if (refresh !== undefined && event.composedPath().includes(refresh)) return;
|
||||
this.refreshMenuOpen = false;
|
||||
this.suppressNextRefreshClick = false;
|
||||
};
|
||||
private readonly onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && this.refreshMenuOpen) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.refreshMenuOpen = false;
|
||||
this.suppressNextRefreshClick = false;
|
||||
return;
|
||||
}
|
||||
if (this.keyboard.handle(event, this.getActions())) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
protected override willUpdate(): void {
|
||||
this.toggleAttribute("pwa-display-mode", this.appShell.isPwaDisplayMode);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("popstate", this.onPopState);
|
||||
window.addEventListener("pageshow", this.onPageShow);
|
||||
window.addEventListener("focus", this.onFocus);
|
||||
document.addEventListener("click", this.onDocumentClick);
|
||||
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
||||
window.addEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
|
||||
this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange);
|
||||
this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange);
|
||||
for (const media of this.pwaDisplayModeMedia) media.addEventListener("change", this.onPwaDisplayModeChange);
|
||||
this.applyPreferredTheme(false);
|
||||
this.connectRealtime();
|
||||
this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS);
|
||||
@@ -212,13 +184,11 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
window.removeEventListener("popstate", this.onPopState);
|
||||
window.removeEventListener("pageshow", this.onPageShow);
|
||||
window.removeEventListener("focus", this.onFocus);
|
||||
document.removeEventListener("click", this.onDocumentClick);
|
||||
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
||||
window.removeEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
|
||||
this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange);
|
||||
this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange);
|
||||
for (const media of this.pwaDisplayModeMedia) media.removeEventListener("change", this.onPwaDisplayModeChange);
|
||||
this.keyboard.reset();
|
||||
this.auth.dispose();
|
||||
this.sessions.dispose();
|
||||
@@ -228,30 +198,9 @@ export class PiWebApp extends LitElement {
|
||||
this.piWebStatusTimer = undefined;
|
||||
if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer);
|
||||
this.workspaceDeletionPollTimer = undefined;
|
||||
this.contextItemsResizeObserver?.disconnect();
|
||||
this.contextItemsResizeObserver = undefined;
|
||||
this.observedContextItems = undefined;
|
||||
this.mobileTabsResizeObserver?.disconnect();
|
||||
this.mobileTabsResizeObserver = undefined;
|
||||
this.observedMobileTabs = undefined;
|
||||
this.clearRefreshLongPressTimer();
|
||||
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>) {
|
||||
if (!patchChangesState(this.state, patch)) return;
|
||||
const previous = this.state;
|
||||
@@ -264,9 +213,11 @@ export class PiWebApp extends LitElement {
|
||||
private async loadProjectsAndRestoreRoute() {
|
||||
const route = readRoute();
|
||||
await this.machines.loadMachines(route.machineId);
|
||||
const machineFallbackMessage = this.state.error;
|
||||
const effectiveRoute = this.routeForSelectedMachine(route);
|
||||
if (effectiveRoute !== route) this.replaceRouteAndClearWorkspaceQuery(effectiveRoute);
|
||||
await this.projects.loadProjects();
|
||||
if (machineFallbackMessage !== "" && this.state.error === "") this.setState({ error: machineFallbackMessage });
|
||||
await this.withChatScrollTransition(() => this.restoreRouteFor(effectiveRoute, false));
|
||||
await this.refreshWorkspaceDeletionRuns();
|
||||
}
|
||||
@@ -289,8 +240,6 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private async refreshAppData(): Promise<void> {
|
||||
if (this.isRefreshingApp) return;
|
||||
this.refreshMenuOpen = false;
|
||||
this.suppressNextRefreshClick = false;
|
||||
this.isRefreshingApp = true;
|
||||
try {
|
||||
await Promise.all([
|
||||
@@ -393,7 +342,11 @@ export class PiWebApp extends LitElement {
|
||||
await this.chatView?.updateComplete;
|
||||
await nextFrame();
|
||||
this.chatView?.restoreScrollPosition();
|
||||
this.promptEditor?.focusInput();
|
||||
if (this.shouldAutoFocusPrompt()) this.promptEditor?.focusInput();
|
||||
}
|
||||
|
||||
private shouldAutoFocusPrompt(): boolean {
|
||||
return this.appShell.shouldAutoFocusPrompt();
|
||||
}
|
||||
|
||||
private async withChatPrependTransition(action: () => Promise<void>) {
|
||||
@@ -403,7 +356,7 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private defaultRouteView(): AppState["mainView"] {
|
||||
return this.isMobileNavigationLayout ? "navigation" : "chat";
|
||||
return this.appShell.defaultRouteView();
|
||||
}
|
||||
|
||||
private updateUrl(options?: { replace?: boolean | undefined }) {
|
||||
@@ -585,7 +538,44 @@ export class PiWebApp extends LitElement {
|
||||
const panelContext = workspace === undefined ? undefined : this.createWorkspacePanelContext(workspace);
|
||||
const workspaceLabelItems = workspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, workspace);
|
||||
const emptyState = workspace === undefined ? this.workspacePanelEmptyState() : undefined;
|
||||
return html`<workspace-panel .workspace=${workspace} .panelContext=${panelContext} .emptyState=${emptyState} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(tool); }}></workspace-panel>`;
|
||||
return html`
|
||||
<workspace-panel
|
||||
id="workspace-panel"
|
||||
.workspace=${workspace}
|
||||
.panelContext=${panelContext}
|
||||
.emptyState=${emptyState}
|
||||
.tool=${this.state.workspaceTool}
|
||||
.panels=${this.visibleWorkspacePanels()}
|
||||
.workspaceLabelItems=${workspaceLabelItems}
|
||||
.onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(tool); }}
|
||||
></workspace-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNavigationPanelEdgeControl() {
|
||||
return html`
|
||||
<app-panel-edge-control
|
||||
side="navigation"
|
||||
controls="navigation-panel"
|
||||
expandLabel="Expand navigation panel"
|
||||
collapseLabel="Collapse navigation panel"
|
||||
.collapsed=${this.panelCollapse.navigationPanelCollapsed}
|
||||
.onToggle=${() => { this.panelCollapse.toggleNavigationPanel(); }}
|
||||
></app-panel-edge-control>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWorkspacePanelEdgeControl() {
|
||||
return html`
|
||||
<app-panel-edge-control
|
||||
side="workspace"
|
||||
controls="workspace-panel"
|
||||
expandLabel="Expand workspace panel"
|
||||
collapseLabel="Collapse workspace panel"
|
||||
.collapsed=${this.panelCollapse.workspacePanelCollapsed}
|
||||
.onToggle=${() => { this.panelCollapse.toggleWorkspacePanel(); }}
|
||||
></app-panel-edge-control>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNavigationPanel(autoSwitchToChat: boolean) {
|
||||
@@ -595,103 +585,62 @@ export class PiWebApp extends LitElement {
|
||||
if (autoSwitchToChat) this.updateUrl();
|
||||
});
|
||||
return html`
|
||||
<header>
|
||||
<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>
|
||||
<machine-list
|
||||
<app-navigation-panel
|
||||
.machines=${this.state.machines}
|
||||
.selected=${this.state.selectedMachine}
|
||||
.statuses=${this.state.machineStatuses}
|
||||
.collapsible=${this.isMobileNavigationLayout}
|
||||
.collapsed=${this.isNavigationSectionCollapsed("machines")}
|
||||
.onToggleCollapsed=${() => { this.toggleNavigationSection("machines"); }}
|
||||
.onSelect=${(machine: Machine) => this.withChatScrollTransition(async () => {
|
||||
this.expandNavigationSection("projects");
|
||||
.selectedMachine=${this.state.selectedMachine}
|
||||
.machineStatuses=${this.state.machineStatuses}
|
||||
.machinesCollapsed=${this.mobileNavigation.isCollapsed("machines")}
|
||||
.onToggleMachines=${() => { this.mobileNavigation.toggle("machines"); }}
|
||||
.onSelectMachine=${(machine: Machine) => this.withChatScrollTransition(async () => {
|
||||
this.mobileNavigation.expand("projects");
|
||||
await this.machines.selectMachine(machine);
|
||||
})}
|
||||
></machine-list>
|
||||
<project-list
|
||||
.projects=${this.state.projects}
|
||||
.selected=${this.state.selectedProject}
|
||||
.activities=${this.state.workspaceActivities}
|
||||
.selectedProject=${this.state.selectedProject}
|
||||
.workspaceActivities=${this.state.workspaceActivities}
|
||||
.workspacesByProjectId=${this.state.workspacesByProjectId}
|
||||
.collapsible=${this.isMobileNavigationLayout}
|
||||
.collapsed=${this.isNavigationSectionCollapsed("projects")}
|
||||
.onToggleCollapsed=${() => { this.toggleNavigationSection("projects"); }}
|
||||
.onSelect=${(project: Project) => this.withChatScrollTransition(async () => {
|
||||
this.expandNavigationSection("workspaces");
|
||||
.workspaces=${this.state.workspaces}
|
||||
.selectedWorkspace=${this.state.selectedWorkspace}
|
||||
.deletingWorkspaceIds=${pendingWorkspaceDeletionIds(this.state.workspaceDeletionRuns)}
|
||||
.sessions=${this.state.sessions}
|
||||
.sessionStatuses=${this.state.sessionStatuses}
|
||||
.sessionActivities=${this.state.sessionActivities}
|
||||
.selectedSession=${this.state.selectedSession}
|
||||
.canStartSession=${!!this.state.selectedWorkspace}
|
||||
.collapsible=${this.appShell.isMobileNavigationLayout}
|
||||
.projectsCollapsed=${this.mobileNavigation.isCollapsed("projects")}
|
||||
.workspacesCollapsed=${this.mobileNavigation.isCollapsed("workspaces")}
|
||||
.sessionsCollapsed=${this.mobileNavigation.isCollapsed("sessions")}
|
||||
.workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)}
|
||||
.refreshControl=${this.appShell.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : undefined}
|
||||
.onShowActions=${() => { this.setState({ actionPaletteOpen: true }); }}
|
||||
.onToggleProjects=${() => { this.mobileNavigation.toggle("projects"); }}
|
||||
.onToggleWorkspaces=${() => { this.mobileNavigation.toggle("workspaces"); }}
|
||||
.onToggleSessions=${() => { this.mobileNavigation.toggle("sessions"); }}
|
||||
.onSelectProject=${(project: Project) => this.withChatScrollTransition(async () => {
|
||||
this.mobileNavigation.expand("workspaces");
|
||||
await this.workspaces.selectProject(project);
|
||||
})}
|
||||
.onClose=${(project: Project) => this.projects.closeProject(project.id)}
|
||||
></project-list>
|
||||
<workspace-list
|
||||
.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");
|
||||
.onCloseProject=${(project: Project) => this.projects.closeProject(project.id)}
|
||||
.onSelectWorkspace=${(workspace: Workspace) => this.withChatScrollTransition(async () => {
|
||||
this.mobileNavigation.expand("sessions");
|
||||
await this.workspaces.selectWorkspace(workspace);
|
||||
})}
|
||||
.onDelete=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
|
||||
></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"); }}
|
||||
.onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
|
||||
.onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }}
|
||||
.onStart=${() => openChatAfter(() => this.sessions.startSession())}
|
||||
.onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))}
|
||||
.onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)}
|
||||
.onArchiveWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
|
||||
.onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))}
|
||||
.onDelete=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)}
|
||||
.onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)}
|
||||
></session-list>
|
||||
.onStartSession=${() => openChatAfter(() => this.sessions.startSession())}
|
||||
.onSelectSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))}
|
||||
.onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)}
|
||||
.onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
|
||||
.onRestoreSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))}
|
||||
.onDeleteCachedNewSession=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)}
|
||||
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
|
||||
></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 {
|
||||
if (!this.isMobileNavigationLayout) return;
|
||||
this.expandNavigationSection(section);
|
||||
this.selectMainView("navigation");
|
||||
this.mobileNavigation.open(section, () => { this.selectMainView("navigation"); });
|
||||
}
|
||||
|
||||
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
|
||||
@@ -827,6 +776,7 @@ export class PiWebApp extends LitElement {
|
||||
deleteWorkspace: (workspace) => this.deleteWorkspace(workspace),
|
||||
startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()),
|
||||
archiveSession: () => this.sessions.archiveSession(),
|
||||
deleteCachedNewSession: () => this.sessions.deleteCachedNewSession(),
|
||||
stopActiveWork: () => this.sessions.stopActiveWork(),
|
||||
}, createContext);
|
||||
return createContext("core");
|
||||
@@ -1111,232 +1061,54 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private renderContextBar() {
|
||||
const machine = this.state.selectedMachine;
|
||||
const project = this.state.selectedProject;
|
||||
const workspace = this.state.selectedWorkspace;
|
||||
const session = this.state.selectedSession;
|
||||
const machineLabel = machineContextLabel(machine);
|
||||
const projectLabel = projectContextLabel(project);
|
||||
const showRefresh = this.shouldShowAppRefreshInContextBar();
|
||||
const workspaceLabel = workspaceContextLabel(workspace);
|
||||
const sessionLabel = sessionContextLabel(session);
|
||||
if (!this.appShell.isMobileNavigationLayout) return null;
|
||||
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=${machine === undefined ? "context-chip empty" : "context-chip"} title=${machineContextTitle(machine)} aria-label=${`Machine: ${machineLabel}. Open machine selection.`} @click=${() => { this.openNavigationSection("machines"); }}>
|
||||
<span class="context-kind">Machine</span>
|
||||
<span class="context-value">${machineLabel}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="context-item">
|
||||
<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"); }}>
|
||||
<span class="context-kind">Project</span>
|
||||
<span class="context-value">${projectLabel}</span>
|
||||
</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>
|
||||
<app-context-bar
|
||||
.machine=${this.state.selectedMachine}
|
||||
.project=${this.state.selectedProject}
|
||||
.workspace=${this.state.selectedWorkspace}
|
||||
.session=${this.state.selectedSession}
|
||||
.refreshControl=${this.appShell.shouldShowAppRefreshInContextBar() ? this.renderAppRefresh() : undefined}
|
||||
.onOpenSection=${(section: NavigationSection) => { this.openNavigationSection(section); }}
|
||||
></app-context-bar>
|
||||
`;
|
||||
}
|
||||
|
||||
private contextBarClass(): string {
|
||||
const classes = ["context-bar"];
|
||||
if (this.shouldShowAppRefreshInContextBar()) classes.push("has-context-actions");
|
||||
if (this.contextCanScrollLeft) classes.push("can-scroll-left");
|
||||
if (this.contextCanScrollRight) classes.push("can-scroll-right");
|
||||
return classes.join(" ");
|
||||
private renderMobileMainTabs() {
|
||||
return html`
|
||||
<app-mobile-main-tabs
|
||||
.tabs=${this.mobileMainTabs()}
|
||||
.selectedView=${this.state.mainView}
|
||||
.onSelect=${(view: AppState["mainView"]) => { this.selectMainView(view); }}
|
||||
></app-mobile-main-tabs>
|
||||
`;
|
||||
}
|
||||
|
||||
private shouldShowAppRefreshInHeader(): boolean {
|
||||
return this.isPwaDisplayMode && !this.isMobileNavigationLayout;
|
||||
}
|
||||
|
||||
private shouldShowAppRefreshInContextBar(): boolean {
|
||||
return this.isPwaDisplayMode && this.isMobileNavigationLayout;
|
||||
}
|
||||
|
||||
private mobileTabsFrameClass(): string {
|
||||
return `mobile-tabs-frame${this.mobileTabsCanScrollLeft ? " can-scroll-left" : ""}${this.mobileTabsCanScrollRight ? " can-scroll-right" : ""}`;
|
||||
}
|
||||
|
||||
private observeContextItems(): void {
|
||||
const contextItems = this.contextItemsElement();
|
||||
if (this.observedContextItems === contextItems) return;
|
||||
this.contextItemsResizeObserver?.disconnect();
|
||||
this.observedContextItems = contextItems;
|
||||
this.contextItemsResizeObserver = undefined;
|
||||
if (contextItems === undefined || typeof ResizeObserver === "undefined") return;
|
||||
this.contextItemsResizeObserver = new ResizeObserver(() => {
|
||||
this.updateContextScrollState();
|
||||
});
|
||||
this.contextItemsResizeObserver.observe(contextItems);
|
||||
}
|
||||
|
||||
private updateContextScrollState(): void {
|
||||
const contextItems = this.contextItemsElement();
|
||||
const maxScrollLeft = contextItems === undefined ? 0 : Math.max(0, contextItems.scrollWidth - contextItems.clientWidth);
|
||||
const canScrollLeft = contextItems !== undefined && contextItems.scrollLeft > 1;
|
||||
const canScrollRight = contextItems !== undefined && maxScrollLeft - contextItems.scrollLeft > 1;
|
||||
if (this.contextCanScrollLeft !== canScrollLeft) this.contextCanScrollLeft = canScrollLeft;
|
||||
if (this.contextCanScrollRight !== canScrollRight) this.contextCanScrollRight = canScrollRight;
|
||||
}
|
||||
|
||||
private contextItemsElement(): HTMLElement | undefined {
|
||||
const contextItems = this.contextItems;
|
||||
return contextItems instanceof HTMLElement ? contextItems : undefined;
|
||||
}
|
||||
|
||||
private observeMobileTabs(): void {
|
||||
const mobileTabs = this.mobileTabsElement();
|
||||
if (this.observedMobileTabs === mobileTabs) return;
|
||||
this.mobileTabsResizeObserver?.disconnect();
|
||||
this.observedMobileTabs = mobileTabs;
|
||||
this.mobileTabsResizeObserver = undefined;
|
||||
if (mobileTabs === undefined || typeof ResizeObserver === "undefined") return;
|
||||
this.mobileTabsResizeObserver = new ResizeObserver(() => {
|
||||
this.updateMobileTabsScrollState();
|
||||
});
|
||||
this.mobileTabsResizeObserver.observe(mobileTabs);
|
||||
}
|
||||
|
||||
private updateMobileTabsScrollState(): void {
|
||||
const mobileTabs = this.mobileTabsElement();
|
||||
const maxScrollLeft = mobileTabs === undefined ? 0 : Math.max(0, mobileTabs.scrollWidth - mobileTabs.clientWidth);
|
||||
const canScrollLeft = mobileTabs !== undefined && mobileTabs.scrollLeft > 1;
|
||||
const canScrollRight = mobileTabs !== undefined && maxScrollLeft - mobileTabs.scrollLeft > 1;
|
||||
if (this.mobileTabsCanScrollLeft !== canScrollLeft) this.mobileTabsCanScrollLeft = canScrollLeft;
|
||||
if (this.mobileTabsCanScrollRight !== canScrollRight) this.mobileTabsCanScrollRight = canScrollRight;
|
||||
}
|
||||
|
||||
private mobileTabsElement(): HTMLElement | undefined {
|
||||
const mobileTabs = this.mobileTabs;
|
||||
return mobileTabs instanceof HTMLElement ? mobileTabs : undefined;
|
||||
}
|
||||
|
||||
private appRefreshElement(): HTMLElement | undefined {
|
||||
const appRefresh = this.appRefresh;
|
||||
return appRefresh instanceof HTMLElement ? appRefresh : undefined;
|
||||
private mobileMainTabs(): AppMobileMainTab[] {
|
||||
return [
|
||||
{ id: "navigation", label: "Sessions", className: "navigation-tab" },
|
||||
{ id: "chat", label: "Chat" },
|
||||
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => ({ id: panel.id, label: this.renderMobilePanelTitle(panel) })),
|
||||
];
|
||||
}
|
||||
|
||||
private renderAppRefresh() {
|
||||
const label = this.isRefreshingApp ? "Refreshing app data. Long-press for reload options." : "Refresh app data. Long-press for reload options.";
|
||||
return html`
|
||||
<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;
|
||||
return html`<app-refresh-control .isRefreshing=${this.isRefreshingApp} .onRefresh=${() => this.refreshAppData()} .onReload=${() => { this.hardReloadApp(); }}></app-refresh-control>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const state = this.state;
|
||||
return html`
|
||||
<div class=${`shell ${state.mainView === "navigation" ? "navigation-view" : state.mainView === "chat" ? "chat-view" : "workspace-view"}`}>
|
||||
<aside>${this.isMobileNavigationLayout ? null : this.renderNavigationPanel(false)}</aside>
|
||||
<main class=${state.mainView === "chat" ? "chat-view" : state.mainView === "navigation" ? "navigation-view" : "workspace-view"}>
|
||||
<div class=${this.panelCollapse.shellClass(state.mainView)}>
|
||||
<aside id="navigation-panel">${this.appShell.isMobileNavigationLayout ? null : this.renderNavigationPanel(false)}</aside>
|
||||
${this.renderNavigationPanelEdgeControl()}
|
||||
<main class=${mainViewClass(state.mainView)}>
|
||||
${this.renderContextBar()}
|
||||
<div class=${this.mobileTabsFrameClass()}>
|
||||
<div class="mobile-tabs" @scroll=${this.onMobileTabsScroll}>
|
||||
<button class=${state.mainView === "navigation" ? "mobile-navigation-tab selected" : "mobile-navigation-tab"} @click=${() => { this.selectMainView("navigation"); }}>Sessions</button>
|
||||
<button class=${state.mainView === "chat" ? "selected" : ""} @click=${() => { this.selectMainView("chat"); }}>Chat</button>
|
||||
${this.visibleWorkspacePanels().map((panel) => html`
|
||||
<button class=${state.mainView === panel.id ? "selected" : ""} @click=${() => { this.openWorkspaceTool(panel.id); }}>${this.renderMobilePanelTitle(panel)}</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
${this.renderMobileMainTabs()}
|
||||
${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`
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .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} .machineId=${selectedMachineId(state)} .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>
|
||||
<status-bar .status=${state.status} .machine=${state.selectedMachine} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(state, state.selectedWorkspace)}></status-bar>
|
||||
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
|
||||
@@ -1345,11 +1117,11 @@ export class PiWebApp extends LitElement {
|
||||
${state.authDialog !== undefined ? html`<auth-dialog .state=${state.authDialog} .onChooseMethod=${(authType: "oauth" | "api_key") => { void this.auth.chooseLoginMethod(authType); }} .onSelectProvider=${(providerId: string, authType: "oauth" | "api_key") => { void this.auth.selectLoginProvider(providerId, authType); }} .onApiKeyInput=${(value: string) => { this.auth.updateApiKey(value); }} .onSaveApiKey=${() => { void this.auth.saveApiKey(); }} .onLogoutProvider=${(providerId: string) => { void this.auth.logoutProvider(providerId); }} .onOAuthInput=${(value: string) => { this.auth.updateOAuthInput(value); }} .onOAuthRespond=${(value?: string) => { void this.auth.respondOAuth(value); }} .onOAuthCancel=${() => { void this.auth.cancelOAuth(); }} .onCancel=${() => { this.auth.closeDialog(); }}></auth-dialog>` : null}
|
||||
` : html`<div class="empty">${this.sessionEmptyMessage()}</div>`}
|
||||
</main>
|
||||
${this.renderWorkspacePanelEdgeControl()}
|
||||
${this.renderWorkspacePanel()}
|
||||
${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 .machineId=${selectedMachineId(state)} .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}
|
||||
${this.renderRefreshMenu()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1364,39 +1136,6 @@ function createPluginRegistry(): PluginRegistry {
|
||||
return registry;
|
||||
}
|
||||
|
||||
function machineContextLabel(machine: Machine | undefined): string {
|
||||
return machine === undefined ? "No machine" : `${machine.name}${machine.kind === "remote" ? " · remote" : ""}`;
|
||||
}
|
||||
|
||||
function machineContextTitle(machine: Machine | undefined): string {
|
||||
return machine === undefined ? "No machine selected" : machine.baseUrl ?? machine.name;
|
||||
}
|
||||
|
||||
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 {
|
||||
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value);
|
||||
|
||||
@@ -44,27 +44,31 @@ export class ProjectList extends LitElement {
|
||||
return html`
|
||||
<section>
|
||||
<h2>${this.renderHeading()}</h2>
|
||||
${this.collapsed ? null : this.projects.map((project) => html`
|
||||
<div
|
||||
class=${`action-row ${this.selected?.id === project.id ? "selected" : ""}`}
|
||||
tabindex="0"
|
||||
title=${project.path}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(project)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
<span class="action-name">${project.name}</span><small>${this.renderActivity(project)}${project.path}</small>
|
||||
</div>
|
||||
<div class="action-menu">
|
||||
<button class="action-menu-toggle" title="Project actions" aria-label=${`Actions for ${project.name}`} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(project.id, event.currentTarget); }}>⋯</button>
|
||||
${this.openMenuProjectId === project.id ? html`
|
||||
<div class="action-menu-panel" style=${this.menuStyle}>
|
||||
<button title="Close project" @click=${() => { this.close(project); }}>Close</button>
|
||||
${this.collapsed ? null : html`
|
||||
<div class="list-body">
|
||||
${this.projects.map((project) => html`
|
||||
<div
|
||||
class=${`action-row ${this.selected?.id === project.id ? "selected" : ""}`}
|
||||
tabindex="0"
|
||||
title=${project.path}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(project)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
<span class="action-name">${project.name}</span><small>${this.renderActivity(project)}${project.path}</small>
|
||||
</div>
|
||||
` : null}
|
||||
</div>
|
||||
<div class="action-menu">
|
||||
<button class="action-menu-toggle" title="Project actions" aria-label=${`Actions for ${project.name}`} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(project.id, event.currentTarget); }}>⋯</button>
|
||||
${this.openMenuProjectId === project.id ? html`
|
||||
<div class="action-menu-panel" style=${this.menuStyle}>
|
||||
<button title="Close project" @click=${() => { this.close(project); }}>Close</button>
|
||||
</div>
|
||||
` : null}
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`)}
|
||||
`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ export class PromptEditor extends LitElement {
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))),
|
||||
placeholder("Message pi... Use / for commands, @ for files"),
|
||||
placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"),
|
||||
this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
|
||||
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
|
||||
EditorView.updateListener.of((update) => {
|
||||
@@ -188,12 +188,12 @@ export class PromptEditor extends LitElement {
|
||||
...(command.description === undefined ? {} : { description: command.description }),
|
||||
}));
|
||||
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
|
||||
const files = await api.files(this.cwd, trigger.query, trigger.fileKind, trigger.fileMode, this.machineId).catch(emptyFileSuggestions);
|
||||
const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId }).catch(emptyFileSuggestions);
|
||||
if (version !== this.requestVersion) return;
|
||||
this.completions = files
|
||||
.slice(0, 12)
|
||||
.map((file) => {
|
||||
const insertText = fileInsertText(file.path, trigger.fileMode === "path", trigger.quoted === true);
|
||||
const insertText = fileInsertText(file.path, trigger.quoted === true, file.path.endsWith("/") ? trigger.allPrefix : undefined);
|
||||
return {
|
||||
kind: "file",
|
||||
replaceFrom: trigger.from,
|
||||
@@ -206,7 +206,7 @@ export class PromptEditor extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileKind?: FileSuggestion["kind"]; fileMode?: "file" | "path"; quoted?: boolean } | undefined {
|
||||
private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean } | undefined {
|
||||
const cursor = this.editor?.state.selection.main.head ?? this.draft.length;
|
||||
const beforeCursor = this.draft.slice(0, cursor);
|
||||
const quotedTrigger = this.currentQuotedTrigger(beforeCursor, cursor);
|
||||
@@ -215,18 +215,20 @@ export class PromptEditor extends LitElement {
|
||||
const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1;
|
||||
const token = beforeCursor.slice(tokenStart);
|
||||
const beforeToken = beforeCursor.slice(0, tokenStart);
|
||||
if (beforeToken.endsWith("@ ")) return { kind: "file", query: token, from: tokenStart, to: cursor, fileMode: "path" };
|
||||
if (beforeToken.endsWith("@ ")) return { kind: "file", query: token, from: tokenStart - 2, to: cursor, fileScope: "all", allPrefix: "@ " };
|
||||
if (token.startsWith("/") && tokenStart === 0) return { kind: "command", query: token.slice(1), from: tokenStart, to: cursor };
|
||||
if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor };
|
||||
if (token.startsWith("!@")) return { kind: "file", query: token.slice(2), from: tokenStart, to: cursor, fileScope: "all", allPrefix: "!@" };
|
||||
if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor, fileScope: "tracked" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private currentQuotedTrigger(beforeCursor: string, cursor: number): { kind: "file"; query: string; from: number; to: number; fileMode?: "file" | "path"; quoted: true } | undefined {
|
||||
private currentQuotedTrigger(beforeCursor: string, cursor: number): { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted: true } | undefined {
|
||||
const quoteStart = beforeCursor.lastIndexOf("\"");
|
||||
if (quoteStart === -1) return undefined;
|
||||
const prefix = beforeCursor.slice(0, quoteStart);
|
||||
if (prefix.endsWith("@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 1, to: cursor, quoted: true };
|
||||
if (prefix.endsWith("@ ")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: quoteStart, to: cursor, fileMode: "path", quoted: true };
|
||||
if (prefix.endsWith("!@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 2, to: cursor, fileScope: "all", allPrefix: "!@", quoted: true };
|
||||
if (prefix.endsWith("@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 1, to: cursor, fileScope: "tracked", quoted: true };
|
||||
if (prefix.endsWith("@ ")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 2, to: cursor, fileScope: "all", allPrefix: "@ ", quoted: true };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -299,8 +301,8 @@ function draftStorageKey(machineId: unknown, sessionId: unknown): string | undef
|
||||
return machineSessionKey(machineId, sessionId);
|
||||
}
|
||||
|
||||
function fileInsertText(path: string, pathMode: boolean, quoted: boolean): string {
|
||||
const prefix = pathMode ? "" : "@";
|
||||
function fileInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string {
|
||||
const prefix = allPrefix ?? "@";
|
||||
if (!quoted && !path.includes(" ")) return `${prefix}${path}`;
|
||||
return `${prefix}"${path}"`;
|
||||
}
|
||||
|
||||
@@ -76,11 +76,15 @@ export class SessionList extends LitElement {
|
||||
return html`
|
||||
<section>
|
||||
${this.renderHeading(activeRows.length + archivedRows.length)}
|
||||
${this.collapsed ? null : activeRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0))}
|
||||
${this.collapsed ? null : archivedRows.length > 0 ? html`
|
||||
<h2 class="subheading"><button class="section-toggle" aria-expanded=${String(this.archivedExpanded)} @click=${() => { this.toggleArchived(); }}><span>${this.archivedExpanded ? "▾" : "▸"} Archived</span><small>${archivedRows.length}</small></button></h2>
|
||||
${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0)) : null}
|
||||
` : null}
|
||||
${this.collapsed ? null : html`
|
||||
<div class="list-body">
|
||||
${activeRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0))}
|
||||
${archivedRows.length > 0 ? html`
|
||||
<h2 class="subheading"><button class="section-toggle" aria-expanded=${String(this.archivedExpanded)} @click=${() => { this.toggleArchived(); }}><span>${this.archivedExpanded ? "▾" : "▸"} Archived</span><small>${archivedRows.length}</small></button></h2>
|
||||
${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0)) : null}
|
||||
` : null}
|
||||
</div>
|
||||
`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { FitAddon, type ITerminalDimensions } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { terminalSocket, terminalsApi, type TerminalCommandRun, type TerminalInfo, type Workspace } from "../api";
|
||||
import { selectFallbackTerminal, selectPreferredTerminal } from "../controllers/terminalSelection";
|
||||
import { createTerminalSoftKeysDefaultEnvironmentMedia, hasTerminalSoftKeysPreference, initialTerminalSoftKeysEnabled, isTerminalSoftKeysDefaultEnvironment, writeTerminalSoftKeysPreference } from "../terminalSoftKeysPreference";
|
||||
import "./TerminalSoftKeys";
|
||||
import type { TerminalSoftKeyInputOptions } from "./TerminalSoftKeys";
|
||||
|
||||
const TERMINAL_OPTIONS_BASE: ITerminalOptions = {
|
||||
cursorBlink: true,
|
||||
@@ -32,6 +35,8 @@ export class TerminalPanel extends LitElement {
|
||||
@state() private visible = false;
|
||||
@state() private cancellingRunIds: string[] = [];
|
||||
@state() private continuingTerminalIds: string[] = [];
|
||||
@state() private defaultSoftKeysEnvironment = false;
|
||||
@state() private softKeysEnabled = initialTerminalSoftKeysEnabled();
|
||||
|
||||
private terminal: Terminal | undefined;
|
||||
private fitAddon: FitAddon | undefined;
|
||||
@@ -44,9 +49,16 @@ export class TerminalPanel extends LitElement {
|
||||
private loadedCwd: string | undefined;
|
||||
private autoStartConsumedCwd: string | undefined;
|
||||
private commandRunPollTimer: number | undefined;
|
||||
private readonly softKeysDefaultEnvironmentMedia = createTerminalSoftKeysDefaultEnvironmentMedia();
|
||||
private softKeysPreferenceStored = hasTerminalSoftKeysPreference();
|
||||
private readonly onSoftKeysDefaultEnvironmentChange = () => {
|
||||
this.syncDefaultSoftKeysEnvironment();
|
||||
};
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.syncDefaultSoftKeysEnvironment();
|
||||
this.softKeysDefaultEnvironmentMedia?.addEventListener("change", this.onSoftKeysDefaultEnvironmentChange);
|
||||
this.themeObserver = new MutationObserver(() => { this.applyTerminalTheme(); });
|
||||
this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] });
|
||||
}
|
||||
@@ -63,11 +75,24 @@ export class TerminalPanel extends LitElement {
|
||||
this.intersectionObserver = undefined;
|
||||
this.themeObserver?.disconnect();
|
||||
this.themeObserver = undefined;
|
||||
this.softKeysDefaultEnvironmentMedia?.removeEventListener("change", this.onSoftKeysDefaultEnvironmentChange);
|
||||
this.updateCommandRunPolling(false);
|
||||
this.disposeTerminalView();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private syncDefaultSoftKeysEnvironment(): void {
|
||||
const nextDefaultEnvironment = isTerminalSoftKeysDefaultEnvironment(this.softKeysDefaultEnvironmentMedia);
|
||||
const previousSoftKeysEnabled = this.softKeysEnabled;
|
||||
this.defaultSoftKeysEnvironment = nextDefaultEnvironment;
|
||||
if (!this.softKeysPreferenceStored) this.softKeysEnabled = nextDefaultEnvironment;
|
||||
if (this.softKeysEnabled !== previousSoftKeysEnabled) this.scheduleFitAndNotify();
|
||||
}
|
||||
|
||||
private scheduleFitAndNotify(): void {
|
||||
void this.updateComplete.then(() => { this.fitAndNotify(); });
|
||||
}
|
||||
|
||||
override willUpdate(changed: PropertyValues<this>): void {
|
||||
const workspaceScope = this.workspace === undefined ? undefined : JSON.stringify([this.machineId, this.workspace.path]);
|
||||
if (workspaceScope !== this.observedWorkspaceScope) {
|
||||
@@ -287,8 +312,7 @@ export class TerminalPanel extends LitElement {
|
||||
this.resizeObserver.observe(terminalHost);
|
||||
terminal.onData((data) => {
|
||||
if (this.suppressTerminalInput) return;
|
||||
const filtered = filterTerminalInput(data);
|
||||
if (filtered !== "") this.send({ type: "input", data: filtered });
|
||||
this.sendTerminalInput(data);
|
||||
});
|
||||
const initialSize = this.fitTerminal();
|
||||
this.connectSocket(workspace.projectId, workspace.id, this.selectedId, terminal, initialSize);
|
||||
@@ -376,6 +400,23 @@ export class TerminalPanel extends LitElement {
|
||||
if (this.terminal !== undefined) this.terminal.options.theme = terminalTheme(this);
|
||||
}
|
||||
|
||||
private sendTerminalInput(data: string): void {
|
||||
const filtered = filterTerminalInput(data);
|
||||
if (filtered !== "") this.send({ type: "input", data: filtered });
|
||||
}
|
||||
|
||||
private sendSoftKeyInput(data: string, options: TerminalSoftKeyInputOptions): void {
|
||||
this.sendTerminalInput(data);
|
||||
if (options.refocus) this.focusTerminal();
|
||||
}
|
||||
|
||||
private focusTerminal(): void {
|
||||
const terminal = this.terminal;
|
||||
if (terminal === undefined) return;
|
||||
terminal.focus();
|
||||
requestAnimationFrame(() => { terminal.focus(); });
|
||||
}
|
||||
|
||||
private send(message: { type: "input"; data: string } | { type: "resize"; cols: number; rows: number }): void {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message));
|
||||
}
|
||||
@@ -423,10 +464,61 @@ export class TerminalPanel extends LitElement {
|
||||
return null;
|
||||
}
|
||||
|
||||
private selectedTerminalAcceptsInput(): boolean {
|
||||
const terminal = this.selectedTerminalInfo();
|
||||
return terminal !== undefined && !terminal.exited;
|
||||
}
|
||||
|
||||
private shouldShowSoftKeys(): boolean {
|
||||
return this.selectedTerminalAcceptsInput() && this.softKeysEnabled;
|
||||
}
|
||||
|
||||
private shouldShowSoftKeysToggle(): boolean {
|
||||
return this.selectedTerminalAcceptsInput();
|
||||
}
|
||||
|
||||
private toggleSoftKeys(): void {
|
||||
this.softKeysEnabled = !this.softKeysEnabled;
|
||||
this.softKeysPreferenceStored = true;
|
||||
writeTerminalSoftKeysPreference(this.softKeysEnabled);
|
||||
this.scheduleFitAndNotify();
|
||||
}
|
||||
|
||||
private renderSoftKeysToggle() {
|
||||
if (!this.shouldShowSoftKeysToggle()) return null;
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
class=${this.softKeysEnabled ? "soft-keys-toggle selected" : "soft-keys-toggle"}
|
||||
title=${this.softKeysEnabled ? "Hide terminal soft keys" : "Show terminal soft keys"}
|
||||
aria-label=${this.softKeysEnabled ? "Hide terminal soft keys" : "Show terminal soft keys"}
|
||||
aria-pressed=${String(this.softKeysEnabled)}
|
||||
@click=${() => { this.toggleSoftKeys(); }}
|
||||
>
|
||||
<svg class="keyboard-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2"></rect>
|
||||
<path d="M7 9h.01M10 9h.01M13 9h.01M16 9h.01M7 12h.01M10 12h.01M13 12h.01M16 12h.01M8 16h8"></path>
|
||||
</svg>
|
||||
<span>Keys</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSoftKeys() {
|
||||
return html`
|
||||
<terminal-soft-keys
|
||||
.modes=${this.terminal?.modes}
|
||||
.refocusOnClick=${!this.defaultSoftKeysEnvironment}
|
||||
.onInput=${(data: string, options: TerminalSoftKeyInputOptions) => { this.sendSoftKeyInput(data, options); }}
|
||||
></terminal-soft-keys>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<section class="terminal-shell">
|
||||
<div class="terminal-tabs">
|
||||
${this.renderSoftKeysToggle()}
|
||||
${this.terminals.map((terminal) => html`
|
||||
<button class=${this.selectedId === terminal.id ? "selected" : ""} @click=${() => { this.selectTerminal(terminal.id); }}>
|
||||
<span>${terminal.name}${terminal.exited ? " · exited" : ""}</span>
|
||||
@@ -437,6 +529,7 @@ export class TerminalPanel extends LitElement {
|
||||
</div>
|
||||
${this.error === undefined ? null : html`<p class="error">${this.error}</p>`}
|
||||
${this.renderCommandRunNotice()}
|
||||
${this.shouldShowSoftKeys() ? this.renderSoftKeys() : null}
|
||||
${this.loading ? html`<p class="muted">Loading terminals…</p>` : null}
|
||||
<div class="terminal-host"></div>
|
||||
</section>
|
||||
@@ -450,6 +543,8 @@ export class TerminalPanel extends LitElement {
|
||||
button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; }
|
||||
button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
button.new { flex: 0 0 auto; color: var(--pi-muted); }
|
||||
.soft-keys-toggle { flex: 0 0 auto; }
|
||||
.soft-keys-toggle .keyboard-icon { flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||
button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
button small { color: var(--pi-muted); font-size: 14px; line-height: 1; }
|
||||
button small:hover { color: var(--pi-danger); }
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { TERMINAL_SOFT_KEYS, terminalSoftKeySequence, type TerminalModesSnapshot, type TerminalSoftKeyDefinition } from "../terminalKeys";
|
||||
|
||||
const SOFT_KEY_TAP_MOVE_THRESHOLD_PX = 8;
|
||||
const SYNTHETIC_CLICK_SUPPRESSION_MS = 500;
|
||||
|
||||
export interface TerminalSoftKeyInputOptions {
|
||||
refocus: boolean;
|
||||
}
|
||||
|
||||
@customElement("terminal-soft-keys")
|
||||
export class TerminalSoftKeys extends LitElement {
|
||||
@property({ attribute: false }) modes: TerminalModesSnapshot | undefined;
|
||||
@property({ type: Boolean }) refocusOnClick = true;
|
||||
@property({ attribute: false }) onInput: (data: string, options: TerminalSoftKeyInputOptions) => void = () => undefined;
|
||||
|
||||
private pointerStart: SoftKeyPointerStart | undefined;
|
||||
private lastPointerFinishedAt = 0;
|
||||
|
||||
private sendSoftKey(key: TerminalSoftKeyDefinition, options: TerminalSoftKeyInputOptions): void {
|
||||
this.onInput(terminalSoftKeySequence(key.id, this.modes), options);
|
||||
}
|
||||
|
||||
private onSoftKeyPointerDown(event: PointerEvent, key: TerminalSoftKeyDefinition): void {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
this.pointerStart = { pointerId: event.pointerId, key, clientX: event.clientX, clientY: event.clientY };
|
||||
}
|
||||
|
||||
private onSoftKeyPointerMove(event: PointerEvent): void {
|
||||
const start = this.pointerStart;
|
||||
if (start?.pointerId !== event.pointerId) return;
|
||||
if (pointerMovedBeyondTap(start, event)) this.finishSoftKeyPointer();
|
||||
}
|
||||
|
||||
private onSoftKeyPointerUp(event: PointerEvent, key: TerminalSoftKeyDefinition): void {
|
||||
const start = this.pointerStart;
|
||||
if (start?.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
this.finishSoftKeyPointer();
|
||||
if (start.key.id !== key.id || pointerMovedBeyondTap(start, event)) return;
|
||||
this.sendSoftKey(key, { refocus: event.pointerType === "mouse" });
|
||||
}
|
||||
|
||||
private onSoftKeyPointerCancel(event: PointerEvent): void {
|
||||
if (this.pointerStart?.pointerId === event.pointerId) this.finishSoftKeyPointer();
|
||||
}
|
||||
|
||||
private finishSoftKeyPointer(): void {
|
||||
this.pointerStart = undefined;
|
||||
this.lastPointerFinishedAt = Date.now();
|
||||
}
|
||||
|
||||
private onSoftKeyClick(event: MouseEvent, key: TerminalSoftKeyDefinition): void {
|
||||
if (Date.now() - this.lastPointerFinishedAt < SYNTHETIC_CLICK_SUPPRESSION_MS) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
this.sendSoftKey(key, { refocus: this.refocusOnClick });
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="terminal-soft-keys" role="toolbar" aria-label="Terminal soft keys">
|
||||
${TERMINAL_SOFT_KEYS.map((key) => html`
|
||||
<button
|
||||
type="button"
|
||||
class="soft-key"
|
||||
title=${key.title}
|
||||
aria-label=${key.ariaLabel}
|
||||
@pointerdown=${(event: PointerEvent) => { this.onSoftKeyPointerDown(event, key); }}
|
||||
@pointermove=${(event: PointerEvent) => { this.onSoftKeyPointerMove(event); }}
|
||||
@pointerup=${(event: PointerEvent) => { this.onSoftKeyPointerUp(event, key); }}
|
||||
@pointercancel=${(event: PointerEvent) => { this.onSoftKeyPointerCancel(event); }}
|
||||
@click=${(event: MouseEvent) => { this.onSoftKeyClick(event, key); }}
|
||||
>${key.label}</button>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { flex: 0 0 auto; display: block; }
|
||||
.terminal-soft-keys { display: flex; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; touch-action: pan-x; }
|
||||
.terminal-soft-keys::-webkit-scrollbar { display: none; }
|
||||
button { display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; max-width: none; min-height: 34px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 6px 9px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; cursor: pointer; touch-action: pan-x; -webkit-touch-callout: none; user-select: none; }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
`;
|
||||
}
|
||||
|
||||
interface SoftKeyPointerStart {
|
||||
pointerId: number;
|
||||
key: TerminalSoftKeyDefinition;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
function pointerMovedBeyondTap(start: SoftKeyPointerStart, event: PointerEvent): boolean {
|
||||
return Math.hypot(event.clientX - start.clientX, event.clientY - start.clientY) > SOFT_KEY_TAP_MOVE_THRESHOLD_PX;
|
||||
}
|
||||
@@ -49,24 +49,28 @@ export class WorkspaceList extends LitElement {
|
||||
return html`
|
||||
<section>
|
||||
<h2>${this.renderHeading()}</h2>
|
||||
${this.collapsed ? null : this.workspaces.map((workspace) => {
|
||||
const label = workspacePrimaryLabel(workspace);
|
||||
const items = this.workspaceLabelItems(workspace);
|
||||
return html`
|
||||
<div
|
||||
class=${`action-row workspace-row ${this.selected?.id === workspace.id ? "selected" : ""}`}
|
||||
tabindex="0"
|
||||
title=${label}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(workspace)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleWorkspaceKeydown(event, workspace); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
${this.renderWorkspaceMain(label, items, workspace)}
|
||||
</div>
|
||||
${this.renderWorkspaceMenu(label, items, workspace)}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
${this.collapsed ? null : html`
|
||||
<div class="list-body">
|
||||
${this.workspaces.map((workspace) => {
|
||||
const label = workspacePrimaryLabel(workspace);
|
||||
const items = this.workspaceLabelItems(workspace);
|
||||
return html`
|
||||
<div
|
||||
class=${`action-row workspace-row ${this.selected?.id === workspace.id ? "selected" : ""}`}
|
||||
tabindex="0"
|
||||
title=${label}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(workspace)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleWorkspaceKeydown(event, workspace); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
${this.renderWorkspaceMain(label, items, workspace)}
|
||||
</div>
|
||||
${this.renderWorkspaceMenu(label, items, workspace)}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
|
||||
describe("actionMenuPanelStyle", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("can constrain menus to the viewport for compact shadow-root controls", () => {
|
||||
vi.stubGlobal("window", { innerWidth: 400, innerHeight: 800 });
|
||||
vi.stubGlobal("HTMLElement", FakeHTMLElement);
|
||||
|
||||
const target = new FakeHTMLElement({ top: 10, right: 390, bottom: 46, left: 354 });
|
||||
|
||||
expect(actionMenuPanelStyle(target, { constrainTo: "viewport" })).toBe("top: 46px; max-height: 754px; right: 10px; max-width: 390px;");
|
||||
});
|
||||
});
|
||||
|
||||
class FakeHTMLElement extends EventTarget {
|
||||
constructor(private readonly rect: { top: number; right: number; bottom: number; left: number }) {
|
||||
super();
|
||||
}
|
||||
|
||||
getBoundingClientRect(): { top: number; right: number; bottom: number; left: number } {
|
||||
return this.rect;
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,14 @@ interface ActionMenuRect {
|
||||
left: number;
|
||||
}
|
||||
|
||||
export function actionMenuPanelStyle(target: EventTarget | null): string {
|
||||
interface ActionMenuPanelStyleOptions {
|
||||
constrainTo?: "host" | "viewport";
|
||||
}
|
||||
|
||||
export function actionMenuPanelStyle(target: EventTarget | null, options: ActionMenuPanelStyleOptions = {}): string {
|
||||
if (typeof HTMLElement === "undefined" || typeof window === "undefined" || !(target instanceof HTMLElement)) return "";
|
||||
const trigger = target.getBoundingClientRect();
|
||||
const bounds = actionMenuBounds(target);
|
||||
const bounds = options.constrainTo === "viewport" ? viewportBounds() : actionMenuBounds(target);
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const leftBound = Math.max(0, bounds.left);
|
||||
@@ -35,6 +39,10 @@ export function actionMenuPanelStyle(target: EventTarget | null): string {
|
||||
function actionMenuBounds(target: HTMLElement): ActionMenuRect {
|
||||
const root = target.getRootNode();
|
||||
if (typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot && root.host instanceof HTMLElement) return root.host.getBoundingClientRect();
|
||||
return viewportBounds();
|
||||
}
|
||||
|
||||
function viewportBounds(): ActionMenuRect {
|
||||
return { top: 0, right: window.innerWidth, bottom: window.innerHeight, left: 0 };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import type { Machine, Project, SessionInfo, Workspace } from "../../api";
|
||||
import type { NavigationSection } from "../../appShell/navigationState";
|
||||
|
||||
@customElement("app-context-bar")
|
||||
export class AppContextBar extends LitElement {
|
||||
@property({ attribute: false }) machine?: Machine;
|
||||
@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 machineLabel = machineContextLabel(this.machine);
|
||||
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.machine === undefined ? "context-chip empty" : "context-chip"} title=${machineContextTitle(this.machine)} aria-label=${`Machine: ${machineLabel}. Open machine selection.`} @click=${() => { this.onOpenSection?.("machines"); }}>
|
||||
<span class="context-kind">Machine</span>
|
||||
<span class="context-value">${machineLabel}</span>
|
||||
</button>
|
||||
</li>
|
||||
<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 machineContextLabel(machine: Machine | undefined): string {
|
||||
return machine === undefined ? "No machine" : `${machine.name}${machine.kind === "remote" ? " · remote" : ""}`;
|
||||
}
|
||||
|
||||
function machineContextTitle(machine: Machine | undefined): string {
|
||||
return machine === undefined ? "No machine selected" : machine.baseUrl ?? machine.name;
|
||||
}
|
||||
|
||||
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,132 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api";
|
||||
import type { WorkspaceLabelItem } from "../../plugins/types";
|
||||
import "../MachineList";
|
||||
import "../ProjectList";
|
||||
import "../WorkspaceList";
|
||||
import "../SessionList";
|
||||
|
||||
@customElement("app-navigation-panel")
|
||||
export class AppNavigationPanel extends LitElement {
|
||||
@property({ attribute: false }) machines: Machine[] = [];
|
||||
@property({ attribute: false }) selectedMachine?: Machine;
|
||||
@property({ attribute: false }) machineStatuses: Record<string, MachineHealth> = {};
|
||||
@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 }) machinesCollapsed = 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 }) onToggleMachines?: () => 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>;
|
||||
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => 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>
|
||||
<machine-list
|
||||
.machines=${this.machines}
|
||||
.selected=${this.selectedMachine}
|
||||
.statuses=${this.machineStatuses}
|
||||
.collapsible=${this.collapsible}
|
||||
.collapsed=${this.machinesCollapsed}
|
||||
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
|
||||
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
|
||||
></machine-list>
|
||||
<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; }
|
||||
machine-list, 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]) machine-list,
|
||||
: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]) machine-list[collapsed],
|
||||
: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, { constrainTo: "viewport" });
|
||||
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); } }
|
||||
`;
|
||||
}
|
||||
@@ -50,14 +50,20 @@ export interface CompletionItem {
|
||||
}
|
||||
|
||||
export const appStyles = css`
|
||||
:host { display: block; height: 100dvh; box-sizing: border-box; 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 { display: grid; grid-template-columns: 340px minmax(420px, 1fr) minmax(360px, 42vw); height: 100%; min-height: 0; }
|
||||
aside { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--pi-border); overflow: hidden; }
|
||||
/* Mobile browsers already subtract browser controls from 100dvh; reserve bottom safe area only in standalone PWA modes. */
|
||||
:host { --pi-app-safe-area-bottom: 0px; 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) var(--pi-app-safe-area-bottom) env(safe-area-inset-left); color: var(--pi-text); background: var(--pi-bg); font: 14px system-ui, sans-serif; }
|
||||
:host([pwa-display-mode]) { --pi-app-safe-area-bottom: env(safe-area-inset-bottom); }
|
||||
@media (display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui) {
|
||||
:host { --pi-app-safe-area-bottom: env(safe-area-inset-bottom); }
|
||||
}
|
||||
.shell { --navigation-panel-width: 340px; --workspace-panel-width: minmax(360px, 42vw); display: grid; grid-template-columns: var(--navigation-panel-width) 1px minmax(420px, 1fr) 1px var(--workspace-panel-width); height: 100%; min-height: 0; }
|
||||
aside { grid-column: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
aside app-navigation-panel { flex: 1 1 auto; min-height: 0; }
|
||||
header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }
|
||||
.header-actions { display: flex; align-items: center; gap: 8px; }
|
||||
project-list, workspace-list { flex: 0 0 auto; max-height: 26%; overflow: auto; border-bottom: 1px solid var(--pi-border-muted); }
|
||||
session-list { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
||||
main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||
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; }
|
||||
main { grid-column: 3; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||
.context-bar { position: relative; flex: 0 0 auto; min-width: 0; display: none; 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%); }
|
||||
@@ -83,6 +89,7 @@ export const appStyles = css`
|
||||
.context-chip.empty { border-style: dashed; color: var(--pi-muted); }
|
||||
.context-kind { display: none; }
|
||||
.context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; }
|
||||
app-mobile-main-tabs { display: none; }
|
||||
.mobile-tabs-frame { position: relative; display: none; flex: 0 0 auto; min-width: 0; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg); }
|
||||
.mobile-tabs-frame::before, .mobile-tabs-frame::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
|
||||
.mobile-tabs-frame::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
|
||||
@@ -93,31 +100,51 @@ export const appStyles = css`
|
||||
.mobile-navigation-tab, .mobile-navigation-panel { display: none; }
|
||||
.mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
.tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
|
||||
workspace-panel { min-width: 0; min-height: 0; border-left: 1px solid var(--pi-border); overflow: hidden; }
|
||||
.navigation-panel-edge, .workspace-panel-edge { min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; }
|
||||
.navigation-panel-edge { grid-column: 2; }
|
||||
.workspace-panel-edge { grid-column: 4; }
|
||||
.navigation-panel-edge-button, .workspace-panel-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; }
|
||||
.navigation-panel-edge-button:hover, .navigation-panel-edge-button:focus-visible, .workspace-panel-edge-button:hover, .workspace-panel-edge-button:focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); opacity: 1; }
|
||||
.shell.navigation-panel-collapsed .navigation-panel-edge-button { transform: translateX(calc(50% - .5px)); }
|
||||
.shell.workspace-panel-collapsed .workspace-panel-edge-button { transform: translateX(calc(-50% + .5px)); }
|
||||
.navigation-panel-edge-icon, .workspace-panel-edge-icon { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||
workspace-panel { grid-column: 5; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
@media (min-width: 1181px) {
|
||||
.shell.navigation-panel-collapsed { --navigation-panel-width: 0px; }
|
||||
.shell.navigation-panel-collapsed > aside { display: none; }
|
||||
.shell.workspace-panel-collapsed { --workspace-panel-width: 0px; }
|
||||
.shell.workspace-panel-collapsed > workspace-panel { display: none; }
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.shell { grid-template-columns: 340px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
|
||||
.shell { grid-template-columns: var(--navigation-panel-width) 1px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
|
||||
.shell.navigation-panel-collapsed { --navigation-panel-width: 0px; }
|
||||
.shell.navigation-panel-collapsed > aside { display: none; }
|
||||
aside { grid-row: 1 / 3; }
|
||||
main { grid-column: 2; grid-row: 1 / 3; }
|
||||
.navigation-panel-edge { grid-row: 1 / 3; }
|
||||
main { grid-column: 3; grid-row: 1 / 3; }
|
||||
app-mobile-main-tabs { display: block; flex: 0 0 auto; min-width: 0; }
|
||||
.mobile-tabs-frame { display: flex; }
|
||||
.shell.workspace-view main { grid-row: 1; min-height: auto; }
|
||||
.shell.workspace-view > workspace-panel { grid-column: 2; grid-row: 2; display: flex; border-left: 0; }
|
||||
.shell.workspace-view > workspace-panel { grid-column: 3; grid-row: 2; display: flex; border-left: 0; }
|
||||
.shell:not(.workspace-view) > workspace-panel { display: none; }
|
||||
.workspace-panel-edge { display: none; }
|
||||
main.workspace-view chat-view, main.workspace-view prompt-editor, main.workspace-view status-bar,
|
||||
main.workspace-view .empty { display: none; }
|
||||
main.workspace-view { overflow: hidden; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.shell { grid-template-columns: minmax(0, 1fr); }
|
||||
aside { display: none; }
|
||||
aside, .navigation-panel-edge { display: none; }
|
||||
main, .shell.workspace-view > workspace-panel { grid-column: 1; }
|
||||
.context-bar { display: flex; }
|
||||
.mobile-navigation-tab { display: block; }
|
||||
main.navigation-view chat-view, main.navigation-view prompt-editor, main.navigation-view status-bar,
|
||||
main.navigation-view .empty { display: none; }
|
||||
main.navigation-view .mobile-navigation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
main.navigation-view .mobile-navigation-panel app-navigation-panel { flex: 1 1 auto; min-height: 0; }
|
||||
main.navigation-view .mobile-navigation-panel project-list,
|
||||
main.navigation-view .mobile-navigation-panel workspace-list,
|
||||
main.navigation-view .mobile-navigation-panel session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: auto; }
|
||||
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 project-list[collapsed],
|
||||
main.navigation-view .mobile-navigation-panel workspace-list[collapsed],
|
||||
main.navigation-view .mobile-navigation-panel session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; }
|
||||
@@ -140,7 +167,7 @@ export const workspacePanelStyles = css`
|
||||
.workspace-header-scroll-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
|
||||
.workspace-header-scroll-frame.can-scroll-left::before, .workspace-header-scroll-frame.can-scroll-right::after { opacity: 1; }
|
||||
.workspace-header-strip { display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
|
||||
.tabs { flex: 0 0 auto; display: flex; gap: 6px; }
|
||||
.tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; }
|
||||
.tabs button { flex: 0 0 auto; white-space: nowrap; }
|
||||
button { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; }
|
||||
button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
@@ -183,10 +210,11 @@ export const workspacePanelStyles = css`
|
||||
`;
|
||||
|
||||
export const listStyles = css`
|
||||
:host { display: block; color: var(--pi-text); font: 14px system-ui, sans-serif; }
|
||||
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; }
|
||||
:host([collapsed]) { flex: 0 0 auto; min-height: auto; overflow: hidden; }
|
||||
section { padding: 10px; }
|
||||
h2 { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
|
||||
section { box-sizing: border-box; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; padding: 10px; }
|
||||
h2 { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
|
||||
.list-body { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||
section > button { display: block; width: 100%; text-align: left; margin: 6px 0; }
|
||||
.subheading { margin-top: 14px; }
|
||||
@@ -242,11 +270,11 @@ export const listStyles = css`
|
||||
`;
|
||||
|
||||
export const chatStyles = css`
|
||||
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; }
|
||||
:host { position: relative; z-index: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; }
|
||||
.chat-wrap { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
||||
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; }
|
||||
.scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; }
|
||||
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 3; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); }
|
||||
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 20; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); }
|
||||
.activity-dock.active { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-bg-overlay); }
|
||||
.activity-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; }
|
||||
|
||||
@@ -62,7 +62,7 @@ export class SessionController {
|
||||
this.socket.close();
|
||||
this.catchupStreamSessionId = undefined;
|
||||
this.clearPendingTranscriptEvents();
|
||||
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
|
||||
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
|
||||
}
|
||||
|
||||
deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) {
|
||||
|
||||
@@ -17,7 +17,10 @@ describe("inputModeForDraft", () => {
|
||||
it("detects file completion contexts", () => {
|
||||
expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("open !@vendor/file.ts")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("!@vendor/file.ts")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("open @ \"src/main.ts")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("open !@\"vendor/file.ts")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("open \"src/main.ts")).toEqual({ kind: "normal" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export type InputMode =
|
||||
|
||||
export function inputModeForDraft(draft: string): InputMode {
|
||||
const trimmed = draft.trimStart();
|
||||
if (trimmed.startsWith("!@")) return { kind: "file" };
|
||||
if (trimmed.startsWith("!")) return { kind: "shell", excludeFromContext: trimmed.startsWith("!!") };
|
||||
if (currentToken(draft).startsWith("/")) return { kind: "command" };
|
||||
if (isFileCompletionContext(draft)) return { kind: "file" };
|
||||
@@ -23,11 +24,11 @@ function currentToken(draft: string): string {
|
||||
|
||||
function isFileCompletionContext(draft: string): boolean {
|
||||
const token = currentToken(draft);
|
||||
if (token.startsWith("@")) return true;
|
||||
if (token.startsWith("@") || token.startsWith("!@")) return true;
|
||||
const tokenStart = draft.length - token.length;
|
||||
if (draft.slice(0, tokenStart).endsWith("@ ")) return true;
|
||||
const quoteStart = draft.lastIndexOf("\"");
|
||||
if (quoteStart === -1) return false;
|
||||
const prefix = draft.slice(0, quoteStart);
|
||||
return prefix.endsWith("@") || prefix.endsWith("@ ");
|
||||
return prefix.endsWith("@") || prefix.endsWith("@ ") || prefix.endsWith("!@");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isSessionActive } from "../../../../shared/activity";
|
||||
import type { AppState } from "../../appState";
|
||||
import { isCachedNewSessionInfo } from "../../cachedNewSessions";
|
||||
import { isWorkspaceDeletionPending } from "../../workspaceDeletion";
|
||||
import type { PluginAction } from "../types";
|
||||
|
||||
@@ -168,9 +169,17 @@ export function createCoreActions(): PluginAction[] {
|
||||
title: "Archive Session",
|
||||
description: "Archive the selected session",
|
||||
group: "Session",
|
||||
enabled: (context) => context.state.selectedSession !== undefined && context.state.selectedSession.archived !== true,
|
||||
enabled: hasArchivableSession,
|
||||
run: (context) => context.archiveSession(),
|
||||
},
|
||||
{
|
||||
id: "session.delete",
|
||||
title: "Delete New Session",
|
||||
description: "Delete the selected browser-cached new session",
|
||||
group: "Session",
|
||||
enabled: hasCachedNewSession,
|
||||
run: (context) => context.deleteCachedNewSession(),
|
||||
},
|
||||
{
|
||||
id: "session.stop",
|
||||
title: "Stop Active Work",
|
||||
@@ -194,3 +203,12 @@ function hasDeletableWorkspace(context: { state: AppState }): boolean {
|
||||
const workspace = context.state.selectedWorkspace;
|
||||
return workspace !== undefined && workspace.isGitWorktree && !workspace.isMain && !isWorkspaceDeletionPending(context.state, workspace);
|
||||
}
|
||||
|
||||
function hasArchivableSession(context: { state: AppState }): boolean {
|
||||
const session = context.state.selectedSession;
|
||||
return session !== undefined && session.archived !== true && !isCachedNewSessionInfo(session);
|
||||
}
|
||||
|
||||
function hasCachedNewSession(context: { state: AppState }): boolean {
|
||||
return isCachedNewSessionInfo(context.state.selectedSession);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Workspace } from "../api";
|
||||
import type { SessionInfo, Workspace } from "../api";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { markCachedNewSessionInfo } from "../cachedNewSessions";
|
||||
import { corePlugin } from "./core";
|
||||
import { PluginRegistry } from "./registry";
|
||||
import { themePackPlugin } from "./themes";
|
||||
@@ -38,6 +39,7 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
||||
deleteWorkspace: vi.fn(() => { calls.push("deleteWorkspace"); }),
|
||||
startSession: vi.fn(() => { calls.push("startSession"); }),
|
||||
archiveSession: vi.fn(() => { calls.push("archiveSession"); }),
|
||||
deleteCachedNewSession: vi.fn(() => { calls.push("deleteCachedNewSession"); }),
|
||||
stopActiveWork: vi.fn(() => { calls.push("stopActiveWork"); }),
|
||||
};
|
||||
return { context, calls };
|
||||
@@ -102,6 +104,34 @@ describe("PluginRegistry", () => {
|
||||
expect(calls).toEqual(["deleteWorkspace"]);
|
||||
});
|
||||
|
||||
it("offers archive only for persisted sessions and delete only for browser-cached new sessions", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
|
||||
const persistedActions = registry.getActions(createContext({ selectedSession: testSession() }).context);
|
||||
expect(persistedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true);
|
||||
expect(persistedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
|
||||
|
||||
const cachedActions = registry.getActions(createContext({ selectedSession: markCachedNewSessionInfo(testSession()) }).context);
|
||||
expect(cachedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
|
||||
expect(cachedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true);
|
||||
|
||||
const archivedActions = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" } }).context);
|
||||
expect(archivedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
|
||||
expect(archivedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("routes browser-cached new session delete through the runtime context", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
const { context, calls } = createContext({ selectedSession: markCachedNewSessionInfo(testSession()) });
|
||||
const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.delete");
|
||||
|
||||
if (action !== undefined) void action.run();
|
||||
|
||||
expect(calls).toEqual(["deleteCachedNewSession"]);
|
||||
});
|
||||
|
||||
it("routes refresh current to the active core workspace panel", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
@@ -237,6 +267,19 @@ function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
|
||||
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false, ...patch };
|
||||
}
|
||||
|
||||
function testSession(patch: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
id: "s1",
|
||||
path: "/tmp/s1.jsonl",
|
||||
cwd: "/tmp/project",
|
||||
created: "2026-05-20T00:00:00.000Z",
|
||||
modified: "2026-05-20T00:00:00.000Z",
|
||||
messageCount: 1,
|
||||
firstMessage: "Hello",
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function testThemeTokens(): ThemeTokens {
|
||||
return {
|
||||
"--pi-bg": "#000000",
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface PluginRuntimeContext {
|
||||
deleteWorkspace: (workspace?: Workspace) => void | Promise<void>;
|
||||
startSession: () => void | Promise<void>;
|
||||
archiveSession: () => void | Promise<void>;
|
||||
deleteCachedNewSession: () => void | Promise<void>;
|
||||
stopActiveWork: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { terminalSoftKeySequence, type TerminalModesSnapshot } from "./terminalKeys";
|
||||
|
||||
describe("terminalSoftKeySequence", () => {
|
||||
it("maps common control keys to terminal bytes", () => {
|
||||
expect(terminalSoftKeySequence("escape")).toBe("\x1b");
|
||||
expect(terminalSoftKeySequence("tab")).toBe("\t");
|
||||
expect(terminalSoftKeySequence("ctrl-c")).toBe("\x03");
|
||||
expect(terminalSoftKeySequence("ctrl-d")).toBe("\x04");
|
||||
expect(terminalSoftKeySequence("ctrl-z")).toBe("\x1a");
|
||||
expect(terminalSoftKeySequence("ctrl-l")).toBe("\x0c");
|
||||
expect(terminalSoftKeySequence("ctrl-r")).toBe("\x12");
|
||||
});
|
||||
|
||||
it("maps navigation keys to xterm-compatible sequences", () => {
|
||||
expect(terminalSoftKeySequence("arrow-up")).toBe("\x1b[A");
|
||||
expect(terminalSoftKeySequence("arrow-down")).toBe("\x1b[B");
|
||||
expect(terminalSoftKeySequence("arrow-right")).toBe("\x1b[C");
|
||||
expect(terminalSoftKeySequence("arrow-left")).toBe("\x1b[D");
|
||||
expect(terminalSoftKeySequence("home")).toBe("\x1b[H");
|
||||
expect(terminalSoftKeySequence("end")).toBe("\x1b[F");
|
||||
expect(terminalSoftKeySequence("page-up")).toBe("\x1b[5~");
|
||||
expect(terminalSoftKeySequence("page-down")).toBe("\x1b[6~");
|
||||
expect(terminalSoftKeySequence("delete")).toBe("\x1b[3~");
|
||||
expect(terminalSoftKeySequence("backspace")).toBe("\x7f");
|
||||
});
|
||||
|
||||
it("respects application cursor key mode", () => {
|
||||
const applicationCursorMode: TerminalModesSnapshot = { applicationCursorKeysMode: true };
|
||||
|
||||
expect(terminalSoftKeySequence("arrow-up", applicationCursorMode)).toBe("\x1bOA");
|
||||
expect(terminalSoftKeySequence("arrow-down", applicationCursorMode)).toBe("\x1bOB");
|
||||
expect(terminalSoftKeySequence("arrow-right", applicationCursorMode)).toBe("\x1bOC");
|
||||
expect(terminalSoftKeySequence("arrow-left", applicationCursorMode)).toBe("\x1bOD");
|
||||
expect(terminalSoftKeySequence("home", applicationCursorMode)).toBe("\x1bOH");
|
||||
expect(terminalSoftKeySequence("end", applicationCursorMode)).toBe("\x1bOF");
|
||||
});
|
||||
|
||||
it("maps meta word movement to escape-prefixed sequences", () => {
|
||||
expect(terminalSoftKeySequence("meta-backward-word")).toBe("\x1bb");
|
||||
expect(terminalSoftKeySequence("meta-forward-word")).toBe("\x1bf");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
export type TerminalSoftKeyId =
|
||||
| "escape"
|
||||
| "tab"
|
||||
| "ctrl-c"
|
||||
| "ctrl-d"
|
||||
| "ctrl-z"
|
||||
| "ctrl-l"
|
||||
| "ctrl-r"
|
||||
| "ctrl-u"
|
||||
| "ctrl-w"
|
||||
| "arrow-left"
|
||||
| "arrow-up"
|
||||
| "arrow-down"
|
||||
| "arrow-right"
|
||||
| "home"
|
||||
| "end"
|
||||
| "page-up"
|
||||
| "page-down"
|
||||
| "delete"
|
||||
| "backspace"
|
||||
| "meta-backward-word"
|
||||
| "meta-forward-word";
|
||||
|
||||
export interface TerminalModesSnapshot {
|
||||
applicationCursorKeysMode: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalSoftKeyDefinition {
|
||||
id: TerminalSoftKeyId;
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const TERMINAL_SOFT_KEYS: readonly TerminalSoftKeyDefinition[] = [
|
||||
{ id: "escape", label: "Esc", ariaLabel: "Escape", title: "Send Escape" },
|
||||
{ id: "tab", label: "Tab", ariaLabel: "Tab", title: "Send Tab" },
|
||||
{ id: "ctrl-c", label: "Ctrl+C", ariaLabel: "Control C", title: "Interrupt the foreground process" },
|
||||
{ id: "ctrl-d", label: "Ctrl+D", ariaLabel: "Control D", title: "Send EOF / close input" },
|
||||
{ id: "ctrl-z", label: "Ctrl+Z", ariaLabel: "Control Z", title: "Suspend the foreground process" },
|
||||
{ id: "ctrl-l", label: "Ctrl+L", ariaLabel: "Control L", title: "Clear / redraw the terminal" },
|
||||
{ id: "ctrl-r", label: "Ctrl+R", ariaLabel: "Control R", title: "Reverse search history" },
|
||||
{ id: "ctrl-u", label: "Ctrl+U", ariaLabel: "Control U", title: "Delete to the start of the line" },
|
||||
{ id: "ctrl-w", label: "Ctrl+W", ariaLabel: "Control W", title: "Delete the previous word" },
|
||||
{ id: "arrow-left", label: "←", ariaLabel: "Left arrow", title: "Move left" },
|
||||
{ id: "arrow-up", label: "↑", ariaLabel: "Up arrow", title: "Move up / previous command" },
|
||||
{ id: "arrow-down", label: "↓", ariaLabel: "Down arrow", title: "Move down / next command" },
|
||||
{ id: "arrow-right", label: "→", ariaLabel: "Right arrow", title: "Move right" },
|
||||
{ id: "home", label: "Home", ariaLabel: "Home", title: "Move to the start" },
|
||||
{ id: "end", label: "End", ariaLabel: "End", title: "Move to the end" },
|
||||
{ id: "page-up", label: "PgUp", ariaLabel: "Page up", title: "Page up" },
|
||||
{ id: "page-down", label: "PgDn", ariaLabel: "Page down", title: "Page down" },
|
||||
{ id: "delete", label: "Del", ariaLabel: "Delete", title: "Delete forward" },
|
||||
{ id: "backspace", label: "⌫", ariaLabel: "Backspace", title: "Backspace" },
|
||||
{ id: "meta-backward-word", label: "M-B", ariaLabel: "Meta B", title: "Move backward one word" },
|
||||
{ id: "meta-forward-word", label: "M-F", ariaLabel: "Meta F", title: "Move forward one word" },
|
||||
];
|
||||
|
||||
const ESC = "\x1b";
|
||||
const DEL = "\x7f";
|
||||
|
||||
export function terminalSoftKeySequence(key: TerminalSoftKeyId, modes?: TerminalModesSnapshot): string {
|
||||
switch (key) {
|
||||
case "escape": return ESC;
|
||||
case "tab": return "\t";
|
||||
case "ctrl-c": return controlSequence("c");
|
||||
case "ctrl-d": return controlSequence("d");
|
||||
case "ctrl-z": return controlSequence("z");
|
||||
case "ctrl-l": return controlSequence("l");
|
||||
case "ctrl-r": return controlSequence("r");
|
||||
case "ctrl-u": return controlSequence("u");
|
||||
case "ctrl-w": return controlSequence("w");
|
||||
case "arrow-left": return cursorSequence("D", modes);
|
||||
case "arrow-up": return cursorSequence("A", modes);
|
||||
case "arrow-down": return cursorSequence("B", modes);
|
||||
case "arrow-right": return cursorSequence("C", modes);
|
||||
case "home": return cursorEndpointSequence("H", modes);
|
||||
case "end": return cursorEndpointSequence("F", modes);
|
||||
case "page-up": return `${ESC}[5~`;
|
||||
case "page-down": return `${ESC}[6~`;
|
||||
case "delete": return `${ESC}[3~`;
|
||||
case "backspace": return DEL;
|
||||
case "meta-backward-word": return `${ESC}b`;
|
||||
case "meta-forward-word": return `${ESC}f`;
|
||||
}
|
||||
}
|
||||
|
||||
function controlSequence(letter: string): string {
|
||||
return String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64);
|
||||
}
|
||||
|
||||
function cursorSequence(code: "A" | "B" | "C" | "D", modes: TerminalModesSnapshot | undefined): string {
|
||||
return modes?.applicationCursorKeysMode === true ? `${ESC}O${code}` : `${ESC}[${code}`;
|
||||
}
|
||||
|
||||
function cursorEndpointSequence(code: "F" | "H", modes: TerminalModesSnapshot | undefined): string {
|
||||
return modes?.applicationCursorKeysMode === true ? `${ESC}O${code}` : `${ESC}[${code}`;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseTerminalSoftKeysPreference,
|
||||
readTerminalSoftKeysPreference,
|
||||
terminalSoftKeysEnabled,
|
||||
TERMINAL_SOFT_KEYS_STORAGE_KEY,
|
||||
writeTerminalSoftKeysPreference,
|
||||
} from "./terminalSoftKeysPreference";
|
||||
|
||||
describe("terminal soft key preferences", () => {
|
||||
it("uses stored preferences before environment defaults", () => {
|
||||
expect(terminalSoftKeysEnabled(true, false)).toBe(true);
|
||||
expect(terminalSoftKeysEnabled(false, true)).toBe(false);
|
||||
expect(terminalSoftKeysEnabled(undefined, true)).toBe(true);
|
||||
expect(terminalSoftKeysEnabled(undefined, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("parses boolean local storage values", () => {
|
||||
expect(parseTerminalSoftKeysPreference("true")).toBe(true);
|
||||
expect(parseTerminalSoftKeysPreference("false")).toBe(false);
|
||||
expect(parseTerminalSoftKeysPreference(null)).toBeUndefined();
|
||||
expect(parseTerminalSoftKeysPreference("yes")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads and writes the stored preference", () => {
|
||||
const storage = new FakeStorage();
|
||||
|
||||
expect(readTerminalSoftKeysPreference(storage)).toBeUndefined();
|
||||
writeTerminalSoftKeysPreference(true, storage);
|
||||
expect(storage.value(TERMINAL_SOFT_KEYS_STORAGE_KEY)).toBe("true");
|
||||
expect(readTerminalSoftKeysPreference(storage)).toBe(true);
|
||||
|
||||
writeTerminalSoftKeysPreference(false, storage);
|
||||
expect(storage.value(TERMINAL_SOFT_KEYS_STORAGE_KEY)).toBe("false");
|
||||
expect(readTerminalSoftKeysPreference(storage)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores storage failures", () => {
|
||||
const storage = new ThrowingStorage();
|
||||
|
||||
expect(readTerminalSoftKeysPreference(storage)).toBeUndefined();
|
||||
expect(() => { writeTerminalSoftKeysPreference(true, storage); }).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
class FakeStorage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
value(key: string): string | undefined {
|
||||
return this.values.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
class ThrowingStorage {
|
||||
getItem(): string | null {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
|
||||
setItem(): void {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export const TERMINAL_SOFT_KEYS_STORAGE_KEY = "pi-web.terminal.softKeys";
|
||||
export const TERMINAL_SOFT_KEYS_DEFAULT_ENVIRONMENT_MEDIA = "(pointer: coarse), (max-width: 760px)";
|
||||
|
||||
export type TerminalSoftKeysStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
export function terminalSoftKeysEnabled(preference: boolean | undefined, defaultEnabled: boolean): boolean {
|
||||
return preference ?? defaultEnabled;
|
||||
}
|
||||
|
||||
export function parseTerminalSoftKeysPreference(value: string | null): boolean | undefined {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createTerminalSoftKeysDefaultEnvironmentMedia(): MediaQueryList | undefined {
|
||||
return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(TERMINAL_SOFT_KEYS_DEFAULT_ENVIRONMENT_MEDIA) : undefined;
|
||||
}
|
||||
|
||||
export function isTerminalSoftKeysDefaultEnvironment(media: MediaQueryList | undefined): boolean {
|
||||
return media?.matches === true;
|
||||
}
|
||||
|
||||
export function initialTerminalSoftKeysEnabled(media = createTerminalSoftKeysDefaultEnvironmentMedia()): boolean {
|
||||
return terminalSoftKeysEnabled(readTerminalSoftKeysPreference(), isTerminalSoftKeysDefaultEnvironment(media));
|
||||
}
|
||||
|
||||
export function hasTerminalSoftKeysPreference(): boolean {
|
||||
return readTerminalSoftKeysPreference() !== undefined;
|
||||
}
|
||||
|
||||
export function readTerminalSoftKeysPreference(storage = browserStorage()): boolean | undefined {
|
||||
if (storage === undefined) return undefined;
|
||||
try {
|
||||
return parseTerminalSoftKeysPreference(storage.getItem(TERMINAL_SOFT_KEYS_STORAGE_KEY));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTerminalSoftKeysPreference(enabled: boolean, storage = browserStorage()): void {
|
||||
if (storage === undefined) return;
|
||||
try {
|
||||
storage.setItem(TERMINAL_SOFT_KEYS_STORAGE_KEY, String(enabled));
|
||||
} catch {
|
||||
// Ignore storage failures; the per-page toggle still works for this session.
|
||||
}
|
||||
}
|
||||
|
||||
function browserStorage(): TerminalSoftKeysStorage | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user