From c26359917f8ffa449d20e7e2eab773dfea2f9c97 Mon Sep 17 00:00:00 2001 From: spellitwithaph Date: Wed, 27 May 2026 10:10:17 -0400 Subject: [PATCH 01/27] feat: add collapse/expand toggle for workspace panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a chevron-right collapse button after the last tab (Files, Git, Terminal, Info) in the workspace panel header. Clicking it hides the workspace panel and lets the main chat column fill the full width. When collapsed, an expand strip with a chevron-left button appears below the context bar to re-open the workspace panel. Changes: - Add workspacePanelCollapsed boolean to AppState - Add collapse button to WorkspacePanel header tabs - Add expand strip with toggle button in main column when collapsed - Adjust grid layout to hide panel and stretch main when collapsed - Handle collapsed state at ≤1180px responsive breakpoint --- src/client/src/appState.ts | 2 ++ src/client/src/components/PiWebApp.ts | 9 +++++++-- src/client/src/components/WorkspacePanel.ts | 7 +++++++ src/client/src/components/shared.ts | 11 ++++++++++- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index 409bf73..17776d7 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -45,6 +45,7 @@ export interface AppState { activeTerminalCount: number; selectedTerminalId: string | undefined; piWebStatus: PiWebStatusResponse | undefined; + workspacePanelCollapsed: boolean; error: string; } @@ -133,6 +134,7 @@ export function initialAppState(): AppState { activeTerminalCount: 0, selectedTerminalId: undefined, piWebStatus: undefined, + workspacePanelCollapsed: false, error: "", }; } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 564f86c..fd4a1fa 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -511,7 +511,11 @@ 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` { this.openWorkspaceTool(tool); }}>`; + return html` { this.openWorkspaceTool(tool); }} .onToggleCollapse=${() => { this.toggleWorkspacePanelCollapse(); }}>`; + } + + private toggleWorkspacePanelCollapse(): void { + this.setState({ workspacePanelCollapsed: !this.state.workspacePanelCollapsed }); } private renderNavigationPanel(autoSwitchToChat: boolean) { @@ -1192,10 +1196,11 @@ export class PiWebApp extends LitElement { override render() { const state = this.state; return html` -
+
${this.renderContextBar()} + ${state.workspacePanelCollapsed ? html`
` : null}
diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 826b293..11d5e75 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -19,7 +19,9 @@ export class WorkspacePanel extends LitElement { @property({ attribute: false }) panels: QualifiedWorkspacePanelContribution[] = []; @property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = []; @property({ type: Boolean }) hideToolTabs = false; + @property({ type: Boolean }) collapsed = false; @property({ attribute: false }) onSelectTool: (tool: QualifiedContributionId) => void = () => undefined; + @property({ attribute: false }) onToggleCollapse: () => void = () => undefined; @query(".workspace-header-strip") private workspaceHeaderStrip?: HTMLElement | null; @state() private workspaceHeaderCanScrollLeft = false; @state() private workspaceHeaderCanScrollRight = false; @@ -69,6 +71,7 @@ export class WorkspacePanel extends LitElement { ${visiblePanels.map((panel) => html` `)} +
`} ${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)} @@ -92,6 +95,10 @@ export class WorkspacePanel extends LitElement { return html`${panel.title} ${badge}`; } + private renderCollapseIcon(): TemplateResult { + return html``; + } + private renderEmptyState(state: WorkspacePanelEmptyState): TemplateResult { return html`
diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 41e06be..118a9b8 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -52,6 +52,8 @@ 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; } + .shell.workspace-panel-collapsed { grid-template-columns: 340px minmax(420px, 1fr); } + .shell.workspace-panel-collapsed > workspace-panel { display: none; } aside { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--pi-border); overflow: hidden; } 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; } @@ -93,6 +95,9 @@ 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; } + .expand-panel-strip { flex: 0 0 auto; display: flex; align-items: center; padding: 4px 8px; border-bottom: 1px solid var(--pi-border-muted); } + .expand-workspace-panel-button { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; padding: 0; border-radius: 6px; } + .expand-workspace-panel-button .expand-icon { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } workspace-panel { min-width: 0; min-height: 0; border-left: 1px solid var(--pi-border); overflow: hidden; } @media (max-width: 1180px) { .shell { grid-template-columns: 340px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } @@ -102,6 +107,8 @@ export const appStyles = css` .shell.workspace-view main { grid-row: 1; min-height: auto; } .shell.workspace-view > workspace-panel { grid-column: 2; grid-row: 2; display: flex; border-left: 0; } .shell:not(.workspace-view) > workspace-panel { display: none; } + .shell.workspace-panel-collapsed > workspace-panel { display: none; } + .shell.workspace-panel-collapsed .expand-panel-strip { display: flex; } 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; } @@ -140,8 +147,10 @@ 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; } + .collapse-button { display: inline-flex; align-items: center; justify-content: center; width: auto; height: auto; padding: 5px 7px; } + .collapse-icon { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } button { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; } button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } .tab-badge { display: inline-block; min-width: 14px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } From c8b05427fd0d3e58eec224d66659e6a492c28932 Mon Sep 17 00:00:00 2001 From: spellitwithaph Date: Wed, 27 May 2026 10:19:59 -0400 Subject: [PATCH 02/27] fix: tooltip reads 'Toggle Panel' and expand button aligns right - Change title/aria-label on both collapse and expand buttons to 'Toggle Panel' - Right-align the expand-panel-strip so the toggle icon sits on the right edge of the main column when the workspace panel is collapsed --- src/client/src/components/PiWebApp.ts | 2 +- src/client/src/components/WorkspacePanel.ts | 2 +- src/client/src/components/shared.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index fd4a1fa..e6475fd 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1200,7 +1200,7 @@ export class PiWebApp extends LitElement {
${this.renderContextBar()} - ${state.workspacePanelCollapsed ? html`
` : null} + ${state.workspacePanelCollapsed ? html`
` : null}
diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 11d5e75..290f9d5 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -71,7 +71,7 @@ export class WorkspacePanel extends LitElement { ${visiblePanels.map((panel) => html` `)} - +
`} ${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 118a9b8..ce5f08f 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -95,7 +95,7 @@ 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; } - .expand-panel-strip { flex: 0 0 auto; display: flex; align-items: center; padding: 4px 8px; border-bottom: 1px solid var(--pi-border-muted); } + .expand-panel-strip { flex: 0 0 auto; display: flex; align-items: center; justify-content: flex-end; padding: 4px 8px; border-bottom: 1px solid var(--pi-border-muted); } .expand-workspace-panel-button { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; padding: 0; border-radius: 6px; } .expand-workspace-panel-button .expand-icon { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } workspace-panel { min-width: 0; min-height: 0; border-left: 1px solid var(--pi-border); overflow: hidden; } From 1784b1e6c6f0284321b69b62c34828d57c99853a Mon Sep 17 00:00:00 2001 From: spellitwithaph Date: Wed, 27 May 2026 10:32:32 -0400 Subject: [PATCH 03/27] fix: add responsive collapsed overrides for tablet/mobile breakpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop .shell.workspace-panel-collapsed selector had higher specificity than the media-query .shell rules, causing horizontal overflow at ≤1180px and ≤760px widths. Add matching collapsed grid overrides inside each breakpoint. --- src/client/src/components/shared.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index ce5f08f..93cdd92 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -101,6 +101,7 @@ export const appStyles = css` workspace-panel { min-width: 0; min-height: 0; border-left: 1px solid var(--pi-border); overflow: hidden; } @media (max-width: 1180px) { .shell { grid-template-columns: 340px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } + .shell.workspace-panel-collapsed { grid-template-columns: 340px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } aside { grid-row: 1 / 3; } main { grid-column: 2; grid-row: 1 / 3; } .mobile-tabs-frame { display: flex; } @@ -115,6 +116,7 @@ export const appStyles = css` } @media (max-width: 760px) { .shell { grid-template-columns: minmax(0, 1fr); } + .shell.workspace-panel-collapsed { grid-template-columns: minmax(0, 1fr); } aside { display: none; } main, .shell.workspace-view > workspace-panel { grid-column: 1; } .context-bar { display: flex; } From 6d45505afc0e4c9a86089e4a47644cb6c5fc653b Mon Sep 17 00:00:00 2001 From: spellitwithaph Date: Wed, 27 May 2026 10:36:31 -0400 Subject: [PATCH 04/27] fix: hide panel toggle icon at responsive breakpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace panel is already hidden at ≤1180px by responsive CSS, so the expand/collapse toggle icon is meaningless there. Hide the expand-panel-strip inside both media queries. --- src/client/src/components/shared.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 93cdd92..7b282cb 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -109,7 +109,7 @@ export const appStyles = css` .shell.workspace-view > workspace-panel { grid-column: 2; grid-row: 2; display: flex; border-left: 0; } .shell:not(.workspace-view) > workspace-panel { display: none; } .shell.workspace-panel-collapsed > workspace-panel { display: none; } - .shell.workspace-panel-collapsed .expand-panel-strip { display: flex; } + .expand-panel-strip { 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; } From 2e2ca283cf8cc9f1f9d6060dc5e947d6c72ef8e4 Mon Sep 17 00:00:00 2001 From: spellitwithaph Date: Wed, 27 May 2026 10:38:42 -0400 Subject: [PATCH 05/27] fix: remove unused collapsed property from WorkspacePanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collapsed state is owned by PiWebApp and used only to toggle the shell CSS class and hide the element — the WorkspacePanel never reads it. Remove the dead property and its pass-through. --- src/client/src/components/PiWebApp.ts | 2 +- src/client/src/components/WorkspacePanel.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index e6475fd..45980af 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -511,7 +511,7 @@ 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` { this.openWorkspaceTool(tool); }} .onToggleCollapse=${() => { this.toggleWorkspacePanelCollapse(); }}>`; + return html` { this.openWorkspaceTool(tool); }} .onToggleCollapse=${() => { this.toggleWorkspacePanelCollapse(); }}>`; } private toggleWorkspacePanelCollapse(): void { diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 290f9d5..0cbdfd8 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -19,7 +19,6 @@ export class WorkspacePanel extends LitElement { @property({ attribute: false }) panels: QualifiedWorkspacePanelContribution[] = []; @property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = []; @property({ type: Boolean }) hideToolTabs = false; - @property({ type: Boolean }) collapsed = false; @property({ attribute: false }) onSelectTool: (tool: QualifiedContributionId) => void = () => undefined; @property({ attribute: false }) onToggleCollapse: () => void = () => undefined; @query(".workspace-header-strip") private workspaceHeaderStrip?: HTMLElement | null; From 6abc41b4dce3780855d1f50482771a480a0d2e92 Mon Sep 17 00:00:00 2001 From: spellitwithaph Date: Wed, 27 May 2026 10:41:46 -0400 Subject: [PATCH 06/27] refactor: extract render methods for workspace panel templates Break the dense one-liner workspace-panel and expand-button templates into multi-line formatted render methods for readability and safer future edits. --- src/client/src/components/PiWebApp.ts | 28 +++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 45980af..7db89b6 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -511,13 +511,37 @@ 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` { this.openWorkspaceTool(tool); }} .onToggleCollapse=${() => { this.toggleWorkspacePanelCollapse(); }}>`; + return html` + { this.openWorkspaceTool(tool); }} + .onToggleCollapse=${() => { this.toggleWorkspacePanelCollapse(); }} + > + `; } private toggleWorkspacePanelCollapse(): void { this.setState({ workspacePanelCollapsed: !this.state.workspacePanelCollapsed }); } + private renderExpandWorkspacePanelButton() { + return html` +
+ +
+ `; + } + private renderNavigationPanel(autoSwitchToChat: boolean) { const openChatAfter = (action: () => Promise) => this.withChatScrollTransition(async () => { await action(); @@ -1200,7 +1224,7 @@ export class PiWebApp extends LitElement {
${this.renderContextBar()} - ${state.workspacePanelCollapsed ? html`
` : null} + ${state.workspacePanelCollapsed ? this.renderExpandWorkspacePanelButton() : null}
From f569467769d639ecac922a42b4ac3731decaea4a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 08:17:53 +0200 Subject: [PATCH 07/27] feat: add terminal soft keys --- .changeset/soft-terminal-keys.md | 5 + src/client/src/components/TerminalPanel.ts | 99 ++++++++++++++++- src/client/src/components/TerminalSoftKeys.ts | 101 ++++++++++++++++++ src/client/src/terminalKeys.test.ts | 43 ++++++++ src/client/src/terminalKeys.ts | 98 +++++++++++++++++ .../src/terminalSoftKeysPreference.test.ts | 70 ++++++++++++ src/client/src/terminalSoftKeysPreference.ts | 57 ++++++++++ 7 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 .changeset/soft-terminal-keys.md create mode 100644 src/client/src/components/TerminalSoftKeys.ts create mode 100644 src/client/src/terminalKeys.test.ts create mode 100644 src/client/src/terminalKeys.ts create mode 100644 src/client/src/terminalSoftKeysPreference.test.ts create mode 100644 src/client/src/terminalSoftKeysPreference.ts diff --git a/.changeset/soft-terminal-keys.md b/.changeset/soft-terminal-keys.md new file mode 100644 index 0000000..37612d5 --- /dev/null +++ b/.changeset/soft-terminal-keys.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add an optional terminal soft-key bar for common control, navigation, and Meta-style key sequences, with mobile-friendly defaults and a persistent toggle. diff --git a/src/client/src/components/TerminalPanel.ts b/src/client/src/components/TerminalPanel.ts index 941a093..f8ce43a 100644 --- a/src/client/src/components/TerminalPanel.ts +++ b/src/client/src/components/TerminalPanel.ts @@ -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, @@ -31,6 +34,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; @@ -43,9 +48,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"] }); } @@ -62,11 +74,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): void { const cwd = this.workspace?.path; if (cwd !== this.observedCwd) { @@ -286,8 +311,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); @@ -375,6 +399,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)); } @@ -422,10 +463,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` + + `; + } + + private renderSoftKeys() { + return html` + { this.sendSoftKeyInput(data, options); }} + > + `; + } + override render() { return html`
+ ${this.renderSoftKeysToggle()} ${this.terminals.map((terminal) => html`
${this.error === undefined ? null : html`

${this.error}

`} ${this.renderCommandRunNotice()} + ${this.shouldShowSoftKeys() ? this.renderSoftKeys() : null} ${this.loading ? html`

Loading terminals…

` : null}
@@ -449,6 +542,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); } diff --git a/src/client/src/components/TerminalSoftKeys.ts b/src/client/src/components/TerminalSoftKeys.ts new file mode 100644 index 0000000..1c9b0ad --- /dev/null +++ b/src/client/src/components/TerminalSoftKeys.ts @@ -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` + + `; + } + + 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; +} diff --git a/src/client/src/terminalKeys.test.ts b/src/client/src/terminalKeys.test.ts new file mode 100644 index 0000000..93eee20 --- /dev/null +++ b/src/client/src/terminalKeys.test.ts @@ -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"); + }); +}); diff --git a/src/client/src/terminalKeys.ts b/src/client/src/terminalKeys.ts new file mode 100644 index 0000000..2bdbf6e --- /dev/null +++ b/src/client/src/terminalKeys.ts @@ -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}`; +} diff --git a/src/client/src/terminalSoftKeysPreference.test.ts b/src/client/src/terminalSoftKeysPreference.test.ts new file mode 100644 index 0000000..0890119 --- /dev/null +++ b/src/client/src/terminalSoftKeysPreference.test.ts @@ -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(); + + 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"); + } +} diff --git a/src/client/src/terminalSoftKeysPreference.ts b/src/client/src/terminalSoftKeysPreference.ts new file mode 100644 index 0000000..6474b38 --- /dev/null +++ b/src/client/src/terminalSoftKeysPreference.ts @@ -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; + +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; + } +} From c73ac5bb9a7ea285bd20ed4743fca1444663b80b Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 08:18:40 +0200 Subject: [PATCH 08/27] fix: keep PWA chrome visible after resume --- .changeset/pwa-viewport-resume.md | 5 +++ src/client/src/components/PiWebApp.ts | 57 ++++++++++++++++++++++++++- src/client/src/components/shared.ts | 2 +- 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 .changeset/pwa-viewport-resume.md diff --git a/.changeset/pwa-viewport-resume.md b/.changeset/pwa-viewport-resume.md new file mode 100644 index 0000000..9d5397c --- /dev/null +++ b/.changeset/pwa-viewport-resume.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep PWA navigation bars visible after returning to the app from the background. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 564f86c..5da2af9 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -51,6 +51,7 @@ const THEME_AUTO_OFF_VALUE = "auto:off"; const THEME_OPTION_PREFIX = "theme:"; const TERMINAL_ROUTE_NAMESPACE = queryNamespace("core:workspace.terminal"); const REFRESH_LONG_PRESS_MS = 550; +const VIEWPORT_POSITION_REPAIR_DELAY_MS = 250; @customElement("pi-web-app") export class PiWebApp extends LitElement { @@ -119,6 +120,8 @@ export class PiWebApp extends LitElement { private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE; private refreshLongPressTimer: number | undefined; private suppressNextRefreshClick = false; + private viewportPositionRepairFrame: number | undefined; + private viewportPositionRepairTimer: number | undefined; @state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID; @state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false; @state() private isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia); @@ -131,7 +134,11 @@ export class PiWebApp extends LitElement { @state() private mobileTabsCanScrollLeft = false; @state() private mobileTabsCanScrollRight = false; private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); + private readonly onPageShow = () => { + this.repairViewportPosition(); + }; private readonly onFocus = () => { + this.repairViewportPosition(); void this.sessions.refreshSelectedSession(); void this.refreshPiWebStatus(); void this.refreshWorkspaceActivity(); @@ -139,6 +146,7 @@ export class PiWebApp extends LitElement { }; private readonly onVisibilityChange = () => { if (document.visibilityState === "visible") { + this.repairViewportPosition(); void this.sessions.refreshSelectedSession(); void this.refreshPiWebStatus(); void this.refreshWorkspaceActivity(); @@ -185,6 +193,7 @@ export class PiWebApp extends LitElement { 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); @@ -203,6 +212,7 @@ 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); @@ -226,6 +236,7 @@ export class PiWebApp extends LitElement { this.mobileTabsResizeObserver = undefined; this.observedMobileTabs = undefined; this.clearRefreshLongPressTimer(); + this.clearViewportPositionRepair(); super.disconnectedCallback(); } @@ -353,7 +364,51 @@ 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.isMobileNavigationLayout && !this.isPwaDisplayMode; + } + + private repairViewportPosition(): void { + if (!this.shouldRepairViewportPosition()) return; + this.resetViewportScroll(); + if (this.viewportPositionRepairFrame !== undefined) window.cancelAnimationFrame(this.viewportPositionRepairFrame); + this.viewportPositionRepairFrame = window.requestAnimationFrame(() => { + this.viewportPositionRepairFrame = undefined; + this.resetViewportScroll(); + this.viewportPositionRepairFrame = window.requestAnimationFrame(() => { + this.viewportPositionRepairFrame = undefined; + this.resetViewportScroll(); + }); + }); + if (this.viewportPositionRepairTimer !== undefined) window.clearTimeout(this.viewportPositionRepairTimer); + this.viewportPositionRepairTimer = window.setTimeout(() => { + this.viewportPositionRepairTimer = undefined; + this.resetViewportScroll(); + }, VIEWPORT_POSITION_REPAIR_DELAY_MS); + } + + private shouldRepairViewportPosition(): boolean { + return this.isMobileNavigationLayout || this.isPwaDisplayMode; + } + + private resetViewportScroll(): void { + window.scrollTo(0, 0); + document.documentElement.scrollTop = 0; + document.body.scrollTop = 0; + } + + private clearViewportPositionRepair(): void { + if (this.viewportPositionRepairFrame !== undefined) { + window.cancelAnimationFrame(this.viewportPositionRepairFrame); + this.viewportPositionRepairFrame = undefined; + } + if (this.viewportPositionRepairTimer !== undefined) { + window.clearTimeout(this.viewportPositionRepairTimer); + this.viewportPositionRepairTimer = undefined; + } } private async withChatPrependTransition(action: () => Promise) { diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 41e06be..1733373 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -50,7 +50,7 @@ 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; } + :host { position: fixed; top: 0; right: 0; left: 0; display: block; height: 100dvh; box-sizing: border-box; overflow: hidden; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); color: var(--pi-text); background: var(--pi-bg); font: 14px system-ui, sans-serif; } .shell { 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; } 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); } From 559c6f6d1f3dec95a422e1ba18274f34a871083b Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 10:47:16 +0200 Subject: [PATCH 09/27] fix: move workspace panel collapse control to edge --- .changeset/workspace-panel-collapse.md | 5 ++++ src/client/src/appState.ts | 2 -- src/client/src/components/PiWebApp.ts | 31 ++++++++++++++------- src/client/src/components/WorkspacePanel.ts | 6 ---- src/client/src/components/shared.ts | 28 +++++++++---------- 5 files changed, 39 insertions(+), 33 deletions(-) create mode 100644 .changeset/workspace-panel-collapse.md diff --git a/.changeset/workspace-panel-collapse.md b/.changeset/workspace-panel-collapse.md new file mode 100644 index 0000000..42589b4 --- /dev/null +++ b/.changeset/workspace-panel-collapse.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a desktop edge control for collapsing and expanding the workspace tools panel. diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index 17776d7..409bf73 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -45,7 +45,6 @@ export interface AppState { activeTerminalCount: number; selectedTerminalId: string | undefined; piWebStatus: PiWebStatusResponse | undefined; - workspacePanelCollapsed: boolean; error: string; } @@ -134,7 +133,6 @@ export function initialAppState(): AppState { activeTerminalCount: 0, selectedTerminalId: undefined, piWebStatus: undefined, - workspacePanelCollapsed: false, error: "", }; } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 7db89b6..de145df 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -55,6 +55,7 @@ const REFRESH_LONG_PRESS_MS = 550; @customElement("pi-web-app") export class PiWebApp extends LitElement { @state() private state: AppState = initialAppState(); + @state() private workspacePanelCollapsed = false; @query("chat-view") private chatView?: ChatView; @query("prompt-editor") private promptEditor?: PromptEditor; @query(".context-items") private contextItems?: HTMLElement | null; @@ -513,6 +514,7 @@ export class PiWebApp extends LitElement { const emptyState = workspace === undefined ? this.workspacePanelEmptyState() : undefined; return html` { this.openWorkspaceTool(tool); }} - .onToggleCollapse=${() => { this.toggleWorkspacePanelCollapse(); }} > `; } private toggleWorkspacePanelCollapse(): void { - this.setState({ workspacePanelCollapsed: !this.state.workspacePanelCollapsed }); + this.workspacePanelCollapsed = !this.workspacePanelCollapsed; } - private renderExpandWorkspacePanelButton() { + private renderWorkspacePanelEdgeControl() { + const collapsed = this.workspacePanelCollapsed; + const label = collapsed ? "Expand workspace panel" : "Collapse workspace panel"; return html` -
+
+ >${this.renderWorkspacePanelEdgeIcon(collapsed)}
`; } + private renderWorkspacePanelEdgeIcon(collapsed: boolean) { + const path = collapsed ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6"; + return html``; + } + private renderNavigationPanel(autoSwitchToChat: boolean) { const openChatAfter = (action: () => Promise) => this.withChatScrollTransition(async () => { await action(); @@ -1220,11 +1231,10 @@ export class PiWebApp extends LitElement { override render() { const state = this.state; return html` -
+
${this.renderContextBar()} - ${state.workspacePanelCollapsed ? this.renderExpandWorkspacePanelButton() : null}
@@ -1246,6 +1256,7 @@ export class PiWebApp extends LitElement { ${state.authDialog !== undefined ? html` { 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(); }}>` : null} ` : html`
${this.sessionEmptyMessage()}
`}
+ ${this.renderWorkspacePanelEdgeControl()} ${this.renderWorkspacePanel()} ${state.actionPaletteOpen ? html` { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}>` : null} ${state.projectDialogOpen ? html` this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}>` : null} diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 0cbdfd8..826b293 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -20,7 +20,6 @@ export class WorkspacePanel extends LitElement { @property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = []; @property({ type: Boolean }) hideToolTabs = false; @property({ attribute: false }) onSelectTool: (tool: QualifiedContributionId) => void = () => undefined; - @property({ attribute: false }) onToggleCollapse: () => void = () => undefined; @query(".workspace-header-strip") private workspaceHeaderStrip?: HTMLElement | null; @state() private workspaceHeaderCanScrollLeft = false; @state() private workspaceHeaderCanScrollRight = false; @@ -70,7 +69,6 @@ export class WorkspacePanel extends LitElement { ${visiblePanels.map((panel) => html` `)} -
`} ${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)} @@ -94,10 +92,6 @@ export class WorkspacePanel extends LitElement { return html`${panel.title} ${badge}`; } - private renderCollapseIcon(): TemplateResult { - return html``; - } - private renderEmptyState(state: WorkspacePanelEmptyState): TemplateResult { return html`
diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 7b282cb..d752c74 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -51,15 +51,13 @@ 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; } - .shell.workspace-panel-collapsed { grid-template-columns: 340px minmax(420px, 1fr); } - .shell.workspace-panel-collapsed > workspace-panel { display: none; } - aside { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--pi-border); overflow: hidden; } + .shell { display: grid; grid-template-columns: 340px minmax(420px, 1fr) 1px minmax(360px, 42vw); height: 100%; min-height: 0; } + aside { grid-column: 1; display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--pi-border); overflow: hidden; } 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; } + main { grid-column: 2; 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%); } @@ -95,28 +93,30 @@ 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; } - .expand-panel-strip { flex: 0 0 auto; display: flex; align-items: center; justify-content: flex-end; padding: 4px 8px; border-bottom: 1px solid var(--pi-border-muted); } - .expand-workspace-panel-button { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; padding: 0; border-radius: 6px; } - .expand-workspace-panel-button .expand-icon { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } - workspace-panel { min-width: 0; min-height: 0; border-left: 1px solid var(--pi-border); overflow: hidden; } + .workspace-panel-edge { grid-column: 3; min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; } + .workspace-panel-edge-button { position: relative; z-index: 1; 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; } + .workspace-panel-edge-button:hover, .workspace-panel-edge-button:focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); opacity: 1; } + .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: 4; min-width: 0; min-height: 0; overflow: hidden; } + @media (min-width: 1181px) { + .shell.workspace-panel-collapsed { grid-template-columns: 340px minmax(420px, 1fr) 1px; } + .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.workspace-panel-collapsed { grid-template-columns: 340px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } aside { grid-row: 1 / 3; } main { grid-column: 2; grid-row: 1 / 3; } .mobile-tabs-frame { display: flex; } .shell.workspace-view main { grid-row: 1; min-height: auto; } .shell.workspace-view > workspace-panel { grid-column: 2; grid-row: 2; display: flex; border-left: 0; } .shell:not(.workspace-view) > workspace-panel { display: none; } - .shell.workspace-panel-collapsed > workspace-panel { display: none; } - .expand-panel-strip { 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); } - .shell.workspace-panel-collapsed { grid-template-columns: minmax(0, 1fr); } aside { display: none; } main, .shell.workspace-view > workspace-panel { grid-column: 1; } .context-bar { display: flex; } @@ -151,8 +151,6 @@ export const workspacePanelStyles = css` .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; align-items: center; } .tabs button { flex: 0 0 auto; white-space: nowrap; } - .collapse-button { display: inline-flex; align-items: center; justify-content: center; width: auto; height: auto; padding: 5px 7px; } - .collapse-icon { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } button { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; } button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } .tab-badge { display: inline-block; min-width: 14px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } From c0b358bf3dad9a441aaf21bc42a60642b7cb9c09 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 11:06:45 +0200 Subject: [PATCH 10/27] fix: keep collapsed workspace panel handle visible --- src/client/src/components/shared.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 6ac093f..098092e 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -94,8 +94,9 @@ export const appStyles = css` .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-edge { grid-column: 3; min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; } - .workspace-panel-edge-button { position: relative; z-index: 1; 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; } + .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; } .workspace-panel-edge-button:hover, .workspace-panel-edge-button:focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); opacity: 1; } + .shell.workspace-panel-collapsed .workspace-panel-edge-button { transform: translateX(calc(-50% + .5px)); } .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: 4; min-width: 0; min-height: 0; overflow: hidden; } @media (min-width: 1181px) { From 5737b228b87237695e289a940ed0231d8257b9df Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 11:18:04 +0200 Subject: [PATCH 11/27] feat: add left panel collapse control --- .changeset/left-panel-collapse.md | 5 ++++ src/client/src/components/PiWebApp.ts | 40 ++++++++++++++++++++++++--- src/client/src/components/shared.ts | 34 ++++++++++++++--------- 3 files changed, 62 insertions(+), 17 deletions(-) create mode 100644 .changeset/left-panel-collapse.md diff --git a/.changeset/left-panel-collapse.md b/.changeset/left-panel-collapse.md new file mode 100644 index 0000000..2fcec61 --- /dev/null +++ b/.changeset/left-panel-collapse.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a collapse control for the left navigation panel in wide and two-panel layouts. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 58cd641..14706c6 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -56,6 +56,7 @@ const VIEWPORT_POSITION_REPAIR_DELAY_MS = 250; @customElement("pi-web-app") export class PiWebApp extends LitElement { @state() private state: AppState = initialAppState(); + @state() private navigationPanelCollapsed = false; @state() private workspacePanelCollapsed = false; @query("chat-view") private chatView?: ChatView; @query("prompt-editor") private promptEditor?: PromptEditor; @@ -581,6 +582,32 @@ export class PiWebApp extends LitElement { `; } + private toggleNavigationPanelCollapse(): void { + this.navigationPanelCollapsed = !this.navigationPanelCollapsed; + } + + private renderNavigationPanelEdgeControl() { + const collapsed = this.navigationPanelCollapsed; + const label = collapsed ? "Expand navigation panel" : "Collapse navigation panel"; + return html` + + `; + } + + private renderNavigationPanelEdgeIcon(collapsed: boolean) { + return this.renderPanelEdgeIcon(collapsed ? "right" : "left", "navigation-panel-edge-icon"); + } + private toggleWorkspacePanelCollapse(): void { this.workspacePanelCollapsed = !this.workspacePanelCollapsed; } @@ -604,8 +631,12 @@ export class PiWebApp extends LitElement { } private renderWorkspacePanelEdgeIcon(collapsed: boolean) { - const path = collapsed ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6"; - return html``; + return this.renderPanelEdgeIcon(collapsed ? "left" : "right", "workspace-panel-edge-icon"); + } + + private renderPanelEdgeIcon(direction: "left" | "right", className: string) { + const path = direction === "left" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6"; + return html``; } private renderNavigationPanel(autoSwitchToChat: boolean) { @@ -1286,8 +1317,9 @@ export class PiWebApp extends LitElement { override render() { const state = this.state; return html` -
- +
+ + ${this.renderNavigationPanelEdgeControl()}
${this.renderContextBar()}
diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 098092e..98eeeb1 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -51,13 +51,13 @@ export interface CompletionItem { export const appStyles = css` :host { position: fixed; top: 0; right: 0; left: 0; display: block; height: 100dvh; box-sizing: border-box; overflow: hidden; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); color: var(--pi-text); background: var(--pi-bg); font: 14px system-ui, sans-serif; } - .shell { display: grid; grid-template-columns: 340px minmax(420px, 1fr) 1px minmax(360px, 42vw); height: 100%; min-height: 0; } - aside { grid-column: 1; display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--pi-border); overflow: hidden; } + .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; } 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 { grid-column: 2; display: flex; flex-direction: column; min-width: 0; min-height: 0; } + 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%); } @@ -93,23 +93,31 @@ 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-edge { grid-column: 3; min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; } - .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; } - .workspace-panel-edge-button:hover, .workspace-panel-edge-button:focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); opacity: 1; } + .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)); } - .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: 4; min-width: 0; min-height: 0; overflow: hidden; } + .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.workspace-panel-collapsed { grid-template-columns: 340px minmax(420px, 1fr) 1px; } + .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; } .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, @@ -118,7 +126,7 @@ export const appStyles = css` } @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; } From 2abd1d9e199583f88ac84006c5ef9330bff37b67 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 14:15:52 +0200 Subject: [PATCH 12/27] fix: queue prompts during compaction --- .changeset/queue-prompts-during-compaction.md | 5 + src/server/sessions/piSessionService.test.ts | 83 +++++++++- src/server/sessions/piSessionService.ts | 156 +++++++++++++++--- 3 files changed, 219 insertions(+), 25 deletions(-) create mode 100644 .changeset/queue-prompts-during-compaction.md diff --git a/.changeset/queue-prompts-during-compaction.md b/.changeset/queue-prompts-during-compaction.md new file mode 100644 index 0000000..45ebbef --- /dev/null +++ b/.changeset/queue-prompts-during-compaction.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Queue prompts submitted during session compaction in pi-web and deliver them only after compaction finishes. diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index 74e3e69..eb23fc1 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -45,6 +45,7 @@ function sessionRecord(id: string, cwd = "/workspace") { function fakeRuntime(sessionId = "session-1", patch: Partial = {}) { const promptCalls: { text: string; options: unknown }[] = []; + const listeners: ((event: unknown) => void)[] = []; const calls = { abort: 0, clearQueue: 0, dispose: 0, prompt: promptCalls }; const session: TestSession = { sessionId, @@ -63,7 +64,13 @@ function fakeRuntime(sessionId = "session-1", patch: Partial = {}) extensionRunner: { getRegisteredCommands: () => [] }, promptTemplates: [], resourceLoader: { getSkills: () => ({ skills: [] }) }, - subscribe: () => () => undefined, + subscribe: (listener: (event: unknown) => void) => { + listeners.push(listener); + return () => { + const index = listeners.indexOf(listener); + if (index !== -1) listeners.splice(index, 1); + }; + }, getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }), getContextUsage: () => undefined, prompt: (text: string, options: unknown) => { @@ -101,7 +108,7 @@ function fakeRuntime(sessionId = "session-1", patch: Partial = {}) return Promise.resolve(); }, }; - return { runtime, session, calls }; + return { runtime, session, calls, emit: (event: unknown) => { for (const listener of [...listeners]) listener(event); } }; } function runtimeCreator(runtime: PiSessionRuntime): RuntimeCreator { @@ -414,6 +421,60 @@ describe("PiSessionService", () => { await service.dispose(); }); + it("holds prompts sent during compaction until compaction finishes", async () => { + const hub = new CapturingSessionEventHub(); + const fake = fakeRuntime("compacting-session", { isCompacting: true }); + let resolveFirstPrompt: (() => void) | undefined; + fake.session.prompt = (text: string, options?: { streamingBehavior?: "steer" | "followUp" }) => { + fake.calls.prompt.push({ text, options }); + if (options === undefined) { + fake.session.isStreaming = true; + return new Promise((resolve) => { resolveFirstPrompt = resolve; }); + } + return Promise.resolve(); + }; + const service = new PiSessionService(hub, { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("compacting-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt("compacting-session", "Start task 1", "followUp"); + await service.prompt("compacting-session", "Then task 2", "followUp"); + + expect(fake.calls.prompt).toEqual([]); + expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false); + await expect(service.status("compacting-session")).resolves.toMatchObject({ + pendingMessageCount: 2, + queuedMessages: [{ kind: "followUp", text: "Start task 1" }, { kind: "followUp", text: "Then task 2" }], + }); + + fake.session.isCompacting = false; + fake.emit({ type: "compaction_end" }); + await new Promise((resolve) => setTimeout(resolve, 5)); + + expect(fake.calls.prompt).toEqual([{ text: "Start task 1", options: undefined }]); + expect(hub.sessionEvents.some(({ event }) => event.type === "message.append" && JSON.stringify(event.message).includes("Start task 1"))).toBe(true); + await expect(service.status("compacting-session")).resolves.toMatchObject({ + pendingMessageCount: 1, + queuedMessages: [{ kind: "followUp", text: "Then task 2" }], + }); + + fake.emit({ type: "agent_start" }); + await new Promise((resolve) => setTimeout(resolve, 5)); + + expect(fake.calls.prompt).toEqual([ + { text: "Start task 1", options: undefined }, + { text: "Then task 2", options: { streamingBehavior: "followUp" } }, + ]); + await expect(service.status("compacting-session")).resolves.toMatchObject({ + pendingMessageCount: 0, + queuedMessages: [], + }); + resolveFirstPrompt?.(); + await service.dispose(); + }); + it("clears queued messages when aborting active work", async () => { const fake = fakeRuntime("abort-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { @@ -430,6 +491,24 @@ describe("PiSessionService", () => { await service.dispose(); }); + it("clears prompts queued during compaction when aborting active work", async () => { + const fake = fakeRuntime("abort-compaction-session", { isCompacting: true }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]), + heartbeatIntervalMs: 60_000, + }); + + await service.prompt("abort-compaction-session", "Do not deliver after abort", "followUp"); + await expect(service.status("abort-compaction-session")).resolves.toMatchObject({ pendingMessageCount: 1 }); + await service.abort("abort-compaction-session"); + + expect(fake.calls.clearQueue).toBe(1); + expect(fake.calls.prompt).toEqual([]); + await expect(service.status("abort-compaction-session")).resolves.toMatchObject({ pendingMessageCount: 0, queuedMessages: [] }); + await service.dispose(); + }); + it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => { const hub = new CapturingSessionEventHub(); const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } }); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 93e9d64..cdb29ed 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -34,6 +34,13 @@ function authLossWarningKey(sessionId: string, provider: string, modelId: string return `${sessionId}:${provider}/${modelId}`; } +type QueuedPromptKind = "steer" | "followUp"; + +interface QueuedPrompt { + kind: QueuedPromptKind; + text: string; +} + type SessionArchiveRepository = Pick; interface PiSessionListEntry { id: string; @@ -180,6 +187,8 @@ export class PiSessionService { private readonly activities = new Map(); private readonly heartbeat: NodeJS.Timeout; private readonly commandService: SessionCommandService; + private readonly compactionPromptQueues = new Map(); + private readonly compactionDrainTimers = new Map(); private readonly authLossWarnings = new Set(); private readonly archiveStore: SessionArchiveRepository; private readonly agentDir: string; @@ -222,9 +231,11 @@ export class PiSessionService { async dispose(): Promise { clearInterval(this.heartbeat); + this.clearCompactionDrainTimers(); const activeSessions = Array.from(new Set(this.active.values())); this.active.clear(); this.activities.clear(); + this.compactionPromptQueues.clear(); this.authLossWarnings.clear(); await Promise.all(activeSessions.map(async (active) => { active.unsubscribe(); @@ -355,18 +366,36 @@ export class PiSessionService { this.maybeGenerateSessionName(session, text); const isQueued = session.isStreaming || session.isCompacting; const behavior = isQueued ? streamingBehavior ?? "followUp" : undefined; - if (isQueued && hasQueuedMessageText(session, text)) { + if (isQueued && this.hasQueuedMessageText(session, text)) { this.publishActivity(session, "duplicate queued message ignored", "active"); this.publishStatus(session); return; } - this.publishActivity(session, session.isCompacting ? "message queued during compaction" : behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active"); - if (!isQueued) this.events.publish(sessionId, { type: "message.append", message: userTextMessage(text) }); - void session.prompt(text, behavior === undefined ? undefined : { streamingBehavior: behavior }).catch((error: unknown) => { + if (session.isCompacting) { + this.enqueuePromptDuringCompaction(session, text, behavior ?? "followUp"); + return; + } + void this.submitPrompt(session, text, behavior); + } + + private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined): Promise { + this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active"); + if (behavior === undefined) this.events.publish(session.sessionId, { type: "message.append", message: userTextMessage(text) }); + const promptPromise = session.prompt(text, behavior === undefined ? undefined : { streamingBehavior: behavior }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); this.publishActivity(session, "error", "error", message); - this.events.publish(sessionId, { type: "session.error", message }); + this.events.publish(session.sessionId, { type: "session.error", message }); }); + void promptPromise; + return promptPromise; + } + + private enqueuePromptDuringCompaction(session: PiAgentSession, text: string, kind: QueuedPromptKind): void { + const queue = this.compactionPromptQueues.get(session.sessionId) ?? []; + queue.push({ kind, text }); + this.compactionPromptQueues.set(session.sessionId, queue); + this.publishActivity(session, "message queued during compaction", "active"); + this.publishStatus(session); } async shell(sessionId: string, text: string): Promise { @@ -416,7 +445,7 @@ export class PiSessionService { async archive(sessionId: string): Promise { const session = await this.getOrOpen(sessionId); - if (sessionHasActiveWork(session)) throw new Error("Stop current session activity before archiving"); + if (this.hasActiveWork(session)) throw new Error("Stop current session activity before archiving"); const archiveInput = await this.archiveInputForSession(session); await this.closeActive(session.sessionId); await this.archiveStore.archive(archiveInput); @@ -427,7 +456,7 @@ export class PiSessionService { const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd()); const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false); const plan = planSessionArchiveTree(root, catalog); - const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && sessionHasActiveWork(target)); + const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target)); if (busy !== undefined) throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`); const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target)); @@ -457,6 +486,7 @@ export class PiSessionService { async abort(sessionId: string): Promise { const active = this.active.get(sessionId); if (!active) return; + this.clearCompactionPromptQueue(sessionId); clearSessionQueue(active.runtime.session); await active.runtime.session.abort(); this.publishActivity(active.runtime.session, "stopped", "idle"); @@ -551,6 +581,7 @@ export class PiSessionService { this.activities.delete(sessionId); this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd()); this.clearAuthLossWarningsForSession(sessionId); + this.clearCompactionPromptQueue(sessionId); clearSessionQueue(active.runtime.session); active.unsubscribe(); try { @@ -595,18 +626,84 @@ export class PiSessionService { private bindRuntime(active: ActiveSession): void { active.unsubscribe(); - for (const [sessionId, candidate] of this.active.entries()) { - if (candidate === active) this.active.delete(sessionId); - } const { session } = active.runtime; + for (const [sessionId, candidate] of this.active.entries()) { + if (candidate === active) { + this.active.delete(sessionId); + if (sessionId !== session.sessionId) this.clearCompactionPromptQueue(sessionId); + } + } active.unsubscribe = session.subscribe((event) => { this.events.publish(session.sessionId, toClientEvent(event)); this.publishActivityForEvent(session, event); + const eventType = getString(event, "type"); + if (eventType === "compaction_end") this.scheduleCompactionQueueDrain(session.sessionId); + if (eventType === "agent_start" || eventType === "agent_end") this.scheduleCompactionQueueDrain(session.sessionId); this.publishStatus(session); }); this.active.set(session.sessionId, active); } + private scheduleCompactionQueueDrain(sessionId: string, delayMs = 0): void { + if (!this.compactionPromptQueues.has(sessionId) || this.compactionDrainTimers.has(sessionId)) return; + const timer = setTimeout(() => { + this.compactionDrainTimers.delete(sessionId); + this.drainCompactionPromptQueue(sessionId); + }, delayMs); + this.compactionDrainTimers.set(sessionId, timer); + } + + private drainCompactionPromptQueue(sessionId: string): void { + const active = this.active.get(sessionId); + if (active === undefined) return; + const { session } = active.runtime; + if (session.isCompacting) { + this.scheduleCompactionQueueDrain(sessionId, 100); + return; + } + + if (session.isStreaming) { + const queued = this.takeCompactionPromptQueue(sessionId); + if (queued.length === 0) return; + this.publishStatus(session); + for (const prompt of queued) void this.submitPrompt(session, prompt.text, prompt.kind); + return; + } + + const prompt = this.shiftCompactionPrompt(sessionId); + if (prompt === undefined) return; + this.publishStatus(session); + const submitted = this.submitPrompt(session, prompt.text, undefined); + void submitted.finally(() => { this.scheduleCompactionQueueDrain(sessionId); }); + } + + private takeCompactionPromptQueue(sessionId: string): QueuedPrompt[] { + const queued = this.compactionPromptQueues.get(sessionId) ?? []; + this.compactionPromptQueues.delete(sessionId); + return queued; + } + + private shiftCompactionPrompt(sessionId: string): QueuedPrompt | undefined { + const queue = this.compactionPromptQueues.get(sessionId); + const prompt = queue?.shift(); + if (queue === undefined || queue.length === 0) this.compactionPromptQueues.delete(sessionId); + return prompt; + } + + private clearCompactionPromptQueue(sessionId: string): void { + this.compactionPromptQueues.delete(sessionId); + const timer = this.compactionDrainTimers.get(sessionId); + if (timer !== undefined) { + clearTimeout(timer); + this.compactionDrainTimers.delete(sessionId); + } + } + + private clearCompactionDrainTimers(): void { + for (const timer of this.compactionDrainTimers.values()) clearTimeout(timer); + this.compactionDrainTimers.clear(); + } + private maybeGenerateSessionName(session: PiAgentSession, firstMessage: string): void { if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting) return; const model = session.model; @@ -674,7 +771,7 @@ export class PiSessionService { for (const active of this.active.values()) { const { session } = active.runtime; const activity = this.activities.get(session.sessionId); - if (!sessionHasActiveWork(session)) { + if (!this.hasActiveWork(session)) { if (activity?.phase === "active") this.publishStatus(session); continue; } @@ -688,10 +785,14 @@ export class PiSessionService { if (session.isCompacting) return "compacting"; if (session.isBashRunning) return "running bash"; if (session.isStreaming) return "agent running"; - if (session.pendingMessageCount) return "queued"; + if (this.pendingMessageCount(session) > 0) return "queued"; return "active"; } + private hasActiveWork(session: PiAgentSession): boolean { + return sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length); + } + private publishActivityForEvent(session: PiAgentSession, event: unknown): void { const eventType = getString(event, "type"); if (eventType === undefined) return; @@ -716,7 +817,7 @@ export class PiSessionService { } if (eventType === "bash_execution_start") { this.publishActivity(session, "running bash", "active"); return; } if (eventType === "bash_execution_end") { this.publishActivity(session, "bash complete", "idle"); return; } - if (sessionHasActiveWork(session)) this.publishActivity(session, eventType.replaceAll("_", " "), "active"); + if (this.hasActiveWork(session)) this.publishActivity(session, eventType.replaceAll("_", " "), "active"); } private publishActivity(session: PiAgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void { @@ -739,7 +840,7 @@ export class PiSessionService { private clearStaleActiveActivity(session: PiAgentSession): void { const current = this.activities.get(session.sessionId); - if (current?.phase !== "active" || sessionHasActiveWork(session)) return; + if (current?.phase !== "active" || this.hasActiveWork(session)) return; const at = new Date().toISOString(); const stored = { phase: "idle" as const, label: "idle", at }; this.activities.set(session.sessionId, stored); @@ -759,14 +860,26 @@ export class PiSessionService { isStreaming: session.isStreaming, isCompacting: session.isCompacting, isBashRunning: session.isBashRunning, - pendingMessageCount: session.pendingMessageCount, - queuedMessages: queuedMessagesFromSession(session), + pendingMessageCount: this.pendingMessageCount(session), + queuedMessages: queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)), messageCount: session.messages.length, tokens: stats.tokens, cost: stats.cost, ...(contextUsage === undefined ? {} : { contextUsage }), }; } + + private pendingMessageCount(session: PiAgentSession): number { + return session.pendingMessageCount + this.compactionQueuedMessages(session.sessionId).length; + } + + private compactionQueuedMessages(sessionId: string): readonly QueuedPrompt[] { + return this.compactionPromptQueues.get(sessionId) ?? []; + } + + private hasQueuedMessageText(session: PiAgentSession, text: string): boolean { + return queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)).some((message) => message.text === text); + } } function modelToClientModel(model: PiAgentSession["model"]): ClientSessionModel { @@ -872,8 +985,8 @@ function archiveInputFromCandidate(candidate: WorkspaceArchiveCandidate): Archiv throw new Error(`Session is not available for archiving: ${candidate.id}`); } -function sessionHasActiveWork(session: PiAgentSession): boolean { - return session.isStreaming || session.isCompacting || session.isBashRunning || session.pendingMessageCount > 0; +function sessionHasActiveWork(session: PiAgentSession, extraQueuedMessageCount = 0): boolean { + return session.isStreaming || session.isCompacting || session.isBashRunning || session.pendingMessageCount + extraQueuedMessageCount > 0; } function sessionDisplayName(session: PiAgentSession): string { @@ -938,14 +1051,11 @@ function clearSessionQueue(session: PiAgentSession): void { session.clearQueue(); } -function hasQueuedMessageText(session: PiAgentSession, text: string): boolean { - return queuedMessagesFromSession(session).some((message) => message.text === text); -} - -function queuedMessagesFromSession(session: PiAgentSession): { kind: "steer" | "followUp"; text: string }[] { +function queuedMessagesFromSession(session: PiAgentSession, extraQueuedMessages: readonly QueuedPrompt[] = []): { kind: "steer" | "followUp"; text: string }[] { return [ ...session.getSteeringMessages().map((text) => ({ kind: "steer" as const, text })), ...session.getFollowUpMessages().map((text) => ({ kind: "followUp" as const, text })), + ...extraQueuedMessages, ]; } From 3bd4773c8791a047310cd3d437334a5b137c2797 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 14:19:44 +0200 Subject: [PATCH 13/27] fix: show raw chat history range --- .changeset/fix-chat-history-range.md | 5 +++++ src/client/src/appState.ts | 2 ++ src/client/src/chatTranscriptStore.test.ts | 17 +++++++++++++++++ src/client/src/chatTranscriptStore.ts | 7 ++++++- src/client/src/components/ChatView.ts | 10 ++++++++-- src/client/src/components/PiWebApp.ts | 2 +- src/client/src/controllers/sessionController.ts | 2 +- 7 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-chat-history-range.md diff --git a/.changeset/fix-chat-history-range.md b/.changeset/fix-chat-history-range.md new file mode 100644 index 0000000..afeac10 --- /dev/null +++ b/.changeset/fix-chat-history-range.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Correct the chat history range label when normalized display messages are fewer than the raw session transcript entries. diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index 409bf73..100bfd3 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -8,6 +8,7 @@ export interface AppState { sessions: SessionInfo[]; messages: ChatLine[]; messagePageStart: number; + messagePageEnd: number; messagePageTotal: number; isLoadingEarlierMessages: boolean; isReceivingPartialStream: boolean; @@ -96,6 +97,7 @@ export function initialAppState(): AppState { sessions: [], messages: [], messagePageStart: 0, + messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, diff --git a/src/client/src/chatTranscriptStore.test.ts b/src/client/src/chatTranscriptStore.test.ts index fcf5045..0bc5eb6 100644 --- a/src/client/src/chatTranscriptStore.test.ts +++ b/src/client/src/chatTranscriptStore.test.ts @@ -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, }); }); diff --git a/src/client/src/chatTranscriptStore.ts b/src/client/src/chatTranscriptStore.ts index 1d696c4..cd8eb95 100644 --- a/src/client/src/chatTranscriptStore.ts +++ b/src/client/src/chatTranscriptStore.ts @@ -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, }; } diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 8b8d994..869e5a3 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -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`Showing messages ${from}–${to} of ${this.messageTotal}`; + const to = this.loadedRawMessageEnd(); + const total = Math.max(this.messageTotal, to); + return html`Showing messages ${from}–${to} of ${total}`; + } + + private loadedRawMessageEnd(): number { + return Math.max(this.messageEnd, this.messageStart + this.messages.length); } private renderMessage(message: ChatLine, index: number) { diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 14706c6..92329dd 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1334,7 +1334,7 @@ export class PiWebApp extends LitElement { ${state.error ? html`
${state.error}
` : null}
${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}
${state.selectedSession ? html` - 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> + 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index b8b8918..aeab86c 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -61,7 +61,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 }) { From 1c1740aeabb22eda669c705e42c80acc45eb69db Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 15:06:55 +0200 Subject: [PATCH 14/27] fix: keep navigation headings visible --- .../keep-navigation-headings-visible.md | 5 +++ src/client/src/components/ProjectList.ts | 42 ++++++++++--------- src/client/src/components/SessionList.ts | 14 ++++--- src/client/src/components/WorkspaceList.ts | 40 ++++++++++-------- src/client/src/components/shared.ts | 13 +++--- 5 files changed, 66 insertions(+), 48 deletions(-) create mode 100644 .changeset/keep-navigation-headings-visible.md diff --git a/.changeset/keep-navigation-headings-visible.md b/.changeset/keep-navigation-headings-visible.md new file mode 100644 index 0000000..3b59f9d --- /dev/null +++ b/.changeset/keep-navigation-headings-visible.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep left navigation section titles visible while project, workspace, and session lists scroll. diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index 2df8363..f857404 100644 --- a/src/client/src/components/ProjectList.ts +++ b/src/client/src/components/ProjectList.ts @@ -44,27 +44,31 @@ export class ProjectList extends LitElement { return html`

${this.renderHeading()}

- ${this.collapsed ? null : this.projects.map((project) => html` -
{ activateSelectableRow(event, () => this.onSelect?.(project)); }} - @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }} - > -
- ${project.name}${this.renderActivity(project)}${project.path} -
-
- - ${this.openMenuProjectId === project.id ? html` -
- + ${this.collapsed ? null : html` +
+ ${this.projects.map((project) => html` +
{ activateSelectableRow(event, () => this.onSelect?.(project)); }} + @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }} + > +
+ ${project.name}${this.renderActivity(project)}${project.path}
- ` : null} -
+
+ + ${this.openMenuProjectId === project.id ? html` +
+ +
+ ` : null} +
+
+ `)}
- `)} + `}
`; } diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index c60b0bb..0793781 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -76,11 +76,15 @@ export class SessionList extends LitElement { return html`
${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` -

- ${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0)) : null} - ` : null} + ${this.collapsed ? null : html` +
+ ${activeRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0))} + ${archivedRows.length > 0 ? html` +

+ ${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0)) : null} + ` : null} +
+ `}
`; } diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index 3874756..b130dbb 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -49,24 +49,28 @@ export class WorkspaceList extends LitElement { return html`

