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/.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-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/.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/CHANGELOG.md b/CHANGELOG.md index eee7e72..8de60eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # @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 + +- 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/README.md b/README.md index 0ce027f..2507927 100644 --- a/README.md +++ b/README.md @@ -161,9 +161,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 @@ -184,6 +187,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..240b8e9 100644 --- a/docs/faq.html +++ b/docs/faq.html @@ -121,7 +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 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/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/package-lock.json b/package-lock.json index 26913df..d24c484 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jmfederico/pi-web", - "version": "1.202605.13", + "version": "1.202606.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jmfederico/pi-web", - "version": "1.202605.13", + "version": "1.202606.0", "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..e81618c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jmfederico/pi-web", - "version": "1.202605.13", + "version": "1.202606.0", "description": "Remote web UI and browser control plane for persistent Pi Coding Agent sessions.", "license": "MIT", "author": "Federico Jaramillo Martinez", 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/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": { 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); } diff --git a/src/cli.ts b/src/cli.ts index d10d45d..ca28bfa 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; } @@ -926,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:"); @@ -952,7 +985,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,7 +993,11 @@ 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()); + printOptionalDoctorChecks(); const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck(); if (supportsSystemdUserServices()) { @@ -1011,6 +1048,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 +1065,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/client/src/api/clients.ts b/src/client/src/api/clients.ts index 8e2621f..bb8e3f2 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -145,8 +145,21 @@ 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; + machineId?: string | undefined; +} + export const filesApi = { - files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path", machineId = "local") => request(`${machinePrefix(machineId)}/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)), + files: (cwd: string, query: string, options: FileSuggestionQueryOptions = {}) => { + const params = new URLSearchParams({ cwd, q: query }); + if (options.kind !== undefined) params.set("kind", options.kind); + if (options.mode !== undefined) params.set("mode", options.mode); + if (options.scope !== undefined) params.set("scope", options.scope); + return request(`${machinePrefix(options.machineId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion)); + }, }; export const gitApi = { diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 4005365..c5c69fe 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -34,7 +34,7 @@ describe("federated route contract", () => { ignoreParseFailure(workspacesApi.workspaces("p 1", machineId)), ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)), ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)), - ignoreParseFailure(filesApi.files("/repo", "README", "tracked", "file", machineId)), + ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })), ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)), ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)), ignoreParseFailure(sessionsApi.sessions("/repo", machineId)), diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index b6af6e5..0329148 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -469,7 +469,13 @@ function parsePiWebReleaseStatus(value: unknown): PiWebReleaseStatus { function parsePiWebCommands(value: unknown): PiWebStatusResponse["commands"] { const record = requireRecord(value); - return { update: requireString(record, "update"), restart: requireString(record, "restart"), restartSystemd: requireString(record, "restartSystemd"), restartDev: requireString(record, "restartDev") }; + return { + ...optionalField("update", optionalString(record, "update")), + ...optionalField("restart", optionalString(record, "restart")), + ...optionalField("restartWeb", optionalString(record, "restartWeb")), + ...optionalField("restartSessiond", optionalString(record, "restartSessiond")), + ...optionalField("status", optionalString(record, "status")), + }; } function parsePiWebStatusMessage(value: unknown): PiWebStatusMessage { 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..6036a3d --- /dev/null +++ b/src/client/src/appShell/navigationState.ts @@ -0,0 +1,81 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; + +export type NavigationSection = "machines" | "projects" | "workspaces" | "sessions"; +export type ExpandedNavigationSection = NavigationSection | "none" | undefined; + +export interface NavigationSelectionState { + selectedProject: object | undefined; + selectedWorkspace: object | undefined; +} + +export function defaultNavigationSection(state: NavigationSelectionState): NavigationSection { + if (state.selectedProject === undefined) return "projects"; + if (state.selectedWorkspace === undefined) return "workspaces"; + return "sessions"; +} + +export function expandedNavigationSection(expanded: ExpandedNavigationSection, state: NavigationSelectionState): NavigationSection | undefined { + if (expanded === "none") return undefined; + return expanded ?? defaultNavigationSection(state); +} + +export function isNavigationSectionCollapsed(section: NavigationSection, options: { isMobileLayout: boolean; expanded: ExpandedNavigationSection; state: NavigationSelectionState }): boolean { + return options.isMobileLayout && expandedNavigationSection(options.expanded, options.state) !== section; +} + +export function toggleNavigationSection(expanded: ExpandedNavigationSection, section: NavigationSection, options: { isMobileLayout: boolean; state: NavigationSelectionState }): ExpandedNavigationSection { + if (!options.isMobileLayout) return expanded; + return expandedNavigationSection(expanded, options.state) === section ? "none" : section; +} + +export function expandNavigationSection(expanded: ExpandedNavigationSection, section: NavigationSection, isMobileLayout: boolean): ExpandedNavigationSection { + return isMobileLayout ? section : expanded; +} + +export class MobileNavigationController implements ReactiveController { + private expanded: ExpandedNavigationSection; + + hostConnected(): void { + return; + } + + constructor( + private readonly host: ReactiveControllerHost, + private readonly getState: () => NavigationSelectionState, + private readonly isMobileLayout: () => boolean, + ) { + host.addController(this); + } + + expandedSection(): NavigationSection | undefined { + return expandedNavigationSection(this.expanded, this.getState()); + } + + isCollapsed(section: NavigationSection): boolean { + return isNavigationSectionCollapsed(section, { + isMobileLayout: this.isMobileLayout(), + expanded: this.expanded, + state: this.getState(), + }); + } + + toggle(section: NavigationSection): void { + this.setExpanded(toggleNavigationSection(this.expanded, section, { isMobileLayout: this.isMobileLayout(), state: this.getState() })); + } + + expand(section: NavigationSection): void { + this.setExpanded(expandNavigationSection(this.expanded, section, this.isMobileLayout())); + } + + open(section: NavigationSection, openNavigationView: () => void): void { + if (!this.isMobileLayout()) return; + this.expand(section); + openNavigationView(); + } + + private setExpanded(expanded: ExpandedNavigationSection): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + this.host.requestUpdate(); + } +} 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/appState.ts b/src/client/src/appState.ts index b37d702..b753435 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -12,6 +12,7 @@ export interface AppState { sessions: SessionInfo[]; messages: ChatLine[]; messagePageStart: number; + messagePageEnd: number; messagePageTotal: number; isLoadingEarlierMessages: boolean; isReceivingPartialStream: boolean; @@ -104,6 +105,7 @@ export function initialAppState(): AppState { sessions: [], messages: [], messagePageStart: 0, + messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, 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 a0ef0f2..8672df9 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -23,7 +23,9 @@ import { themePackPlugin } from "../plugins/themes"; import { loadExternalPlugins } from "../plugins/external"; import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry"; import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs"; -import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMode"; +import { AppShellController } from "../appShell/appShellController"; +import { MobileNavigationController, type NavigationSection } from "../appShell/navigationState"; +import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController"; import { readRoute, writeRoute, type AppRoute } from "../route"; import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime"; import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion"; @@ -42,10 +44,14 @@ import "./AuthDialog"; import "./ProjectDialog"; import "./WorkspacePanel"; import type { WorkspacePanelEmptyState } from "./WorkspacePanel"; -import { actionMenuPanelStyle } from "./actionMenu"; +import "./appShell/AppContextBar"; +import "./appShell/AppMobileMainTabs"; +import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs"; +import "./appShell/AppNavigationPanel"; +import "./appShell/AppPanelEdgeControl"; +import "./appShell/AppRefreshControl"; import { appStyles } from "./shared"; -type NavigationSection = "machines" | "projects" | "workspaces" | "sessions"; const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000; const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const; @@ -53,16 +59,12 @@ const THEME_AUTO_ON_VALUE = "auto:on"; const THEME_AUTO_OFF_VALUE = "auto:off"; const THEME_OPTION_PREFIX = "theme:"; const TERMINAL_ROUTE_NAMESPACE = queryNamespace("core:workspace.terminal"); -const REFRESH_LONG_PRESS_MS = 550; @customElement("pi-web-app") export class PiWebApp extends LitElement { @state() private state: AppState = initialAppState(); @query("chat-view") private chatView?: ChatView; @query("prompt-editor") private promptEditor?: PromptEditor; - @query(".context-items") private contextItems?: HTMLElement | null; - @query(".mobile-tabs") private mobileTabs?: HTMLElement | null; - @query(".app-refresh") private appRefresh?: HTMLElement | null; private readonly sessions = new SessionController( () => this.state, @@ -109,13 +111,14 @@ export class PiWebApp extends LitElement { private readonly realtime = new RealtimeSocket(); private readonly activeTerminalIds = new Set(); private readonly terminalSelection = new InMemoryTerminalSelectionMemory(); - private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined; + private readonly appShell = new AppShellController(this); + private readonly panelCollapse = new PanelCollapseController(this); + private readonly mobileNavigation = new MobileNavigationController( + this, + () => this.state, + () => this.appShell.isMobileNavigationLayout, + ); private readonly systemLightThemeMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(prefers-color-scheme: light)") : undefined; - private readonly pwaDisplayModeMedia = createPwaDisplayModeMedia(); - private observedContextItems: HTMLElement | undefined; - private observedMobileTabs: HTMLElement | undefined; - private contextItemsResizeObserver: ResizeObserver | undefined; - private mobileTabsResizeObserver: ResizeObserver | undefined; private terminalAutoStartWorkspaceId: string | undefined; private piWebStatusTimer: number | undefined; private workspaceDeletionPollTimer: number | undefined; @@ -126,21 +129,14 @@ export class PiWebApp extends LitElement { private restoringRouteTerminalId: string | undefined; private readonly plugins = createPluginRegistry(); private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE; - private refreshLongPressTimer: number | undefined; - private suppressNextRefreshClick = false; @state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID; - @state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false; - @state() private isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia); @state() private isRefreshingApp = false; - @state() private refreshMenuOpen = false; - @state() private refreshMenuStyle = ""; - @state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined; - @state() private contextCanScrollLeft = false; - @state() private contextCanScrollRight = false; - @state() private mobileTabsCanScrollLeft = false; - @state() private mobileTabsCanScrollRight = false; private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); + private readonly onPageShow = () => { + this.appShell.repairViewportPosition(); + }; private readonly onFocus = () => { + this.appShell.repairViewportPosition(); void this.sessions.refreshSelectedSession(); void this.refreshPiWebStatus(); void this.refreshWorkspaceActivity(); @@ -148,59 +144,35 @@ export class PiWebApp extends LitElement { }; private readonly onVisibilityChange = () => { if (document.visibilityState === "visible") { + this.appShell.repairViewportPosition(); void this.sessions.refreshSelectedSession(); void this.refreshPiWebStatus(); void this.refreshWorkspaceActivity(); void this.refreshWorkspaceDeletionRuns(); } }; - private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => { - this.isMobileNavigationLayout = event.matches; - this.updateContextScrollState(); - this.updateMobileTabsScrollState(); - }; private readonly onSystemLightThemeChange = () => { if (this.themePreference.auto) this.applyPreferredTheme(false); }; - private readonly onPwaDisplayModeChange = () => { - this.isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia); - }; - private readonly onContextScroll = () => { - this.updateContextScrollState(); - }; - private readonly onMobileTabsScroll = () => { - this.updateMobileTabsScrollState(); - }; - private readonly onDocumentClick = (event: MouseEvent) => { - const refresh = this.appRefreshElement(); - if (refresh !== undefined && event.composedPath().includes(refresh)) return; - this.refreshMenuOpen = false; - this.suppressNextRefreshClick = false; - }; private readonly onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && this.refreshMenuOpen) { - event.preventDefault(); - event.stopPropagation(); - this.refreshMenuOpen = false; - this.suppressNextRefreshClick = false; - return; - } if (this.keyboard.handle(event, this.getActions())) { event.preventDefault(); event.stopPropagation(); } }; + protected override willUpdate(): void { + this.toggleAttribute("pwa-display-mode", this.appShell.isPwaDisplayMode); + } + override connectedCallback(): void { super.connectedCallback(); window.addEventListener("popstate", this.onPopState); + window.addEventListener("pageshow", this.onPageShow); window.addEventListener("focus", this.onFocus); - document.addEventListener("click", this.onDocumentClick); document.addEventListener("visibilitychange", this.onVisibilityChange); window.addEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS); - this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange); this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange); - for (const media of this.pwaDisplayModeMedia) media.addEventListener("change", this.onPwaDisplayModeChange); this.applyPreferredTheme(false); this.connectRealtime(); this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS); @@ -212,13 +184,11 @@ export class PiWebApp extends LitElement { override disconnectedCallback(): void { window.removeEventListener("popstate", this.onPopState); + window.removeEventListener("pageshow", this.onPageShow); window.removeEventListener("focus", this.onFocus); - document.removeEventListener("click", this.onDocumentClick); document.removeEventListener("visibilitychange", this.onVisibilityChange); window.removeEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS); - this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange); this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange); - for (const media of this.pwaDisplayModeMedia) media.removeEventListener("change", this.onPwaDisplayModeChange); this.keyboard.reset(); this.auth.dispose(); this.sessions.dispose(); @@ -228,30 +198,9 @@ export class PiWebApp extends LitElement { this.piWebStatusTimer = undefined; if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer); this.workspaceDeletionPollTimer = undefined; - this.contextItemsResizeObserver?.disconnect(); - this.contextItemsResizeObserver = undefined; - this.observedContextItems = undefined; - this.mobileTabsResizeObserver?.disconnect(); - this.mobileTabsResizeObserver = undefined; - this.observedMobileTabs = undefined; - this.clearRefreshLongPressTimer(); super.disconnectedCallback(); } - override firstUpdated(): void { - this.observeContextItems(); - this.observeMobileTabs(); - this.updateContextScrollState(); - this.updateMobileTabsScrollState(); - } - - override updated(): void { - this.observeContextItems(); - this.observeMobileTabs(); - this.updateContextScrollState(); - this.updateMobileTabsScrollState(); - } - private setState(patch: Partial) { if (!patchChangesState(this.state, patch)) return; const previous = this.state; @@ -264,9 +213,11 @@ export class PiWebApp extends LitElement { private async loadProjectsAndRestoreRoute() { const route = readRoute(); await this.machines.loadMachines(route.machineId); + const machineFallbackMessage = this.state.error; const effectiveRoute = this.routeForSelectedMachine(route); if (effectiveRoute !== route) this.replaceRouteAndClearWorkspaceQuery(effectiveRoute); await this.projects.loadProjects(); + if (machineFallbackMessage !== "" && this.state.error === "") this.setState({ error: machineFallbackMessage }); await this.withChatScrollTransition(() => this.restoreRouteFor(effectiveRoute, false)); await this.refreshWorkspaceDeletionRuns(); } @@ -289,8 +240,6 @@ export class PiWebApp extends LitElement { private async refreshAppData(): Promise { if (this.isRefreshingApp) return; - this.refreshMenuOpen = false; - this.suppressNextRefreshClick = false; this.isRefreshingApp = true; try { await Promise.all([ @@ -393,7 +342,11 @@ export class PiWebApp extends LitElement { await this.chatView?.updateComplete; await nextFrame(); this.chatView?.restoreScrollPosition(); - this.promptEditor?.focusInput(); + if (this.shouldAutoFocusPrompt()) this.promptEditor?.focusInput(); + } + + private shouldAutoFocusPrompt(): boolean { + return this.appShell.shouldAutoFocusPrompt(); } private async withChatPrependTransition(action: () => Promise) { @@ -403,7 +356,7 @@ export class PiWebApp extends LitElement { } private defaultRouteView(): AppState["mainView"] { - return this.isMobileNavigationLayout ? "navigation" : "chat"; + return this.appShell.defaultRouteView(); } private updateUrl(options?: { replace?: boolean | undefined }) { @@ -585,7 +538,44 @@ export class PiWebApp extends LitElement { const panelContext = workspace === undefined ? undefined : this.createWorkspacePanelContext(workspace); const workspaceLabelItems = workspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, workspace); const emptyState = workspace === undefined ? this.workspacePanelEmptyState() : undefined; - return html` { this.openWorkspaceTool(tool); }}>`; + return html` + { this.openWorkspaceTool(tool); }} + > + `; + } + + private renderNavigationPanelEdgeControl() { + return html` + { this.panelCollapse.toggleNavigationPanel(); }} + > + `; + } + + private renderWorkspacePanelEdgeControl() { + return html` + { this.panelCollapse.toggleWorkspacePanel(); }} + > + `; } private renderNavigationPanel(autoSwitchToChat: boolean) { @@ -595,103 +585,62 @@ export class PiWebApp extends LitElement { if (autoSwitchToChat) this.updateUrl(); }); return html` -
- PI WEB -
- ${this.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : null} - -
-
- { this.toggleNavigationSection("machines"); }} - .onSelect=${(machine: Machine) => this.withChatScrollTransition(async () => { - this.expandNavigationSection("projects"); + .selectedMachine=${this.state.selectedMachine} + .machineStatuses=${this.state.machineStatuses} + .machinesCollapsed=${this.mobileNavigation.isCollapsed("machines")} + .onToggleMachines=${() => { this.mobileNavigation.toggle("machines"); }} + .onSelectMachine=${(machine: Machine) => this.withChatScrollTransition(async () => { + this.mobileNavigation.expand("projects"); await this.machines.selectMachine(machine); })} - > - { 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[] { @@ -827,6 +776,7 @@ export class PiWebApp extends LitElement { deleteWorkspace: (workspace) => this.deleteWorkspace(workspace), startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()), archiveSession: () => this.sessions.archiveSession(), + deleteCachedNewSession: () => this.sessions.deleteCachedNewSession(), stopActiveWork: () => this.sessions.stopActiveWork(), }, createContext); return createContext("core"); @@ -1111,232 +1061,54 @@ export class PiWebApp extends LitElement { } private renderContextBar() { - const machine = this.state.selectedMachine; - const project = this.state.selectedProject; - const workspace = this.state.selectedWorkspace; - const session = this.state.selectedSession; - const machineLabel = machineContextLabel(machine); - const projectLabel = projectContextLabel(project); - const showRefresh = this.shouldShowAppRefreshInContextBar(); - const workspaceLabel = workspaceContextLabel(workspace); - const sessionLabel = sessionContextLabel(session); + if (!this.appShell.isMobileNavigationLayout) return null; return html` - + { 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} .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} @@ -1345,11 +1117,11 @@ 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} ${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} - ${this.renderRefreshMenu()}
`; } @@ -1364,39 +1136,6 @@ function createPluginRegistry(): PluginRegistry { return registry; } -function machineContextLabel(machine: Machine | undefined): string { - return machine === undefined ? "No machine" : `${machine.name}${machine.kind === "remote" ? " · remote" : ""}`; -} - -function machineContextTitle(machine: Machine | undefined): string { - return machine === undefined ? "No machine selected" : machine.baseUrl ?? machine.name; -} - -function projectContextLabel(project: Project | undefined): string { - return project?.name ?? "No project"; -} - -function projectContextTitle(project: Project | undefined): string { - return project === undefined ? "No project selected" : `${project.name} — ${project.path}`; -} - -function workspaceContextLabel(workspace: Workspace | undefined): string { - return workspace === undefined ? "No workspace" : `${workspace.label}${workspace.isMain ? " · main" : ""} · ${workspace.path}`; -} - -function workspaceContextTitle(workspace: Workspace | undefined): string { - return workspace === undefined ? "No workspace selected" : `${workspace.label}${workspace.isMain ? " · main" : ""} — ${workspace.path}`; -} - -function sessionContextLabel(session: SessionInfo | undefined): string { - const name = session?.name?.trim(); - const firstMessage = session?.firstMessage.trim(); - return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session?.id.slice(0, 8) ?? "No session"; -} - -function sessionContextTitle(session: SessionInfo | undefined): string { - return session === undefined ? "No session selected" : session.path; -} function patchChangesState(state: AppState, patch: Partial): boolean { return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value); 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/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index e820b23..ea7b40f 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -115,7 +115,7 @@ export class PromptEditor extends LitElement { syntaxHighlighting(defaultHighlightStyle, { fallback: true }), EditorView.lineWrapping, EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))), - placeholder("Message pi... Use / for commands, @ for files"), + placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"), this.editableCompartment.of(EditorView.editable.of(!this.disabled)), this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)), EditorView.updateListener.of((update) => { @@ -188,12 +188,12 @@ export class PromptEditor extends LitElement { ...(command.description === undefined ? {} : { description: command.description }), })); } else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") { - const files = await api.files(this.cwd, trigger.query, trigger.fileKind, trigger.fileMode, this.machineId).catch(emptyFileSuggestions); + const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId }).catch(emptyFileSuggestions); if (version !== this.requestVersion) return; this.completions = files .slice(0, 12) .map((file) => { - const insertText = fileInsertText(file.path, trigger.fileMode === "path", trigger.quoted === true); + const insertText = fileInsertText(file.path, trigger.quoted === true, file.path.endsWith("/") ? trigger.allPrefix : undefined); return { kind: "file", replaceFrom: trigger.from, @@ -206,7 +206,7 @@ export class PromptEditor extends LitElement { } } - private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileKind?: FileSuggestion["kind"]; fileMode?: "file" | "path"; quoted?: boolean } | undefined { + private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean } | undefined { const cursor = this.editor?.state.selection.main.head ?? this.draft.length; const beforeCursor = this.draft.slice(0, cursor); const quotedTrigger = this.currentQuotedTrigger(beforeCursor, cursor); @@ -215,18 +215,20 @@ export class PromptEditor extends LitElement { const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1; const token = beforeCursor.slice(tokenStart); const beforeToken = beforeCursor.slice(0, tokenStart); - if (beforeToken.endsWith("@ ")) return { kind: "file", query: token, from: tokenStart, to: cursor, fileMode: "path" }; + if (beforeToken.endsWith("@ ")) return { kind: "file", query: token, from: tokenStart - 2, to: cursor, fileScope: "all", allPrefix: "@ " }; if (token.startsWith("/") && tokenStart === 0) return { kind: "command", query: token.slice(1), from: tokenStart, to: cursor }; - if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor }; + if (token.startsWith("!@")) return { kind: "file", query: token.slice(2), from: tokenStart, to: cursor, fileScope: "all", allPrefix: "!@" }; + if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor, fileScope: "tracked" }; return undefined; } - private currentQuotedTrigger(beforeCursor: string, cursor: number): { kind: "file"; query: string; from: number; to: number; fileMode?: "file" | "path"; quoted: true } | undefined { + private currentQuotedTrigger(beforeCursor: string, cursor: number): { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted: true } | undefined { const quoteStart = beforeCursor.lastIndexOf("\""); if (quoteStart === -1) return undefined; const prefix = beforeCursor.slice(0, quoteStart); - if (prefix.endsWith("@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 1, to: cursor, quoted: true }; - if (prefix.endsWith("@ ")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: quoteStart, to: cursor, fileMode: "path", quoted: true }; + if (prefix.endsWith("!@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 2, to: cursor, fileScope: "all", allPrefix: "!@", quoted: true }; + if (prefix.endsWith("@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 1, to: cursor, fileScope: "tracked", quoted: true }; + if (prefix.endsWith("@ ")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 2, to: cursor, fileScope: "all", allPrefix: "@ ", quoted: true }; return undefined; } @@ -299,8 +301,8 @@ function draftStorageKey(machineId: unknown, sessionId: unknown): string | undef return machineSessionKey(machineId, sessionId); } -function fileInsertText(path: string, pathMode: boolean, quoted: boolean): string { - const prefix = pathMode ? "" : "@"; +function fileInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string { + const prefix = allPrefix ?? "@"; if (!quoted && !path.includes(" ")) return `${prefix}${path}`; return `${prefix}"${path}"`; } 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/TerminalPanel.ts b/src/client/src/components/TerminalPanel.ts index d5cb430..2122810 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, @@ -32,6 +35,8 @@ export class TerminalPanel extends LitElement { @state() private visible = false; @state() private cancellingRunIds: string[] = []; @state() private continuingTerminalIds: string[] = []; + @state() private defaultSoftKeysEnvironment = false; + @state() private softKeysEnabled = initialTerminalSoftKeysEnabled(); private terminal: Terminal | undefined; private fitAddon: FitAddon | undefined; @@ -44,9 +49,16 @@ export class TerminalPanel extends LitElement { private loadedCwd: string | undefined; private autoStartConsumedCwd: string | undefined; private commandRunPollTimer: number | undefined; + private readonly softKeysDefaultEnvironmentMedia = createTerminalSoftKeysDefaultEnvironmentMedia(); + private softKeysPreferenceStored = hasTerminalSoftKeysPreference(); + private readonly onSoftKeysDefaultEnvironmentChange = () => { + this.syncDefaultSoftKeysEnvironment(); + }; override connectedCallback(): void { super.connectedCallback(); + this.syncDefaultSoftKeysEnvironment(); + this.softKeysDefaultEnvironmentMedia?.addEventListener("change", this.onSoftKeysDefaultEnvironmentChange); this.themeObserver = new MutationObserver(() => { this.applyTerminalTheme(); }); this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] }); } @@ -63,11 +75,24 @@ export class TerminalPanel extends LitElement { this.intersectionObserver = undefined; this.themeObserver?.disconnect(); this.themeObserver = undefined; + this.softKeysDefaultEnvironmentMedia?.removeEventListener("change", this.onSoftKeysDefaultEnvironmentChange); this.updateCommandRunPolling(false); this.disposeTerminalView(); super.disconnectedCallback(); } + private syncDefaultSoftKeysEnvironment(): void { + const nextDefaultEnvironment = isTerminalSoftKeysDefaultEnvironment(this.softKeysDefaultEnvironmentMedia); + const previousSoftKeysEnabled = this.softKeysEnabled; + this.defaultSoftKeysEnvironment = nextDefaultEnvironment; + if (!this.softKeysPreferenceStored) this.softKeysEnabled = nextDefaultEnvironment; + if (this.softKeysEnabled !== previousSoftKeysEnabled) this.scheduleFitAndNotify(); + } + + private scheduleFitAndNotify(): void { + void this.updateComplete.then(() => { this.fitAndNotify(); }); + } + override willUpdate(changed: PropertyValues): void { const workspaceScope = this.workspace === undefined ? undefined : JSON.stringify([this.machineId, this.workspace.path]); if (workspaceScope !== this.observedWorkspaceScope) { @@ -287,8 +312,7 @@ export class TerminalPanel extends LitElement { this.resizeObserver.observe(terminalHost); terminal.onData((data) => { if (this.suppressTerminalInput) return; - const filtered = filterTerminalInput(data); - if (filtered !== "") this.send({ type: "input", data: filtered }); + this.sendTerminalInput(data); }); const initialSize = this.fitTerminal(); this.connectSocket(workspace.projectId, workspace.id, this.selectedId, terminal, initialSize); @@ -376,6 +400,23 @@ export class TerminalPanel extends LitElement { if (this.terminal !== undefined) this.terminal.options.theme = terminalTheme(this); } + private sendTerminalInput(data: string): void { + const filtered = filterTerminalInput(data); + if (filtered !== "") this.send({ type: "input", data: filtered }); + } + + private sendSoftKeyInput(data: string, options: TerminalSoftKeyInputOptions): void { + this.sendTerminalInput(data); + if (options.refocus) this.focusTerminal(); + } + + private focusTerminal(): void { + const terminal = this.terminal; + if (terminal === undefined) return; + terminal.focus(); + requestAnimationFrame(() => { terminal.focus(); }); + } + private send(message: { type: "input"; data: string } | { type: "resize"; cols: number; rows: number }): void { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)); } @@ -423,10 +464,61 @@ export class TerminalPanel extends LitElement { return null; } + private selectedTerminalAcceptsInput(): boolean { + const terminal = this.selectedTerminalInfo(); + return terminal !== undefined && !terminal.exited; + } + + private shouldShowSoftKeys(): boolean { + return this.selectedTerminalAcceptsInput() && this.softKeysEnabled; + } + + private shouldShowSoftKeysToggle(): boolean { + return this.selectedTerminalAcceptsInput(); + } + + private toggleSoftKeys(): void { + this.softKeysEnabled = !this.softKeysEnabled; + this.softKeysPreferenceStored = true; + writeTerminalSoftKeysPreference(this.softKeysEnabled); + this.scheduleFitAndNotify(); + } + + private renderSoftKeysToggle() { + if (!this.shouldShowSoftKeysToggle()) return null; + return html` + + `; + } + + 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}
@@ -450,6 +543,8 @@ export class TerminalPanel extends LitElement { button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; } button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } button.new { flex: 0 0 auto; color: var(--pi-muted); } + .soft-keys-toggle { flex: 0 0 auto; } + .soft-keys-toggle .keyboard-icon { flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } button small { color: var(--pi-muted); font-size: 14px; line-height: 1; } button small:hover { color: var(--pi-danger); } 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/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/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/AppContextBar.ts b/src/client/src/components/appShell/AppContextBar.ts new file mode 100644 index 0000000..78774e1 --- /dev/null +++ b/src/client/src/components/appShell/AppContextBar.ts @@ -0,0 +1,171 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import type { Machine, Project, SessionInfo, Workspace } from "../../api"; +import type { NavigationSection } from "../../appShell/navigationState"; + +@customElement("app-context-bar") +export class AppContextBar extends LitElement { + @property({ attribute: false }) machine?: Machine; + @property({ attribute: false }) project?: Project; + @property({ attribute: false }) workspace?: Workspace; + @property({ attribute: false }) session?: SessionInfo; + @property({ attribute: false }) refreshControl: unknown; + @property({ attribute: false }) onOpenSection?: (section: NavigationSection) => void; + @query(".context-items") private contextItems?: HTMLElement | null; + @state() private canScrollLeft = false; + @state() private canScrollRight = false; + private observedContextItems: HTMLElement | undefined; + private contextItemsResizeObserver: ResizeObserver | undefined; + + override disconnectedCallback(): void { + this.contextItemsResizeObserver?.disconnect(); + this.contextItemsResizeObserver = undefined; + this.observedContextItems = undefined; + super.disconnectedCallback(); + } + + override firstUpdated(): void { + this.observeContextItems(); + this.updateScrollState(); + } + + override updated(): void { + this.observeContextItems(); + this.updateScrollState(); + } + + override render() { + const machineLabel = machineContextLabel(this.machine); + const projectLabel = projectContextLabel(this.project); + const workspaceLabel = workspaceContextLabel(this.workspace); + const sessionLabel = sessionContextLabel(this.session); + return html` + + `; + } + + private contextBarClass(): string { + const classes = ["context-bar"]; + if (this.refreshControl !== undefined) classes.push("has-context-actions"); + if (this.canScrollLeft) classes.push("can-scroll-left"); + if (this.canScrollRight) classes.push("can-scroll-right"); + return classes.join(" "); + } + + private observeContextItems(): void { + const contextItems = this.contextItemsElement(); + if (this.observedContextItems === contextItems) return; + this.contextItemsResizeObserver?.disconnect(); + this.observedContextItems = contextItems; + this.contextItemsResizeObserver = undefined; + if (contextItems === undefined || typeof ResizeObserver === "undefined") return; + this.contextItemsResizeObserver = new ResizeObserver(() => { + this.updateScrollState(); + }); + this.contextItemsResizeObserver.observe(contextItems); + } + + private updateScrollState(): void { + const contextItems = this.contextItemsElement(); + const maxScrollLeft = contextItems === undefined ? 0 : Math.max(0, contextItems.scrollWidth - contextItems.clientWidth); + const canScrollLeft = contextItems !== undefined && contextItems.scrollLeft > 1; + const canScrollRight = contextItems !== undefined && maxScrollLeft - contextItems.scrollLeft > 1; + if (this.canScrollLeft !== canScrollLeft) this.canScrollLeft = canScrollLeft; + if (this.canScrollRight !== canScrollRight) this.canScrollRight = canScrollRight; + } + + private contextItemsElement(): HTMLElement | undefined { + const contextItems = this.contextItems; + return contextItems instanceof HTMLElement ? contextItems : undefined; + } + + private readonly onContextScroll = () => { + this.updateScrollState(); + }; + + static override styles = css` + :host { flex: 0 0 auto; min-width: 0; } + .context-bar { position: relative; flex: 0 0 auto; min-width: 0; display: flex; align-items: center; gap: 0; padding: 6px 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); } + .context-bar::before, .context-bar::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; } + .context-bar::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } + .context-bar::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } + .context-bar.can-scroll-left::before, .context-bar.can-scroll-right::after { opacity: 1; } + .context-bar-label { display: none; } + .context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 5px; margin: 0; padding: 0 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px; scrollbar-width: thin; } + .context-bar.has-context-actions .context-items { padding-right: 52px; scroll-padding-inline: 8px 52px; } + .context-item { flex: 0 0 auto; min-width: 0; display: flex; } + .context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; padding: 0 8px 0 0; pointer-events: none; } + .context-actions::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; z-index: 0; width: 26px; background: var(--pi-bg); pointer-events: none; } + app-refresh-control { pointer-events: auto; } + .context-chip { flex: 0 0 auto; min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 4px 8px; font: inherit; text-align: left; } + .context-chip:hover { background: var(--pi-surface-hover); } + .context-chip:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; } + .context-chip.empty { border-style: dashed; color: var(--pi-muted); } + .context-kind { display: none; } + .context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; } + button { cursor: pointer; } + `; +} + +function machineContextLabel(machine: Machine | undefined): string { + return machine === undefined ? "No machine" : `${machine.name}${machine.kind === "remote" ? " · remote" : ""}`; +} + +function machineContextTitle(machine: Machine | undefined): string { + return machine === undefined ? "No machine selected" : machine.baseUrl ?? machine.name; +} + +function projectContextLabel(project: Project | undefined): string { + return project?.name ?? "No project"; +} + +function projectContextTitle(project: Project | undefined): string { + return project === undefined ? "No project selected" : `${project.name} — ${project.path}`; +} + +function workspaceContextLabel(workspace: Workspace | undefined): string { + return workspace === undefined ? "No workspace" : `${workspace.label}${workspace.isMain ? " · main" : ""} · ${workspace.path}`; +} + +function workspaceContextTitle(workspace: Workspace | undefined): string { + return workspace === undefined ? "No workspace selected" : `${workspace.label}${workspace.isMain ? " · main" : ""} — ${workspace.path}`; +} + +function sessionContextLabel(session: SessionInfo | undefined): string { + const name = session?.name?.trim(); + const firstMessage = session?.firstMessage.trim(); + return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session?.id.slice(0, 8) ?? "No session"; +} + +function sessionContextTitle(session: SessionInfo | undefined): string { + return session === undefined ? "No session selected" : session.path; +} 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..853bb74 --- /dev/null +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -0,0 +1,132 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api"; +import type { WorkspaceLabelItem } from "../../plugins/types"; +import "../MachineList"; +import "../ProjectList"; +import "../WorkspaceList"; +import "../SessionList"; + +@customElement("app-navigation-panel") +export class AppNavigationPanel extends LitElement { + @property({ attribute: false }) machines: Machine[] = []; + @property({ attribute: false }) selectedMachine?: Machine; + @property({ attribute: false }) machineStatuses: Record = {}; + @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 }) machinesCollapsed = false; + @property({ type: Boolean }) projectsCollapsed = false; + @property({ type: Boolean }) workspacesCollapsed = false; + @property({ type: Boolean }) sessionsCollapsed = false; + @property({ type: Boolean }) canStartSession = false; + @property({ attribute: false }) onShowActions?: () => void; + @property({ attribute: false }) onToggleMachines?: () => void; + @property({ attribute: false }) onToggleProjects?: () => void; + @property({ attribute: false }) onToggleWorkspaces?: () => void; + @property({ attribute: false }) onToggleSessions?: () => void; + @property({ attribute: false }) onSelectProject?: (project: Project) => void | Promise; + @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; + @property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise; + + override render() { + return html` +
+ PI WEB +
+ ${this.refreshControl} + +
+
+ { this.onToggleMachines?.(); }} + .onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)} + > + { 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; } + machine-list, project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); } + session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; } + :host([collapsible]) machine-list, + :host([collapsible]) project-list, + :host([collapsible]) workspace-list, + :host([collapsible]) session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; } + :host([collapsible]) machine-list[collapsed], + :host([collapsible]) project-list[collapsed], + :host([collapsible]) workspace-list[collapsed], + :host([collapsible]) session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } + `; +} 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..624abbd --- /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, { constrainTo: "viewport" }); + this.menuOpen = true; + } + + private closeMenu(): void { + this.menuOpen = false; + this.suppressNextClick = false; + } + + private refresh(): void { + this.closeMenu(); + void this.onRefresh?.(); + } + + private reload(): void { + this.closeMenu(); + this.onReload?.(); + } + + private clearLongPressTimer(): void { + if (this.longPressTimer === undefined) return; + window.clearTimeout(this.longPressTimer); + this.longPressTimer = undefined; + } + + static override styles = css` + :host { position: relative; z-index: 1; display: flex; align-items: center; pointer-events: auto; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; } + :host, :host * { -webkit-user-select: none; user-select: none; } + .app-refresh-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 0; line-height: 1; cursor: pointer; touch-action: manipulation; -webkit-touch-callout: none; } + .app-refresh-icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } + .app-refresh-button.refreshing .app-refresh-icon { animation: app-refresh-spin .8s linear infinite; } + .app-refresh-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(170px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); overflow-wrap: anywhere; } + .app-refresh-menu button { display: block; width: 100%; border: 0; border-radius: 8px; background: transparent; color: var(--pi-text); padding: 7px 9px; text-align: left; white-space: normal; overflow-wrap: anywhere; cursor: pointer; } + .app-refresh-menu button:hover, .app-refresh-menu button:focus { background: var(--pi-selection-bg); } + @keyframes app-refresh-spin { to { transform: rotate(360deg); } } + `; +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 41e06be..462c469 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -50,14 +50,20 @@ export interface CompletionItem { } export const appStyles = css` - :host { display: block; height: 100dvh; box-sizing: border-box; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); color: var(--pi-text); background: var(--pi-bg); font: 14px system-ui, sans-serif; } - .shell { display: grid; grid-template-columns: 340px minmax(420px, 1fr) minmax(360px, 42vw); height: 100%; min-height: 0; } - aside { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--pi-border); overflow: hidden; } + /* Mobile browsers already subtract browser controls from 100dvh; reserve bottom safe area only in standalone PWA modes. */ + :host { --pi-app-safe-area-bottom: 0px; position: fixed; top: 0; right: 0; left: 0; display: block; height: 100dvh; box-sizing: border-box; overflow: hidden; padding: env(safe-area-inset-top) env(safe-area-inset-right) var(--pi-app-safe-area-bottom) env(safe-area-inset-left); color: var(--pi-text); background: var(--pi-bg); font: 14px system-ui, sans-serif; } + :host([pwa-display-mode]) { --pi-app-safe-area-bottom: env(safe-area-inset-bottom); } + @media (display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui) { + :host { --pi-app-safe-area-bottom: env(safe-area-inset-bottom); } + } + .shell { --navigation-panel-width: 340px; --workspace-panel-width: minmax(360px, 42vw); display: grid; grid-template-columns: var(--navigation-panel-width) 1px minmax(420px, 1fr) 1px var(--workspace-panel-width); height: 100%; min-height: 0; } + aside { grid-column: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; } + aside app-navigation-panel { flex: 1 1 auto; min-height: 0; } header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); } .header-actions { display: flex; align-items: center; gap: 8px; } - project-list, workspace-list { flex: 0 0 auto; max-height: 26%; overflow: auto; border-bottom: 1px solid var(--pi-border-muted); } - session-list { flex: 1 1 auto; min-height: 0; overflow: auto; } - main { display: flex; flex-direction: column; min-width: 0; min-height: 0; } + project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); } + session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; } + main { grid-column: 3; display: flex; flex-direction: column; min-width: 0; min-height: 0; } .context-bar { position: relative; flex: 0 0 auto; min-width: 0; display: none; align-items: center; gap: 0; padding: 6px 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); } .context-bar::before, .context-bar::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; } .context-bar::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } @@ -83,6 +89,7 @@ export const appStyles = css` .context-chip.empty { border-style: dashed; color: var(--pi-muted); } .context-kind { display: none; } .context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; } + app-mobile-main-tabs { display: none; } .mobile-tabs-frame { position: relative; display: none; flex: 0 0 auto; min-width: 0; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg); } .mobile-tabs-frame::before, .mobile-tabs-frame::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; } .mobile-tabs-frame::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } @@ -93,31 +100,51 @@ export const appStyles = css` .mobile-navigation-tab, .mobile-navigation-panel { display: none; } .mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } .tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } - workspace-panel { min-width: 0; min-height: 0; border-left: 1px solid var(--pi-border); overflow: hidden; } + .navigation-panel-edge, .workspace-panel-edge { min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; } + .navigation-panel-edge { grid-column: 2; } + .workspace-panel-edge { grid-column: 4; } + .navigation-panel-edge-button, .workspace-panel-edge-button { position: relative; z-index: 1; box-sizing: border-box; display: grid; place-items: center; width: 18px; height: 48px; padding: 0; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-bg); color: var(--pi-muted); opacity: .75; cursor: pointer; } + .navigation-panel-edge-button:hover, .navigation-panel-edge-button:focus-visible, .workspace-panel-edge-button:hover, .workspace-panel-edge-button:focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); opacity: 1; } + .shell.navigation-panel-collapsed .navigation-panel-edge-button { transform: translateX(calc(50% - .5px)); } + .shell.workspace-panel-collapsed .workspace-panel-edge-button { transform: translateX(calc(-50% + .5px)); } + .navigation-panel-edge-icon, .workspace-panel-edge-icon { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } + workspace-panel { grid-column: 5; min-width: 0; min-height: 0; overflow: hidden; } + @media (min-width: 1181px) { + .shell.navigation-panel-collapsed { --navigation-panel-width: 0px; } + .shell.navigation-panel-collapsed > aside { display: none; } + .shell.workspace-panel-collapsed { --workspace-panel-width: 0px; } + .shell.workspace-panel-collapsed > workspace-panel { display: none; } + } @media (max-width: 1180px) { - .shell { grid-template-columns: 340px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } + .shell { grid-template-columns: var(--navigation-panel-width) 1px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } + .shell.navigation-panel-collapsed { --navigation-panel-width: 0px; } + .shell.navigation-panel-collapsed > aside { display: none; } aside { grid-row: 1 / 3; } - main { grid-column: 2; grid-row: 1 / 3; } + .navigation-panel-edge { grid-row: 1 / 3; } + main { grid-column: 3; grid-row: 1 / 3; } + app-mobile-main-tabs { display: block; flex: 0 0 auto; min-width: 0; } .mobile-tabs-frame { display: flex; } .shell.workspace-view main { grid-row: 1; min-height: auto; } - .shell.workspace-view > workspace-panel { grid-column: 2; grid-row: 2; display: flex; border-left: 0; } + .shell.workspace-view > workspace-panel { grid-column: 3; grid-row: 2; display: flex; border-left: 0; } .shell:not(.workspace-view) > workspace-panel { display: none; } + .workspace-panel-edge { display: none; } main.workspace-view chat-view, main.workspace-view prompt-editor, main.workspace-view status-bar, main.workspace-view .empty { display: none; } main.workspace-view { overflow: hidden; } } @media (max-width: 760px) { .shell { grid-template-columns: minmax(0, 1fr); } - aside { display: none; } + aside, .navigation-panel-edge { display: none; } main, .shell.workspace-view > workspace-panel { grid-column: 1; } .context-bar { display: flex; } .mobile-navigation-tab { display: block; } main.navigation-view chat-view, main.navigation-view prompt-editor, main.navigation-view status-bar, main.navigation-view .empty { display: none; } main.navigation-view .mobile-navigation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } + main.navigation-view .mobile-navigation-panel app-navigation-panel { flex: 1 1 auto; min-height: 0; } main.navigation-view .mobile-navigation-panel project-list, main.navigation-view .mobile-navigation-panel workspace-list, - main.navigation-view .mobile-navigation-panel session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: auto; } + main.navigation-view .mobile-navigation-panel session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; } main.navigation-view .mobile-navigation-panel project-list[collapsed], main.navigation-view .mobile-navigation-panel workspace-list[collapsed], main.navigation-view .mobile-navigation-panel session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; } @@ -140,7 +167,7 @@ export const workspacePanelStyles = css` .workspace-header-scroll-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } .workspace-header-scroll-frame.can-scroll-left::before, .workspace-header-scroll-frame.can-scroll-right::after { opacity: 1; } .workspace-header-strip { display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; } - .tabs { flex: 0 0 auto; display: flex; gap: 6px; } + .tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; } .tabs button { flex: 0 0 auto; white-space: nowrap; } button { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; } button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } @@ -183,10 +210,11 @@ export const workspacePanelStyles = css` `; export const listStyles = css` - :host { display: block; color: var(--pi-text); font: 14px system-ui, sans-serif; } + :host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; } :host([collapsed]) { flex: 0 0 auto; min-height: auto; overflow: hidden; } - section { padding: 10px; } - h2 { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } + section { box-sizing: border-box; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; padding: 10px; } + h2 { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } + .list-body { flex: 1 1 auto; min-height: 0; overflow: auto; } button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } section > button { display: block; width: 100%; text-align: left; margin: 6px 0; } .subheading { margin-top: 14px; } @@ -242,11 +270,11 @@ export const listStyles = css` `; export const chatStyles = css` - :host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; } + :host { position: relative; z-index: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; } .chat-wrap { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; } .chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; } .scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; } - .activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 3; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); } + .activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 20; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); } .activity-dock.active { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-bg-overlay); } .activity-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; } diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index f7b13df..3bc4bba 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -62,7 +62,7 @@ export class SessionController { this.socket.close(); this.catchupStreamSessionId = undefined; this.clearPendingTranscriptEvents(); - this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined }); + this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined }); } deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) { 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/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index 9583ddd..f5d046b 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"; @@ -168,9 +169,17 @@ export function createCoreActions(): PluginAction[] { title: "Archive Session", description: "Archive the selected session", group: "Session", - enabled: (context) => context.state.selectedSession !== undefined && context.state.selectedSession.archived !== true, + enabled: hasArchivableSession, run: (context) => context.archiveSession(), }, + { + id: "session.delete", + title: "Delete New Session", + description: "Delete the selected browser-cached new session", + group: "Session", + enabled: hasCachedNewSession, + run: (context) => context.deleteCachedNewSession(), + }, { id: "session.stop", title: "Stop Active Work", @@ -194,3 +203,12 @@ function hasDeletableWorkspace(context: { state: AppState }): boolean { const workspace = context.state.selectedWorkspace; return workspace !== undefined && workspace.isGitWorktree && !workspace.isMain && !isWorkspaceDeletionPending(context.state, workspace); } + +function hasArchivableSession(context: { state: AppState }): boolean { + const session = context.state.selectedSession; + return session !== undefined && session.archived !== true && !isCachedNewSessionInfo(session); +} + +function hasCachedNewSession(context: { state: AppState }): boolean { + return isCachedNewSessionInfo(context.state.selectedSession); +} diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 0929e52..c009241 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"; @@ -38,6 +39,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 }; @@ -102,6 +104,34 @@ describe("PluginRegistry", () => { expect(calls).toEqual(["deleteWorkspace"]); }); + it("offers archive only for persisted sessions and delete only for browser-cached new sessions", () => { + const registry = new PluginRegistry(); + registry.register({ id: "core", plugin: corePlugin }); + + const persistedActions = registry.getActions(createContext({ selectedSession: testSession() }).context); + expect(persistedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true); + expect(persistedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false); + + const cachedActions = registry.getActions(createContext({ selectedSession: markCachedNewSessionInfo(testSession()) }).context); + expect(cachedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false); + expect(cachedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true); + + const archivedActions = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" } }).context); + expect(archivedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false); + expect(archivedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false); + }); + + it("routes browser-cached new session delete through the runtime context", () => { + const registry = new PluginRegistry(); + registry.register({ id: "core", plugin: corePlugin }); + const { context, calls } = createContext({ selectedSession: markCachedNewSessionInfo(testSession()) }); + const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.delete"); + + if (action !== undefined) void action.run(); + + expect(calls).toEqual(["deleteCachedNewSession"]); + }); + it("routes refresh current to the active core workspace panel", () => { const registry = new PluginRegistry(); registry.register({ id: "core", plugin: corePlugin }); @@ -237,6 +267,19 @@ function testWorkspace(patch: Partial = {}): Workspace { 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 e4f82a7..d9f2588 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -70,6 +70,7 @@ export interface PluginRuntimeContext { deleteWorkspace: (workspace?: Workspace) => void | Promise; startSession: () => void | Promise; archiveSession: () => void | Promise; + deleteCachedNewSession: () => void | Promise; stopActiveWork: () => void | Promise; } 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; + } +} 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 6432acd..4bfe9fe 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -9,13 +9,13 @@ import { ProjectService } from "./projects/projectService.js"; import { WorkspaceService } from "./workspaces/workspaceService.js"; import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js"; import { listDirectorySuggestions } from "./projects/directorySuggestions.js"; -import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js"; +import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; import { registerSessionProxyRoutes, type SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js"; 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"; import { MachineService } from "./machines/machineService.js"; import { registerMachineRoutes } from "./machines/machineRoutes.js"; import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js"; @@ -69,11 +69,11 @@ function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectServi } function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string): void { - app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>(`${prefix}/files`, async (request, reply) => { + app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>(`${prefix}/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) }); } @@ -99,6 +99,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebStatus()); + app.get("/api/pi-web/version", async () => getPiWebVersionStatus()); registerMachineRoutes(app, machines); diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index bd6dd42..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 } from "./piWebStatus.js"; -import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js"; +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(); }); @@ -17,23 +22,34 @@ 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 = daemonWithComponent({ + 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(); - 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); @@ -41,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 f60039e..b44ee6e 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -1,11 +1,13 @@ 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"; -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}`; @@ -13,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; @@ -47,20 +83,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); - 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, + 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(components); + const messages = buildMessages(components, release, commands); + return { + ...versionStatus, release, commands, messages, @@ -179,54 +222,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", @@ -280,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 { @@ -310,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), }); } @@ -330,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/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 7a64630..68b1f55 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 interface SessionProxyDaemon { request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }>; 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, ]; } diff --git a/src/server/terminalProxyRoutes.ts b/src/server/terminalProxyRoutes.ts index a0bac1c..7310352 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 type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js"; import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js"; import type { WorkspaceService } from "./workspaces/workspaceService.js"; 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 09263da..67af66c 100644 --- a/src/server/workspaces/fileSuggestions.ts +++ b/src/server/workspaces/fileSuggestions.ts @@ -6,13 +6,33 @@ import { sanitizedGitEnv } from "../git/gitEnv.js"; 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; + env?: NodeJS.ProcessEnv; +} + +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); } @@ -40,10 +60,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"), @@ -51,16 +81,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, env: sanitizedGitEnv(), 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, env: sanitizedGitEnv(), 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 1e213c8..bcb09a5 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 }; } 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 800fe74..817530a 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -308,19 +308,23 @@ 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; - restart: string; - restartSystemd: string; - restartDev: string; + update?: string; + restart?: string; + restartWeb?: string; + restartSessiond?: string; + status?: string; }; messages: PiWebStatusMessage[]; } 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); +}