${this.renderHeading()}

- ${this.collapsed ? null : this.workspaces.map((workspace) => { - const label = workspacePrimaryLabel(workspace); - const items = this.workspaceLabelItems(workspace); - return html` -
{ activateSelectableRow(event, () => this.onSelect?.(workspace)); }} - @keydown=${(event: KeyboardEvent) => { this.handleWorkspaceKeydown(event, workspace); }} - > -
- ${this.renderWorkspaceMain(label, items, workspace)} -
- ${this.renderWorkspaceMenu(label, items, workspace)} -
- `; - })} + ${this.collapsed ? null : html` +
+ ${this.workspaces.map((workspace) => { + const label = workspacePrimaryLabel(workspace); + const items = this.workspaceLabelItems(workspace); + return html` +
{ activateSelectableRow(event, () => this.onSelect?.(workspace)); }} + @keydown=${(event: KeyboardEvent) => { this.handleWorkspaceKeydown(event, workspace); }} + > +
+ ${this.renderWorkspaceMain(label, items, workspace)} +
+ ${this.renderWorkspaceMenu(label, items, workspace)} +
+ `; + })} +
+ `}
`; } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 98eeeb1..a9e08f2 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -55,8 +55,8 @@ export const appStyles = css` aside { grid-column: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; } 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; } + 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; } @@ -135,7 +135,7 @@ export const appStyles = css` 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 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; } @@ -201,10 +201,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; } From 61a763a4fbb0fc8336b90153c54ee7cfd1015001 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 15:13:24 +0200 Subject: [PATCH 15/27] fix: keep chat status bubble above message titles --- .changeset/status-bubble-over-titles.md | 5 +++++ src/client/src/components/shared.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/status-bubble-over-titles.md diff --git a/.changeset/status-bubble-over-titles.md b/.changeset/status-bubble-over-titles.md new file mode 100644 index 0000000..79ebe5c --- /dev/null +++ b/.changeset/status-bubble-over-titles.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep the chat status indicator bubble above sticky message titles. diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index a9e08f2..5122423 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -265,7 +265,7 @@ export const chatStyles = css` .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; } From 8f62deff1a2915b9926650f5f1d319425d446ab8 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 20:27:12 +0200 Subject: [PATCH 16/27] fix(actions): avoid redundant action panel rerenders --- .changeset/actions-click-feedback.md | 5 ++ plugins/actions/src/actionsPanelElement.ts | 65 +++++++++++++++++----- 2 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 .changeset/actions-click-feedback.md diff --git a/.changeset/actions-click-feedback.md b/.changeset/actions-click-feedback.md new file mode 100644 index 0000000..b7cf5ad --- /dev/null +++ b/.changeset/actions-click-feedback.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web-actions": patch +--- + +Prevent redundant workspace action panel re-renders from resetting mobile scroll position or replacing action buttons mid-click, and show feedback for stale, cancelled, or already-starting actions. diff --git a/plugins/actions/src/actionsPanelElement.ts b/plugins/actions/src/actionsPanelElement.ts index 66565f8..2d8fbf9 100644 --- a/plugins/actions/src/actionsPanelElement.ts +++ b/plugins/actions/src/actionsPanelElement.ts @@ -15,6 +15,12 @@ type ConfigState = | { kind: "loading" } | WorkspaceActionsConfigLoadResult; +interface ActionStatus { + kind: "info" | "success" | "error"; + message: string; + detail?: string; +} + const configCache = new Map(); export function defineActionsPanelElement(): void { @@ -33,7 +39,7 @@ class PiWebActionsPanel extends HTMLElement { private openTerminalValue: OpenTerminal | undefined; private terminalCommandRunsValue: InternalTerminalCommandRunsRuntime | undefined; private runningActionId: string | undefined; - private status: { kind: "info" | "success" | "error"; message: string; detail?: string } | undefined; + private status: ActionStatus | undefined; private readonly root: ShadowRoot; private readonly onConfigChanged = () => { this.render(); @@ -45,7 +51,14 @@ class PiWebActionsPanel extends HTMLElement { } set workspace(value: Workspace | undefined) { + const previousKey = this.workspaceValue === undefined ? undefined : cacheKeyForWorkspace(this.workspaceValue); + const nextKey = value === undefined ? undefined : cacheKeyForWorkspace(value); this.workspaceValue = value; + // Parent app updates should not rebuild this shadow DOM for the same workspace: + // doing so resets the mobile scroll position and can replace buttons mid-click. + if (previousKey === nextKey) return; + this.runningActionId = undefined; + this.status = undefined; this.render(); } @@ -83,6 +96,7 @@ class PiWebActionsPanel extends HTMLElement {
+ ${this.renderStatus()}
${this.renderConfigState(state)}
@@ -94,8 +108,7 @@ class PiWebActionsPanel extends HTMLElement { for (const button of this.root.querySelectorAll("button[data-action-id]")) { button.addEventListener("click", () => { - const action = actionFromConfigState(state, button.getAttribute("data-action-id")); - if (action !== undefined) void this.dispatchAction(workspace, action); + void this.dispatchActionById(workspace, button.getAttribute("data-action-id")); }); } @@ -104,22 +117,36 @@ class PiWebActionsPanel extends HTMLElement { }); } + private dispatchActionById(workspace: Workspace, actionId: string | null): Promise { + if (!this.isCurrentWorkspace(workspace)) return Promise.resolve(); + const action = actionFromConfigState(getCachedWorkspaceConfig(workspace), actionId); + if (action === undefined) { + this.status = { kind: "error", message: "That action is no longer available. Click Refresh, then try again." }; + this.render(); + return Promise.resolve(); + } + return this.dispatchAction(workspace, action); + } + + private isCurrentWorkspace(workspace: Workspace): boolean { + return this.workspaceValue !== undefined && cacheKeyForWorkspace(this.workspaceValue) === cacheKeyForWorkspace(workspace); + } + private renderConfigState(state: ConfigState): string { - if (state.kind === "loading") return `

Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…

${this.renderStatus()}`; - if (state.kind === "missing") return `${renderMissingState(state)}${this.renderStatus()}`; - if (state.kind === "unavailable") return `${renderUnavailableState(state)}${this.renderStatus()}`; - if (state.config.actions.length === 0) return `

No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.

${this.renderStatus()}`; + if (state.kind === "loading") return `

Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…

`; + if (state.kind === "missing") return renderMissingState(state); + if (state.kind === "unavailable") return renderUnavailableState(state); + if (state.config.actions.length === 0) return `

No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.

`; return `

Actions run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.

${renderActionGroups(state.config.actions, this.runningActionId)} - ${this.renderStatus()} `; } private renderStatus(): string { if (this.status === undefined) return ""; const detail = this.status.detail === undefined ? "" : `
${escapeHtml(this.status.detail)}
`; - return `
${escapeHtml(this.status.message)}${detail}
`; + return `
${escapeHtml(this.status.message)}${detail}
`; } private async refreshConfig(workspace: Workspace): Promise { @@ -128,6 +155,7 @@ class PiWebActionsPanel extends HTMLElement { this.render(); const state = await refreshWorkspaceConfig(workspace); + if (!this.isCurrentWorkspace(workspace)) return; this.status = state.kind === "loaded" ? { kind: "success", message: `Loaded ${String(state.config.actions.length)} action${state.config.actions.length === 1 ? "" : "s"}.` } : undefined; @@ -135,8 +163,16 @@ class PiWebActionsPanel extends HTMLElement { } private async dispatchAction(workspace: Workspace, action: WorkspaceAction): Promise { - if (this.runningActionId !== undefined) return; - if (action.confirm && !window.confirm(`Run ${action.title}?\n\n${action.command}`)) return; + if (this.runningActionId !== undefined) { + this.status = { kind: "info", message: "Another action is already starting. Wait for it to finish dispatching, then try again." }; + this.render(); + return; + } + if (action.confirm && !window.confirm(`Run ${action.title}?\n\n${action.command}`)) { + this.status = { kind: "info", message: `Cancelled ${action.title}.` }; + this.render(); + return; + } const terminal = this.terminalCommandRunsValue; if (terminal === undefined) { @@ -151,6 +187,7 @@ class PiWebActionsPanel extends HTMLElement { try { const handle = await runWorkspaceActionInTerminal(terminal, workspace, action); + if (!this.isCurrentWorkspace(workspace)) return; this.status = { kind: "success", message: `Started terminal command “${handle.run.title}”.`, @@ -159,6 +196,7 @@ class PiWebActionsPanel extends HTMLElement { this.runningActionId = undefined; this.render(); } catch (error) { + if (!this.isCurrentWorkspace(workspace)) return; this.runningActionId = undefined; this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; this.render(); @@ -260,8 +298,8 @@ function renderAction(action: WorkspaceAction, runningActionId: string | undefin `; } -function actionFromConfigState(state: ConfigState, actionId: string | null): WorkspaceAction | undefined { - if (state.kind !== "loaded" || actionId === null) return undefined; +function actionFromConfigState(state: ConfigState | undefined, actionId: string | null): WorkspaceAction | undefined { + if (state?.kind !== "loaded" || actionId === null) return undefined; return state.config.actions.find((action) => action.id === actionId); } @@ -287,6 +325,7 @@ function actionStyles(): string { button:disabled { cursor: wait; opacity: 0.65; } .empty-state { border: 1px dashed var(--pi-border-muted); border-radius: 8px; color: var(--pi-muted); padding: 12px; } .empty-state p { margin: 6px 0 0; } + .panel-status { margin: 12px 12px 0; } .status { border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; } .status.info { border-color: var(--pi-accent-border); background: var(--pi-bg-overlay-soft); } .status.success { border-color: var(--pi-success-border); background: var(--pi-success-surface); color: var(--pi-success); } From 4043ce7eca116578bb0b1a2020d2d5f6f20205b0 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 20:41:52 +0200 Subject: [PATCH 17/27] chore(release): v1.202605.14 --- .changeset/fix-chat-history-range.md | 5 ----- .changeset/keep-navigation-headings-visible.md | 5 ----- .changeset/left-panel-collapse.md | 5 ----- .changeset/live-session-message-counts.md | 5 ----- .changeset/pwa-viewport-resume.md | 5 ----- .changeset/queue-prompts-during-compaction.md | 5 ----- .changeset/quiet-status-command.md | 5 ----- .changeset/soft-terminal-keys.md | 5 ----- .changeset/status-bubble-over-titles.md | 5 ----- .changeset/workspace-panel-collapse.md | 5 ----- CHANGELOG.md | 15 +++++++++++++++ package-lock.json | 6 +++--- package.json | 2 +- plugins/actions/package.json | 2 +- 14 files changed, 20 insertions(+), 55 deletions(-) delete mode 100644 .changeset/fix-chat-history-range.md delete mode 100644 .changeset/keep-navigation-headings-visible.md delete mode 100644 .changeset/left-panel-collapse.md delete mode 100644 .changeset/live-session-message-counts.md delete mode 100644 .changeset/pwa-viewport-resume.md delete mode 100644 .changeset/queue-prompts-during-compaction.md delete mode 100644 .changeset/quiet-status-command.md delete mode 100644 .changeset/soft-terminal-keys.md delete mode 100644 .changeset/status-bubble-over-titles.md delete mode 100644 .changeset/workspace-panel-collapse.md diff --git a/.changeset/fix-chat-history-range.md b/.changeset/fix-chat-history-range.md deleted file mode 100644 index afeac10..0000000 --- a/.changeset/fix-chat-history-range.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Correct the chat history range label when normalized display messages are fewer than the raw session transcript entries. diff --git a/.changeset/keep-navigation-headings-visible.md b/.changeset/keep-navigation-headings-visible.md deleted file mode 100644 index 3b59f9d..0000000 --- a/.changeset/keep-navigation-headings-visible.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Keep left navigation section titles visible while project, workspace, and session lists scroll. diff --git a/.changeset/left-panel-collapse.md b/.changeset/left-panel-collapse.md deleted file mode 100644 index 2fcec61..0000000 --- a/.changeset/left-panel-collapse.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add a collapse control for the left navigation panel in wide and two-panel layouts. diff --git a/.changeset/live-session-message-counts.md b/.changeset/live-session-message-counts.md deleted file mode 100644 index dc683f9..0000000 --- a/.changeset/live-session-message-counts.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Refresh session list message counts from live session status updates. diff --git a/.changeset/pwa-viewport-resume.md b/.changeset/pwa-viewport-resume.md deleted file mode 100644 index 9d5397c..0000000 --- a/.changeset/pwa-viewport-resume.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Keep PWA navigation bars visible after returning to the app from the background. diff --git a/.changeset/queue-prompts-during-compaction.md b/.changeset/queue-prompts-during-compaction.md deleted file mode 100644 index 45ebbef..0000000 --- a/.changeset/queue-prompts-during-compaction.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Queue prompts submitted during session compaction in pi-web and deliver them only after compaction finishes. diff --git a/.changeset/quiet-status-command.md b/.changeset/quiet-status-command.md deleted file mode 100644 index 50ec7f6..0000000 --- a/.changeset/quiet-status-command.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Make `pi-web status` print a concise service health report without invoking paged system service output. diff --git a/.changeset/soft-terminal-keys.md b/.changeset/soft-terminal-keys.md deleted file mode 100644 index 37612d5..0000000 --- a/.changeset/soft-terminal-keys.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add an optional terminal soft-key bar for common control, navigation, and Meta-style key sequences, with mobile-friendly defaults and a persistent toggle. diff --git a/.changeset/status-bubble-over-titles.md b/.changeset/status-bubble-over-titles.md deleted file mode 100644 index 79ebe5c..0000000 --- a/.changeset/status-bubble-over-titles.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Keep the chat status indicator bubble above sticky message titles. diff --git a/.changeset/workspace-panel-collapse.md b/.changeset/workspace-panel-collapse.md deleted file mode 100644 index 42589b4..0000000 --- a/.changeset/workspace-panel-collapse.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add a desktop edge control for collapsing and expanding the workspace tools panel. diff --git a/CHANGELOG.md b/CHANGELOG.md index eee7e72..d459c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # @jmfederico/pi-web +## 1.202605.14 + +### Patch Changes + +- 3bd4773: Correct the chat history range label when normalized display messages are fewer than the raw session transcript entries. +- 1c1740a: Keep left navigation section titles visible while project, workspace, and session lists scroll. +- 5737b22: Add a collapse control for the left navigation panel in wide and two-panel layouts. +- 50f1ddc: Refresh session list message counts from live session status updates. +- c73ac5b: Keep PWA navigation bars visible after returning to the app from the background. +- 2abd1d9: Queue prompts submitted during session compaction in pi-web and deliver them only after compaction finishes. +- 958596a: Make `pi-web status` print a concise service health report without invoking paged system service output. +- f569467: Add an optional terminal soft-key bar for common control, navigation, and Meta-style key sequences, with mobile-friendly defaults and a persistent toggle. +- 61a763a: Keep the chat status indicator bubble above sticky message titles. +- 559c6f6: Add a desktop edge control for collapsing and expanding the workspace tools panel. + ## 1.202605.13 ### Patch Changes diff --git a/package-lock.json b/package-lock.json index 26913df..dad789f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jmfederico/pi-web", - "version": "1.202605.13", + "version": "1.202605.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jmfederico/pi-web", - "version": "1.202605.13", + "version": "1.202605.14", "license": "MIT", "workspaces": [ ".", @@ -8688,7 +8688,7 @@ "vitest": "^4.1.5" }, "peerDependencies": { - "@jmfederico/pi-web": ">=1.202605.13" + "@jmfederico/pi-web": ">=1.202605.14" }, "peerDependenciesMeta": { "@jmfederico/pi-web": { diff --git a/package.json b/package.json index fb448cd..51c39fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jmfederico/pi-web", - "version": "1.202605.13", + "version": "1.202605.14", "description": "Remote web UI and browser control plane for persistent Pi Coding Agent sessions.", "license": "MIT", "author": "Federico Jaramillo Martinez", diff --git a/plugins/actions/package.json b/plugins/actions/package.json index 2692229..78d924b 100644 --- a/plugins/actions/package.json +++ b/plugins/actions/package.json @@ -23,7 +23,7 @@ "workspace" ], "peerDependencies": { - "@jmfederico/pi-web": ">=1.202605.13" + "@jmfederico/pi-web": ">=1.202605.14" }, "peerDependenciesMeta": { "@jmfederico/pi-web": { From 50906617a0e275e988b8a7d6851c1e41d538e442 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 21:33:14 +0200 Subject: [PATCH 18/27] feat: add pi-web version reporting --- .changeset/pi-web-version-doctor.md | 5 + README.md | 4 + docs/faq.html | 3 +- docs/index.html | 1 + docs/install.html | 5 +- extensions/pi-web.ts | 3 +- src/cli.ts | 25 +- src/piWebVersionReport.ts | 239 ++++++++++++++++++ src/server/app.ts | 3 +- src/server/piWebStatus.test.ts | 29 ++- src/server/piWebStatus.ts | 71 ++---- src/server/sessiond.ts | 2 +- src/server/sessiond/sessionProxyRoutes.ts | 2 +- src/server/terminalProxyRoutes.ts | 2 +- src/{server => }/sessiond/config.ts | 2 +- .../sessiond/sessionDaemonClient.ts | 0 src/shared/apiTypes.ts | 5 +- src/shared/piWebStatusParsing.ts | 58 +++++ 18 files changed, 389 insertions(+), 70 deletions(-) create mode 100644 .changeset/pi-web-version-doctor.md create mode 100644 src/piWebVersionReport.ts rename src/{server => }/sessiond/config.ts (85%) rename src/{server => }/sessiond/sessionDaemonClient.ts (100%) create mode 100644 src/shared/piWebStatusParsing.ts diff --git a/.changeset/pi-web-version-doctor.md b/.changeset/pi-web-version-doctor.md new file mode 100644 index 0000000..c08e542 --- /dev/null +++ b/.changeset/pi-web-version-doctor.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add `pi-web version` and include installed and running PI WEB version details in doctor output. diff --git a/README.md b/README.md index 344a195..7ddd505 100644 --- a/README.md +++ b/README.md @@ -150,9 +150,12 @@ pi-web status pi-web logs pi-web restart pi-web doctor +pi-web version pi-web uninstall ``` +Use `pi-web version` to compare the installed package version with the versions reported by the running Web/UI and session daemon services. + One-line install is also available for users who prefer it: ```bash @@ -173,6 +176,7 @@ Then in Pi: /pi-web logs /pi-web restart /pi-web doctor +/pi-web version ``` The Pi command is a convenience wrapper around the same service installer. When installed this way, the service installer can use PI WEB's package-local server entrypoints, so `pi-web-server` and `pi-web-sessiond` do not need to be on your shell `PATH`. `/pi-web logs` shows the last 100 service log lines; use `pi-web logs` in a shell when you want to follow logs continuously. diff --git a/docs/faq.html b/docs/faq.html index a643853..f4c9a4b 100644 --- a/docs/faq.html +++ b/docs/faq.html @@ -121,7 +121,8 @@

What does pi-web doctor check?

It checks whether the service shell and native service environment can find Node 22+, npm, Pi, and the Pi - Web binaries. It also reports user service lingering when relevant for server-style installs. + Web binaries. It also prints installed and running PI WEB versions when available, and reports user service + lingering when relevant for server-style installs.

If something works in your terminal but fails in doctor, treat that as a login-shell PATH mismatch and diff --git a/docs/index.html b/docs/index.html index 5f3c11b..ea0ea08 100644 --- a/docs/index.html +++ b/docs/index.html @@ -257,6 +257,7 @@

$ npm install -g @jmfederico/pi-web
 $ pi-web install
 $ pi-web doctor
+$ pi-web version
 # Open http://127.0.0.1:8504
diff --git a/docs/install.html b/docs/install.html index b929625..c469876 100644 --- a/docs/install.html +++ b/docs/install.html @@ -138,7 +138,8 @@ /pi-web install /pi-web status /pi-web logs -/pi-web doctor +/pi-web doctor +/pi-web version
@@ -195,6 +196,7 @@

Manage services

+

pi-web version compares the installed package version with the versions reported by the running Web/UI and session daemon services.

Useful commands @@ -204,6 +206,7 @@ $ pi-web logs $ pi-web restart $ pi-web doctor +$ pi-web version # From a checkout, install the split development services: $ pi-web install --dev diff --git a/extensions/pi-web.ts b/extensions/pi-web.ts index baee6c5..d333c46 100644 --- a/extensions/pi-web.ts +++ b/extensions/pi-web.ts @@ -18,6 +18,7 @@ const subcommands = [ "start", "stop", "doctor", + "version", "uninstall", "open", "help", @@ -88,7 +89,7 @@ async function boundedLogs(): Promise<{ code: number; output: string }> { export default function piWebExtension(pi: ExtensionAPI): void { pi.registerCommand("pi-web", { - description: "Manage PI WEB services: install, status, logs, restart, start, stop, doctor, open", + description: "Manage PI WEB services: install, status, logs, restart, start, stop, doctor, version, open", getArgumentCompletions(prefix: string): { value: string; label: string }[] | null { const [first = ""] = parseArgs(prefix); const items = subcommands diff --git a/src/cli.ts b/src/cli.ts index d10d45d..745a422 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import { homedir, userInfo } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { defaultPiWebConfigPath, defaultPiWebDataDir, examplePiWebConfig } from "./config.js"; +import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js"; import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; @@ -885,6 +886,10 @@ function commandCheck(command: string): string { return `command -v ${command}`; } +function commandWithVersionCheck(command: string): string { + return `${commandCheck(command)} && (${command} --version 2>&1 || true)`; +} + function nodeVersionCheck(): string { return [ commandCheck("node"), @@ -898,21 +903,21 @@ function doctorChecks(): Check[] { if (backend === undefined) { return [ [`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())], - [`${shell} can find npm`, serviceShellCommand(commandCheck("npm"))], - [`${shell} can find pi`, serviceShellCommand(commandCheck("pi"))], + [`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))], + [`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))], ]; } const checks: Check[] = [ ...backendAvailabilityChecks(backend), ...baseShellChecks(backend), - [`${shell} can find npm`, serviceShellCommand(commandCheck("npm"))], - [`${shell} can find pi`, serviceShellCommand(commandCheck("pi"))], + [`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))], + [`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))], ]; const executables = resolveServiceExecutables(backend); checks.push(...executables.web.checks, ...executables.sessiond.checks); if (backend.kind === "systemd") { - checks.push([`systemd user ${shell} can find pi`, systemdUserServiceShellCommand(commandCheck("pi"))]); + checks.push([`systemd user ${shell} can find pi`, systemdUserServiceShellCommand(commandWithVersionCheck("pi"))]); } return checks; } @@ -952,7 +957,7 @@ function printPathSetupAdvice(): void { } } -function doctor(): void { +async function doctor(): Promise { const backend = currentServiceBackend(); console.log(`Platform: ${platformLabel()}`); console.log(`Service backend: ${backend?.label ?? "manual run only"}`); @@ -960,6 +965,9 @@ function doctor(): void { if (backend === undefined) { console.log(`- Native user service checks skipped on ${platformLabel()}`); } + console.log(""); + await printPiWebVersionReport(); + console.log("\nDoctor checks:"); const ok = runChecks(doctorChecks()); const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck(); @@ -1011,6 +1019,7 @@ Usage: pi-web uninstall pi-web start|stop|restart|status|logs pi-web doctor + pi-web version Recommended install: npm install -g @jmfederico/pi-web @@ -1027,7 +1036,9 @@ async function main(): Promise { else if (command === "uninstall") await uninstall(); else if (command === "start" || command === "stop" || command === "restart" || command === "status") serviceAction(command); else if (command === "logs") logs(); - else if (command === "doctor") doctor(); + else if (command === "doctor") await doctor(); + else if (command === "version") await printPiWebVersionReport(); + else if (command === "--version" || command === "-v") console.log(packageVersion()); else if (command === "help" || command === "--help" || command === "-h") help(); else throw new Error(`Unknown command: ${command}`); } diff --git a/src/piWebVersionReport.ts b/src/piWebVersionReport.ts new file mode 100644 index 0000000..d3567ca --- /dev/null +++ b/src/piWebVersionReport.ts @@ -0,0 +1,239 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { effectivePiWebConfig } from "./config.js"; +import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js"; +import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebVersionResponse } from "./shared/apiTypes.js"; +import { parsePiWebComponentStatus, parsePiWebVersionResponse } from "./shared/piWebStatusParsing.js"; + +const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; +const PI_WEB_VERSION_TIMEOUT_MS = 2000; +const PI_WEB_VERSION_ENDPOINT_PATH = "/api/pi-web/version"; +const PI_WEB_STATUS_ENDPOINT_PATH = "/api/pi-web/status"; +const DEFAULT_PACKAGE_VERSION = "0.0.0-dev"; + +interface PackageInfo { + name: string; + version: string; + path: string; +} + +interface RunningVersionInfo { + generatedAt?: string; + web?: PiWebComponentStatus; + sessiond?: PiWebComponentStatus; + webError?: string; + sessiondError?: string; +} + +export function packageVersion(): string { + return readPackageInfo()?.version ?? DEFAULT_PACKAGE_VERSION; +} + +export async function printPiWebVersionReport(): Promise { + console.log("PI WEB version"); + printInstalledPackageVersions(); + printRunningVersionInfo(await collectRunningVersionInfo()); +} + +function packageRootPath(): string { + return dirname(dirname(fileURLToPath(import.meta.url))); +} + +function packageJsonPath(): string { + return join(packageRootPath(), "package.json"); +} + +function readPackageInfo(): PackageInfo | undefined { + const path = packageJsonPath(); + try { + return parsePackageInfo(JSON.parse(readFileSync(path, "utf8")), path); + } catch { + return undefined; + } +} + +function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined { + if (!isRecord(value)) return undefined; + const name = value["name"]; + const version = value["version"]; + if (typeof name !== "string" || name === "" || typeof version !== "string" || version === "") return undefined; + return { name, version, path }; +} + +function webVersionEndpoint(): { endpoint?: string; error?: string } { + try { + const { config } = effectivePiWebConfig(); + const host = httpClientHost(config.host); + const port = config.port ?? 8504; + return { endpoint: `http://${urlHost(host)}:${String(port)}${PI_WEB_VERSION_ENDPOINT_PATH}` }; + } catch (error) { + return { error: `could not read PI WEB config: ${errorMessage(error)}` }; + } +} + +function httpClientHost(configuredHost: string | undefined): string { + const host = configuredHost === undefined || configuredHost === "" ? "127.0.0.1" : configuredHost; + if (host === "0.0.0.0" || host === "::" || host === "[::]") return "127.0.0.1"; + return host; +} + +function urlHost(host: string): string { + if (host.startsWith("[") || !host.includes(":")) return host; + return `[${host}]`; +} + +function statusEndpointFor(versionEndpoint: string): string { + if (!versionEndpoint.endsWith(PI_WEB_VERSION_ENDPOINT_PATH)) return versionEndpoint; + return `${versionEndpoint.slice(0, -PI_WEB_VERSION_ENDPOINT_PATH.length)}${PI_WEB_STATUS_ENDPOINT_PATH}`; +} + +async function fetchPiWebVersionResponse(endpoint: string): Promise { + const response = await fetch(endpoint, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(PI_WEB_VERSION_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`HTTP ${String(response.status)}`); + const parsed: unknown = await response.json(); + const status = parsePiWebVersionResponse(parsed); + if (status === undefined) throw new Error("response did not include PI WEB version information"); + return status; +} + +async function collectRunningVersionInfo(): Promise { + const endpoint = webVersionEndpoint(); + if (endpoint.endpoint !== undefined) { + try { + const status = await fetchPiWebVersionResponse(endpoint.endpoint); + return { generatedAt: status.generatedAt, web: status.components.web, sessiond: status.components.sessiond }; + } catch (error) { + let webError = `${endpoint.endpoint}: ${errorMessage(error)}`; + const statusEndpoint = statusEndpointFor(endpoint.endpoint); + if (statusEndpoint !== endpoint.endpoint && isHttpNotFound(error)) { + try { + const status = await fetchPiWebVersionResponse(statusEndpoint); + return { generatedAt: status.generatedAt, web: status.components.web, sessiond: status.components.sessiond }; + } catch (statusError) { + webError = `${webError}; ${statusEndpoint}: ${errorMessage(statusError)}`; + } + } + return runningVersionInfoWithSessiondFallback({ webError }); + } + } + + return runningVersionInfoWithSessiondFallback({ webError: endpoint.error ?? "web/API status endpoint unavailable" }); +} + +async function runningVersionInfoWithSessiondFallback(base: { webError: string }): Promise { + const sessiond = await collectRunningSessiondInfo(); + return { + webError: base.webError, + ...(sessiond.component === undefined ? {} : { sessiond: sessiond.component }), + ...(sessiond.error === undefined ? {} : { sessiondError: sessiond.error }), + }; +} + +async function collectRunningSessiondInfo(): Promise<{ component?: PiWebComponentStatus; error?: string }> { + try { + const response = await withTimeout( + new SessionDaemonClient().request("GET", "/health"), + PI_WEB_VERSION_TIMEOUT_MS, + "session daemon health check timed out", + ); + if (response.statusCode < 200 || response.statusCode >= 300) throw new Error(`HTTP ${String(response.statusCode)}`); + const parsed: unknown = response.body === "" ? undefined : JSON.parse(response.body); + const version = isRecord(parsed) ? parsed["version"] : undefined; + const component = parsePiWebComponentStatus(version); + if (component === undefined) throw new Error("health response did not include version information"); + return { component }; + } catch (error) { + return { error: errorMessage(error) }; + } +} + +async function withTimeout(promise: Promise, timeoutMs: number, timeoutMessage: string): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(timeoutMessage)); + }, timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + +function isHttpNotFound(error: unknown): boolean { + return error instanceof Error && error.message === "HTTP 404"; +} + +function printInstalledPackageVersions(): void { + const info = readPackageInfo(); + console.log("Installed packages:"); + if (info === undefined) { + console.log(`? ${PI_WEB_PACKAGE_NAME}: unknown`); + console.log(` missing package metadata: ${packageJsonPath()}`); + return; + } + console.log(`✓ ${info.name}: ${info.version}`); + console.log(` ${info.path}`); +} + +function printRunningVersionInfo(info: RunningVersionInfo): void { + console.log("Running services:"); + if (info.web === undefined) printUnavailableComponent("Web/UI", info.webError); + else printComponentVersion(info.web); + if (info.sessiond === undefined) printUnavailableComponent("Session daemon", info.sessiondError); + else printComponentVersion(info.sessiond); + if (info.generatedAt !== undefined) console.log(` reported by web/API at ${info.generatedAt}`); +} + +function printComponentVersion(component: PiWebComponentStatus): void { + const icon = component.available ? component.stale ? "!" : "✓" : "?"; + const status = !component.available ? "unavailable" : component.stale ? "restart needed" : "current"; + console.log(`${icon} ${component.label}: ${status}`); + if (component.available || component.runtimeVersion !== undefined || component.installedVersion !== undefined) { + console.log(` running: ${formatVersion(component.runtimeVersion)}; installed: ${formatVersion(component.installedVersion)}`); + } + const installation = installationLabel(component.installation); + if (installation !== undefined) console.log(` installation: ${installation}`); + if (component.error !== undefined) console.log(` ${component.error}`); +} + +function printUnavailableComponent(label: string, error: string | undefined): void { + console.log(`? ${label}: unavailable`); + if (error !== undefined && error !== "") console.log(` ${error}`); +} + +function installationLabel(installation: PiWebInstallationInfo | undefined): string | undefined { + if (installation === undefined) return undefined; + if (installation.kind === "pi-package") { + const source = installation.source ?? "Pi package"; + const scope = installation.scope === undefined ? "" : ` · ${installation.scope}`; + const path = installation.path === undefined ? "" : ` · ${installation.path}`; + return `${source}${scope}${path}`; + } + if (installation.kind === "npm-global") { + const npmRoot = installation.npmRoot === undefined ? "" : ` · ${installation.npmRoot}`; + const path = installation.path === undefined ? "" : ` · ${installation.path}`; + return `global npm package${npmRoot}${path}`; + } + if (installation.kind === "local") return installation.path === undefined ? "local checkout" : `local checkout · ${installation.path}`; + return installation.path === undefined ? "installation unknown" : `installation unknown · ${installation.path}`; +} + +function formatVersion(version: string | undefined): string { + return version === undefined || version === "" ? "unknown" : version; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/server/app.ts b/src/server/app.ts index e35d503..88ce5a9 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -14,7 +14,7 @@ import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js"; import { registerGitRoutes } from "./gitRoutes.js"; import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js"; import { PiWebPluginService } from "./piWebPluginService.js"; -import { getPiWebStatus } from "./piWebStatus.js"; +import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; export interface AppDependencies { projects?: ProjectService; @@ -41,6 +41,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebStatus()); + app.get("/api/pi-web/version", async () => getPiWebVersionStatus()); app.get("/api/projects", async () => projects.list()); diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index bd6dd42..d71193e 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { comparePackageVersions, getPiWebStatus } from "./piWebStatus.js"; -import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js"; +import { comparePackageVersions, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; +import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"]; @@ -17,6 +17,31 @@ describe("PI WEB status", () => { expect(comparePackageVersions("1.202605.7", "1.202605.8")).toBeLessThan(0); }); + it("returns installed and running version components without release metadata", async () => { + const daemon = new SessionDaemonClient(); + vi.spyOn(daemon, "request").mockResolvedValue({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + version: { + component: "sessiond", + label: "Session daemon", + runtimeVersion: "1.202605.7", + installedVersion: "1.202605.8", + stale: true, + available: true, + }, + }), + }); + + const status = await getPiWebVersionStatus(daemon); + + expect(status.packageName).toBe("@jmfederico/pi-web"); + expect(status.components.web.component).toBe("web"); + expect(status.components.sessiond.runtimeVersion).toBe("1.202605.7"); + expect(status).not.toHaveProperty("release"); + }); + it("reports stale session daemon versions as messages", async () => { process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1"; const daemon = new SessionDaemonClient(); diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts index f60039e..ba93d12 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -4,8 +4,9 @@ import { readFile, realpath, stat } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; -import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse } from "../shared/apiTypes.js"; -import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js"; +import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js"; +import { parsePiWebComponentStatus } from "../shared/piWebStatusParsing.js"; +import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`; @@ -47,20 +48,27 @@ export async function getPiWebComponentStatus(component: PiWebServiceComponent): }; } -export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise { - const web = await getPiWebComponentStatus("web"); - const [installed, sessiond] = await Promise.all([ - readInstalledPackageInfo(), +export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()): Promise { + const [web, sessiond] = await Promise.all([ + getPiWebComponentStatus("web"), getSessiondComponentStatus(daemon), ]); - const release = await getLatestReleaseStatus(installed?.version ?? web.runtimeVersion ?? DEFAULT_VERSION); + return { + packageName: PI_WEB_PACKAGE_NAME, + generatedAt: new Date().toISOString(), + components: { web, sessiond }, + }; +} + +export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise { + const versionStatus = await getPiWebVersionStatus(daemon); + const { web, sessiond } = versionStatus.components; + const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION); const components = { web, sessiond }; const commands = commandsFor(web.installation ?? sessiond.installation); const messages = buildMessages(components, release, commands); return { - packageName: PI_WEB_PACKAGE_NAME, - generatedAt: new Date().toISOString(), - components, + ...versionStatus, release, commands, messages, @@ -179,54 +187,13 @@ async function getSessiondComponentStatus(daemon: SessionDaemonClient): Promise< } const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body); const version = isRecord(parsed) ? parsed["version"] : undefined; - const component = parseComponentStatus(version); + const component = parsePiWebComponentStatus(version); return component ?? unavailableSessiond("health response did not include version information"); } catch (error) { return unavailableSessiond(error instanceof Error ? error.message : String(error)); } } -function parseComponentStatus(value: unknown): PiWebComponentStatus | undefined { - if (!isRecord(value)) return undefined; - const component = value["component"]; - const label = value["label"]; - const runtimeVersion = value["runtimeVersion"]; - const installedVersion = value["installedVersion"]; - const stale = value["stale"]; - const available = value["available"]; - const error = value["error"]; - const installation = parseInstallationInfo(value["installation"]); - if (component !== "web" && component !== "sessiond") return undefined; - if (typeof label !== "string" || typeof stale !== "boolean" || typeof available !== "boolean") return undefined; - return { - component, - label, - ...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}), - ...(typeof installedVersion === "string" ? { installedVersion } : {}), - stale, - available, - ...(installation === undefined ? {} : { installation }), - ...(typeof error === "string" ? { error } : {}), - }; -} - -function parseInstallationInfo(value: unknown): PiWebInstallationInfo | undefined { - if (!isRecord(value)) return undefined; - const kind = value["kind"]; - const path = value["path"]; - const source = value["source"]; - const scope = value["scope"]; - const npmRoot = value["npmRoot"]; - if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") return undefined; - return { - kind, - ...(typeof path === "string" ? { path } : {}), - ...(typeof source === "string" ? { source } : {}), - ...(scope === "user" || scope === "project" ? { scope } : {}), - ...(typeof npmRoot === "string" ? { npmRoot } : {}), - }; -} - function unavailableSessiond(error: string): PiWebComponentStatus { return { component: "sessiond", diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 4f49a54..7f4b6d9 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -10,7 +10,7 @@ import { AuthService } from "./sessions/authService.js"; import { registerAuthRoutes } from "./sessions/authRoutes.js"; import { PiSessionService } from "./sessions/piSessionService.js"; import { registerSessionRoutes } from "./sessions/sessionRoutes.js"; -import { sessiondSocketPath } from "./sessiond/config.js"; +import { sessiondSocketPath } from "../sessiond/config.js"; import { TerminalService } from "./terminals/terminalService.js"; import { registerTerminalRoutes } from "./terminals/terminalRoutes.js"; import { getPiWebComponentStatus } from "./piWebStatus.js"; diff --git a/src/server/sessiond/sessionProxyRoutes.ts b/src/server/sessiond/sessionProxyRoutes.ts index 37789d7..8cca535 100644 --- a/src/server/sessiond/sessionProxyRoutes.ts +++ b/src/server/sessiond/sessionProxyRoutes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance, FastifyReply } from "fastify"; import { WebSocket, type RawData } from "ws"; -import { SessionDaemonClient } from "./sessionDaemonClient.js"; +import { SessionDaemonClient } from "../../sessiond/sessionDaemonClient.js"; export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new SessionDaemonClient()): void { const proxy = async (request: { method: string; url: string; body?: unknown }, reply: FastifyReply) => { diff --git a/src/server/terminalProxyRoutes.ts b/src/server/terminalProxyRoutes.ts index d5e2dee..cedb7ab 100644 --- a/src/server/terminalProxyRoutes.ts +++ b/src/server/terminalProxyRoutes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance, FastifyReply } from "fastify"; import type { ProjectService } from "./projects/projectService.js"; -import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js"; +import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js"; import type { WorkspaceService } from "./workspaces/workspaceService.js"; import { terminalSizeQuery } from "./terminals/terminalSize.js"; diff --git a/src/server/sessiond/config.ts b/src/sessiond/config.ts similarity index 85% rename from src/server/sessiond/config.ts rename to src/sessiond/config.ts index a20e9b3..70b9fcf 100644 --- a/src/server/sessiond/config.ts +++ b/src/sessiond/config.ts @@ -1,5 +1,5 @@ import { join } from "node:path"; -import { piWebDataDir } from "../../config.js"; +import { piWebDataDir } from "../config.js"; export function sessiondSocketPath(): string { return process.env["PI_WEB_SESSIOND_SOCKET"] ?? join(piWebDataDir(), "sessiond.sock"); diff --git a/src/server/sessiond/sessionDaemonClient.ts b/src/sessiond/sessionDaemonClient.ts similarity index 100% rename from src/server/sessiond/sessionDaemonClient.ts rename to src/sessiond/sessionDaemonClient.ts diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 3d2ae87..14a2c76 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -284,13 +284,16 @@ export interface PiWebStatusMessage { command?: string; } -export interface PiWebStatusResponse { +export interface PiWebVersionResponse { packageName: string; generatedAt: string; components: { web: PiWebComponentStatus; sessiond: PiWebComponentStatus; }; +} + +export interface PiWebStatusResponse extends PiWebVersionResponse { release: PiWebReleaseStatus; commands: { update: string; diff --git a/src/shared/piWebStatusParsing.ts b/src/shared/piWebStatusParsing.ts new file mode 100644 index 0000000..a588dc5 --- /dev/null +++ b/src/shared/piWebStatusParsing.ts @@ -0,0 +1,58 @@ +import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebVersionResponse } from "./apiTypes.js"; + +export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined { + if (!isRecord(value)) return undefined; + const packageName = value["packageName"]; + const generatedAt = value["generatedAt"]; + const components = value["components"]; + if (typeof packageName !== "string" || packageName === "" || typeof generatedAt !== "string" || generatedAt === "" || !isRecord(components)) return undefined; + const web = parsePiWebComponentStatus(components["web"]); + const sessiond = parsePiWebComponentStatus(components["sessiond"]); + if (web === undefined || sessiond === undefined) return undefined; + return { packageName, generatedAt, components: { web, sessiond } }; +} + +export function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus | undefined { + if (!isRecord(value)) return undefined; + const component = value["component"]; + const label = value["label"]; + const runtimeVersion = value["runtimeVersion"]; + const installedVersion = value["installedVersion"]; + const stale = value["stale"]; + const available = value["available"]; + const error = value["error"]; + const installation = parsePiWebInstallationInfo(value["installation"]); + if (component !== "web" && component !== "sessiond") return undefined; + if (typeof label !== "string" || label === "" || typeof stale !== "boolean" || typeof available !== "boolean") return undefined; + return { + component, + label, + ...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}), + ...(typeof installedVersion === "string" ? { installedVersion } : {}), + stale, + available, + ...(installation === undefined ? {} : { installation }), + ...(typeof error === "string" ? { error } : {}), + }; +} + +export function parsePiWebInstallationInfo(value: unknown): PiWebInstallationInfo | undefined { + if (!isRecord(value)) return undefined; + const kind = value["kind"]; + const path = value["path"]; + const source = value["source"]; + const scope = value["scope"]; + const npmRoot = value["npmRoot"]; + if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") return undefined; + return { + kind, + ...(typeof path === "string" ? { path } : {}), + ...(typeof source === "string" ? { source } : {}), + ...(scope === "user" || scope === "project" ? { scope } : {}), + ...(typeof npmRoot === "string" ? { npmRoot } : {}), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} From bad3a185ff0ed1fe5f1cd57f1faf29daaf343e52 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 21:48:04 +0200 Subject: [PATCH 19/27] feat: add delete new session action --- .changeset/delete-new-session-action.md | 5 +++ src/client/src/components/PiWebApp.ts | 1 + src/client/src/plugins/core/actions.ts | 20 ++++++++++- src/client/src/plugins/registry.test.ts | 45 ++++++++++++++++++++++++- src/client/src/plugins/types.ts | 1 + 5 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 .changeset/delete-new-session-action.md diff --git a/.changeset/delete-new-session-action.md b/.changeset/delete-new-session-action.md new file mode 100644 index 0000000..9481d7e --- /dev/null +++ b/.changeset/delete-new-session-action.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add an action-palette command for deleting browser-cached new sessions, while keeping archive and delete session actions context-specific. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 92329dd..be43dd9 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -862,6 +862,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"); diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index e8fd181..219e178 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -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"; @@ -138,9 +139,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", @@ -164,3 +173,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); +} diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 03508de..ac8d12c 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -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"; @@ -34,6 +35,7 @@ function createContext(statePatch: Partial = {}) { 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 }; @@ -98,6 +100,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 }); @@ -233,6 +263,19 @@ function testWorkspace(patch: Partial = {}): Workspace { return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false, ...patch }; } +function testSession(patch: Partial = {}): 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", diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index ed2c779..3071c0e 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -66,6 +66,7 @@ export interface PluginRuntimeContext { deleteWorkspace: (workspace?: Workspace) => void | Promise; startSession: () => void | Promise; archiveSession: () => void | Promise; + deleteCachedNewSession: () => void | Promise; stopActiveWork: () => void | Promise; } From 1ae28d8f592ff381c3e6bdfc22f96c012d91fd24 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 28 May 2026 21:51:30 +0200 Subject: [PATCH 20/27] refactor(client): extract app shell chrome --- src/client/src/appShell/appShellController.ts | 82 +++ .../src/appShell/navigationState.test.ts | 40 ++ src/client/src/appShell/navigationState.ts | 81 +++ .../src/appShell/panelCollapseController.ts | 40 ++ .../appShell/viewportPositionRepair.test.ts | 119 ++++ .../src/appShell/viewportPositionRepair.ts | 87 +++ src/client/src/components/PiWebApp.ts | 581 +++--------------- .../src/components/appShell/AppContextBar.ts | 155 +++++ .../components/appShell/AppMobileMainTabs.ts | 110 ++++ .../components/appShell/AppNavigationPanel.ts | 114 ++++ .../appShell/AppPanelEdgeControl.ts | 58 ++ .../components/appShell/AppRefreshControl.ts | 151 +++++ src/client/src/components/shared.ts | 4 + 13 files changed, 1142 insertions(+), 480 deletions(-) create mode 100644 src/client/src/appShell/appShellController.ts create mode 100644 src/client/src/appShell/navigationState.test.ts create mode 100644 src/client/src/appShell/navigationState.ts create mode 100644 src/client/src/appShell/panelCollapseController.ts create mode 100644 src/client/src/appShell/viewportPositionRepair.test.ts create mode 100644 src/client/src/appShell/viewportPositionRepair.ts create mode 100644 src/client/src/components/appShell/AppContextBar.ts create mode 100644 src/client/src/components/appShell/AppMobileMainTabs.ts create mode 100644 src/client/src/components/appShell/AppNavigationPanel.ts create mode 100644 src/client/src/components/appShell/AppPanelEdgeControl.ts create mode 100644 src/client/src/components/appShell/AppRefreshControl.ts diff --git a/src/client/src/appShell/appShellController.ts b/src/client/src/appShell/appShellController.ts new file mode 100644 index 0000000..b726684 --- /dev/null +++ b/src/client/src/appShell/appShellController.ts @@ -0,0 +1,82 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; +import type { AppState } from "../appState"; +import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMode"; +import { ViewportPositionRepairer } from "./viewportPositionRepair"; + +export const MOBILE_NAVIGATION_MEDIA_QUERY = "(max-width: 760px)"; + +export interface AppShellControllerOptions { + mobileNavigationMedia?: MediaQueryList | undefined; + pwaDisplayModeMedia?: MediaQueryList[] | undefined; + viewportPositionRepairer?: ViewportPositionRepairer | undefined; +} + +export class AppShellController implements ReactiveController { + private readonly mobileNavigationMedia: MediaQueryList | undefined; + private readonly pwaDisplayModeMedia: MediaQueryList[]; + private readonly viewportPositionRepairer: ViewportPositionRepairer; + isMobileNavigationLayout: boolean; + isPwaDisplayMode: boolean; + + constructor(private readonly host: ReactiveControllerHost, options: AppShellControllerOptions = {}) { + host.addController(this); + this.mobileNavigationMedia = options.mobileNavigationMedia ?? createMobileNavigationMedia(); + this.pwaDisplayModeMedia = options.pwaDisplayModeMedia ?? createPwaDisplayModeMedia(); + this.viewportPositionRepairer = options.viewportPositionRepairer ?? new ViewportPositionRepairer(); + this.isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false; + this.isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia); + } + + hostConnected(): void { + this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange); + for (const media of this.pwaDisplayModeMedia) media.addEventListener("change", this.onPwaDisplayModeChange); + } + + hostDisconnected(): void { + this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange); + for (const media of this.pwaDisplayModeMedia) media.removeEventListener("change", this.onPwaDisplayModeChange); + this.viewportPositionRepairer.clear(); + } + + shouldAutoFocusPrompt(): boolean { + return !this.isMobileNavigationLayout && !this.isPwaDisplayMode; + } + + shouldShowAppRefreshInHeader(): boolean { + return this.isPwaDisplayMode && !this.isMobileNavigationLayout; + } + + shouldShowAppRefreshInContextBar(): boolean { + return this.isPwaDisplayMode && this.isMobileNavigationLayout; + } + + defaultRouteView(): AppState["mainView"] { + return this.isMobileNavigationLayout ? "navigation" : "chat"; + } + + repairViewportPosition(): void { + this.viewportPositionRepairer.repair(this.shouldRepairViewportPosition()); + } + + private shouldRepairViewportPosition(): boolean { + return this.isMobileNavigationLayout || this.isPwaDisplayMode; + } + + private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => { + if (this.isMobileNavigationLayout === event.matches) return; + this.isMobileNavigationLayout = event.matches; + this.host.requestUpdate(); + }; + + private readonly onPwaDisplayModeChange = () => { + const isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia); + if (this.isPwaDisplayMode === isPwaDisplayMode) return; + this.isPwaDisplayMode = isPwaDisplayMode; + this.host.requestUpdate(); + }; +} + +function createMobileNavigationMedia(): MediaQueryList | undefined { + if (typeof window === "undefined" || !("matchMedia" in window)) return undefined; + return window.matchMedia(MOBILE_NAVIGATION_MEDIA_QUERY); +} diff --git a/src/client/src/appShell/navigationState.test.ts b/src/client/src/appShell/navigationState.test.ts new file mode 100644 index 0000000..d90669e --- /dev/null +++ b/src/client/src/appShell/navigationState.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { defaultNavigationSection, expandedNavigationSection, isNavigationSectionCollapsed, toggleNavigationSection } from "./navigationState"; + +describe("navigationState", () => { + it("defaults to the first incomplete selection section", () => { + expect(defaultNavigationSection({ selectedProject: undefined, selectedWorkspace: undefined })).toBe("projects"); + expect(defaultNavigationSection({ selectedProject: {}, selectedWorkspace: undefined })).toBe("workspaces"); + expect(defaultNavigationSection({ selectedProject: {}, selectedWorkspace: {} })).toBe("sessions"); + }); + + it("expands the default section until the user explicitly toggles a section", () => { + const state = { selectedProject: {}, selectedWorkspace: undefined }; + + expect(expandedNavigationSection(undefined, state)).toBe("workspaces"); + expect(expandedNavigationSection("sessions", state)).toBe("sessions"); + expect(expandedNavigationSection("none", state)).toBeUndefined(); + }); + + it("only collapses sections in mobile navigation layouts", () => { + const state = { selectedProject: {}, selectedWorkspace: {} }; + + expect(isNavigationSectionCollapsed("projects", { isMobileLayout: false, expanded: "sessions", state })).toBe(false); + expect(isNavigationSectionCollapsed("projects", { isMobileLayout: true, expanded: "sessions", state })).toBe(true); + expect(isNavigationSectionCollapsed("sessions", { isMobileLayout: true, expanded: "sessions", state })).toBe(false); + }); + + it("toggles the effective section, including the implicit default section", () => { + const state = { selectedProject: undefined, selectedWorkspace: undefined }; + + expect(toggleNavigationSection(undefined, "projects", { isMobileLayout: true, state })).toBe("none"); + expect(toggleNavigationSection("none", "projects", { isMobileLayout: true, state })).toBe("projects"); + expect(toggleNavigationSection("projects", "workspaces", { isMobileLayout: true, state })).toBe("workspaces"); + }); + + it("does not mutate expanded section on desktop layouts", () => { + const state = { selectedProject: undefined, selectedWorkspace: undefined }; + + expect(toggleNavigationSection("projects", "projects", { isMobileLayout: false, state })).toBe("projects"); + }); +}); diff --git a/src/client/src/appShell/navigationState.ts b/src/client/src/appShell/navigationState.ts new file mode 100644 index 0000000..6eaff83 --- /dev/null +++ b/src/client/src/appShell/navigationState.ts @@ -0,0 +1,81 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; + +export type NavigationSection = "projects" | "workspaces" | "sessions"; +export type ExpandedNavigationSection = NavigationSection | "none" | undefined; + +export interface NavigationSelectionState { + selectedProject: object | undefined; + selectedWorkspace: object | undefined; +} + +export function defaultNavigationSection(state: NavigationSelectionState): NavigationSection { + if (state.selectedProject === undefined) return "projects"; + if (state.selectedWorkspace === undefined) return "workspaces"; + return "sessions"; +} + +export function expandedNavigationSection(expanded: ExpandedNavigationSection, state: NavigationSelectionState): NavigationSection | undefined { + if (expanded === "none") return undefined; + return expanded ?? defaultNavigationSection(state); +} + +export function isNavigationSectionCollapsed(section: NavigationSection, options: { isMobileLayout: boolean; expanded: ExpandedNavigationSection; state: NavigationSelectionState }): boolean { + return options.isMobileLayout && expandedNavigationSection(options.expanded, options.state) !== section; +} + +export function toggleNavigationSection(expanded: ExpandedNavigationSection, section: NavigationSection, options: { isMobileLayout: boolean; state: NavigationSelectionState }): ExpandedNavigationSection { + if (!options.isMobileLayout) return expanded; + return expandedNavigationSection(expanded, options.state) === section ? "none" : section; +} + +export function expandNavigationSection(expanded: ExpandedNavigationSection, section: NavigationSection, isMobileLayout: boolean): ExpandedNavigationSection { + return isMobileLayout ? section : expanded; +} + +export class MobileNavigationController implements ReactiveController { + private expanded: ExpandedNavigationSection; + + hostConnected(): void { + return; + } + + constructor( + private readonly host: ReactiveControllerHost, + private readonly getState: () => NavigationSelectionState, + private readonly isMobileLayout: () => boolean, + ) { + host.addController(this); + } + + expandedSection(): NavigationSection | undefined { + return expandedNavigationSection(this.expanded, this.getState()); + } + + isCollapsed(section: NavigationSection): boolean { + return isNavigationSectionCollapsed(section, { + isMobileLayout: this.isMobileLayout(), + expanded: this.expanded, + state: this.getState(), + }); + } + + toggle(section: NavigationSection): void { + this.setExpanded(toggleNavigationSection(this.expanded, section, { isMobileLayout: this.isMobileLayout(), state: this.getState() })); + } + + expand(section: NavigationSection): void { + this.setExpanded(expandNavigationSection(this.expanded, section, this.isMobileLayout())); + } + + open(section: NavigationSection, openNavigationView: () => void): void { + if (!this.isMobileLayout()) return; + this.expand(section); + openNavigationView(); + } + + private setExpanded(expanded: ExpandedNavigationSection): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + this.host.requestUpdate(); + } +} diff --git a/src/client/src/appShell/panelCollapseController.ts b/src/client/src/appShell/panelCollapseController.ts new file mode 100644 index 0000000..378563d --- /dev/null +++ b/src/client/src/appShell/panelCollapseController.ts @@ -0,0 +1,40 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; +import type { AppState } from "../appState"; + +export class PanelCollapseController implements ReactiveController { + navigationPanelCollapsed = false; + workspacePanelCollapsed = false; + + hostConnected(): void { + return; + } + + constructor(private readonly host: ReactiveControllerHost) { + host.addController(this); + } + + toggleNavigationPanel(): void { + this.navigationPanelCollapsed = !this.navigationPanelCollapsed; + this.host.requestUpdate(); + } + + toggleWorkspacePanel(): void { + this.workspacePanelCollapsed = !this.workspacePanelCollapsed; + this.host.requestUpdate(); + } + + shellClass(mainView: AppState["mainView"]): string { + return [ + "shell", + mainViewClass(mainView), + ...(this.navigationPanelCollapsed ? ["navigation-panel-collapsed"] : []), + ...(this.workspacePanelCollapsed ? ["workspace-panel-collapsed"] : []), + ].join(" "); + } +} + +export function mainViewClass(mainView: AppState["mainView"]): "navigation-view" | "chat-view" | "workspace-view" { + if (mainView === "navigation") return "navigation-view"; + if (mainView === "chat") return "chat-view"; + return "workspace-view"; +} diff --git a/src/client/src/appShell/viewportPositionRepair.test.ts b/src/client/src/appShell/viewportPositionRepair.test.ts new file mode 100644 index 0000000..588ad69 --- /dev/null +++ b/src/client/src/appShell/viewportPositionRepair.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { VIEWPORT_POSITION_REPAIR_DELAY_MS, ViewportPositionRepairer, type ViewportPositionRepairScheduler } from "./viewportPositionRepair"; + +class FakeViewportScheduler implements ViewportPositionRepairScheduler { + documentElement = { scrollTop: 12 }; + body = { scrollTop: 34 }; + scrollCalls: [number, number][] = []; + animationFrames = new Map void>(); + timers = new Map void; delayMs: number }>(); + canceledAnimationFrames: number[] = []; + clearedTimers: number[] = []; + private nextId = 1; + + requestAnimationFrame(callback: () => void): number { + const id = this.nextId; + this.nextId += 1; + this.animationFrames.set(id, callback); + return id; + } + + cancelAnimationFrame(id: number): void { + this.canceledAnimationFrames.push(id); + this.animationFrames.delete(id); + } + + setTimeout(callback: () => void, delayMs: number): number { + const id = this.nextId; + this.nextId += 1; + this.timers.set(id, { callback, delayMs }); + return id; + } + + clearTimeout(id: number): void { + this.clearedTimers.push(id); + this.timers.delete(id); + } + + scrollTo(x: number, y: number): void { + this.scrollCalls.push([x, y]); + } + + runAnimationFrame(id: number): void { + const callback = this.animationFrames.get(id); + if (callback === undefined) throw new Error(`Animation frame ${String(id)} not scheduled`); + this.animationFrames.delete(id); + callback(); + } + + runTimer(id: number): void { + const timer = this.timers.get(id); + if (timer === undefined) throw new Error(`Timer ${String(id)} not scheduled`); + this.timers.delete(id); + timer.callback(); + } +} + +function firstMapKey(map: Map): K { + const key = map.keys().next().value; + if (key === undefined) throw new Error("Expected map to have a key"); + return key; +} + +function firstMapEntry(map: Map): [K, V] { + const entry = map.entries().next().value; + if (entry === undefined) throw new Error("Expected map to have an entry"); + return entry; +} + +describe("ViewportPositionRepairer", () => { + it("resets viewport position immediately, across two animation frames, and on a delayed timer", () => { + const scheduler = new FakeViewportScheduler(); + const repairer = new ViewportPositionRepairer(scheduler); + + repairer.repair(true); + + expect(scheduler.scrollCalls).toEqual([[0, 0]]); + expect(scheduler.documentElement.scrollTop).toBe(0); + expect(scheduler.body.scrollTop).toBe(0); + const firstFrame = firstMapKey(scheduler.animationFrames); + const timer = firstMapEntry(scheduler.timers); + expect(timer[1].delayMs).toBe(VIEWPORT_POSITION_REPAIR_DELAY_MS); + + scheduler.runAnimationFrame(firstFrame); + expect(scheduler.scrollCalls).toHaveLength(2); + const secondFrame = firstMapKey(scheduler.animationFrames); + + scheduler.runAnimationFrame(secondFrame); + expect(scheduler.scrollCalls).toHaveLength(3); + + scheduler.runTimer(timer[0]); + expect(scheduler.scrollCalls).toHaveLength(4); + }); + + it("replaces pending scheduled repairs", () => { + const scheduler = new FakeViewportScheduler(); + const repairer = new ViewportPositionRepairer(scheduler); + + repairer.repair(true); + const firstFrame = firstMapKey(scheduler.animationFrames); + const firstTimer = firstMapKey(scheduler.timers); + repairer.repair(true); + + expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]); + expect(scheduler.clearedTimers).toEqual([firstTimer]); + }); + + it("clears pending work when repair is no longer needed", () => { + const scheduler = new FakeViewportScheduler(); + const repairer = new ViewportPositionRepairer(scheduler); + + repairer.repair(true); + const firstFrame = firstMapKey(scheduler.animationFrames); + const firstTimer = firstMapKey(scheduler.timers); + repairer.repair(false); + + expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]); + expect(scheduler.clearedTimers).toEqual([firstTimer]); + }); +}); diff --git a/src/client/src/appShell/viewportPositionRepair.ts b/src/client/src/appShell/viewportPositionRepair.ts new file mode 100644 index 0000000..d098c22 --- /dev/null +++ b/src/client/src/appShell/viewportPositionRepair.ts @@ -0,0 +1,87 @@ +export const VIEWPORT_POSITION_REPAIR_DELAY_MS = 250; + +export interface ViewportPositionRepairScheduler { + requestAnimationFrame(callback: () => void): number; + cancelAnimationFrame(id: number): void; + setTimeout(callback: () => void, delayMs: number): number; + clearTimeout(id: number): void; + scrollTo(x: number, y: number): void; + readonly documentElement: { scrollTop: number } | undefined; + readonly body: { scrollTop: number } | undefined; +} + +export class ViewportPositionRepairer { + private repairFrame: number | undefined; + private repairTimer: number | undefined; + + constructor(private readonly scheduler: ViewportPositionRepairScheduler = createBrowserViewportPositionRepairScheduler()) {} + + repair(shouldRepair: boolean): void { + if (!shouldRepair) { + this.clear(); + return; + } + + this.resetViewportScroll(); + if (this.repairFrame !== undefined) this.scheduler.cancelAnimationFrame(this.repairFrame); + this.repairFrame = this.scheduler.requestAnimationFrame(() => { + this.repairFrame = undefined; + this.resetViewportScroll(); + this.repairFrame = this.scheduler.requestAnimationFrame(() => { + this.repairFrame = undefined; + this.resetViewportScroll(); + }); + }); + + if (this.repairTimer !== undefined) this.scheduler.clearTimeout(this.repairTimer); + this.repairTimer = this.scheduler.setTimeout(() => { + this.repairTimer = undefined; + this.resetViewportScroll(); + }, VIEWPORT_POSITION_REPAIR_DELAY_MS); + } + + clear(): void { + if (this.repairFrame !== undefined) { + this.scheduler.cancelAnimationFrame(this.repairFrame); + this.repairFrame = undefined; + } + if (this.repairTimer !== undefined) { + this.scheduler.clearTimeout(this.repairTimer); + this.repairTimer = undefined; + } + } + + private resetViewportScroll(): void { + this.scheduler.scrollTo(0, 0); + const documentElement = this.scheduler.documentElement; + if (documentElement !== undefined) documentElement.scrollTop = 0; + const body = this.scheduler.body; + if (body !== undefined) body.scrollTop = 0; + } +} + +export function createBrowserViewportPositionRepairScheduler(): ViewportPositionRepairScheduler { + return { + requestAnimationFrame(callback: () => void): number { + return window.requestAnimationFrame(callback); + }, + cancelAnimationFrame(id: number): void { + window.cancelAnimationFrame(id); + }, + setTimeout(callback: () => void, delayMs: number): number { + return window.setTimeout(callback, delayMs); + }, + clearTimeout(id: number): void { + window.clearTimeout(id); + }, + scrollTo(x: number, y: number): void { + window.scrollTo(x, y); + }, + get documentElement(): { scrollTop: number } | undefined { + return typeof document === "undefined" ? undefined : document.documentElement; + }, + get body(): { scrollTop: number } | undefined { + return typeof document === "undefined" ? undefined : document.body; + }, + }; +} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index be43dd9..f456464 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -21,7 +21,9 @@ import { themePackPlugin } from "../plugins/themes"; import { loadExternalPlugins } from "../plugins/external"; import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry"; import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs"; -import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMode"; +import { AppShellController } from "../appShell/appShellController"; +import { MobileNavigationController, type NavigationSection } from "../appShell/navigationState"; +import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController"; import { readRoute, writeRoute, type AppRoute } from "../route"; import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime"; import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion"; @@ -39,30 +41,26 @@ import "./AuthDialog"; import "./ProjectDialog"; import "./WorkspacePanel"; import type { WorkspacePanelEmptyState } from "./WorkspacePanel"; -import { actionMenuPanelStyle } from "./actionMenu"; +import "./appShell/AppContextBar"; +import "./appShell/AppMobileMainTabs"; +import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs"; +import "./appShell/AppNavigationPanel"; +import "./appShell/AppPanelEdgeControl"; +import "./appShell/AppRefreshControl"; import { appStyles } from "./shared"; -type NavigationSection = "projects" | "workspaces" | "sessions"; - const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000; const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const; const THEME_AUTO_ON_VALUE = "auto:on"; const THEME_AUTO_OFF_VALUE = "auto:off"; const THEME_OPTION_PREFIX = "theme:"; const TERMINAL_ROUTE_NAMESPACE = queryNamespace("core:workspace.terminal"); -const REFRESH_LONG_PRESS_MS = 550; -const VIEWPORT_POSITION_REPAIR_DELAY_MS = 250; @customElement("pi-web-app") export class PiWebApp extends LitElement { @state() private state: AppState = initialAppState(); - @state() private navigationPanelCollapsed = false; - @state() private workspacePanelCollapsed = false; @query("chat-view") private chatView?: ChatView; @query("prompt-editor") private promptEditor?: PromptEditor; - @query(".context-items") private contextItems?: HTMLElement | null; - @query(".mobile-tabs") private mobileTabs?: HTMLElement | null; - @query(".app-refresh") private appRefresh?: HTMLElement | null; private readonly sessions = new SessionController( () => this.state, @@ -103,13 +101,14 @@ export class PiWebApp extends LitElement { private readonly realtime = new RealtimeSocket(); private readonly activeTerminalIds = new Set(); private readonly terminalSelection = new InMemoryTerminalSelectionMemory(); - private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined; + private readonly appShell = new AppShellController(this); + private readonly panelCollapse = new PanelCollapseController(this); + private readonly mobileNavigation = new MobileNavigationController( + this, + () => this.state, + () => this.appShell.isMobileNavigationLayout, + ); private readonly systemLightThemeMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(prefers-color-scheme: light)") : undefined; - private readonly pwaDisplayModeMedia = createPwaDisplayModeMedia(); - private observedContextItems: HTMLElement | undefined; - private observedMobileTabs: HTMLElement | undefined; - private contextItemsResizeObserver: ResizeObserver | undefined; - private mobileTabsResizeObserver: ResizeObserver | undefined; private terminalAutoStartWorkspaceId: string | undefined; private piWebStatusTimer: number | undefined; private workspaceDeletionPollTimer: number | undefined; @@ -120,27 +119,14 @@ export class PiWebApp extends LitElement { private restoringRouteTerminalId: string | undefined; private readonly plugins = createPluginRegistry(); private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE; - private refreshLongPressTimer: number | undefined; - private suppressNextRefreshClick = false; - private viewportPositionRepairFrame: number | undefined; - private viewportPositionRepairTimer: number | undefined; @state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID; - @state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false; - @state() private isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia); @state() private isRefreshingApp = false; - @state() private refreshMenuOpen = false; - @state() private refreshMenuStyle = ""; - @state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined; - @state() private contextCanScrollLeft = false; - @state() private contextCanScrollRight = false; - @state() private mobileTabsCanScrollLeft = false; - @state() private mobileTabsCanScrollRight = false; private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); private readonly onPageShow = () => { - this.repairViewportPosition(); + this.appShell.repairViewportPosition(); }; private readonly onFocus = () => { - this.repairViewportPosition(); + this.appShell.repairViewportPosition(); void this.sessions.refreshSelectedSession(); void this.refreshPiWebStatus(); void this.refreshWorkspaceActivity(); @@ -148,44 +134,17 @@ export class PiWebApp extends LitElement { }; private readonly onVisibilityChange = () => { if (document.visibilityState === "visible") { - this.repairViewportPosition(); + this.appShell.repairViewportPosition(); void this.sessions.refreshSelectedSession(); void this.refreshPiWebStatus(); void this.refreshWorkspaceActivity(); void this.refreshWorkspaceDeletionRuns(); } }; - private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => { - this.isMobileNavigationLayout = event.matches; - this.updateContextScrollState(); - this.updateMobileTabsScrollState(); - }; private readonly onSystemLightThemeChange = () => { if (this.themePreference.auto) this.applyPreferredTheme(false); }; - private readonly onPwaDisplayModeChange = () => { - this.isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia); - }; - private readonly onContextScroll = () => { - this.updateContextScrollState(); - }; - private readonly onMobileTabsScroll = () => { - this.updateMobileTabsScrollState(); - }; - private readonly onDocumentClick = (event: MouseEvent) => { - const refresh = this.appRefreshElement(); - if (refresh !== undefined && event.composedPath().includes(refresh)) return; - this.refreshMenuOpen = false; - this.suppressNextRefreshClick = false; - }; private readonly onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && this.refreshMenuOpen) { - event.preventDefault(); - event.stopPropagation(); - this.refreshMenuOpen = false; - this.suppressNextRefreshClick = false; - return; - } if (this.keyboard.handle(event, this.getActions())) { event.preventDefault(); event.stopPropagation(); @@ -197,12 +156,9 @@ export class PiWebApp extends LitElement { window.addEventListener("popstate", this.onPopState); window.addEventListener("pageshow", this.onPageShow); window.addEventListener("focus", this.onFocus); - document.addEventListener("click", this.onDocumentClick); document.addEventListener("visibilitychange", this.onVisibilityChange); window.addEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS); - this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange); this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange); - for (const media of this.pwaDisplayModeMedia) media.addEventListener("change", this.onPwaDisplayModeChange); this.applyPreferredTheme(false); this.connectRealtime(); this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS); @@ -216,12 +172,9 @@ export class PiWebApp extends LitElement { window.removeEventListener("popstate", this.onPopState); window.removeEventListener("pageshow", this.onPageShow); window.removeEventListener("focus", this.onFocus); - document.removeEventListener("click", this.onDocumentClick); document.removeEventListener("visibilitychange", this.onVisibilityChange); window.removeEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS); - this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange); this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange); - for (const media of this.pwaDisplayModeMedia) media.removeEventListener("change", this.onPwaDisplayModeChange); this.keyboard.reset(); this.auth.dispose(); this.sessions.dispose(); @@ -231,31 +184,9 @@ export class PiWebApp extends LitElement { this.piWebStatusTimer = undefined; if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer); this.workspaceDeletionPollTimer = undefined; - this.contextItemsResizeObserver?.disconnect(); - this.contextItemsResizeObserver = undefined; - this.observedContextItems = undefined; - this.mobileTabsResizeObserver?.disconnect(); - this.mobileTabsResizeObserver = undefined; - this.observedMobileTabs = undefined; - this.clearRefreshLongPressTimer(); - this.clearViewportPositionRepair(); super.disconnectedCallback(); } - override firstUpdated(): void { - this.observeContextItems(); - this.observeMobileTabs(); - this.updateContextScrollState(); - this.updateMobileTabsScrollState(); - } - - override updated(): void { - this.observeContextItems(); - this.observeMobileTabs(); - this.updateContextScrollState(); - this.updateMobileTabsScrollState(); - } - private setState(patch: Partial) { if (!patchChangesState(this.state, patch)) return; const previous = this.state; @@ -288,8 +219,6 @@ export class PiWebApp extends LitElement { private async refreshAppData(): Promise { if (this.isRefreshingApp) return; - this.refreshMenuOpen = false; - this.suppressNextRefreshClick = false; this.isRefreshingApp = true; try { await Promise.all([ @@ -370,47 +299,7 @@ export class PiWebApp extends LitElement { } private shouldAutoFocusPrompt(): boolean { - return !this.isMobileNavigationLayout && !this.isPwaDisplayMode; - } - - private repairViewportPosition(): void { - if (!this.shouldRepairViewportPosition()) return; - this.resetViewportScroll(); - if (this.viewportPositionRepairFrame !== undefined) window.cancelAnimationFrame(this.viewportPositionRepairFrame); - this.viewportPositionRepairFrame = window.requestAnimationFrame(() => { - this.viewportPositionRepairFrame = undefined; - this.resetViewportScroll(); - this.viewportPositionRepairFrame = window.requestAnimationFrame(() => { - this.viewportPositionRepairFrame = undefined; - this.resetViewportScroll(); - }); - }); - if (this.viewportPositionRepairTimer !== undefined) window.clearTimeout(this.viewportPositionRepairTimer); - this.viewportPositionRepairTimer = window.setTimeout(() => { - this.viewportPositionRepairTimer = undefined; - this.resetViewportScroll(); - }, VIEWPORT_POSITION_REPAIR_DELAY_MS); - } - - private shouldRepairViewportPosition(): boolean { - return this.isMobileNavigationLayout || this.isPwaDisplayMode; - } - - private resetViewportScroll(): void { - window.scrollTo(0, 0); - document.documentElement.scrollTop = 0; - document.body.scrollTop = 0; - } - - private clearViewportPositionRepair(): void { - if (this.viewportPositionRepairFrame !== undefined) { - window.cancelAnimationFrame(this.viewportPositionRepairFrame); - this.viewportPositionRepairFrame = undefined; - } - if (this.viewportPositionRepairTimer !== undefined) { - window.clearTimeout(this.viewportPositionRepairTimer); - this.viewportPositionRepairTimer = undefined; - } + return this.appShell.shouldAutoFocusPrompt(); } private async withChatPrependTransition(action: () => Promise) { @@ -420,7 +309,7 @@ export class PiWebApp extends LitElement { } private defaultRouteView(): AppState["mainView"] { - return this.isMobileNavigationLayout ? "navigation" : "chat"; + return this.appShell.defaultRouteView(); } private updateUrl(options?: { replace?: boolean | undefined }) { @@ -582,63 +471,32 @@ export class PiWebApp extends LitElement { `; } - private toggleNavigationPanelCollapse(): void { - this.navigationPanelCollapsed = !this.navigationPanelCollapsed; - } - private renderNavigationPanelEdgeControl() { - const collapsed = this.navigationPanelCollapsed; - const label = collapsed ? "Expand navigation panel" : "Collapse navigation panel"; return html` - + { this.panelCollapse.toggleNavigationPanel(); }} + > `; } - private renderNavigationPanelEdgeIcon(collapsed: boolean) { - return this.renderPanelEdgeIcon(collapsed ? "right" : "left", "navigation-panel-edge-icon"); - } - - private toggleWorkspacePanelCollapse(): void { - this.workspacePanelCollapsed = !this.workspacePanelCollapsed; - } - private renderWorkspacePanelEdgeControl() { - const collapsed = this.workspacePanelCollapsed; - const label = collapsed ? "Expand workspace panel" : "Collapse workspace panel"; return html` -
- -
+ { this.panelCollapse.toggleWorkspacePanel(); }} + > `; } - private renderWorkspacePanelEdgeIcon(collapsed: boolean) { - return this.renderPanelEdgeIcon(collapsed ? "left" : "right", "workspace-panel-edge-icon"); - } - - private renderPanelEdgeIcon(direction: "left" | "right", className: string) { - const path = direction === "left" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6"; - return html``; - } - private renderNavigationPanel(autoSwitchToChat: boolean) { const openChatAfter = (action: () => Promise) => this.withChatScrollTransition(async () => { await action(); @@ -646,91 +504,53 @@ export class PiWebApp extends LitElement { if (autoSwitchToChat) this.updateUrl(); }); return html` -
- PI WEB -
- ${this.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : null} - -
-
- { this.toggleNavigationSection("projects"); }} - .onSelect=${(project: Project) => this.withChatScrollTransition(async () => { - this.expandNavigationSection("workspaces"); + .workspaces=${this.state.workspaces} + .selectedWorkspace=${this.state.selectedWorkspace} + .deletingWorkspaceIds=${pendingWorkspaceDeletionIds(this.state.workspaceDeletionRuns)} + .sessions=${this.state.sessions} + .sessionStatuses=${this.state.sessionStatuses} + .sessionActivities=${this.state.sessionActivities} + .selectedSession=${this.state.selectedSession} + .canStartSession=${!!this.state.selectedWorkspace} + .collapsible=${this.appShell.isMobileNavigationLayout} + .projectsCollapsed=${this.mobileNavigation.isCollapsed("projects")} + .workspacesCollapsed=${this.mobileNavigation.isCollapsed("workspaces")} + .sessionsCollapsed=${this.mobileNavigation.isCollapsed("sessions")} + .workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)} + .refreshControl=${this.appShell.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : undefined} + .onShowActions=${() => { this.setState({ actionPaletteOpen: true }); }} + .onToggleProjects=${() => { this.mobileNavigation.toggle("projects"); }} + .onToggleWorkspaces=${() => { this.mobileNavigation.toggle("workspaces"); }} + .onToggleSessions=${() => { this.mobileNavigation.toggle("sessions"); }} + .onSelectProject=${(project: Project) => this.withChatScrollTransition(async () => { + this.mobileNavigation.expand("workspaces"); await this.workspaces.selectProject(project); })} - .onClose=${(project: Project) => this.projects.closeProject(project.id)} - > - this.plugins.getWorkspaceLabelItems(this.state, workspace)} - .onToggleCollapsed=${() => { this.toggleNavigationSection("workspaces"); }} - .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(async () => { - this.expandNavigationSection("sessions"); + .onCloseProject=${(project: Project) => this.projects.closeProject(project.id)} + .onSelectWorkspace=${(workspace: Workspace) => this.withChatScrollTransition(async () => { + this.mobileNavigation.expand("sessions"); await this.workspaces.selectWorkspace(workspace); })} - .onDelete=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }} - > - { this.toggleNavigationSection("sessions"); }} + .onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }} .onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }} - .onStart=${() => openChatAfter(() => this.sessions.startSession())} - .onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} - .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} - .onArchiveWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)} - .onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))} - .onDelete=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)} - .onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)} - > + .onStartSession=${() => openChatAfter(() => this.sessions.startSession())} + .onSelectSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} + .onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)} + .onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)} + .onRestoreSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))} + .onDeleteCachedNewSession=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)} + .onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)} + > `; } - private expandedNavigationSection(): NavigationSection | undefined { - if (this.expandedMobileNavigationSection === "none") return undefined; - return this.expandedMobileNavigationSection ?? this.defaultNavigationSection(); - } - - private defaultNavigationSection(): NavigationSection { - if (this.state.selectedProject === undefined) return "projects"; - if (this.state.selectedWorkspace === undefined) return "workspaces"; - return "sessions"; - } - - private isNavigationSectionCollapsed(section: NavigationSection): boolean { - return this.isMobileNavigationLayout && this.expandedNavigationSection() !== section; - } - - private toggleNavigationSection(section: NavigationSection): void { - if (!this.isMobileNavigationLayout) return; - this.expandedMobileNavigationSection = this.expandedNavigationSection() === section ? "none" : section; - } - - private expandNavigationSection(section: NavigationSection): void { - if (this.isMobileNavigationLayout) this.expandedMobileNavigationSection = section; - } - private openNavigationSection(section: NavigationSection): void { - if (!this.isMobileNavigationLayout) return; - this.expandNavigationSection(section); - this.selectMainView("navigation"); + this.mobileNavigation.open(section, () => { this.selectMainView("navigation"); }); } private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] { @@ -1117,223 +937,51 @@ export class PiWebApp extends LitElement { } private renderContextBar() { - const project = this.state.selectedProject; - const workspace = this.state.selectedWorkspace; - const session = this.state.selectedSession; - const projectLabel = projectContextLabel(project); - const showRefresh = this.shouldShowAppRefreshInContextBar(); - const workspaceLabel = workspaceContextLabel(workspace); - const sessionLabel = sessionContextLabel(session); + if (!this.appShell.isMobileNavigationLayout) return null; return html` - + { this.openNavigationSection(section); }} + > `; } - private contextBarClass(): string { - const classes = ["context-bar"]; - if (this.shouldShowAppRefreshInContextBar()) classes.push("has-context-actions"); - if (this.contextCanScrollLeft) classes.push("can-scroll-left"); - if (this.contextCanScrollRight) classes.push("can-scroll-right"); - return classes.join(" "); + private renderMobileMainTabs() { + return html` + { this.selectMainView(view); }} + > + `; } - private shouldShowAppRefreshInHeader(): boolean { - return this.isPwaDisplayMode && !this.isMobileNavigationLayout; - } - - private shouldShowAppRefreshInContextBar(): boolean { - return this.isPwaDisplayMode && this.isMobileNavigationLayout; - } - - private mobileTabsFrameClass(): string { - return `mobile-tabs-frame${this.mobileTabsCanScrollLeft ? " can-scroll-left" : ""}${this.mobileTabsCanScrollRight ? " can-scroll-right" : ""}`; - } - - private observeContextItems(): void { - const contextItems = this.contextItemsElement(); - if (this.observedContextItems === contextItems) return; - this.contextItemsResizeObserver?.disconnect(); - this.observedContextItems = contextItems; - this.contextItemsResizeObserver = undefined; - if (contextItems === undefined || typeof ResizeObserver === "undefined") return; - this.contextItemsResizeObserver = new ResizeObserver(() => { - this.updateContextScrollState(); - }); - this.contextItemsResizeObserver.observe(contextItems); - } - - private updateContextScrollState(): void { - const contextItems = this.contextItemsElement(); - const maxScrollLeft = contextItems === undefined ? 0 : Math.max(0, contextItems.scrollWidth - contextItems.clientWidth); - const canScrollLeft = contextItems !== undefined && contextItems.scrollLeft > 1; - const canScrollRight = contextItems !== undefined && maxScrollLeft - contextItems.scrollLeft > 1; - if (this.contextCanScrollLeft !== canScrollLeft) this.contextCanScrollLeft = canScrollLeft; - if (this.contextCanScrollRight !== canScrollRight) this.contextCanScrollRight = canScrollRight; - } - - private contextItemsElement(): HTMLElement | undefined { - const contextItems = this.contextItems; - return contextItems instanceof HTMLElement ? contextItems : undefined; - } - - private observeMobileTabs(): void { - const mobileTabs = this.mobileTabsElement(); - if (this.observedMobileTabs === mobileTabs) return; - this.mobileTabsResizeObserver?.disconnect(); - this.observedMobileTabs = mobileTabs; - this.mobileTabsResizeObserver = undefined; - if (mobileTabs === undefined || typeof ResizeObserver === "undefined") return; - this.mobileTabsResizeObserver = new ResizeObserver(() => { - this.updateMobileTabsScrollState(); - }); - this.mobileTabsResizeObserver.observe(mobileTabs); - } - - private updateMobileTabsScrollState(): void { - const mobileTabs = this.mobileTabsElement(); - const maxScrollLeft = mobileTabs === undefined ? 0 : Math.max(0, mobileTabs.scrollWidth - mobileTabs.clientWidth); - const canScrollLeft = mobileTabs !== undefined && mobileTabs.scrollLeft > 1; - const canScrollRight = mobileTabs !== undefined && maxScrollLeft - mobileTabs.scrollLeft > 1; - if (this.mobileTabsCanScrollLeft !== canScrollLeft) this.mobileTabsCanScrollLeft = canScrollLeft; - if (this.mobileTabsCanScrollRight !== canScrollRight) this.mobileTabsCanScrollRight = canScrollRight; - } - - private mobileTabsElement(): HTMLElement | undefined { - const mobileTabs = this.mobileTabs; - return mobileTabs instanceof HTMLElement ? mobileTabs : undefined; - } - - private appRefreshElement(): HTMLElement | undefined { - const appRefresh = this.appRefresh; - return appRefresh instanceof HTMLElement ? appRefresh : undefined; + private mobileMainTabs(): AppMobileMainTab[] { + return [ + { id: "navigation", label: "Sessions", className: "navigation-tab" }, + { id: "chat", label: "Chat" }, + ...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => ({ id: panel.id, label: this.renderMobilePanelTitle(panel) })), + ]; } private renderAppRefresh() { - const label = this.isRefreshingApp ? "Refreshing app data. Long-press for reload options." : "Refresh app data. Long-press for reload options."; - return html` -
- -
- `; - } - - private renderRefreshMenu() { - if (!this.refreshMenuOpen) return null; - return html` - - `; - } - - private renderRefreshIcon() { - return html` - - `; - } - - private onRefreshClick(event: MouseEvent): void { - event.stopPropagation(); - if (this.suppressNextRefreshClick) { - this.suppressNextRefreshClick = false; - return; - } - void this.refreshAppData(); - } - - private onRefreshPointerDown(event: PointerEvent): void { - if (!event.isPrimary || event.button !== 0) return; - const target = event.currentTarget; - if (!(target instanceof HTMLElement)) return; - this.clearRefreshLongPressTimer(); - this.suppressNextRefreshClick = false; - this.refreshLongPressTimer = window.setTimeout(() => { - this.refreshLongPressTimer = undefined; - this.suppressNextRefreshClick = true; - this.openRefreshMenu(target); - }, REFRESH_LONG_PRESS_MS); - } - - private onRefreshContextMenu(event: MouseEvent): void { - event.preventDefault(); - event.stopPropagation(); - this.clearRefreshLongPressTimer(); - this.suppressNextRefreshClick = true; - this.openRefreshMenu(event.currentTarget); - } - - private openRefreshMenu(target: EventTarget | null): void { - this.refreshMenuStyle = actionMenuPanelStyle(target); - this.refreshMenuOpen = true; - } - - private clearRefreshLongPressTimer(): void { - if (this.refreshLongPressTimer === undefined) return; - window.clearTimeout(this.refreshLongPressTimer); - this.refreshLongPressTimer = undefined; + return html` this.refreshAppData()} .onReload=${() => { this.hardReloadApp(); }}>`; } override render() { const state = this.state; return html` -
- +
+ ${this.renderNavigationPanelEdgeControl()} -
+
${this.renderContextBar()} -
-
- - - ${this.visibleWorkspacePanels().map((panel) => html` - - `)} -
-
+ ${this.renderMobileMainTabs()} ${state.error ? html`
${state.error}
` : null} -
${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}
+
${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}
${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> @@ -1349,7 +997,6 @@ export class PiWebApp extends LitElement { ${state.actionPaletteOpen ? html` { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}>` : null} ${state.projectDialogOpen ? html` this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}>` : null} ${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} - ${this.renderRefreshMenu()}
`; } @@ -1364,32 +1011,6 @@ function createPluginRegistry(): PluginRegistry { return registry; } -function projectContextLabel(project: Project | undefined): string { - return project?.name ?? "No project"; -} - -function projectContextTitle(project: Project | undefined): string { - return project === undefined ? "No project selected" : `${project.name} — ${project.path}`; -} - -function workspaceContextLabel(workspace: Workspace | undefined): string { - return workspace === undefined ? "No workspace" : `${workspace.label}${workspace.isMain ? " · main" : ""} · ${workspace.path}`; -} - -function workspaceContextTitle(workspace: Workspace | undefined): string { - return workspace === undefined ? "No workspace selected" : `${workspace.label}${workspace.isMain ? " · main" : ""} — ${workspace.path}`; -} - -function sessionContextLabel(session: SessionInfo | undefined): string { - const name = session?.name?.trim(); - const firstMessage = session?.firstMessage.trim(); - return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session?.id.slice(0, 8) ?? "No session"; -} - -function sessionContextTitle(session: SessionInfo | undefined): string { - return session === undefined ? "No session selected" : session.path; -} - function patchChangesState(state: AppState, patch: Partial): boolean { return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value); } diff --git a/src/client/src/components/appShell/AppContextBar.ts b/src/client/src/components/appShell/AppContextBar.ts new file mode 100644 index 0000000..45ce6ad --- /dev/null +++ b/src/client/src/components/appShell/AppContextBar.ts @@ -0,0 +1,155 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import type { Project, SessionInfo, Workspace } from "../../api"; +import type { NavigationSection } from "../../appShell/navigationState"; + +@customElement("app-context-bar") +export class AppContextBar extends LitElement { + @property({ attribute: false }) project?: Project; + @property({ attribute: false }) workspace?: Workspace; + @property({ attribute: false }) session?: SessionInfo; + @property({ attribute: false }) refreshControl: unknown; + @property({ attribute: false }) onOpenSection?: (section: NavigationSection) => void; + @query(".context-items") private contextItems?: HTMLElement | null; + @state() private canScrollLeft = false; + @state() private canScrollRight = false; + private observedContextItems: HTMLElement | undefined; + private contextItemsResizeObserver: ResizeObserver | undefined; + + override disconnectedCallback(): void { + this.contextItemsResizeObserver?.disconnect(); + this.contextItemsResizeObserver = undefined; + this.observedContextItems = undefined; + super.disconnectedCallback(); + } + + override firstUpdated(): void { + this.observeContextItems(); + this.updateScrollState(); + } + + override updated(): void { + this.observeContextItems(); + this.updateScrollState(); + } + + override render() { + const projectLabel = projectContextLabel(this.project); + const workspaceLabel = workspaceContextLabel(this.workspace); + const sessionLabel = sessionContextLabel(this.session); + return html` + + `; + } + + private contextBarClass(): string { + const classes = ["context-bar"]; + if (this.refreshControl !== undefined) classes.push("has-context-actions"); + if (this.canScrollLeft) classes.push("can-scroll-left"); + if (this.canScrollRight) classes.push("can-scroll-right"); + return classes.join(" "); + } + + private observeContextItems(): void { + const contextItems = this.contextItemsElement(); + if (this.observedContextItems === contextItems) return; + this.contextItemsResizeObserver?.disconnect(); + this.observedContextItems = contextItems; + this.contextItemsResizeObserver = undefined; + if (contextItems === undefined || typeof ResizeObserver === "undefined") return; + this.contextItemsResizeObserver = new ResizeObserver(() => { + this.updateScrollState(); + }); + this.contextItemsResizeObserver.observe(contextItems); + } + + private updateScrollState(): void { + const contextItems = this.contextItemsElement(); + const maxScrollLeft = contextItems === undefined ? 0 : Math.max(0, contextItems.scrollWidth - contextItems.clientWidth); + const canScrollLeft = contextItems !== undefined && contextItems.scrollLeft > 1; + const canScrollRight = contextItems !== undefined && maxScrollLeft - contextItems.scrollLeft > 1; + if (this.canScrollLeft !== canScrollLeft) this.canScrollLeft = canScrollLeft; + if (this.canScrollRight !== canScrollRight) this.canScrollRight = canScrollRight; + } + + private contextItemsElement(): HTMLElement | undefined { + const contextItems = this.contextItems; + return contextItems instanceof HTMLElement ? contextItems : undefined; + } + + private readonly onContextScroll = () => { + this.updateScrollState(); + }; + + static override styles = css` + :host { flex: 0 0 auto; min-width: 0; } + .context-bar { position: relative; flex: 0 0 auto; min-width: 0; display: flex; align-items: center; gap: 0; padding: 6px 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); } + .context-bar::before, .context-bar::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; } + .context-bar::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } + .context-bar::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } + .context-bar.can-scroll-left::before, .context-bar.can-scroll-right::after { opacity: 1; } + .context-bar-label { display: none; } + .context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 5px; margin: 0; padding: 0 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px; scrollbar-width: thin; } + .context-bar.has-context-actions .context-items { padding-right: 52px; scroll-padding-inline: 8px 52px; } + .context-item { flex: 0 0 auto; min-width: 0; display: flex; } + .context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; padding: 0 8px 0 0; pointer-events: none; } + .context-actions::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; z-index: 0; width: 26px; background: var(--pi-bg); pointer-events: none; } + app-refresh-control { pointer-events: auto; } + .context-chip { flex: 0 0 auto; min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 4px 8px; font: inherit; text-align: left; } + .context-chip:hover { background: var(--pi-surface-hover); } + .context-chip:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; } + .context-chip.empty { border-style: dashed; color: var(--pi-muted); } + .context-kind { display: none; } + .context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; } + button { cursor: pointer; } + `; +} + +function projectContextLabel(project: Project | undefined): string { + return project?.name ?? "No project"; +} + +function projectContextTitle(project: Project | undefined): string { + return project === undefined ? "No project selected" : `${project.name} — ${project.path}`; +} + +function workspaceContextLabel(workspace: Workspace | undefined): string { + return workspace === undefined ? "No workspace" : `${workspace.label}${workspace.isMain ? " · main" : ""} · ${workspace.path}`; +} + +function workspaceContextTitle(workspace: Workspace | undefined): string { + return workspace === undefined ? "No workspace selected" : `${workspace.label}${workspace.isMain ? " · main" : ""} — ${workspace.path}`; +} + +function sessionContextLabel(session: SessionInfo | undefined): string { + const name = session?.name?.trim(); + const firstMessage = session?.firstMessage.trim(); + return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session?.id.slice(0, 8) ?? "No session"; +} + +function sessionContextTitle(session: SessionInfo | undefined): string { + return session === undefined ? "No session selected" : session.path; +} diff --git a/src/client/src/components/appShell/AppMobileMainTabs.ts b/src/client/src/components/appShell/AppMobileMainTabs.ts new file mode 100644 index 0000000..4eaf5fa --- /dev/null +++ b/src/client/src/components/appShell/AppMobileMainTabs.ts @@ -0,0 +1,110 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import type { AppState } from "../../appState"; + +export interface AppMobileMainTab { + id: AppState["mainView"]; + label: unknown; + className?: string | undefined; +} + +@customElement("app-mobile-main-tabs") +export class AppMobileMainTabs extends LitElement { + @property({ attribute: false }) tabs: AppMobileMainTab[] = []; + @property({ attribute: false }) selectedView: AppState["mainView"] = "chat"; + @property({ attribute: false }) onSelect?: (view: AppState["mainView"]) => void; + @query(".mobile-tabs") private mobileTabs?: HTMLElement | null; + @state() private canScrollLeft = false; + @state() private canScrollRight = false; + private observedMobileTabs: HTMLElement | undefined; + private mobileTabsResizeObserver: ResizeObserver | undefined; + + override disconnectedCallback(): void { + this.mobileTabsResizeObserver?.disconnect(); + this.mobileTabsResizeObserver = undefined; + this.observedMobileTabs = undefined; + super.disconnectedCallback(); + } + + override firstUpdated(): void { + this.observeMobileTabs(); + this.updateScrollState(); + } + + override updated(): void { + this.observeMobileTabs(); + this.updateScrollState(); + } + + override render() { + return html` +
+
+ ${this.tabs.map((tab) => html` + + `)} +
+
+ `; + } + + private frameClass(): string { + return `mobile-tabs-frame${this.canScrollLeft ? " can-scroll-left" : ""}${this.canScrollRight ? " can-scroll-right" : ""}`; + } + + private tabClass(tab: AppMobileMainTab): string { + return [ + ...(tab.className === undefined ? [] : [tab.className]), + ...(this.selectedView === tab.id ? ["selected"] : []), + ].join(" "); + } + + private observeMobileTabs(): void { + const mobileTabs = this.mobileTabsElement(); + if (this.observedMobileTabs === mobileTabs) return; + this.mobileTabsResizeObserver?.disconnect(); + this.observedMobileTabs = mobileTabs; + this.mobileTabsResizeObserver = undefined; + if (mobileTabs === undefined || typeof ResizeObserver === "undefined") return; + this.mobileTabsResizeObserver = new ResizeObserver(() => { + this.updateScrollState(); + }); + this.mobileTabsResizeObserver.observe(mobileTabs); + } + + private updateScrollState(): void { + const mobileTabs = this.mobileTabsElement(); + const maxScrollLeft = mobileTabs === undefined ? 0 : Math.max(0, mobileTabs.scrollWidth - mobileTabs.clientWidth); + const canScrollLeft = mobileTabs !== undefined && mobileTabs.scrollLeft > 1; + const canScrollRight = mobileTabs !== undefined && maxScrollLeft - mobileTabs.scrollLeft > 1; + if (this.canScrollLeft !== canScrollLeft) this.canScrollLeft = canScrollLeft; + if (this.canScrollRight !== canScrollRight) this.canScrollRight = canScrollRight; + } + + private mobileTabsElement(): HTMLElement | undefined { + const mobileTabs = this.mobileTabs; + return mobileTabs instanceof HTMLElement ? mobileTabs : undefined; + } + + private readonly onMobileTabsScroll = () => { + this.updateScrollState(); + }; + + static override styles = css` + :host { flex: 0 0 auto; min-width: 0; } + .mobile-tabs-frame { position: relative; display: flex; flex: 0 0 auto; min-width: 0; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg); } + .mobile-tabs-frame::before, .mobile-tabs-frame::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; } + .mobile-tabs-frame::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } + .mobile-tabs-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } + .mobile-tabs-frame.can-scroll-left::before, .mobile-tabs-frame.can-scroll-right::after { opacity: 1; } + .mobile-tabs { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; } + .mobile-tabs button { flex: 0 0 auto; white-space: nowrap; } + .navigation-tab { display: none; } + .mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } + .tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } + @media (max-width: 760px) { + .navigation-tab { display: block; } + } + `; +} diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts new file mode 100644 index 0000000..7ac7982 --- /dev/null +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -0,0 +1,114 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api"; +import type { WorkspaceLabelItem } from "../../plugins/types"; +import "../ProjectList"; +import "../WorkspaceList"; +import "../SessionList"; + +@customElement("app-navigation-panel") +export class AppNavigationPanel extends LitElement { + @property({ attribute: false }) projects: Project[] = []; + @property({ attribute: false }) selectedProject?: Project; + @property({ attribute: false }) workspaces: Workspace[] = []; + @property({ attribute: false }) selectedWorkspace?: Workspace; + @property({ attribute: false }) sessions: SessionInfo[] = []; + @property({ attribute: false }) selectedSession?: SessionInfo; + @property({ attribute: false }) workspaceActivities: Record = {}; + @property({ attribute: false }) sessionActivities: Record = {}; + @property({ attribute: false }) sessionStatuses: Record = {}; + @property({ attribute: false }) workspacesByProjectId: Record = {}; + @property({ attribute: false }) deletingWorkspaceIds: string[] = []; + @property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => []; + @property({ attribute: false }) refreshControl: unknown; + @property({ type: Boolean, reflect: true }) collapsible = false; + @property({ type: Boolean }) projectsCollapsed = false; + @property({ type: Boolean }) workspacesCollapsed = false; + @property({ type: Boolean }) sessionsCollapsed = false; + @property({ type: Boolean }) canStartSession = false; + @property({ attribute: false }) onShowActions?: () => void; + @property({ attribute: false }) onToggleProjects?: () => void; + @property({ attribute: false }) onToggleWorkspaces?: () => void; + @property({ attribute: false }) onToggleSessions?: () => void; + @property({ attribute: false }) onSelectProject?: (project: Project) => void | Promise; + @property({ attribute: false }) onCloseProject?: (project: Project) => void | Promise; + @property({ attribute: false }) onSelectWorkspace?: (workspace: Workspace) => void | Promise; + @property({ attribute: false }) onDeleteWorkspace?: (workspace: Workspace) => void | Promise; + @property({ attribute: false }) onStartSession?: () => void | Promise; + @property({ attribute: false }) onSelectSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onArchiveSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onArchiveSessionWithDescendants?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onRestoreSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onDeleteCachedNewSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise; + @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise; + + override render() { + return html` +
+ PI WEB +
+ ${this.refreshControl} + +
+
+ { this.onToggleProjects?.(); }} + .onSelect=${(project: Project) => this.onSelectProject?.(project)} + .onClose=${(project: Project) => this.onCloseProject?.(project)} + > + { this.onToggleWorkspaces?.(); }} + .onSelect=${(workspace: Workspace) => this.onSelectWorkspace?.(workspace)} + .onDelete=${(workspace: Workspace) => this.onDeleteWorkspace?.(workspace)} + > + { this.onToggleSessions?.(); }} + .onArchivedCollapsed=${() => this.onArchivedCollapsed?.()} + .onStart=${() => this.onStartSession?.()} + .onSelect=${(session: SessionInfo) => this.onSelectSession?.(session)} + .onArchive=${(session: SessionInfo) => this.onArchiveSession?.(session)} + .onArchiveWithDescendants=${(session: SessionInfo) => this.onArchiveSessionWithDescendants?.(session)} + .onRestore=${(session: SessionInfo) => this.onRestoreSession?.(session)} + .onDelete=${(session: SessionInfo) => this.onDeleteCachedNewSession?.(session)} + .onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)} + > + `; + } + + static override styles = css` + :host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; } + :host([collapsible]) { flex: 1 1 auto; } + header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); } + .header-actions { display: flex; align-items: center; gap: 8px; } + project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); } + session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; } + :host([collapsible]) project-list, + :host([collapsible]) workspace-list, + :host([collapsible]) session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; } + :host([collapsible]) project-list[collapsed], + :host([collapsible]) workspace-list[collapsed], + :host([collapsible]) session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } + `; +} diff --git a/src/client/src/components/appShell/AppPanelEdgeControl.ts b/src/client/src/components/appShell/AppPanelEdgeControl.ts new file mode 100644 index 0000000..2f9594f --- /dev/null +++ b/src/client/src/components/appShell/AppPanelEdgeControl.ts @@ -0,0 +1,58 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +export type PanelEdgeSide = "navigation" | "workspace"; + +@customElement("app-panel-edge-control") +export class AppPanelEdgeControl extends LitElement { + @property({ reflect: true }) side: PanelEdgeSide = "navigation"; + @property({ type: Boolean, reflect: true }) collapsed = false; + @property() controls = ""; + @property() expandLabel = "Expand panel"; + @property() collapseLabel = "Collapse panel"; + @property({ attribute: false }) onToggle?: () => void; + + override render() { + const label = this.collapsed ? this.expandLabel : this.collapseLabel; + return html` + + `; + } + + private renderIcon() { + const direction = this.iconDirection(); + const path = direction === "left" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6"; + return html``; + } + + private iconDirection(): "left" | "right" { + if (this.side === "navigation") return this.collapsed ? "right" : "left"; + return this.collapsed ? "left" : "right"; + } + + static override styles = css` + :host { min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; } + :host([side="navigation"]) { grid-column: 2; } + :host([side="workspace"]) { grid-column: 4; } + .edge-button { position: relative; z-index: 1; box-sizing: border-box; display: grid; place-items: center; width: 18px; height: 48px; padding: 0; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-bg); color: var(--pi-muted); opacity: .75; cursor: pointer; } + .edge-button:hover, .edge-button:focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); opacity: 1; } + :host([side="navigation"][collapsed]) .edge-button { transform: translateX(calc(50% - .5px)); } + :host([side="workspace"][collapsed]) .edge-button { transform: translateX(calc(-50% + .5px)); } + .edge-icon { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } + @media (max-width: 1180px) { + :host([side="navigation"]) { grid-row: 1 / 3; } + :host([side="workspace"]) { display: none; } + } + @media (max-width: 760px) { + :host([side="navigation"]) { display: none; } + } + `; +} diff --git a/src/client/src/components/appShell/AppRefreshControl.ts b/src/client/src/components/appShell/AppRefreshControl.ts new file mode 100644 index 0000000..8bda17a --- /dev/null +++ b/src/client/src/components/appShell/AppRefreshControl.ts @@ -0,0 +1,151 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { actionMenuPanelStyle } from "../actionMenu"; + +const REFRESH_LONG_PRESS_MS = 550; + +@customElement("app-refresh-control") +export class AppRefreshControl extends LitElement { + @property({ type: Boolean }) isRefreshing = false; + @property({ attribute: false }) onRefresh?: () => void | Promise; + @property({ attribute: false }) onReload?: () => void; + @state() private menuOpen = false; + @state() private menuStyle = ""; + private longPressTimer: number | undefined; + private suppressNextClick = false; + + override connectedCallback(): void { + super.connectedCallback(); + document.addEventListener("click", this.onDocumentClick); + document.addEventListener("keydown", this.onDocumentKeyDown); + } + + override disconnectedCallback(): void { + document.removeEventListener("click", this.onDocumentClick); + document.removeEventListener("keydown", this.onDocumentKeyDown); + this.clearLongPressTimer(); + super.disconnectedCallback(); + } + + override render() { + const label = this.isRefreshing ? "Refreshing app data. Long-press for reload options." : "Refresh app data. Long-press for reload options."; + return html` + + ${this.renderMenu()} + `; + } + + private renderMenu() { + if (!this.menuOpen) return null; + return html` + + `; + } + + private renderRefreshIcon() { + return html` + + `; + } + + private readonly onRefreshClick = (event: MouseEvent): void => { + event.stopPropagation(); + if (this.suppressNextClick) { + this.suppressNextClick = false; + return; + } + this.refresh(); + }; + + private readonly onRefreshPointerDown = (event: PointerEvent): void => { + if (!event.isPrimary || event.button !== 0) return; + const target = event.currentTarget; + if (!(target instanceof HTMLElement)) return; + this.clearLongPressTimer(); + this.suppressNextClick = false; + this.longPressTimer = window.setTimeout(() => { + this.longPressTimer = undefined; + this.suppressNextClick = true; + this.openMenu(target); + }, REFRESH_LONG_PRESS_MS); + }; + + private readonly onRefreshContextMenu = (event: MouseEvent): void => { + event.preventDefault(); + event.stopPropagation(); + this.clearLongPressTimer(); + this.suppressNextClick = true; + this.openMenu(event.currentTarget); + }; + + private readonly onDocumentClick = (event: MouseEvent): void => { + if (event.composedPath().includes(this)) return; + this.closeMenu(); + }; + + private readonly onDocumentKeyDown = (event: KeyboardEvent): void => { + if (event.key !== "Escape" || !this.menuOpen) return; + event.preventDefault(); + event.stopPropagation(); + this.closeMenu(); + }; + + private openMenu(target: EventTarget | null): void { + this.menuStyle = actionMenuPanelStyle(target); + this.menuOpen = true; + } + + private closeMenu(): void { + this.menuOpen = false; + this.suppressNextClick = false; + } + + private refresh(): void { + this.closeMenu(); + void this.onRefresh?.(); + } + + private reload(): void { + this.closeMenu(); + this.onReload?.(); + } + + private clearLongPressTimer(): void { + if (this.longPressTimer === undefined) return; + window.clearTimeout(this.longPressTimer); + this.longPressTimer = undefined; + } + + static override styles = css` + :host { position: relative; z-index: 1; display: flex; align-items: center; pointer-events: auto; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; } + :host, :host * { -webkit-user-select: none; user-select: none; } + .app-refresh-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 0; line-height: 1; cursor: pointer; touch-action: manipulation; -webkit-touch-callout: none; } + .app-refresh-icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } + .app-refresh-button.refreshing .app-refresh-icon { animation: app-refresh-spin .8s linear infinite; } + .app-refresh-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(170px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); overflow-wrap: anywhere; } + .app-refresh-menu button { display: block; width: 100%; border: 0; border-radius: 8px; background: transparent; color: var(--pi-text); padding: 7px 9px; text-align: left; white-space: normal; overflow-wrap: anywhere; cursor: pointer; } + .app-refresh-menu button:hover, .app-refresh-menu button:focus { background: var(--pi-selection-bg); } + @keyframes app-refresh-spin { to { transform: rotate(360deg); } } + `; +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 5122423..b3fc6cb 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -53,6 +53,7 @@ export const appStyles = css` :host { position: fixed; top: 0; right: 0; left: 0; display: block; height: 100dvh; box-sizing: border-box; overflow: hidden; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); color: var(--pi-text); background: var(--pi-bg); font: 14px system-ui, sans-serif; } .shell { --navigation-panel-width: 340px; --workspace-panel-width: minmax(360px, 42vw); display: grid; grid-template-columns: var(--navigation-panel-width) 1px minmax(420px, 1fr) 1px var(--workspace-panel-width); height: 100%; min-height: 0; } aside { grid-column: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; } + aside app-navigation-panel { flex: 1 1 auto; min-height: 0; } header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); } .header-actions { display: flex; align-items: center; gap: 8px; } project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); } @@ -83,6 +84,7 @@ export const appStyles = css` .context-chip.empty { border-style: dashed; color: var(--pi-muted); } .context-kind { display: none; } .context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; } + app-mobile-main-tabs { display: none; } .mobile-tabs-frame { position: relative; display: none; flex: 0 0 auto; min-width: 0; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg); } .mobile-tabs-frame::before, .mobile-tabs-frame::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; } .mobile-tabs-frame::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } @@ -115,6 +117,7 @@ export const appStyles = css` aside { grid-row: 1 / 3; } .navigation-panel-edge { grid-row: 1 / 3; } main { grid-column: 3; grid-row: 1 / 3; } + app-mobile-main-tabs { display: block; flex: 0 0 auto; min-width: 0; } .mobile-tabs-frame { display: flex; } .shell.workspace-view main { grid-row: 1; min-height: auto; } .shell.workspace-view > workspace-panel { grid-column: 3; grid-row: 2; display: flex; border-left: 0; } @@ -133,6 +136,7 @@ export const appStyles = css` main.navigation-view chat-view, main.navigation-view prompt-editor, main.navigation-view status-bar, main.navigation-view .empty { display: none; } main.navigation-view .mobile-navigation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } + main.navigation-view .mobile-navigation-panel app-navigation-panel { flex: 1 1 auto; min-height: 0; } main.navigation-view .mobile-navigation-panel project-list, main.navigation-view .mobile-navigation-panel workspace-list, main.navigation-view .mobile-navigation-panel session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; } From c2147c7089ff24ad9b5684a9bf6e2ce105e136eb Mon Sep 17 00:00:00 2001 From: FerOliveira-dev Date: Sat, 30 May 2026 19:25:45 -0300 Subject: [PATCH 21/27] fix: increase prompt-editor z-index to 30 to prevent activity-dock overlap --- src/client/src/components/shared.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index b3fc6cb..c037f34 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -442,7 +442,7 @@ export const actionPaletteStyles = css` `; export const promptEditorStyles = css` - :host { position: relative; z-index: 5; display: block; color: var(--pi-text); font: 14px system-ui, sans-serif; } + :host { position: relative; z-index: 30; display: block; color: var(--pi-text); font: 14px system-ui, sans-serif; } footer { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; padding: 12px; border-top: 1px solid var(--pi-border); } footer.shell-mode { border-top-color: var(--pi-success); background: var(--pi-success-bg); } .editor-wrap { position: relative; min-width: 0; } From fdd2cf239061d39c7734faa975accb5e45347e16 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 31 May 2026 20:04:12 +0200 Subject: [PATCH 22/27] fix: improve file mention suggestions without ripgrep --- .changeset/file-suggestions-without-rg.md | 5 + docs/faq.html | 5 +- src/cli.ts | 41 +++++-- src/client/src/api/clients.ts | 14 ++- src/client/src/components/PromptEditor.ts | 24 +++-- src/client/src/inputModes.test.ts | 3 + src/client/src/inputModes.ts | 5 +- src/server/app.ts | 4 +- src/server/workspaces/fileSuggestions.test.ts | 75 +++++++++++++ src/server/workspaces/fileSuggestions.ts | 102 +++++++++++++++--- src/server/workspaces/fileTreeService.test.ts | 4 +- src/server/workspaces/fileTreeService.ts | 6 +- 12 files changed, 247 insertions(+), 41 deletions(-) create mode 100644 .changeset/file-suggestions-without-rg.md create mode 100644 src/server/workspaces/fileSuggestions.test.ts diff --git a/.changeset/file-suggestions-without-rg.md b/.changeset/file-suggestions-without-rg.md new file mode 100644 index 0000000..d166d1a --- /dev/null +++ b/.changeset/file-suggestions-without-rg.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep chat file mention suggestions working on installations that do not have ripgrep available, add an all-file `@` mention mode, stop hiding directories in the file explorer, and report optional ripgrep availability in `pi-web doctor`. diff --git a/docs/faq.html b/docs/faq.html index f4c9a4b..240b8e9 100644 --- a/docs/faq.html +++ b/docs/faq.html @@ -121,8 +121,9 @@

What does pi-web doctor check?

It checks whether the service shell and native service environment can find Node 22+, npm, Pi, and the Pi - Web binaries. It also prints installed and running PI WEB versions when available, and reports user service - lingering when relevant for server-style installs. + Web binaries. It also prints installed and running PI WEB versions when available, reports optional ripgrep + availability for faster all-file @-mention suggestions, uses a bounded filesystem fallback when + ripgrep is unavailable, and reports user service lingering when relevant for server-style installs.

If something works in your terminal but fails in doctor, treat that as a login-shell PATH mismatch and diff --git a/src/cli.ts b/src/cli.ts index 745a422..ca28bfa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -931,16 +931,44 @@ function runChecks(checks: Check[]): boolean { const ok = result.status === 0; failed ||= !ok; console.log(`${ok ? "✓" : "✗"} ${label}`); - const output = (result.stdout || result.stderr).trim(); - if (output !== "") { - const lines = output.split("\n"); - for (const line of lines.slice(0, 3)) console.log(` ${line}`); - if (lines.length > 3) console.log(" ..."); - } + printCheckOutput(result.stdout || result.stderr); } return !failed; } +function printCheckOutput(output: string): void { + const trimmed = output.trim(); + if (trimmed === "") return; + const lines = trimmed.split("\n"); + for (const line of lines.slice(0, 3)) console.log(` ${line}`); + if (lines.length > 3) console.log(" ..."); +} + +function optionalDoctorChecks(): Check[] { + const shell = serviceShellLabel(); + const backend = currentServiceBackend(); + const checks: Check[] = [[`${shell} can find optional ripgrep (rg)`, serviceShellCommand(commandCheck("rg"))]]; + if (backend?.kind === "systemd") checks.push([`systemd user ${shell} can find optional ripgrep (rg)`, systemdUserServiceShellCommand(commandCheck("rg"))]); + return checks; +} + +function printOptionalDoctorChecks(): void { + let missingOptionalTool = false; + for (const [label, command] of optionalDoctorChecks()) { + const [bin, ...args] = command; + if (bin === undefined) continue; + const result = capture(bin, args); + const ok = result.status === 0; + missingOptionalTool ||= !ok; + console.log(`${ok ? "✓" : "!"} ${label}`); + printCheckOutput(result.stdout || result.stderr); + } + if (missingOptionalTool) { + console.log(" Install ripgrep, or make rg visible to the service shell, for faster all-file @ suggestions."); + console.log(" PI WEB falls back to a bounded filesystem scan when rg is unavailable."); + } +} + function printPathSetupAdvice(): void { const shell = detectServiceShell(); console.log("\nPATH setup advice:"); @@ -969,6 +997,7 @@ async function doctor(): Promise { await printPiWebVersionReport(); console.log("\nDoctor checks:"); const ok = runChecks(doctorChecks()); + printOptionalDoctorChecks(); const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck(); if (supportsSystemdUserServices()) { diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 0026218..0c0f9b6 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -133,8 +133,20 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +export interface FileSuggestionQueryOptions { + kind?: FileSuggestion["kind"] | undefined; + mode?: "file" | "path" | undefined; + scope?: "tracked" | "all" | undefined; +} + export const filesApi = { - files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/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(`/api/files?${params.toString()}`, arrayOf(parseFileSuggestion)); + }, }; export const gitApi = { diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 78f2581..947f56f 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -110,7 +110,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) => { @@ -182,12 +182,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).catch(emptyFileSuggestions); + const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope }).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, @@ -200,7 +200,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); @@ -209,18 +209,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; } @@ -286,8 +288,8 @@ export class PromptEditor extends LitElement { static override styles = promptEditorStyles; } -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}"`; } diff --git a/src/client/src/inputModes.test.ts b/src/client/src/inputModes.test.ts index 717da29..228daa4 100644 --- a/src/client/src/inputModes.test.ts +++ b/src/client/src/inputModes.test.ts @@ -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" }); }); }); diff --git a/src/client/src/inputModes.ts b/src/client/src/inputModes.ts index 680683b..38c0453 100644 --- a/src/client/src/inputModes.ts +++ b/src/client/src/inputModes.ts @@ -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("!@"); } diff --git a/src/server/app.ts b/src/server/app.ts index 88ce5a9..99ee439 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -84,11 +84,11 @@ export async function buildApp(deps: AppDependencies = {}): Promise("/api/files", async (request, reply) => { + app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>("/api/files", async (request, reply) => { if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); try { if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? ""); - return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind); + return await listFileSuggestions(request.query.cwd, request.query.q ?? "", { kind: request.query.kind, scope: request.query.scope }); } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); } diff --git a/src/server/workspaces/fileSuggestions.test.ts b/src/server/workspaces/fileSuggestions.test.ts new file mode 100644 index 0000000..be02b20 --- /dev/null +++ b/src/server/workspaces/fileSuggestions.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { listFileSuggestions, type FileSuggestionDependencies } from "./fileSuggestions"; + +const temporaryRoots: string[] = []; + +async function tempWorkspace(): Promise { + const root = await mkdtemp(join(tmpdir(), "pi-web-files-")); + temporaryRoots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("file suggestions", () => { + it("uses tracked git files for tracked-scope suggestions", async () => { + const calls: { file: string; args: string[] }[] = []; + const deps: FileSuggestionDependencies = { + execFile: (file, args) => { + calls.push({ file, args }); + if (file === "git" && args.join(" ") === "ls-files") return Promise.resolve({ stdout: "src/app.ts\nREADME.md\n" }); + return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`)); + }, + }; + + await expect(listFileSuggestions("/repo", "", { scope: "tracked" }, deps)).resolves.toEqual([ + { path: "src/", kind: "tracked" }, + { path: "README.md", kind: "tracked" }, + { path: "src/app.ts", kind: "tracked" }, + ]); + expect(calls).toEqual([{ file: "git", args: ["ls-files"] }]); + }); + + it("asks ripgrep for hidden and ignored files in all-file scope", async () => { + const calls: { file: string; args: string[] }[] = []; + const deps: FileSuggestionDependencies = { + execFile: (file, args) => { + calls.push({ file, args }); + return Promise.resolve({ stdout: "node_modules/pkg/index.js\nsrc/app.ts\n" }); + }, + }; + + await expect(listFileSuggestions("/repo", "pkg", { scope: "all" }, deps)).resolves.toEqual([ + { path: "node_modules/pkg/", kind: "other" }, + { path: "node_modules/pkg/index.js", kind: "other" }, + ]); + expect(calls).toEqual([{ file: "rg", args: ["--files", "--hidden", "--no-ignore"] }]); + }); + + it("falls back to a bounded filesystem scan without directory exclusions when git and rg are unavailable", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "src"), { recursive: true }); + await mkdir(join(root, "node_modules", "pkg"), { recursive: true }); + await writeFile(join(root, "README.md"), "hello"); + await writeFile(join(root, "src", "app.ts"), "export {};\n"); + await writeFile(join(root, "node_modules", "pkg", "index.js"), "module.exports = {};\n"); + + const deps: FileSuggestionDependencies = { + execFile: (file) => Promise.reject(Object.assign(new Error(`spawn ${file} ENOENT`), { code: "ENOENT" })), + }; + + await expect(listFileSuggestions(root, "", { scope: "all" }, deps)).resolves.toEqual([ + { path: "node_modules/", kind: "other" }, + { path: "node_modules/pkg/", kind: "other" }, + { path: "src/", kind: "other" }, + { path: "node_modules/pkg/index.js", kind: "other" }, + { path: "README.md", kind: "other" }, + { path: "src/app.ts", kind: "other" }, + ]); + }); +}); diff --git a/src/server/workspaces/fileSuggestions.ts b/src/server/workspaces/fileSuggestions.ts index 6147fa8..2f544b3 100644 --- a/src/server/workspaces/fileSuggestions.ts +++ b/src/server/workspaces/fileSuggestions.ts @@ -5,13 +5,32 @@ import { promisify } from "node:util"; import type { ClientFileSuggestion } from "../types.js"; const execFileAsync = promisify(execFile); +const commandMaxBuffer = 1024 * 1024 * 8; +const maxFilesystemFallbackPaths = 20_000; -export async function listFileSuggestions(cwd: string, query = "", kind?: ClientFileSuggestion["kind"]): Promise { - const normalizedQuery = query.replace(/^@/, "").toLowerCase(); - const files = await listGitFiles(cwd).catch(() => listPlainFiles(cwd)); +interface ExecFileOptions { + cwd: string; + maxBuffer: number; +} + +export type FileSuggestionScope = "tracked" | "all"; + +export interface FileSuggestionOptions { + kind?: ClientFileSuggestion["kind"] | undefined; + scope?: FileSuggestionScope | undefined; +} + +export interface FileSuggestionDependencies { + execFile?: (file: string, args: string[], options: ExecFileOptions) => Promise<{ stdout: string }>; +} + +export async function listFileSuggestions(cwd: string, query = "", options: FileSuggestionOptions = {}, deps: FileSuggestionDependencies = {}): Promise { + const normalizedQuery = normalizeFileQuery(query); + const exec = deps.execFile ?? execFileAsync; + const files = await listFilesForScope(cwd, options.scope, exec); return files - .filter((file) => !kind || file.kind === kind) - .filter((file) => !normalizedQuery || file.path.toLowerCase().includes(normalizedQuery)) + .filter((file) => options.kind === undefined || file.kind === options.kind) + .filter((file) => normalizedQuery === "" || file.path.toLowerCase().includes(normalizedQuery)) .sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path)) .slice(0, 80); } @@ -39,10 +58,20 @@ export async function listPathSuggestions(cwd: string, prefix = ""): Promise { +async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: NonNullable): Promise { + if (scope === "all") return listPlainFiles(cwd, exec, true); + if (scope === "tracked") return listTrackedFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, true)); + return listGitFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, false)); +} + +async function listTrackedFiles(cwd: string, exec: NonNullable): Promise { + return withDirectories(lines(await git(cwd, ["ls-files"], exec)), "tracked"); +} + +async function listGitFiles(cwd: string, exec: NonNullable): Promise { const [tracked, untracked] = await Promise.all([ - git(cwd, ["ls-files"]), - git(cwd, ["ls-files", "--others", "--exclude-standard"]), + git(cwd, ["ls-files"], exec), + git(cwd, ["ls-files", "--others", "--exclude-standard"], exec), ]); return [ ...withDirectories(lines(tracked), "tracked"), @@ -50,16 +79,63 @@ async function listGitFiles(cwd: string): Promise { ]; } -async function listPlainFiles(cwd: string): Promise { - const { stdout } = await execFileAsync("rg", ["--files"], { cwd, maxBuffer: 1024 * 1024 * 8 }); - return withDirectories(lines(stdout), "other"); +async function listPlainFiles(cwd: string, exec: NonNullable, includeIgnored: boolean): Promise { + try { + const args = includeIgnored ? ["--files", "--hidden", "--no-ignore"] : ["--files"]; + const { stdout } = await exec("rg", args, { cwd, maxBuffer: commandMaxBuffer }); + return withDirectories(lines(stdout), "other"); + } catch { + return withDirectories(await filesystemFiles(cwd), "other"); + } } -async function git(cwd: string, args: string[]): Promise { - const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: 1024 * 1024 * 8 }); +async function filesystemFiles(cwd: string): Promise { + const paths: string[] = []; + await collectFilesystemFiles(cwd, "", paths, false); + return paths; +} + +async function collectFilesystemFiles(cwd: string, relativeDirectory: string, paths: string[], optionalDirectory: boolean): Promise { + if (paths.length >= maxFilesystemFallbackPaths) return; + const absoluteDirectory = relativeDirectory === "" ? cwd : join(cwd, relativeDirectory); + let entries; + try { + entries = await readdir(absoluteDirectory, { withFileTypes: true }); + } catch (error) { + if (optionalDirectory) return; + throw error; + } + + entries.sort((a, b) => Number(!a.isDirectory()) - Number(!b.isDirectory()) || a.name.localeCompare(b.name)); + for (const entry of entries) { + if (paths.length >= maxFilesystemFallbackPaths) return; + const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`; + if (entry.isDirectory()) { + await collectFilesystemFiles(cwd, relativePath, paths, true); + continue; + } + if (entry.isFile() || await isSymlinkedFile(cwd, relativePath, entry.isSymbolicLink())) paths.push(relativePath); + } +} + +async function isSymlinkedFile(cwd: string, relativePath: string, symbolicLink: boolean): Promise { + if (!symbolicLink) return false; + try { + return (await stat(join(cwd, relativePath))).isFile(); + } catch { + return false; + } +} + +async function git(cwd: string, args: string[], exec: NonNullable): Promise { + const { stdout } = await exec("git", args, { cwd, maxBuffer: commandMaxBuffer }); return stdout; } +function normalizeFileQuery(query: string): string { + return query.replace(/^!@/, "").replace(/^@\s?/, "").toLowerCase(); +} + function lines(text: string): string[] { return text.split("\n").map((line) => line.trim()).filter(Boolean); } diff --git a/src/server/workspaces/fileTreeService.test.ts b/src/server/workspaces/fileTreeService.test.ts index f5d9f18..b0b5710 100644 --- a/src/server/workspaces/fileTreeService.test.ts +++ b/src/server/workspaces/fileTreeService.test.ts @@ -17,7 +17,7 @@ afterEach(async () => { }); describe("listWorkspaceTree", () => { - it("lists visible entries with directories first, sorted by name", async () => { + it("lists entries with directories first, sorted by name", async () => { const root = await tempWorkspace(); await mkdir(join(root, "z-dir")); await mkdir(join(root, "a-dir")); @@ -32,7 +32,9 @@ describe("listWorkspaceTree", () => { expect(tree.path).toBe(""); expect(tree.truncated).toBe(false); expect(tree.entries.map((entry) => [entry.name, entry.type])).toEqual([ + [".git", "directory"], ["a-dir", "directory"], + ["node_modules", "directory"], ["z-dir", "directory"], ["a.txt", "file"], ["b.txt", "file"], diff --git a/src/server/workspaces/fileTreeService.ts b/src/server/workspaces/fileTreeService.ts index 6e62bde..fcd7ff2 100644 --- a/src/server/workspaces/fileTreeService.ts +++ b/src/server/workspaces/fileTreeService.ts @@ -11,11 +11,11 @@ export async function listWorkspaceTree(rootPath: string, path: string | undefin if (!stat.isDirectory()) throw new Error("Path is not a directory"); const dirents = await readdir(target, { withFileTypes: true }); - const visible = dirents.filter((entry) => entry.name !== ".git" && entry.name !== "node_modules").sort((a, b) => { + const sorted = dirents.sort((a, b) => { if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1; return a.name.localeCompare(b.name); }); - const selected = visible.slice(0, MAX_ENTRIES); + const selected = sorted.slice(0, MAX_ENTRIES); const entries = await Promise.all(selected.map(async (entry): Promise => { const absolute = join(target, entry.name); const childRelative = relativePath === "" ? entry.name : `${relativePath}/${entry.name}`; @@ -24,5 +24,5 @@ export async function listWorkspaceTree(rootPath: string, path: string | undefin return { name: entry.name, path: childRelative, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() }; })); - return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: visible.length > selected.length }; + return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: sorted.length > selected.length }; } From a038da669c0ec855502631f0c1e4aa08d76c4310 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 31 May 2026 20:13:28 +0200 Subject: [PATCH 23/27] fix: avoid extra mobile browser bottom inset --- .changeset/fix-mobile-browser-height.md | 5 +++++ src/client/src/components/PiWebApp.ts | 4 ++++ src/client/src/components/shared.ts | 7 ++++++- 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-mobile-browser-height.md diff --git a/.changeset/fix-mobile-browser-height.md b/.changeset/fix-mobile-browser-height.md new file mode 100644 index 0000000..455ad9a --- /dev/null +++ b/.changeset/fix-mobile-browser-height.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Fix mobile browser layout so the app no longer leaves an extra bottom gap above browser controls while preserving standalone PWA safe-area spacing. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index f456464..2eb64d0 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -151,6 +151,10 @@ export class PiWebApp extends LitElement { } }; + protected override willUpdate(): void { + this.toggleAttribute("pwa-display-mode", this.appShell.isPwaDisplayMode); + } + override connectedCallback(): void { super.connectedCallback(); window.addEventListener("popstate", this.onPopState); diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index b3fc6cb..01e86d7 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -50,7 +50,12 @@ export interface CompletionItem { } export const appStyles = css` - :host { position: fixed; top: 0; right: 0; left: 0; display: block; height: 100dvh; box-sizing: border-box; overflow: hidden; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); color: var(--pi-text); background: var(--pi-bg); font: 14px system-ui, sans-serif; } + /* 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; } From 9c80eb0a929f0eb980bec75afa97e90c536e6c2e Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 29 May 2026 23:24:19 +0200 Subject: [PATCH 24/27] fix: improve PI WEB updates panel commands --- .changeset/local-update-commands.md | 5 + .changeset/rename-updates-tab.md | 5 + pi-web-plugins/pi-web/pi-web-plugin.ts | 36 +++-- src/client/src/api/parsers.ts | 8 +- src/server/piWebStatus.test.ts | 129 ++++++++++++----- src/server/piWebStatus.ts | 192 ++++++++++++++++++++++--- src/shared/apiTypes.ts | 9 +- 7 files changed, 314 insertions(+), 70 deletions(-) create mode 100644 .changeset/local-update-commands.md create mode 100644 .changeset/rename-updates-tab.md diff --git a/.changeset/local-update-commands.md b/.changeset/local-update-commands.md new file mode 100644 index 0000000..9d0c459 --- /dev/null +++ b/.changeset/local-update-commands.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Avoid suggesting unavailable `pi-web` restart commands for local checkout installs, and show native service commands only when PI WEB can detect matching service files. diff --git a/.changeset/rename-updates-tab.md b/.changeset/rename-updates-tab.md new file mode 100644 index 0000000..d63371e --- /dev/null +++ b/.changeset/rename-updates-tab.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Rename the PI WEB status workspace tab to Updates so version and restart guidance is easier to find. diff --git a/pi-web-plugins/pi-web/pi-web-plugin.ts b/pi-web-plugins/pi-web/pi-web-plugin.ts index fcfff83..bf78596 100644 --- a/pi-web-plugins/pi-web/pi-web-plugin.ts +++ b/pi-web-plugins/pi-web/pi-web-plugin.ts @@ -69,12 +69,30 @@ function renderCommand(html: HtmlTemplateTag, label: string, command: string): T `; } +function renderCommands(html: HtmlTemplateTag, status: PiWebStatusResponse): TemplateResult | undefined { + const commands = [ + ["Update", status.commands.update], + ["Restart all", status.commands.restart], + ["Restart Web/UI", status.commands.restartWeb], + ["Restart session daemon", status.commands.restartSessiond], + ["Status", status.commands.status], + ].filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] !== ""); + + if (commands.length === 0) return undefined; + return html` +

+ Suggested commands + ${commands.map(([label, command]) => renderCommand(html, label, command))} +
+ `; +} + function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResult { const status = statusFor(state); if (status === undefined) { return html` -
PI WEB
-

Checking PI WEB status…

+
Updates
+

Checking PI WEB update status…

`; } @@ -98,7 +116,7 @@ function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResu .pi-web-command > span { grid-column: 1 / -1; } } -
PI WEBbeta${messages.length > 0 ? html`${String(messages.length)}` : null}
+
Updatesbeta${messages.length > 0 ? html`${String(messages.length)}` : null}
${messages.length === 0 ? html`

No PI WEB update or restart messages.

` : messages.map((message) => html` @@ -116,13 +134,7 @@ function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResu ${renderComponent(html, status.components.sessiond)}
-
- Commands - ${renderCommand(html, "Update", status.commands.update)} - ${renderCommand(html, "Restart", status.commands.restart)} - ${renderCommand(html, "systemd", status.commands.restartSystemd)} - ${renderCommand(html, "dev", status.commands.restartDev)} -
+ ${renderCommands(html, status)}
Generated ${status.generatedAt} @@ -136,13 +148,13 @@ function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResu const plugin: PiWebPlugin = { apiVersion: 1, - name: "PI WEB Status", + name: "PI WEB Updates", activate: ({ html }) => ({ contributions: { workspacePanels: [ { id: "workspace.status", - title: "PI WEB", + title: "Updates", order: 100, visible: (context) => shouldShowStatusPanel(context.state), badge: (context) => { diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 28538be..5b41b6d 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -418,7 +418,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 { diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index d71193e..b31c5da 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -1,12 +1,17 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { comparePackageVersions, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; +import type { PiWebComponentStatus } from "../shared/apiTypes.js"; const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"]; +const originalHome = process.env["HOME"]; afterEach(() => { - if (originalSkipVersionCheck === undefined) delete process.env["PI_WEB_SKIP_VERSION_CHECK"]; - else process.env["PI_WEB_SKIP_VERSION_CHECK"] = originalSkipVersionCheck; + restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck); + restoreEnv("HOME", originalHome); vi.restoreAllMocks(); }); @@ -18,20 +23,13 @@ describe("PI WEB status", () => { }); it("returns installed and running version components without release metadata", async () => { - const daemon = new SessionDaemonClient(); - vi.spyOn(daemon, "request").mockResolvedValue({ - statusCode: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - version: { - component: "sessiond", - label: "Session daemon", - runtimeVersion: "1.202605.7", - installedVersion: "1.202605.8", - stale: true, - available: true, - }, - }), + const daemon = daemonWithComponent({ + component: "sessiond", + label: "Session daemon", + runtimeVersion: "1.202605.7", + installedVersion: "1.202605.8", + stale: true, + available: true, }); const status = await getPiWebVersionStatus(daemon); @@ -44,21 +42,14 @@ describe("PI WEB status", () => { it("reports stale session daemon versions as messages", async () => { process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1"; - const daemon = new SessionDaemonClient(); - vi.spyOn(daemon, "request").mockResolvedValue({ - statusCode: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - version: { - component: "sessiond", - label: "Session daemon", - runtimeVersion: "1.202605.7", - installedVersion: "1.202605.8", - stale: true, - available: true, - installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, - }, - }), + const daemon = daemonWithComponent({ + component: "sessiond", + label: "Session daemon", + runtimeVersion: "1.202605.7", + installedVersion: "1.202605.8", + stale: true, + available: true, + installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, }); const status = await getPiWebStatus(daemon); @@ -66,7 +57,81 @@ describe("PI WEB status", () => { expect(status.release.skipped).toBe(true); expect(status.components.sessiond.stale).toBe(true); expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user" }); - expect(status.commands.update).not.toBe(""); expect(status.messages.map((message) => message.id)).toContain("sessiond-stale"); }); + + it("suggests native systemd commands for local development services", async () => { + if (process.platform !== "linux") return; + process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1"; + const home = await tempHome(); + try { + process.env["HOME"] = home; + await installSystemdServiceFiles(home, ["pi-web-sessiond.service", "pi-web-ui-dev.service"]); + const daemon = daemonWithComponent(staleLocalSessiond()); + + const status = await getPiWebStatus(daemon); + + expect(status.commands.restart).toBe("systemctl --user restart pi-web-sessiond.service pi-web-ui-dev.service"); + expect(status.commands.restartWeb).toBe("systemctl --user restart pi-web-ui-dev.service"); + expect(status.commands.restartSessiond).toBe("systemctl --user restart pi-web-sessiond.service"); + expect(status.messages.find((message) => message.id === "sessiond-stale")?.command).toBe("systemctl --user restart pi-web-sessiond.service"); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it("omits local restart commands when no native service command is known", async () => { + process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1"; + const home = await tempHome(); + try { + process.env["HOME"] = home; + const daemon = daemonWithComponent(staleLocalSessiond()); + + const status = await getPiWebStatus(daemon); + const staleMessage = status.messages.find((message) => message.id === "sessiond-stale"); + + expect(status.commands.restart).toBeUndefined(); + expect(staleMessage?.command).toBeUndefined(); + expect(JSON.stringify(status)).not.toContain("pi-web restart"); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); }); + +function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClient { + const daemon = new SessionDaemonClient(); + vi.spyOn(daemon, "request").mockResolvedValue({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ version: component }), + }); + return daemon; +} + +function staleLocalSessiond(): PiWebComponentStatus { + return { + component: "sessiond", + label: "Session daemon", + runtimeVersion: "1.202605.7", + installedVersion: "1.202605.8", + stale: true, + available: true, + installation: { kind: "local", path: "/srv/dev/pi-web" }, + }; +} + +async function tempHome(): Promise { + return await mkdtemp(join(tmpdir(), "pi-web-status-")); +} + +async function installSystemdServiceFiles(home: string, names: string[]): Promise { + const dir = join(home, ".config", "systemd", "user"); + await mkdir(dir, { recursive: true }); + await Promise.all(names.map((name) => writeFile(join(dir, name), ""))); +} + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) Reflect.deleteProperty(process.env, key); + else process.env[key] = value; +} diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts index ba93d12..b44ee6e 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { readFile, realpath, stat } from "node:fs/promises"; +import { homedir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent"; @@ -14,12 +15,46 @@ const DEFAULT_VERSION = "0.0.0-dev"; const LATEST_RELEASE_CACHE_MS = 6 * 60 * 60 * 1000; const VERSION_CHECK_TIMEOUT_MS = 5000; -const restartCommands = { - restart: "pi-web restart", - restartSystemd: "pi-web restart", - restartDev: "pi-web restart", +type ServiceId = "sessiond" | "web" | "uiDev"; +type NativeServiceBackendKind = "systemd" | "launchd"; + +interface NativeServiceRef { + id: ServiceId; + systemdName: string; + launchdLabel: string; + launchdPlistName: string; +} + +interface NativeServiceCommands { + restart?: string; + restartWeb?: string; + restartSessiond?: string; + status?: string; +} + +const serviceRefs: Record = { + sessiond: { + id: "sessiond", + systemdName: "pi-web-sessiond.service", + launchdLabel: "com.pi-web.sessiond", + launchdPlistName: "com.pi-web.sessiond.plist", + }, + web: { + id: "web", + systemdName: "pi-web.service", + launchdLabel: "com.pi-web.web", + launchdPlistName: "com.pi-web.web.plist", + }, + uiDev: { + id: "uiDev", + systemdName: "pi-web-ui-dev.service", + launchdLabel: "com.pi-web.ui-dev", + launchdPlistName: "com.pi-web.ui-dev.plist", + }, }; +const startServiceOrder: ServiceId[] = ["sessiond", "web", "uiDev"]; + interface PackageInfo { name: string; version: string; @@ -65,7 +100,7 @@ export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promis const { web, sessiond } = versionStatus.components; const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION); const components = { web, sessiond }; - const commands = commandsFor(web.installation ?? sessiond.installation); + const commands = commandsFor(components); const messages = buildMessages(components, release, commands); return { ...versionStatus, @@ -247,21 +282,122 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise { return version; } -function commandsFor(installation: PiWebInstallationInfo | undefined): PiWebStatusResponse["commands"] { +function commandsFor(components: PiWebStatusResponse["components"]): PiWebStatusResponse["commands"] { + const installation = preferredInstallation(components); + const serviceCommands = nativeServiceCommands(); + const cliCommands = piWebCliCommands(installation); + const restart = restartCommandFor(installation, serviceCommands, cliCommands); + const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart; + const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart; + const status = serviceCommands.status ?? cliCommands.status; + const update = updateCommandFor(installation, restart); + return { - update: updateCommandFor(installation), - ...restartCommands, + ...(update === undefined ? {} : { update }), + ...(restart === undefined ? {} : { restart }), + ...(restartWeb === undefined ? {} : { restartWeb }), + ...(restartSessiond === undefined ? {} : { restartSessiond }), + ...(status === undefined ? {} : { status }), }; } -function updateCommandFor(installation: PiWebInstallationInfo | undefined): string { +function preferredInstallation(components: PiWebStatusResponse["components"]): PiWebInstallationInfo | undefined { + const web = components.web.installation; + const sessiond = components.sessiond.installation; + if (web?.kind === "local" || sessiond?.kind === "local") return web?.kind === "local" ? web : sessiond; + return web ?? sessiond; +} + +function piWebCliCommands(installation: PiWebInstallationInfo | undefined): NativeServiceCommands { + if (installation?.kind !== "npm-global" || !hasCommand("pi-web")) return {}; + return { restart: "pi-web restart", status: "pi-web status" }; +} + +function restartCommandFor(installation: PiWebInstallationInfo | undefined, serviceCommands: NativeServiceCommands, cliCommands: NativeServiceCommands): string | undefined { + if (installation?.kind === "local" || installation?.kind === "pi-package") return serviceCommands.restart ?? cliCommands.restart; + return cliCommands.restart ?? serviceCommands.restart; +} + +function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): string | undefined { + if (restartCommand === undefined) return undefined; if (installation?.kind === "pi-package") { - return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommands.restart}`; + if (!hasCommand("pi")) return undefined; + return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommand}`; } if (installation?.kind === "local" && installation.path !== undefined) { - return `cd ${shellQuote(installation.path)} && git pull && npm install && npm run build && ${restartCommands.restart}`; + if (!hasCommand("npm") || !isGitCheckoutWithUpstream(installation.path)) return undefined; + return `cd ${shellQuote(installation.path)} && git pull --ff-only && npm install && npm run build && ${restartCommand}`; } - return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommands.restart}`; + if (installation?.kind !== "npm-global" || !hasCommand("npm")) return undefined; + return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommand}`; +} + +function nativeServiceCommands(): NativeServiceCommands { + const backend = nativeServiceBackend(); + if (backend === undefined) return {}; + const installed = installedServiceIds(backend); + if (installed.size === 0) return {}; + const web = installedServiceRefs(installed, ["web", "uiDev"]); + const sessiond = installedServiceRefs(installed, ["sessiond"]); + const restartable = web.length === 0 ? [] : installedServiceRefs(installed); + const status = installedServiceRefs(installed); + return { + ...(restartable.length === 0 ? {} : { restart: restartNativeServicesCommand(backend, restartable) }), + ...(web.length === 0 ? {} : { restartWeb: restartNativeServicesCommand(backend, web) }), + ...(sessiond.length === 0 ? {} : { restartSessiond: restartNativeServicesCommand(backend, sessiond) }), + ...(status.length === 0 ? {} : { status: statusNativeServicesCommand(backend, status) }), + }; +} + +function nativeServiceBackend(): NativeServiceBackendKind | undefined { + if (process.platform === "linux" && hasCommand("systemctl")) return "systemd"; + if (process.platform === "darwin" && hasCommand("launchctl")) return "launchd"; + return undefined; +} + +function installedServiceIds(backend: NativeServiceBackendKind): Set { + return new Set(startServiceOrder.filter((id) => existsSync(serviceFilePath(backend, serviceRefs[id])))); +} + +function installedServiceRefs(installed: Set, candidates: ServiceId[] = startServiceOrder): NativeServiceRef[] { + return startServiceOrder.filter((id) => candidates.includes(id) && installed.has(id)).map((id) => serviceRefs[id]); +} + +function serviceFilePath(backend: NativeServiceBackendKind, ref: NativeServiceRef): string { + return backend === "systemd" ? join(systemdServiceDir(), ref.systemdName) : join(launchdServiceDir(), ref.launchdPlistName); +} + +function systemdServiceDir(): string { + return join(homedir(), ".config", "systemd", "user"); +} + +function launchdServiceDir(): string { + return join(homedir(), "Library", "LaunchAgents"); +} + +function restartNativeServicesCommand(backend: NativeServiceBackendKind, refs: NativeServiceRef[]): string { + if (backend === "systemd") return `systemctl --user restart ${refs.map((ref) => ref.systemdName).join(" ")}`; + return refs.map((ref) => `launchctl kickstart -k gui/$(id -u)/${ref.launchdLabel}`).join(" && "); +} + +function statusNativeServicesCommand(backend: NativeServiceBackendKind, refs: NativeServiceRef[]): string { + if (backend === "systemd") return `systemctl --user status ${refs.map((ref) => ref.systemdName).join(" ")}`; + return refs.map((ref) => `launchctl print gui/$(id -u)/${ref.launchdLabel}`).join(" && "); +} + +function isGitCheckoutWithUpstream(path: string): boolean { + return hasCommand("git") + && commandSucceeds("git", ["-C", path, "rev-parse", "--is-inside-work-tree"]) + && commandSucceeds("git", ["-C", path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]); +} + +function hasCommand(command: string): boolean { + return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${command}`]); +} + +function commandSucceeds(command: string, args: string[]): boolean { + const result = spawnSync(command, args, { encoding: "utf8" }); + return result.status === 0; } function shellQuote(value: string): string { @@ -277,18 +413,23 @@ function buildMessages(components: PiWebStatusResponse["components"], release: P id: "update-available", severity: "info", title: "PI WEB update available", - body: `PI WEB ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Update PI WEB, then restart PI WEB services.`, - command: commands.update, + body: commands.update === undefined + ? `PI WEB ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Update PI WEB, then restart the services or processes for this installation.` + : `PI WEB ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Run the update command to update PI WEB and restart its services.`, + ...optionalMessageCommand(commands.update), }); } if (components.web.stale) { + const command = commands.restartWeb ?? commands.restart; messages.push({ id: "web-stale", severity: "warning", title: "Web/UI service restart needed", - body: `The Web/UI service is running ${formatVersion(components.web.runtimeVersion)}, but ${formatVersion(components.web.installedVersion)} is installed. Restart the service to use the installed version.`, - command: commands.restart, + body: command === undefined + ? `The Web/UI service is running ${formatVersion(components.web.runtimeVersion)}, but ${formatVersion(components.web.installedVersion)} is installed. Restart the Web/UI service or process to use the installed version.` + : `The Web/UI service is running ${formatVersion(components.web.runtimeVersion)}, but ${formatVersion(components.web.installedVersion)} is installed. Restart the service to use the installed version.`, + ...optionalMessageCommand(command), }); } @@ -297,22 +438,31 @@ function buildMessages(components: PiWebStatusResponse["components"], release: P id: "sessiond-unavailable", severity: "warning", title: "Session daemon version unavailable", - body: `PI WEB could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}`, - command: "pi-web status", + body: commands.status === undefined + ? `PI WEB could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}. Check the session daemon service or process that runs this installation.` + : `PI WEB could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}`, + ...optionalMessageCommand(commands.status), }); } else if (components.sessiond.stale) { + const command = commands.restartSessiond ?? commands.restart; messages.push({ id: "sessiond-stale", severity: "warning", title: "Session daemon restart needed", - body: `The session daemon is running ${formatVersion(components.sessiond.runtimeVersion)}, but ${formatVersion(components.sessiond.installedVersion)} is installed. Restart the daemon to use the installed version.`, - command: commands.restart, + body: command === undefined + ? `The session daemon is running ${formatVersion(components.sessiond.runtimeVersion)}, but ${formatVersion(components.sessiond.installedVersion)} is installed. Restart the session daemon service or process to use the installed version.` + : `The session daemon is running ${formatVersion(components.sessiond.runtimeVersion)}, but ${formatVersion(components.sessiond.installedVersion)} is installed. Restart the daemon to use the installed version.`, + ...optionalMessageCommand(command), }); } return messages; } +function optionalMessageCommand(command: string | undefined): Pick | object { + return command === undefined ? {} : { command }; +} + function skipVersionCheck(): boolean { return ["PI_WEB_SKIP_VERSION_CHECK", "PI_WEB_OFFLINE", "PI_SKIP_VERSION_CHECK", "PI_OFFLINE"].some((key) => { const value = process.env[key]; diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 14a2c76..f55661c 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -296,10 +296,11 @@ export interface PiWebVersionResponse { export interface PiWebStatusResponse extends PiWebVersionResponse { release: PiWebReleaseStatus; commands: { - update: string; - restart: string; - restartSystemd: string; - restartDev: string; + update?: string; + restart?: string; + restartWeb?: string; + restartSessiond?: string; + status?: string; }; messages: PiWebStatusMessage[]; } From 6c094af917176cd2b11a517379c06ef5e47c12d3 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 1 Jun 2026 18:09:49 +0200 Subject: [PATCH 25/27] fix: contain chat overlay stacking --- .changeset/contain-chat-overlay-z-index.md | 5 +++++ src/client/src/components/shared.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/contain-chat-overlay-z-index.md diff --git a/.changeset/contain-chat-overlay-z-index.md b/.changeset/contain-chat-overlay-z-index.md new file mode 100644 index 0000000..bc2d34b --- /dev/null +++ b/.changeset/contain-chat-overlay-z-index.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep slash command autocomplete visible above the chat status indicator. diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 077dfb9..462c469 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -270,7 +270,7 @@ 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; } @@ -447,7 +447,7 @@ export const actionPaletteStyles = css` `; export const promptEditorStyles = css` - :host { position: relative; z-index: 30; display: block; color: var(--pi-text); font: 14px system-ui, sans-serif; } + :host { position: relative; z-index: 5; display: block; color: var(--pi-text); font: 14px system-ui, sans-serif; } footer { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; padding: 12px; border-top: 1px solid var(--pi-border); } footer.shell-mode { border-top-color: var(--pi-success); background: var(--pi-success-bg); } .editor-wrap { position: relative; min-width: 0; } From 09225a4cea18b7a2a84c5bb51e91b4159ae63c1b Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 1 Jun 2026 21:18:49 +0200 Subject: [PATCH 26/27] chore(release): v1.202606.0 --- .changeset/contain-chat-overlay-z-index.md | 5 ----- .changeset/delete-new-session-action.md | 5 ----- .changeset/file-suggestions-without-rg.md | 5 ----- .changeset/fix-mobile-browser-height.md | 5 ----- .changeset/local-update-commands.md | 5 ----- .changeset/pi-web-version-doctor.md | 5 ----- .changeset/rename-updates-tab.md | 5 ----- CHANGELOG.md | 12 ++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 10 files changed, 15 insertions(+), 38 deletions(-) delete mode 100644 .changeset/contain-chat-overlay-z-index.md delete mode 100644 .changeset/delete-new-session-action.md delete mode 100644 .changeset/file-suggestions-without-rg.md delete mode 100644 .changeset/fix-mobile-browser-height.md delete mode 100644 .changeset/local-update-commands.md delete mode 100644 .changeset/pi-web-version-doctor.md delete mode 100644 .changeset/rename-updates-tab.md diff --git a/.changeset/contain-chat-overlay-z-index.md b/.changeset/contain-chat-overlay-z-index.md deleted file mode 100644 index bc2d34b..0000000 --- a/.changeset/contain-chat-overlay-z-index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Keep slash command autocomplete visible above the chat status indicator. diff --git a/.changeset/delete-new-session-action.md b/.changeset/delete-new-session-action.md deleted file mode 100644 index 9481d7e..0000000 --- a/.changeset/delete-new-session-action.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add an action-palette command for deleting browser-cached new sessions, while keeping archive and delete session actions context-specific. diff --git a/.changeset/file-suggestions-without-rg.md b/.changeset/file-suggestions-without-rg.md deleted file mode 100644 index d166d1a..0000000 --- a/.changeset/file-suggestions-without-rg.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Keep chat file mention suggestions working on installations that do not have ripgrep available, add an all-file `@` mention mode, stop hiding directories in the file explorer, and report optional ripgrep availability in `pi-web doctor`. diff --git a/.changeset/fix-mobile-browser-height.md b/.changeset/fix-mobile-browser-height.md deleted file mode 100644 index 455ad9a..0000000 --- a/.changeset/fix-mobile-browser-height.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Fix mobile browser layout so the app no longer leaves an extra bottom gap above browser controls while preserving standalone PWA safe-area spacing. diff --git a/.changeset/local-update-commands.md b/.changeset/local-update-commands.md deleted file mode 100644 index 9d0c459..0000000 --- a/.changeset/local-update-commands.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Avoid suggesting unavailable `pi-web` restart commands for local checkout installs, and show native service commands only when PI WEB can detect matching service files. diff --git a/.changeset/pi-web-version-doctor.md b/.changeset/pi-web-version-doctor.md deleted file mode 100644 index c08e542..0000000 --- a/.changeset/pi-web-version-doctor.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add `pi-web version` and include installed and running PI WEB version details in doctor output. diff --git a/.changeset/rename-updates-tab.md b/.changeset/rename-updates-tab.md deleted file mode 100644 index d63371e..0000000 --- a/.changeset/rename-updates-tab.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Rename the PI WEB status workspace tab to Updates so version and restart guidance is easier to find. diff --git a/CHANGELOG.md b/CHANGELOG.md index d459c89..8de60eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # @jmfederico/pi-web +## 1.202606.0 + +### Patch Changes + +- 6c094af: Keep slash command autocomplete visible above the chat status indicator. +- bad3a18: Add an action-palette command for deleting browser-cached new sessions, while keeping archive and delete session actions context-specific. +- fdd2cf2: Keep chat file mention suggestions working on installations that do not have ripgrep available, add an all-file `@` mention mode, stop hiding directories in the file explorer, and report optional ripgrep availability in `pi-web doctor`. +- a038da6: Fix mobile browser layout so the app no longer leaves an extra bottom gap above browser controls while preserving standalone PWA safe-area spacing. +- 9c80eb0: Avoid suggesting unavailable `pi-web` restart commands for local checkout installs, and show native service commands only when PI WEB can detect matching service files. +- 5090661: Add `pi-web version` and include installed and running PI WEB version details in doctor output. +- 9c80eb0: Rename the PI WEB status workspace tab to Updates so version and restart guidance is easier to find. + ## 1.202605.14 ### Patch Changes diff --git a/package-lock.json b/package-lock.json index dad789f..d24c484 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jmfederico/pi-web", - "version": "1.202605.14", + "version": "1.202606.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jmfederico/pi-web", - "version": "1.202605.14", + "version": "1.202606.0", "license": "MIT", "workspaces": [ ".", diff --git a/package.json b/package.json index 51c39fb..e81618c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jmfederico/pi-web", - "version": "1.202605.14", + "version": "1.202606.0", "description": "Remote web UI and browser control plane for persistent Pi Coding Agent sessions.", "license": "MIT", "author": "Federico Jaramillo Martinez", From 8cd2bbaf1d42129aced96e92ced93f7c0f46f473 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 1 Jun 2026 22:07:03 +0200 Subject: [PATCH 27/27] fix: show pwa refresh menu options --- .changeset/pwa-refresh-menu.md | 5 ++++ src/client/src/components/actionMenu.test.ts | 27 +++++++++++++++++++ src/client/src/components/actionMenu.ts | 12 +++++++-- .../components/appShell/AppRefreshControl.ts | 2 +- 4 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 .changeset/pwa-refresh-menu.md create mode 100644 src/client/src/components/actionMenu.test.ts diff --git a/.changeset/pwa-refresh-menu.md b/.changeset/pwa-refresh-menu.md new file mode 100644 index 0000000..8308575 --- /dev/null +++ b/.changeset/pwa-refresh-menu.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Fix the PWA refresh control menu so its reload options are visible when opened from the compact header button. diff --git a/src/client/src/components/actionMenu.test.ts b/src/client/src/components/actionMenu.test.ts new file mode 100644 index 0000000..ced599e --- /dev/null +++ b/src/client/src/components/actionMenu.test.ts @@ -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; + } +} diff --git a/src/client/src/components/actionMenu.ts b/src/client/src/components/actionMenu.ts index dac8a8c..5d46c2d 100644 --- a/src/client/src/components/actionMenu.ts +++ b/src/client/src/components/actionMenu.ts @@ -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 }; } diff --git a/src/client/src/components/appShell/AppRefreshControl.ts b/src/client/src/components/appShell/AppRefreshControl.ts index 8bda17a..624abbd 100644 --- a/src/client/src/components/appShell/AppRefreshControl.ts +++ b/src/client/src/components/appShell/AppRefreshControl.ts @@ -112,7 +112,7 @@ export class AppRefreshControl extends LitElement { }; private openMenu(target: EventTarget | null): void { - this.menuStyle = actionMenuPanelStyle(target); + this.menuStyle = actionMenuPanelStyle(target, { constrainTo: "viewport" }); this.menuOpen = true; }