Merge remote-tracking branch 'origin/main' into review/pr-5-machine-federation-fixes

# Conflicts:
#	src/client/src/api/clients.ts
#	src/client/src/components/PiWebApp.ts
#	src/client/src/components/PromptEditor.ts
#	src/server/app.ts
#	src/server/terminalProxyRoutes.ts
#	src/server/workspaces/fileSuggestions.ts
This commit is contained in:
Federico Jaramillo Martinez
2026-06-02 17:42:16 +02:00
71 changed files with 3146 additions and 699 deletions
+5
View File
@@ -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.
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Refresh session list message counts from live session status updates.
+5
View File
@@ -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.
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Make `pi-web status` print a concise service health report without invoking paged system service output.
+27
View File
@@ -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
+4
View File
@@ -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.
+3 -1
View File
@@ -121,7 +121,9 @@
<h2>What does <code>pi-web doctor</code> check?</h2>
<p>
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 <code>@</code>-mention suggestions, uses a bounded filesystem fallback when
ripgrep is unavailable, and reports user service lingering when relevant for server-style installs.
</p>
<p>
If something works in your terminal but fails in doctor, treat that as a login-shell PATH mismatch and
+1
View File
@@ -257,6 +257,7 @@
<pre id="home-install"><code><span class="prompt">$</span> npm install -g @jmfederico/pi-web
<span class="prompt">$</span> pi-web install
<span class="prompt">$</span> pi-web doctor
<span class="prompt">$</span> pi-web version
<span class="comment"># Open http://127.0.0.1:8504</span></code></pre>
</div>
</div>
+4 -1
View File
@@ -138,7 +138,8 @@
/pi-web install
/pi-web status
/pi-web logs
/pi-web doctor</code></pre>
/pi-web doctor
/pi-web version</code></pre>
</div>
</section>
@@ -195,6 +196,7 @@
<section id="manage-services">
<h2>Manage services</h2>
<p><code>pi-web version</code> compares the installed package version with the versions reported by the running Web/UI and session daemon services.</p>
<div class="code-card">
<div class="copy-row">
<strong>Useful commands</strong>
@@ -204,6 +206,7 @@
<span class="prompt">$</span> pi-web logs
<span class="prompt">$</span> pi-web restart
<span class="prompt">$</span> pi-web doctor
<span class="prompt">$</span> pi-web version
<span class="comment"># From a checkout, install the split development services:</span>
<span class="prompt">$</span> pi-web install --dev</code></pre>
+2 -1
View File
@@ -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
+3 -3
View File
@@ -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": {
+1 -1
View File
@@ -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",
+24 -12
View File
@@ -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`
<section>
<strong>Suggested commands</strong>
${commands.map(([label, command]) => renderCommand(html, label, command))}
</section>
`;
}
function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResult {
const status = statusFor(state);
if (status === undefined) {
return html`
<section class="toolbar"><strong>PI WEB</strong></section>
<section class="viewer"><p class="muted">Checking PI WEB status…</p></section>
<section class="toolbar"><strong>Updates</strong></section>
<section class="viewer"><p class="muted">Checking PI WEB update status…</p></section>
`;
}
@@ -98,7 +116,7 @@ function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResu
.pi-web-command > span { grid-column: 1 / -1; }
}
</style>
<section class="toolbar"><strong>PI WEB</strong><span class="stale">beta</span>${messages.length > 0 ? html`<span class="stale">${String(messages.length)}</span>` : null}</section>
<section class="toolbar"><strong>Updates</strong><span class="stale">beta</span>${messages.length > 0 ? html`<span class="stale">${String(messages.length)}</span>` : null}</section>
<section class="viewer pi-web-status">
<section>
${messages.length === 0 ? html`<p class="muted">No PI WEB update or restart messages.</p>` : messages.map((message) => html`
@@ -116,13 +134,7 @@ function renderStatusPanel(html: HtmlTemplateTag, state: AppState): TemplateResu
${renderComponent(html, status.components.sessiond)}
</section>
<section>
<strong>Commands</strong>
${renderCommand(html, "Update", status.commands.update)}
${renderCommand(html, "Restart", status.commands.restart)}
${renderCommand(html, "systemd", status.commands.restartSystemd)}
${renderCommand(html, "dev", status.commands.restartDev)}
</section>
${renderCommands(html, status)}
<section class="pi-web-meta">
<span>Generated ${status.generatedAt}</span>
@@ -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) => {
+1 -1
View File
@@ -23,7 +23,7 @@
"workspace"
],
"peerDependencies": {
"@jmfederico/pi-web": ">=1.202605.13"
"@jmfederico/pi-web": ">=1.202605.14"
},
"peerDependenciesMeta": {
"@jmfederico/pi-web": {
+52 -13
View File
@@ -15,6 +15,12 @@ type ConfigState =
| { kind: "loading" }
| WorkspaceActionsConfigLoadResult;
interface ActionStatus {
kind: "info" | "success" | "error";
message: string;
detail?: string;
}
const configCache = new Map<string, ConfigState>();
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 {
<button class="secondary" data-open-terminal>Open Terminal</button>
</span>
</section>
${this.renderStatus()}
<section class="viewer actions-viewer">
${this.renderConfigState(state)}
</section>
@@ -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<void> {
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 `<p class="muted">Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…</p>${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 `<p class="muted">No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.</p>${this.renderStatus()}`;
if (state.kind === "loading") return `<p class="muted">Loading ${escapeHtml(ACTIONS_CONFIG_PATH)}…</p>`;
if (state.kind === "missing") return renderMissingState(state);
if (state.kind === "unavailable") return renderUnavailableState(state);
if (state.config.actions.length === 0) return `<p class="muted">No actions are defined in ${escapeHtml(ACTIONS_CONFIG_PATH)}. Add actions to the file, then click Refresh.</p>`;
return `
<p class="muted">Actions run in a dedicated workspace terminal, then switch to that terminal. Edit ${escapeHtml(ACTIONS_CONFIG_PATH)} and click Refresh to reload.</p>
${renderActionGroups(state.config.actions, this.runningActionId)}
${this.renderStatus()}
`;
}
private renderStatus(): string {
if (this.status === undefined) return "";
const detail = this.status.detail === undefined ? "" : `<pre>${escapeHtml(this.status.detail)}</pre>`;
return `<div class="status ${escapeAttr(this.status.kind)}">${escapeHtml(this.status.message)}${detail}</div>`;
return `<div class="status panel-status ${escapeAttr(this.status.kind)}">${escapeHtml(this.status.message)}${detail}</div>`;
}
private async refreshConfig(workspace: Workspace): Promise<void> {
@@ -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<void> {
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); }
+51 -11
View File
@@ -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,14 +931,42 @@ 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");
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.");
}
return !failed;
}
function printPathSetupAdvice(): void {
@@ -952,7 +985,7 @@ function printPathSetupAdvice(): void {
}
}
function doctor(): void {
async function doctor(): Promise<void> {
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<void> {
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}`);
}
+14 -1
View File
@@ -145,8 +145,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export interface FileSuggestionQueryOptions {
kind?: FileSuggestion["kind"] | undefined;
mode?: "file" | "path" | undefined;
scope?: "tracked" | "all" | undefined;
machineId?: string | undefined;
}
export const filesApi = {
files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path", machineId = "local") => request(`${machinePrefix(machineId)}/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)),
files: (cwd: string, query: string, options: FileSuggestionQueryOptions = {}) => {
const params = new URLSearchParams({ cwd, q: query });
if (options.kind !== undefined) params.set("kind", options.kind);
if (options.mode !== undefined) params.set("mode", options.mode);
if (options.scope !== undefined) params.set("scope", options.scope);
return request(`${machinePrefix(options.machineId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion));
},
};
export const gitApi = {
@@ -34,7 +34,7 @@ describe("federated route contract", () => {
ignoreParseFailure(workspacesApi.workspaces("p 1", machineId)),
ignoreParseFailure(workspacesApi.workspaceTree("p 1", "w 1", "src", machineId)),
ignoreParseFailure(workspacesApi.workspaceFile("p 1", "w 1", "README.md", machineId)),
ignoreParseFailure(filesApi.files("/repo", "README", "tracked", "file", machineId)),
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })),
ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)),
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
+7 -1
View File
@@ -469,7 +469,13 @@ function parsePiWebReleaseStatus(value: unknown): PiWebReleaseStatus {
function parsePiWebCommands(value: unknown): PiWebStatusResponse["commands"] {
const record = requireRecord(value);
return { update: requireString(record, "update"), restart: requireString(record, "restart"), restartSystemd: requireString(record, "restartSystemd"), restartDev: requireString(record, "restartDev") };
return {
...optionalField("update", optionalString(record, "update")),
...optionalField("restart", optionalString(record, "restart")),
...optionalField("restartWeb", optionalString(record, "restartWeb")),
...optionalField("restartSessiond", optionalString(record, "restartSessiond")),
...optionalField("status", optionalString(record, "status")),
};
}
function parsePiWebStatusMessage(value: unknown): PiWebStatusMessage {
@@ -0,0 +1,82 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import type { AppState } from "../appState";
import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMode";
import { ViewportPositionRepairer } from "./viewportPositionRepair";
export const MOBILE_NAVIGATION_MEDIA_QUERY = "(max-width: 760px)";
export interface AppShellControllerOptions {
mobileNavigationMedia?: MediaQueryList | undefined;
pwaDisplayModeMedia?: MediaQueryList[] | undefined;
viewportPositionRepairer?: ViewportPositionRepairer | undefined;
}
export class AppShellController implements ReactiveController {
private readonly mobileNavigationMedia: MediaQueryList | undefined;
private readonly pwaDisplayModeMedia: MediaQueryList[];
private readonly viewportPositionRepairer: ViewportPositionRepairer;
isMobileNavigationLayout: boolean;
isPwaDisplayMode: boolean;
constructor(private readonly host: ReactiveControllerHost, options: AppShellControllerOptions = {}) {
host.addController(this);
this.mobileNavigationMedia = options.mobileNavigationMedia ?? createMobileNavigationMedia();
this.pwaDisplayModeMedia = options.pwaDisplayModeMedia ?? createPwaDisplayModeMedia();
this.viewportPositionRepairer = options.viewportPositionRepairer ?? new ViewportPositionRepairer();
this.isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false;
this.isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia);
}
hostConnected(): void {
this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange);
for (const media of this.pwaDisplayModeMedia) media.addEventListener("change", this.onPwaDisplayModeChange);
}
hostDisconnected(): void {
this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange);
for (const media of this.pwaDisplayModeMedia) media.removeEventListener("change", this.onPwaDisplayModeChange);
this.viewportPositionRepairer.clear();
}
shouldAutoFocusPrompt(): boolean {
return !this.isMobileNavigationLayout && !this.isPwaDisplayMode;
}
shouldShowAppRefreshInHeader(): boolean {
return this.isPwaDisplayMode && !this.isMobileNavigationLayout;
}
shouldShowAppRefreshInContextBar(): boolean {
return this.isPwaDisplayMode && this.isMobileNavigationLayout;
}
defaultRouteView(): AppState["mainView"] {
return this.isMobileNavigationLayout ? "navigation" : "chat";
}
repairViewportPosition(): void {
this.viewportPositionRepairer.repair(this.shouldRepairViewportPosition());
}
private shouldRepairViewportPosition(): boolean {
return this.isMobileNavigationLayout || this.isPwaDisplayMode;
}
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
if (this.isMobileNavigationLayout === event.matches) return;
this.isMobileNavigationLayout = event.matches;
this.host.requestUpdate();
};
private readonly onPwaDisplayModeChange = () => {
const isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia);
if (this.isPwaDisplayMode === isPwaDisplayMode) return;
this.isPwaDisplayMode = isPwaDisplayMode;
this.host.requestUpdate();
};
}
function createMobileNavigationMedia(): MediaQueryList | undefined {
if (typeof window === "undefined" || !("matchMedia" in window)) return undefined;
return window.matchMedia(MOBILE_NAVIGATION_MEDIA_QUERY);
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { defaultNavigationSection, expandedNavigationSection, isNavigationSectionCollapsed, toggleNavigationSection } from "./navigationState";
describe("navigationState", () => {
it("defaults to the first incomplete selection section", () => {
expect(defaultNavigationSection({ selectedProject: undefined, selectedWorkspace: undefined })).toBe("projects");
expect(defaultNavigationSection({ selectedProject: {}, selectedWorkspace: undefined })).toBe("workspaces");
expect(defaultNavigationSection({ selectedProject: {}, selectedWorkspace: {} })).toBe("sessions");
});
it("expands the default section until the user explicitly toggles a section", () => {
const state = { selectedProject: {}, selectedWorkspace: undefined };
expect(expandedNavigationSection(undefined, state)).toBe("workspaces");
expect(expandedNavigationSection("sessions", state)).toBe("sessions");
expect(expandedNavigationSection("none", state)).toBeUndefined();
});
it("only collapses sections in mobile navigation layouts", () => {
const state = { selectedProject: {}, selectedWorkspace: {} };
expect(isNavigationSectionCollapsed("projects", { isMobileLayout: false, expanded: "sessions", state })).toBe(false);
expect(isNavigationSectionCollapsed("projects", { isMobileLayout: true, expanded: "sessions", state })).toBe(true);
expect(isNavigationSectionCollapsed("sessions", { isMobileLayout: true, expanded: "sessions", state })).toBe(false);
});
it("toggles the effective section, including the implicit default section", () => {
const state = { selectedProject: undefined, selectedWorkspace: undefined };
expect(toggleNavigationSection(undefined, "projects", { isMobileLayout: true, state })).toBe("none");
expect(toggleNavigationSection("none", "projects", { isMobileLayout: true, state })).toBe("projects");
expect(toggleNavigationSection("projects", "workspaces", { isMobileLayout: true, state })).toBe("workspaces");
});
it("does not mutate expanded section on desktop layouts", () => {
const state = { selectedProject: undefined, selectedWorkspace: undefined };
expect(toggleNavigationSection("projects", "projects", { isMobileLayout: false, state })).toBe("projects");
});
});
@@ -0,0 +1,81 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
export type NavigationSection = "machines" | "projects" | "workspaces" | "sessions";
export type ExpandedNavigationSection = NavigationSection | "none" | undefined;
export interface NavigationSelectionState {
selectedProject: object | undefined;
selectedWorkspace: object | undefined;
}
export function defaultNavigationSection(state: NavigationSelectionState): NavigationSection {
if (state.selectedProject === undefined) return "projects";
if (state.selectedWorkspace === undefined) return "workspaces";
return "sessions";
}
export function expandedNavigationSection(expanded: ExpandedNavigationSection, state: NavigationSelectionState): NavigationSection | undefined {
if (expanded === "none") return undefined;
return expanded ?? defaultNavigationSection(state);
}
export function isNavigationSectionCollapsed(section: NavigationSection, options: { isMobileLayout: boolean; expanded: ExpandedNavigationSection; state: NavigationSelectionState }): boolean {
return options.isMobileLayout && expandedNavigationSection(options.expanded, options.state) !== section;
}
export function toggleNavigationSection(expanded: ExpandedNavigationSection, section: NavigationSection, options: { isMobileLayout: boolean; state: NavigationSelectionState }): ExpandedNavigationSection {
if (!options.isMobileLayout) return expanded;
return expandedNavigationSection(expanded, options.state) === section ? "none" : section;
}
export function expandNavigationSection(expanded: ExpandedNavigationSection, section: NavigationSection, isMobileLayout: boolean): ExpandedNavigationSection {
return isMobileLayout ? section : expanded;
}
export class MobileNavigationController implements ReactiveController {
private expanded: ExpandedNavigationSection;
hostConnected(): void {
return;
}
constructor(
private readonly host: ReactiveControllerHost,
private readonly getState: () => NavigationSelectionState,
private readonly isMobileLayout: () => boolean,
) {
host.addController(this);
}
expandedSection(): NavigationSection | undefined {
return expandedNavigationSection(this.expanded, this.getState());
}
isCollapsed(section: NavigationSection): boolean {
return isNavigationSectionCollapsed(section, {
isMobileLayout: this.isMobileLayout(),
expanded: this.expanded,
state: this.getState(),
});
}
toggle(section: NavigationSection): void {
this.setExpanded(toggleNavigationSection(this.expanded, section, { isMobileLayout: this.isMobileLayout(), state: this.getState() }));
}
expand(section: NavigationSection): void {
this.setExpanded(expandNavigationSection(this.expanded, section, this.isMobileLayout()));
}
open(section: NavigationSection, openNavigationView: () => void): void {
if (!this.isMobileLayout()) return;
this.expand(section);
openNavigationView();
}
private setExpanded(expanded: ExpandedNavigationSection): void {
if (this.expanded === expanded) return;
this.expanded = expanded;
this.host.requestUpdate();
}
}
@@ -0,0 +1,40 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import type { AppState } from "../appState";
export class PanelCollapseController implements ReactiveController {
navigationPanelCollapsed = false;
workspacePanelCollapsed = false;
hostConnected(): void {
return;
}
constructor(private readonly host: ReactiveControllerHost) {
host.addController(this);
}
toggleNavigationPanel(): void {
this.navigationPanelCollapsed = !this.navigationPanelCollapsed;
this.host.requestUpdate();
}
toggleWorkspacePanel(): void {
this.workspacePanelCollapsed = !this.workspacePanelCollapsed;
this.host.requestUpdate();
}
shellClass(mainView: AppState["mainView"]): string {
return [
"shell",
mainViewClass(mainView),
...(this.navigationPanelCollapsed ? ["navigation-panel-collapsed"] : []),
...(this.workspacePanelCollapsed ? ["workspace-panel-collapsed"] : []),
].join(" ");
}
}
export function mainViewClass(mainView: AppState["mainView"]): "navigation-view" | "chat-view" | "workspace-view" {
if (mainView === "navigation") return "navigation-view";
if (mainView === "chat") return "chat-view";
return "workspace-view";
}
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { VIEWPORT_POSITION_REPAIR_DELAY_MS, ViewportPositionRepairer, type ViewportPositionRepairScheduler } from "./viewportPositionRepair";
class FakeViewportScheduler implements ViewportPositionRepairScheduler {
documentElement = { scrollTop: 12 };
body = { scrollTop: 34 };
scrollCalls: [number, number][] = [];
animationFrames = new Map<number, () => void>();
timers = new Map<number, { callback: () => void; delayMs: number }>();
canceledAnimationFrames: number[] = [];
clearedTimers: number[] = [];
private nextId = 1;
requestAnimationFrame(callback: () => void): number {
const id = this.nextId;
this.nextId += 1;
this.animationFrames.set(id, callback);
return id;
}
cancelAnimationFrame(id: number): void {
this.canceledAnimationFrames.push(id);
this.animationFrames.delete(id);
}
setTimeout(callback: () => void, delayMs: number): number {
const id = this.nextId;
this.nextId += 1;
this.timers.set(id, { callback, delayMs });
return id;
}
clearTimeout(id: number): void {
this.clearedTimers.push(id);
this.timers.delete(id);
}
scrollTo(x: number, y: number): void {
this.scrollCalls.push([x, y]);
}
runAnimationFrame(id: number): void {
const callback = this.animationFrames.get(id);
if (callback === undefined) throw new Error(`Animation frame ${String(id)} not scheduled`);
this.animationFrames.delete(id);
callback();
}
runTimer(id: number): void {
const timer = this.timers.get(id);
if (timer === undefined) throw new Error(`Timer ${String(id)} not scheduled`);
this.timers.delete(id);
timer.callback();
}
}
function firstMapKey<K, V>(map: Map<K, V>): K {
const key = map.keys().next().value;
if (key === undefined) throw new Error("Expected map to have a key");
return key;
}
function firstMapEntry<K, V>(map: Map<K, V>): [K, V] {
const entry = map.entries().next().value;
if (entry === undefined) throw new Error("Expected map to have an entry");
return entry;
}
describe("ViewportPositionRepairer", () => {
it("resets viewport position immediately, across two animation frames, and on a delayed timer", () => {
const scheduler = new FakeViewportScheduler();
const repairer = new ViewportPositionRepairer(scheduler);
repairer.repair(true);
expect(scheduler.scrollCalls).toEqual([[0, 0]]);
expect(scheduler.documentElement.scrollTop).toBe(0);
expect(scheduler.body.scrollTop).toBe(0);
const firstFrame = firstMapKey(scheduler.animationFrames);
const timer = firstMapEntry(scheduler.timers);
expect(timer[1].delayMs).toBe(VIEWPORT_POSITION_REPAIR_DELAY_MS);
scheduler.runAnimationFrame(firstFrame);
expect(scheduler.scrollCalls).toHaveLength(2);
const secondFrame = firstMapKey(scheduler.animationFrames);
scheduler.runAnimationFrame(secondFrame);
expect(scheduler.scrollCalls).toHaveLength(3);
scheduler.runTimer(timer[0]);
expect(scheduler.scrollCalls).toHaveLength(4);
});
it("replaces pending scheduled repairs", () => {
const scheduler = new FakeViewportScheduler();
const repairer = new ViewportPositionRepairer(scheduler);
repairer.repair(true);
const firstFrame = firstMapKey(scheduler.animationFrames);
const firstTimer = firstMapKey(scheduler.timers);
repairer.repair(true);
expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]);
expect(scheduler.clearedTimers).toEqual([firstTimer]);
});
it("clears pending work when repair is no longer needed", () => {
const scheduler = new FakeViewportScheduler();
const repairer = new ViewportPositionRepairer(scheduler);
repairer.repair(true);
const firstFrame = firstMapKey(scheduler.animationFrames);
const firstTimer = firstMapKey(scheduler.timers);
repairer.repair(false);
expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]);
expect(scheduler.clearedTimers).toEqual([firstTimer]);
});
});
@@ -0,0 +1,87 @@
export const VIEWPORT_POSITION_REPAIR_DELAY_MS = 250;
export interface ViewportPositionRepairScheduler {
requestAnimationFrame(callback: () => void): number;
cancelAnimationFrame(id: number): void;
setTimeout(callback: () => void, delayMs: number): number;
clearTimeout(id: number): void;
scrollTo(x: number, y: number): void;
readonly documentElement: { scrollTop: number } | undefined;
readonly body: { scrollTop: number } | undefined;
}
export class ViewportPositionRepairer {
private repairFrame: number | undefined;
private repairTimer: number | undefined;
constructor(private readonly scheduler: ViewportPositionRepairScheduler = createBrowserViewportPositionRepairScheduler()) {}
repair(shouldRepair: boolean): void {
if (!shouldRepair) {
this.clear();
return;
}
this.resetViewportScroll();
if (this.repairFrame !== undefined) this.scheduler.cancelAnimationFrame(this.repairFrame);
this.repairFrame = this.scheduler.requestAnimationFrame(() => {
this.repairFrame = undefined;
this.resetViewportScroll();
this.repairFrame = this.scheduler.requestAnimationFrame(() => {
this.repairFrame = undefined;
this.resetViewportScroll();
});
});
if (this.repairTimer !== undefined) this.scheduler.clearTimeout(this.repairTimer);
this.repairTimer = this.scheduler.setTimeout(() => {
this.repairTimer = undefined;
this.resetViewportScroll();
}, VIEWPORT_POSITION_REPAIR_DELAY_MS);
}
clear(): void {
if (this.repairFrame !== undefined) {
this.scheduler.cancelAnimationFrame(this.repairFrame);
this.repairFrame = undefined;
}
if (this.repairTimer !== undefined) {
this.scheduler.clearTimeout(this.repairTimer);
this.repairTimer = undefined;
}
}
private resetViewportScroll(): void {
this.scheduler.scrollTo(0, 0);
const documentElement = this.scheduler.documentElement;
if (documentElement !== undefined) documentElement.scrollTop = 0;
const body = this.scheduler.body;
if (body !== undefined) body.scrollTop = 0;
}
}
export function createBrowserViewportPositionRepairScheduler(): ViewportPositionRepairScheduler {
return {
requestAnimationFrame(callback: () => void): number {
return window.requestAnimationFrame(callback);
},
cancelAnimationFrame(id: number): void {
window.cancelAnimationFrame(id);
},
setTimeout(callback: () => void, delayMs: number): number {
return window.setTimeout(callback, delayMs);
},
clearTimeout(id: number): void {
window.clearTimeout(id);
},
scrollTo(x: number, y: number): void {
window.scrollTo(x, y);
},
get documentElement(): { scrollTop: number } | undefined {
return typeof document === "undefined" ? undefined : document.documentElement;
},
get body(): { scrollTop: number } | undefined {
return typeof document === "undefined" ? undefined : document.body;
},
};
}
+2
View File
@@ -12,6 +12,7 @@ export interface AppState {
sessions: SessionInfo[];
messages: ChatLine[];
messagePageStart: number;
messagePageEnd: number;
messagePageTotal: number;
isLoadingEarlierMessages: boolean;
isReceivingPartialStream: boolean;
@@ -104,6 +105,7 @@ export function initialAppState(): AppState {
sessions: [],
messages: [],
messagePageStart: 0,
messagePageEnd: 0,
messagePageTotal: 0,
isLoadingEarlierMessages: false,
isReceivingPartialStream: false,
@@ -29,10 +29,26 @@ describe("ChatTranscriptStore", () => {
{ role: "assistant", parts: [{ type: "text", text: "hello" }] },
],
messagePageStart: 0,
messagePageEnd: 2,
messagePageTotal: 2,
});
});
it("tracks the raw page end separately from normalized display messages", () => {
const store = new ChatTranscriptStore(new MemoryChatHistoryCache());
const view = store.mergeHistory("s1", page(0, 3, [
{ role: "user", content: "run the tool" },
{ role: "assistant", content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "src/app.ts" } }] },
{ role: "toolResult", toolCallId: "tool-1", toolName: "read", content: [{ type: "text", text: "ok" }] },
]));
expect(view.messages).toHaveLength(2);
expect(view.messagePageStart).toBe(0);
expect(view.messagePageEnd).toBe(3);
expect(view.messagePageTotal).toBe(3);
});
it("keeps live streamed transcript state out of the raw history cache", () => {
const cache = new MemoryChatHistoryCache();
const store = new ChatTranscriptStore(cache);
@@ -51,6 +67,7 @@ describe("ChatTranscriptStore", () => {
{ role: "user", parts: [{ type: "text", text: "next" }] },
],
messagePageStart: 0,
messagePageEnd: 3,
messagePageTotal: 3,
});
});
+6 -1
View File
@@ -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,
};
}
+8 -2
View File
@@ -43,6 +43,7 @@ export class ChatView extends LitElement {
@property({ attribute: false }) messages: ChatLine[] = [];
@property() sessionId = "";
@property({ type: Number }) messageStart = 0;
@property({ type: Number }) messageEnd = 0;
@property({ type: Number }) messageTotal = 0;
@property({ type: Boolean }) hasMore = false;
@property({ type: Boolean }) loadingMore = false;
@@ -307,8 +308,13 @@ export class ChatView extends LitElement {
private historyRangeLabel() {
if (!this.messages.length || this.messageTotal <= 0) return null;
const from = this.messageStart + 1;
const to = this.messageStart + this.messages.length;
return html`<small>Showing messages ${from}${to} of ${this.messageTotal}</small>`;
const to = this.loadedRawMessageEnd();
const total = Math.max(this.messageTotal, to);
return html`<small>Showing messages ${from}${to} of ${total}</small>`;
}
private loadedRawMessageEnd(): number {
return Math.max(this.messageEnd, this.messageStart + this.messages.length);
}
private renderMessage(message: ChatLine, index: number) {
+148 -409
View File
@@ -23,7 +23,9 @@ import { themePackPlugin } from "../plugins/themes";
import { loadExternalPlugins } from "../plugins/external";
import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry";
import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs";
import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMode";
import { AppShellController } from "../appShell/appShellController";
import { MobileNavigationController, type NavigationSection } from "../appShell/navigationState";
import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController";
import { readRoute, writeRoute, type AppRoute } from "../route";
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionMetadata, workspaceDeletionRunFilter } from "../workspaceDeletion";
@@ -42,10 +44,14 @@ import "./AuthDialog";
import "./ProjectDialog";
import "./WorkspacePanel";
import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
import { actionMenuPanelStyle } from "./actionMenu";
import "./appShell/AppContextBar";
import "./appShell/AppMobileMainTabs";
import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs";
import "./appShell/AppNavigationPanel";
import "./appShell/AppPanelEdgeControl";
import "./appShell/AppRefreshControl";
import { appStyles } from "./shared";
type NavigationSection = "machines" | "projects" | "workspaces" | "sessions";
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
@@ -53,16 +59,12 @@ const THEME_AUTO_ON_VALUE = "auto:on";
const THEME_AUTO_OFF_VALUE = "auto:off";
const THEME_OPTION_PREFIX = "theme:";
const TERMINAL_ROUTE_NAMESPACE = queryNamespace("core:workspace.terminal");
const REFRESH_LONG_PRESS_MS = 550;
@customElement("pi-web-app")
export class PiWebApp extends LitElement {
@state() private state: AppState = initialAppState();
@query("chat-view") private chatView?: ChatView;
@query("prompt-editor") private promptEditor?: PromptEditor;
@query(".context-items") private contextItems?: HTMLElement | null;
@query(".mobile-tabs") private mobileTabs?: HTMLElement | null;
@query(".app-refresh") private appRefresh?: HTMLElement | null;
private readonly sessions = new SessionController(
() => this.state,
@@ -109,13 +111,14 @@ export class PiWebApp extends LitElement {
private readonly realtime = new RealtimeSocket();
private readonly activeTerminalIds = new Set<string>();
private readonly terminalSelection = new InMemoryTerminalSelectionMemory();
private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined;
private readonly appShell = new AppShellController(this);
private readonly panelCollapse = new PanelCollapseController(this);
private readonly mobileNavigation = new MobileNavigationController(
this,
() => this.state,
() => this.appShell.isMobileNavigationLayout,
);
private readonly systemLightThemeMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(prefers-color-scheme: light)") : undefined;
private readonly pwaDisplayModeMedia = createPwaDisplayModeMedia();
private observedContextItems: HTMLElement | undefined;
private observedMobileTabs: HTMLElement | undefined;
private contextItemsResizeObserver: ResizeObserver | undefined;
private mobileTabsResizeObserver: ResizeObserver | undefined;
private terminalAutoStartWorkspaceId: string | undefined;
private piWebStatusTimer: number | undefined;
private workspaceDeletionPollTimer: number | undefined;
@@ -126,21 +129,14 @@ export class PiWebApp extends LitElement {
private restoringRouteTerminalId: string | undefined;
private readonly plugins = createPluginRegistry();
private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE;
private refreshLongPressTimer: number | undefined;
private suppressNextRefreshClick = false;
@state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID;
@state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false;
@state() private isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia);
@state() private isRefreshingApp = false;
@state() private refreshMenuOpen = false;
@state() private refreshMenuStyle = "";
@state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined;
@state() private contextCanScrollLeft = false;
@state() private contextCanScrollRight = false;
@state() private mobileTabsCanScrollLeft = false;
@state() private mobileTabsCanScrollRight = false;
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
private readonly onPageShow = () => {
this.appShell.repairViewportPosition();
};
private readonly onFocus = () => {
this.appShell.repairViewportPosition();
void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus();
void this.refreshWorkspaceActivity();
@@ -148,59 +144,35 @@ export class PiWebApp extends LitElement {
};
private readonly onVisibilityChange = () => {
if (document.visibilityState === "visible") {
this.appShell.repairViewportPosition();
void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus();
void this.refreshWorkspaceActivity();
void this.refreshWorkspaceDeletionRuns();
}
};
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
this.isMobileNavigationLayout = event.matches;
this.updateContextScrollState();
this.updateMobileTabsScrollState();
};
private readonly onSystemLightThemeChange = () => {
if (this.themePreference.auto) this.applyPreferredTheme(false);
};
private readonly onPwaDisplayModeChange = () => {
this.isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia);
};
private readonly onContextScroll = () => {
this.updateContextScrollState();
};
private readonly onMobileTabsScroll = () => {
this.updateMobileTabsScrollState();
};
private readonly onDocumentClick = (event: MouseEvent) => {
const refresh = this.appRefreshElement();
if (refresh !== undefined && event.composedPath().includes(refresh)) return;
this.refreshMenuOpen = false;
this.suppressNextRefreshClick = false;
};
private readonly onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && this.refreshMenuOpen) {
event.preventDefault();
event.stopPropagation();
this.refreshMenuOpen = false;
this.suppressNextRefreshClick = false;
return;
}
if (this.keyboard.handle(event, this.getActions())) {
event.preventDefault();
event.stopPropagation();
}
};
protected override willUpdate(): void {
this.toggleAttribute("pwa-display-mode", this.appShell.isPwaDisplayMode);
}
override connectedCallback(): void {
super.connectedCallback();
window.addEventListener("popstate", this.onPopState);
window.addEventListener("pageshow", this.onPageShow);
window.addEventListener("focus", this.onFocus);
document.addEventListener("click", this.onDocumentClick);
document.addEventListener("visibilitychange", this.onVisibilityChange);
window.addEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange);
this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange);
for (const media of this.pwaDisplayModeMedia) media.addEventListener("change", this.onPwaDisplayModeChange);
this.applyPreferredTheme(false);
this.connectRealtime();
this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS);
@@ -212,13 +184,11 @@ export class PiWebApp extends LitElement {
override disconnectedCallback(): void {
window.removeEventListener("popstate", this.onPopState);
window.removeEventListener("pageshow", this.onPageShow);
window.removeEventListener("focus", this.onFocus);
document.removeEventListener("click", this.onDocumentClick);
document.removeEventListener("visibilitychange", this.onVisibilityChange);
window.removeEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange);
this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange);
for (const media of this.pwaDisplayModeMedia) media.removeEventListener("change", this.onPwaDisplayModeChange);
this.keyboard.reset();
this.auth.dispose();
this.sessions.dispose();
@@ -228,30 +198,9 @@ export class PiWebApp extends LitElement {
this.piWebStatusTimer = undefined;
if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer);
this.workspaceDeletionPollTimer = undefined;
this.contextItemsResizeObserver?.disconnect();
this.contextItemsResizeObserver = undefined;
this.observedContextItems = undefined;
this.mobileTabsResizeObserver?.disconnect();
this.mobileTabsResizeObserver = undefined;
this.observedMobileTabs = undefined;
this.clearRefreshLongPressTimer();
super.disconnectedCallback();
}
override firstUpdated(): void {
this.observeContextItems();
this.observeMobileTabs();
this.updateContextScrollState();
this.updateMobileTabsScrollState();
}
override updated(): void {
this.observeContextItems();
this.observeMobileTabs();
this.updateContextScrollState();
this.updateMobileTabsScrollState();
}
private setState(patch: Partial<AppState>) {
if (!patchChangesState(this.state, patch)) return;
const previous = this.state;
@@ -264,9 +213,11 @@ export class PiWebApp extends LitElement {
private async loadProjectsAndRestoreRoute() {
const route = readRoute();
await this.machines.loadMachines(route.machineId);
const machineFallbackMessage = this.state.error;
const effectiveRoute = this.routeForSelectedMachine(route);
if (effectiveRoute !== route) this.replaceRouteAndClearWorkspaceQuery(effectiveRoute);
await this.projects.loadProjects();
if (machineFallbackMessage !== "" && this.state.error === "") this.setState({ error: machineFallbackMessage });
await this.withChatScrollTransition(() => this.restoreRouteFor(effectiveRoute, false));
await this.refreshWorkspaceDeletionRuns();
}
@@ -289,8 +240,6 @@ export class PiWebApp extends LitElement {
private async refreshAppData(): Promise<void> {
if (this.isRefreshingApp) return;
this.refreshMenuOpen = false;
this.suppressNextRefreshClick = false;
this.isRefreshingApp = true;
try {
await Promise.all([
@@ -393,7 +342,11 @@ export class PiWebApp extends LitElement {
await this.chatView?.updateComplete;
await nextFrame();
this.chatView?.restoreScrollPosition();
this.promptEditor?.focusInput();
if (this.shouldAutoFocusPrompt()) this.promptEditor?.focusInput();
}
private shouldAutoFocusPrompt(): boolean {
return this.appShell.shouldAutoFocusPrompt();
}
private async withChatPrependTransition(action: () => Promise<void>) {
@@ -403,7 +356,7 @@ export class PiWebApp extends LitElement {
}
private defaultRouteView(): AppState["mainView"] {
return this.isMobileNavigationLayout ? "navigation" : "chat";
return this.appShell.defaultRouteView();
}
private updateUrl(options?: { replace?: boolean | undefined }) {
@@ -585,7 +538,44 @@ export class PiWebApp extends LitElement {
const panelContext = workspace === undefined ? undefined : this.createWorkspacePanelContext(workspace);
const workspaceLabelItems = workspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, workspace);
const emptyState = workspace === undefined ? this.workspacePanelEmptyState() : undefined;
return html`<workspace-panel .workspace=${workspace} .panelContext=${panelContext} .emptyState=${emptyState} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(tool); }}></workspace-panel>`;
return html`
<workspace-panel
id="workspace-panel"
.workspace=${workspace}
.panelContext=${panelContext}
.emptyState=${emptyState}
.tool=${this.state.workspaceTool}
.panels=${this.visibleWorkspacePanels()}
.workspaceLabelItems=${workspaceLabelItems}
.onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(tool); }}
></workspace-panel>
`;
}
private renderNavigationPanelEdgeControl() {
return html`
<app-panel-edge-control
side="navigation"
controls="navigation-panel"
expandLabel="Expand navigation panel"
collapseLabel="Collapse navigation panel"
.collapsed=${this.panelCollapse.navigationPanelCollapsed}
.onToggle=${() => { this.panelCollapse.toggleNavigationPanel(); }}
></app-panel-edge-control>
`;
}
private renderWorkspacePanelEdgeControl() {
return html`
<app-panel-edge-control
side="workspace"
controls="workspace-panel"
expandLabel="Expand workspace panel"
collapseLabel="Collapse workspace panel"
.collapsed=${this.panelCollapse.workspacePanelCollapsed}
.onToggle=${() => { this.panelCollapse.toggleWorkspacePanel(); }}
></app-panel-edge-control>
`;
}
private renderNavigationPanel(autoSwitchToChat: boolean) {
@@ -595,103 +585,62 @@ export class PiWebApp extends LitElement {
if (autoSwitchToChat) this.updateUrl();
});
return html`
<header>
<strong>PI WEB</strong>
<div class="header-actions">
${this.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : null}
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.setState({ actionPaletteOpen: true }); }}>Actions</button>
</div>
</header>
<machine-list
<app-navigation-panel
.machines=${this.state.machines}
.selected=${this.state.selectedMachine}
.statuses=${this.state.machineStatuses}
.collapsible=${this.isMobileNavigationLayout}
.collapsed=${this.isNavigationSectionCollapsed("machines")}
.onToggleCollapsed=${() => { this.toggleNavigationSection("machines"); }}
.onSelect=${(machine: Machine) => this.withChatScrollTransition(async () => {
this.expandNavigationSection("projects");
.selectedMachine=${this.state.selectedMachine}
.machineStatuses=${this.state.machineStatuses}
.machinesCollapsed=${this.mobileNavigation.isCollapsed("machines")}
.onToggleMachines=${() => { this.mobileNavigation.toggle("machines"); }}
.onSelectMachine=${(machine: Machine) => this.withChatScrollTransition(async () => {
this.mobileNavigation.expand("projects");
await this.machines.selectMachine(machine);
})}
></machine-list>
<project-list
.projects=${this.state.projects}
.selected=${this.state.selectedProject}
.activities=${this.state.workspaceActivities}
.selectedProject=${this.state.selectedProject}
.workspaceActivities=${this.state.workspaceActivities}
.workspacesByProjectId=${this.state.workspacesByProjectId}
.collapsible=${this.isMobileNavigationLayout}
.collapsed=${this.isNavigationSectionCollapsed("projects")}
.onToggleCollapsed=${() => { this.toggleNavigationSection("projects"); }}
.onSelect=${(project: Project) => this.withChatScrollTransition(async () => {
this.expandNavigationSection("workspaces");
.workspaces=${this.state.workspaces}
.selectedWorkspace=${this.state.selectedWorkspace}
.deletingWorkspaceIds=${pendingWorkspaceDeletionIds(this.state.workspaceDeletionRuns)}
.sessions=${this.state.sessions}
.sessionStatuses=${this.state.sessionStatuses}
.sessionActivities=${this.state.sessionActivities}
.selectedSession=${this.state.selectedSession}
.canStartSession=${!!this.state.selectedWorkspace}
.collapsible=${this.appShell.isMobileNavigationLayout}
.projectsCollapsed=${this.mobileNavigation.isCollapsed("projects")}
.workspacesCollapsed=${this.mobileNavigation.isCollapsed("workspaces")}
.sessionsCollapsed=${this.mobileNavigation.isCollapsed("sessions")}
.workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)}
.refreshControl=${this.appShell.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : undefined}
.onShowActions=${() => { this.setState({ actionPaletteOpen: true }); }}
.onToggleProjects=${() => { this.mobileNavigation.toggle("projects"); }}
.onToggleWorkspaces=${() => { this.mobileNavigation.toggle("workspaces"); }}
.onToggleSessions=${() => { this.mobileNavigation.toggle("sessions"); }}
.onSelectProject=${(project: Project) => this.withChatScrollTransition(async () => {
this.mobileNavigation.expand("workspaces");
await this.workspaces.selectProject(project);
})}
.onClose=${(project: Project) => this.projects.closeProject(project.id)}
></project-list>
<workspace-list
.workspaces=${this.state.workspaces}
.selected=${this.state.selectedWorkspace}
.activities=${this.state.workspaceActivities}
.deletingWorkspaceIds=${pendingWorkspaceDeletionIds(this.state.workspaceDeletionRuns)}
.collapsible=${this.isMobileNavigationLayout}
.collapsed=${this.isNavigationSectionCollapsed("workspaces")}
.workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)}
.onToggleCollapsed=${() => { this.toggleNavigationSection("workspaces"); }}
.onSelect=${(workspace: Workspace) => this.withChatScrollTransition(async () => {
this.expandNavigationSection("sessions");
.onCloseProject=${(project: Project) => this.projects.closeProject(project.id)}
.onSelectWorkspace=${(workspace: Workspace) => this.withChatScrollTransition(async () => {
this.mobileNavigation.expand("sessions");
await this.workspaces.selectWorkspace(workspace);
})}
.onDelete=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
></workspace-list>
<session-list
.sessions=${this.state.sessions}
.statuses=${this.state.sessionStatuses}
.activities=${this.state.sessionActivities}
.selected=${this.state.selectedSession}
.canStart=${!!this.state.selectedWorkspace}
.collapsible=${this.isMobileNavigationLayout}
.collapsed=${this.isNavigationSectionCollapsed("sessions")}
.onToggleCollapsed=${() => { this.toggleNavigationSection("sessions"); }}
.onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
.onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }}
.onStart=${() => openChatAfter(() => this.sessions.startSession())}
.onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))}
.onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)}
.onArchiveWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
.onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))}
.onDelete=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)}
.onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)}
></session-list>
.onStartSession=${() => openChatAfter(() => this.sessions.startSession())}
.onSelectSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))}
.onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)}
.onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
.onRestoreSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))}
.onDeleteCachedNewSession=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)}
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
></app-navigation-panel>
`;
}
private expandedNavigationSection(): NavigationSection | undefined {
if (this.expandedMobileNavigationSection === "none") return undefined;
return this.expandedMobileNavigationSection ?? this.defaultNavigationSection();
}
private defaultNavigationSection(): NavigationSection {
if (this.state.selectedProject === undefined) return "projects";
if (this.state.selectedWorkspace === undefined) return "workspaces";
return "sessions";
}
private isNavigationSectionCollapsed(section: NavigationSection): boolean {
return this.isMobileNavigationLayout && this.expandedNavigationSection() !== section;
}
private toggleNavigationSection(section: NavigationSection): void {
if (!this.isMobileNavigationLayout) return;
this.expandedMobileNavigationSection = this.expandedNavigationSection() === section ? "none" : section;
}
private expandNavigationSection(section: NavigationSection): void {
if (this.isMobileNavigationLayout) this.expandedMobileNavigationSection = section;
}
private openNavigationSection(section: NavigationSection): void {
if (!this.isMobileNavigationLayout) return;
this.expandNavigationSection(section);
this.selectMainView("navigation");
this.mobileNavigation.open(section, () => { this.selectMainView("navigation"); });
}
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
@@ -827,6 +776,7 @@ export class PiWebApp extends LitElement {
deleteWorkspace: (workspace) => this.deleteWorkspace(workspace),
startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()),
archiveSession: () => this.sessions.archiveSession(),
deleteCachedNewSession: () => this.sessions.deleteCachedNewSession(),
stopActiveWork: () => this.sessions.stopActiveWork(),
}, createContext);
return createContext("core");
@@ -1111,232 +1061,54 @@ export class PiWebApp extends LitElement {
}
private renderContextBar() {
const machine = this.state.selectedMachine;
const project = this.state.selectedProject;
const workspace = this.state.selectedWorkspace;
const session = this.state.selectedSession;
const machineLabel = machineContextLabel(machine);
const projectLabel = projectContextLabel(project);
const showRefresh = this.shouldShowAppRefreshInContextBar();
const workspaceLabel = workspaceContextLabel(workspace);
const sessionLabel = sessionContextLabel(session);
if (!this.appShell.isMobileNavigationLayout) return null;
return html`
<nav class=${this.contextBarClass()} aria-label="Current location">
<span class="context-bar-label">Location</span>
<ol class="context-items" @scroll=${this.onContextScroll}>
<li class="context-item">
<button type="button" class=${machine === undefined ? "context-chip empty" : "context-chip"} title=${machineContextTitle(machine)} aria-label=${`Machine: ${machineLabel}. Open machine selection.`} @click=${() => { this.openNavigationSection("machines"); }}>
<span class="context-kind">Machine</span>
<span class="context-value">${machineLabel}</span>
</button>
</li>
<li class="context-item">
<button type="button" class=${project === undefined ? "context-chip empty" : "context-chip"} title=${projectContextTitle(project)} aria-label=${`Project: ${projectLabel}. Open project selection.`} @click=${() => { this.openNavigationSection("projects"); }}>
<span class="context-kind">Project</span>
<span class="context-value">${projectLabel}</span>
</button>
</li>
<li class="context-item">
<button type="button" class=${workspace === undefined ? "context-chip empty" : "context-chip"} title=${workspaceContextTitle(workspace)} aria-label=${`Workspace: ${workspaceLabel}. Open workspace selection.`} @click=${() => { this.openNavigationSection("workspaces"); }}>
<span class="context-kind">Workspace</span>
<span class="context-value">${workspaceLabel}</span>
</button>
</li>
<li class="context-item">
<button type="button" class=${session === undefined ? "context-chip empty" : "context-chip"} title=${sessionContextTitle(session)} aria-label=${`Session: ${sessionLabel}. Open session selection.`} @click=${() => { this.openNavigationSection("sessions"); }}>
<span class="context-kind">Session</span>
<span class="context-value">${sessionLabel}</span>
</button>
</li>
</ol>
${showRefresh ? html`<div class="context-actions">${this.renderAppRefresh()}</div>` : null}
</nav>
<app-context-bar
.machine=${this.state.selectedMachine}
.project=${this.state.selectedProject}
.workspace=${this.state.selectedWorkspace}
.session=${this.state.selectedSession}
.refreshControl=${this.appShell.shouldShowAppRefreshInContextBar() ? this.renderAppRefresh() : undefined}
.onOpenSection=${(section: NavigationSection) => { this.openNavigationSection(section); }}
></app-context-bar>
`;
}
private contextBarClass(): string {
const classes = ["context-bar"];
if (this.shouldShowAppRefreshInContextBar()) classes.push("has-context-actions");
if (this.contextCanScrollLeft) classes.push("can-scroll-left");
if (this.contextCanScrollRight) classes.push("can-scroll-right");
return classes.join(" ");
private renderMobileMainTabs() {
return html`
<app-mobile-main-tabs
.tabs=${this.mobileMainTabs()}
.selectedView=${this.state.mainView}
.onSelect=${(view: AppState["mainView"]) => { this.selectMainView(view); }}
></app-mobile-main-tabs>
`;
}
private shouldShowAppRefreshInHeader(): boolean {
return this.isPwaDisplayMode && !this.isMobileNavigationLayout;
}
private shouldShowAppRefreshInContextBar(): boolean {
return this.isPwaDisplayMode && this.isMobileNavigationLayout;
}
private mobileTabsFrameClass(): string {
return `mobile-tabs-frame${this.mobileTabsCanScrollLeft ? " can-scroll-left" : ""}${this.mobileTabsCanScrollRight ? " can-scroll-right" : ""}`;
}
private observeContextItems(): void {
const contextItems = this.contextItemsElement();
if (this.observedContextItems === contextItems) return;
this.contextItemsResizeObserver?.disconnect();
this.observedContextItems = contextItems;
this.contextItemsResizeObserver = undefined;
if (contextItems === undefined || typeof ResizeObserver === "undefined") return;
this.contextItemsResizeObserver = new ResizeObserver(() => {
this.updateContextScrollState();
});
this.contextItemsResizeObserver.observe(contextItems);
}
private updateContextScrollState(): void {
const contextItems = this.contextItemsElement();
const maxScrollLeft = contextItems === undefined ? 0 : Math.max(0, contextItems.scrollWidth - contextItems.clientWidth);
const canScrollLeft = contextItems !== undefined && contextItems.scrollLeft > 1;
const canScrollRight = contextItems !== undefined && maxScrollLeft - contextItems.scrollLeft > 1;
if (this.contextCanScrollLeft !== canScrollLeft) this.contextCanScrollLeft = canScrollLeft;
if (this.contextCanScrollRight !== canScrollRight) this.contextCanScrollRight = canScrollRight;
}
private contextItemsElement(): HTMLElement | undefined {
const contextItems = this.contextItems;
return contextItems instanceof HTMLElement ? contextItems : undefined;
}
private observeMobileTabs(): void {
const mobileTabs = this.mobileTabsElement();
if (this.observedMobileTabs === mobileTabs) return;
this.mobileTabsResizeObserver?.disconnect();
this.observedMobileTabs = mobileTabs;
this.mobileTabsResizeObserver = undefined;
if (mobileTabs === undefined || typeof ResizeObserver === "undefined") return;
this.mobileTabsResizeObserver = new ResizeObserver(() => {
this.updateMobileTabsScrollState();
});
this.mobileTabsResizeObserver.observe(mobileTabs);
}
private updateMobileTabsScrollState(): void {
const mobileTabs = this.mobileTabsElement();
const maxScrollLeft = mobileTabs === undefined ? 0 : Math.max(0, mobileTabs.scrollWidth - mobileTabs.clientWidth);
const canScrollLeft = mobileTabs !== undefined && mobileTabs.scrollLeft > 1;
const canScrollRight = mobileTabs !== undefined && maxScrollLeft - mobileTabs.scrollLeft > 1;
if (this.mobileTabsCanScrollLeft !== canScrollLeft) this.mobileTabsCanScrollLeft = canScrollLeft;
if (this.mobileTabsCanScrollRight !== canScrollRight) this.mobileTabsCanScrollRight = canScrollRight;
}
private mobileTabsElement(): HTMLElement | undefined {
const mobileTabs = this.mobileTabs;
return mobileTabs instanceof HTMLElement ? mobileTabs : undefined;
}
private appRefreshElement(): HTMLElement | undefined {
const appRefresh = this.appRefresh;
return appRefresh instanceof HTMLElement ? appRefresh : undefined;
private mobileMainTabs(): AppMobileMainTab[] {
return [
{ id: "navigation", label: "Sessions", className: "navigation-tab" },
{ id: "chat", label: "Chat" },
...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => ({ id: panel.id, label: this.renderMobilePanelTitle(panel) })),
];
}
private renderAppRefresh() {
const label = this.isRefreshingApp ? "Refreshing app data. Long-press for reload options." : "Refresh app data. Long-press for reload options.";
return html`
<div class="app-refresh">
<button
class=${`app-refresh-button${this.isRefreshingApp ? " refreshing" : ""}`}
title=${label}
aria-label=${label}
aria-haspopup="menu"
aria-expanded=${String(this.refreshMenuOpen)}
aria-busy=${String(this.isRefreshingApp)}
@click=${(event: MouseEvent) => { this.onRefreshClick(event); }}
@contextmenu=${(event: MouseEvent) => { this.onRefreshContextMenu(event); }}
@pointerdown=${(event: PointerEvent) => { this.onRefreshPointerDown(event); }}
@pointerup=${() => { this.clearRefreshLongPressTimer(); }}
@pointercancel=${() => { this.clearRefreshLongPressTimer(); }}
@pointerleave=${() => { this.clearRefreshLongPressTimer(); }}
>${this.renderRefreshIcon()}</button>
</div>
`;
}
private renderRefreshMenu() {
if (!this.refreshMenuOpen) return null;
return html`
<div class="app-refresh-menu" role="menu" style=${this.refreshMenuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
<button role="menuitem" @click=${() => { void this.refreshAppData(); }}>Refresh app data</button>
<button role="menuitem" @click=${() => { this.refreshMenuOpen = false; this.suppressNextRefreshClick = false; this.hardReloadApp(); }}>Full page reload</button>
</div>
`;
}
private renderRefreshIcon() {
return html`
<svg class="app-refresh-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M20 6v5h-5"></path>
<path d="M4 18v-5h5"></path>
<path d="M18.2 9A7 7 0 0 0 6.1 6.8L4 9"></path>
<path d="M5.8 15a7 7 0 0 0 12.1 2.2L20 15"></path>
</svg>
`;
}
private onRefreshClick(event: MouseEvent): void {
event.stopPropagation();
if (this.suppressNextRefreshClick) {
this.suppressNextRefreshClick = false;
return;
}
void this.refreshAppData();
}
private onRefreshPointerDown(event: PointerEvent): void {
if (!event.isPrimary || event.button !== 0) return;
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return;
this.clearRefreshLongPressTimer();
this.suppressNextRefreshClick = false;
this.refreshLongPressTimer = window.setTimeout(() => {
this.refreshLongPressTimer = undefined;
this.suppressNextRefreshClick = true;
this.openRefreshMenu(target);
}, REFRESH_LONG_PRESS_MS);
}
private onRefreshContextMenu(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.clearRefreshLongPressTimer();
this.suppressNextRefreshClick = true;
this.openRefreshMenu(event.currentTarget);
}
private openRefreshMenu(target: EventTarget | null): void {
this.refreshMenuStyle = actionMenuPanelStyle(target);
this.refreshMenuOpen = true;
}
private clearRefreshLongPressTimer(): void {
if (this.refreshLongPressTimer === undefined) return;
window.clearTimeout(this.refreshLongPressTimer);
this.refreshLongPressTimer = undefined;
return html`<app-refresh-control .isRefreshing=${this.isRefreshingApp} .onRefresh=${() => this.refreshAppData()} .onReload=${() => { this.hardReloadApp(); }}></app-refresh-control>`;
}
override render() {
const state = this.state;
return html`
<div class=${`shell ${state.mainView === "navigation" ? "navigation-view" : state.mainView === "chat" ? "chat-view" : "workspace-view"}`}>
<aside>${this.isMobileNavigationLayout ? null : this.renderNavigationPanel(false)}</aside>
<main class=${state.mainView === "chat" ? "chat-view" : state.mainView === "navigation" ? "navigation-view" : "workspace-view"}>
<div class=${this.panelCollapse.shellClass(state.mainView)}>
<aside id="navigation-panel">${this.appShell.isMobileNavigationLayout ? null : this.renderNavigationPanel(false)}</aside>
${this.renderNavigationPanelEdgeControl()}
<main class=${mainViewClass(state.mainView)}>
${this.renderContextBar()}
<div class=${this.mobileTabsFrameClass()}>
<div class="mobile-tabs" @scroll=${this.onMobileTabsScroll}>
<button class=${state.mainView === "navigation" ? "mobile-navigation-tab selected" : "mobile-navigation-tab"} @click=${() => { this.selectMainView("navigation"); }}>Sessions</button>
<button class=${state.mainView === "chat" ? "selected" : ""} @click=${() => { this.selectMainView("chat"); }}>Chat</button>
${this.visibleWorkspacePanels().map((panel) => html`
<button class=${state.mainView === panel.id ? "selected" : ""} @click=${() => { this.openWorkspaceTool(panel.id); }}>${this.renderMobilePanelTitle(panel)}</button>
`)}
</div>
</div>
${this.renderMobileMainTabs()}
${state.error ? html`<div class="error">${state.error}</div>` : null}
<div class="mobile-navigation-panel">${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
<status-bar .status=${state.status} .machine=${state.selectedMachine} .workspace=${state.selectedWorkspace} .workspaceLabelItems=${state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(state, state.selectedWorkspace)}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
@@ -1345,11 +1117,11 @@ export class PiWebApp extends LitElement {
${state.authDialog !== undefined ? html`<auth-dialog .state=${state.authDialog} .onChooseMethod=${(authType: "oauth" | "api_key") => { void this.auth.chooseLoginMethod(authType); }} .onSelectProvider=${(providerId: string, authType: "oauth" | "api_key") => { void this.auth.selectLoginProvider(providerId, authType); }} .onApiKeyInput=${(value: string) => { this.auth.updateApiKey(value); }} .onSaveApiKey=${() => { void this.auth.saveApiKey(); }} .onLogoutProvider=${(providerId: string) => { void this.auth.logoutProvider(providerId); }} .onOAuthInput=${(value: string) => { this.auth.updateOAuthInput(value); }} .onOAuthRespond=${(value?: string) => { void this.auth.respondOAuth(value); }} .onOAuthCancel=${() => { void this.auth.cancelOAuth(); }} .onCancel=${() => { this.auth.closeDialog(); }}></auth-dialog>` : null}
` : html`<div class="empty">${this.sessionEmptyMessage()}</div>`}
</main>
${this.renderWorkspacePanelEdgeControl()}
${this.renderWorkspacePanel()}
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null}
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
${this.renderRefreshMenu()}
</div>
`;
}
@@ -1364,39 +1136,6 @@ function createPluginRegistry(): PluginRegistry {
return registry;
}
function machineContextLabel(machine: Machine | undefined): string {
return machine === undefined ? "No machine" : `${machine.name}${machine.kind === "remote" ? " · remote" : ""}`;
}
function machineContextTitle(machine: Machine | undefined): string {
return machine === undefined ? "No machine selected" : machine.baseUrl ?? machine.name;
}
function projectContextLabel(project: Project | undefined): string {
return project?.name ?? "No project";
}
function projectContextTitle(project: Project | undefined): string {
return project === undefined ? "No project selected" : `${project.name}${project.path}`;
}
function workspaceContextLabel(workspace: Workspace | undefined): string {
return workspace === undefined ? "No workspace" : `${workspace.label}${workspace.isMain ? " · main" : ""} · ${workspace.path}`;
}
function workspaceContextTitle(workspace: Workspace | undefined): string {
return workspace === undefined ? "No workspace selected" : `${workspace.label}${workspace.isMain ? " · main" : ""}${workspace.path}`;
}
function sessionContextLabel(session: SessionInfo | undefined): string {
const name = session?.name?.trim();
const firstMessage = session?.firstMessage.trim();
return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session?.id.slice(0, 8) ?? "No session";
}
function sessionContextTitle(session: SessionInfo | undefined): string {
return session === undefined ? "No session selected" : session.path;
}
function patchChangesState(state: AppState, patch: Partial<AppState>): boolean {
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value);
+5 -1
View File
@@ -44,7 +44,9 @@ export class ProjectList extends LitElement {
return html`
<section>
<h2>${this.renderHeading()}</h2>
${this.collapsed ? null : this.projects.map((project) => html`
${this.collapsed ? null : html`
<div class="list-body">
${this.projects.map((project) => html`
<div
class=${`action-row ${this.selected?.id === project.id ? "selected" : ""}`}
tabindex="0"
@@ -65,6 +67,8 @@ export class ProjectList extends LitElement {
</div>
</div>
`)}
</div>
`}
</section>
`;
}
+13 -11
View File
@@ -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}"`;
}
+6 -2
View File
@@ -76,11 +76,15 @@ export class SessionList extends LitElement {
return html`
<section>
${this.renderHeading(activeRows.length + archivedRows.length)}
${this.collapsed ? null : activeRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0))}
${this.collapsed ? null : archivedRows.length > 0 ? html`
${this.collapsed ? null : html`
<div class="list-body">
${activeRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0))}
${archivedRows.length > 0 ? html`
<h2 class="subheading"><button class="section-toggle" aria-expanded=${String(this.archivedExpanded)} @click=${() => { this.toggleArchived(); }}><span>${this.archivedExpanded ? "▾" : "▸"} Archived</span><small>${archivedRows.length}</small></button></h2>
${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0)) : null}
` : null}
</div>
`}
</section>
`;
}
+97 -2
View File
@@ -5,6 +5,9 @@ import { FitAddon, type ITerminalDimensions } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import { terminalSocket, terminalsApi, type TerminalCommandRun, type TerminalInfo, type Workspace } from "../api";
import { selectFallbackTerminal, selectPreferredTerminal } from "../controllers/terminalSelection";
import { createTerminalSoftKeysDefaultEnvironmentMedia, hasTerminalSoftKeysPreference, initialTerminalSoftKeysEnabled, isTerminalSoftKeysDefaultEnvironment, writeTerminalSoftKeysPreference } from "../terminalSoftKeysPreference";
import "./TerminalSoftKeys";
import type { TerminalSoftKeyInputOptions } from "./TerminalSoftKeys";
const TERMINAL_OPTIONS_BASE: ITerminalOptions = {
cursorBlink: true,
@@ -32,6 +35,8 @@ export class TerminalPanel extends LitElement {
@state() private visible = false;
@state() private cancellingRunIds: string[] = [];
@state() private continuingTerminalIds: string[] = [];
@state() private defaultSoftKeysEnvironment = false;
@state() private softKeysEnabled = initialTerminalSoftKeysEnabled();
private terminal: Terminal | undefined;
private fitAddon: FitAddon | undefined;
@@ -44,9 +49,16 @@ export class TerminalPanel extends LitElement {
private loadedCwd: string | undefined;
private autoStartConsumedCwd: string | undefined;
private commandRunPollTimer: number | undefined;
private readonly softKeysDefaultEnvironmentMedia = createTerminalSoftKeysDefaultEnvironmentMedia();
private softKeysPreferenceStored = hasTerminalSoftKeysPreference();
private readonly onSoftKeysDefaultEnvironmentChange = () => {
this.syncDefaultSoftKeysEnvironment();
};
override connectedCallback(): void {
super.connectedCallback();
this.syncDefaultSoftKeysEnvironment();
this.softKeysDefaultEnvironmentMedia?.addEventListener("change", this.onSoftKeysDefaultEnvironmentChange);
this.themeObserver = new MutationObserver(() => { this.applyTerminalTheme(); });
this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] });
}
@@ -63,11 +75,24 @@ export class TerminalPanel extends LitElement {
this.intersectionObserver = undefined;
this.themeObserver?.disconnect();
this.themeObserver = undefined;
this.softKeysDefaultEnvironmentMedia?.removeEventListener("change", this.onSoftKeysDefaultEnvironmentChange);
this.updateCommandRunPolling(false);
this.disposeTerminalView();
super.disconnectedCallback();
}
private syncDefaultSoftKeysEnvironment(): void {
const nextDefaultEnvironment = isTerminalSoftKeysDefaultEnvironment(this.softKeysDefaultEnvironmentMedia);
const previousSoftKeysEnabled = this.softKeysEnabled;
this.defaultSoftKeysEnvironment = nextDefaultEnvironment;
if (!this.softKeysPreferenceStored) this.softKeysEnabled = nextDefaultEnvironment;
if (this.softKeysEnabled !== previousSoftKeysEnabled) this.scheduleFitAndNotify();
}
private scheduleFitAndNotify(): void {
void this.updateComplete.then(() => { this.fitAndNotify(); });
}
override willUpdate(changed: PropertyValues<this>): void {
const workspaceScope = this.workspace === undefined ? undefined : JSON.stringify([this.machineId, this.workspace.path]);
if (workspaceScope !== this.observedWorkspaceScope) {
@@ -287,8 +312,7 @@ export class TerminalPanel extends LitElement {
this.resizeObserver.observe(terminalHost);
terminal.onData((data) => {
if (this.suppressTerminalInput) return;
const filtered = filterTerminalInput(data);
if (filtered !== "") this.send({ type: "input", data: filtered });
this.sendTerminalInput(data);
});
const initialSize = this.fitTerminal();
this.connectSocket(workspace.projectId, workspace.id, this.selectedId, terminal, initialSize);
@@ -376,6 +400,23 @@ export class TerminalPanel extends LitElement {
if (this.terminal !== undefined) this.terminal.options.theme = terminalTheme(this);
}
private sendTerminalInput(data: string): void {
const filtered = filterTerminalInput(data);
if (filtered !== "") this.send({ type: "input", data: filtered });
}
private sendSoftKeyInput(data: string, options: TerminalSoftKeyInputOptions): void {
this.sendTerminalInput(data);
if (options.refocus) this.focusTerminal();
}
private focusTerminal(): void {
const terminal = this.terminal;
if (terminal === undefined) return;
terminal.focus();
requestAnimationFrame(() => { terminal.focus(); });
}
private send(message: { type: "input"; data: string } | { type: "resize"; cols: number; rows: number }): void {
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message));
}
@@ -423,10 +464,61 @@ export class TerminalPanel extends LitElement {
return null;
}
private selectedTerminalAcceptsInput(): boolean {
const terminal = this.selectedTerminalInfo();
return terminal !== undefined && !terminal.exited;
}
private shouldShowSoftKeys(): boolean {
return this.selectedTerminalAcceptsInput() && this.softKeysEnabled;
}
private shouldShowSoftKeysToggle(): boolean {
return this.selectedTerminalAcceptsInput();
}
private toggleSoftKeys(): void {
this.softKeysEnabled = !this.softKeysEnabled;
this.softKeysPreferenceStored = true;
writeTerminalSoftKeysPreference(this.softKeysEnabled);
this.scheduleFitAndNotify();
}
private renderSoftKeysToggle() {
if (!this.shouldShowSoftKeysToggle()) return null;
return html`
<button
type="button"
class=${this.softKeysEnabled ? "soft-keys-toggle selected" : "soft-keys-toggle"}
title=${this.softKeysEnabled ? "Hide terminal soft keys" : "Show terminal soft keys"}
aria-label=${this.softKeysEnabled ? "Hide terminal soft keys" : "Show terminal soft keys"}
aria-pressed=${String(this.softKeysEnabled)}
@click=${() => { this.toggleSoftKeys(); }}
>
<svg class="keyboard-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="3" y="5" width="18" height="14" rx="2"></rect>
<path d="M7 9h.01M10 9h.01M13 9h.01M16 9h.01M7 12h.01M10 12h.01M13 12h.01M16 12h.01M8 16h8"></path>
</svg>
<span>Keys</span>
</button>
`;
}
private renderSoftKeys() {
return html`
<terminal-soft-keys
.modes=${this.terminal?.modes}
.refocusOnClick=${!this.defaultSoftKeysEnvironment}
.onInput=${(data: string, options: TerminalSoftKeyInputOptions) => { this.sendSoftKeyInput(data, options); }}
></terminal-soft-keys>
`;
}
override render() {
return html`
<section class="terminal-shell">
<div class="terminal-tabs">
${this.renderSoftKeysToggle()}
${this.terminals.map((terminal) => html`
<button class=${this.selectedId === terminal.id ? "selected" : ""} @click=${() => { this.selectTerminal(terminal.id); }}>
<span>${terminal.name}${terminal.exited ? " · exited" : ""}</span>
@@ -437,6 +529,7 @@ export class TerminalPanel extends LitElement {
</div>
${this.error === undefined ? null : html`<p class="error">${this.error}</p>`}
${this.renderCommandRunNotice()}
${this.shouldShowSoftKeys() ? this.renderSoftKeys() : null}
${this.loading ? html`<p class="muted">Loading terminals…</p>` : null}
<div class="terminal-host"></div>
</section>
@@ -450,6 +543,8 @@ export class TerminalPanel extends LitElement {
button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; }
button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
button.new { flex: 0 0 auto; color: var(--pi-muted); }
.soft-keys-toggle { flex: 0 0 auto; }
.soft-keys-toggle .keyboard-icon { flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
button small { color: var(--pi-muted); font-size: 14px; line-height: 1; }
button small:hover { color: var(--pi-danger); }
@@ -0,0 +1,101 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { TERMINAL_SOFT_KEYS, terminalSoftKeySequence, type TerminalModesSnapshot, type TerminalSoftKeyDefinition } from "../terminalKeys";
const SOFT_KEY_TAP_MOVE_THRESHOLD_PX = 8;
const SYNTHETIC_CLICK_SUPPRESSION_MS = 500;
export interface TerminalSoftKeyInputOptions {
refocus: boolean;
}
@customElement("terminal-soft-keys")
export class TerminalSoftKeys extends LitElement {
@property({ attribute: false }) modes: TerminalModesSnapshot | undefined;
@property({ type: Boolean }) refocusOnClick = true;
@property({ attribute: false }) onInput: (data: string, options: TerminalSoftKeyInputOptions) => void = () => undefined;
private pointerStart: SoftKeyPointerStart | undefined;
private lastPointerFinishedAt = 0;
private sendSoftKey(key: TerminalSoftKeyDefinition, options: TerminalSoftKeyInputOptions): void {
this.onInput(terminalSoftKeySequence(key.id, this.modes), options);
}
private onSoftKeyPointerDown(event: PointerEvent, key: TerminalSoftKeyDefinition): void {
if (event.pointerType === "mouse" && event.button !== 0) return;
event.preventDefault();
this.pointerStart = { pointerId: event.pointerId, key, clientX: event.clientX, clientY: event.clientY };
}
private onSoftKeyPointerMove(event: PointerEvent): void {
const start = this.pointerStart;
if (start?.pointerId !== event.pointerId) return;
if (pointerMovedBeyondTap(start, event)) this.finishSoftKeyPointer();
}
private onSoftKeyPointerUp(event: PointerEvent, key: TerminalSoftKeyDefinition): void {
const start = this.pointerStart;
if (start?.pointerId !== event.pointerId) return;
event.preventDefault();
this.finishSoftKeyPointer();
if (start.key.id !== key.id || pointerMovedBeyondTap(start, event)) return;
this.sendSoftKey(key, { refocus: event.pointerType === "mouse" });
}
private onSoftKeyPointerCancel(event: PointerEvent): void {
if (this.pointerStart?.pointerId === event.pointerId) this.finishSoftKeyPointer();
}
private finishSoftKeyPointer(): void {
this.pointerStart = undefined;
this.lastPointerFinishedAt = Date.now();
}
private onSoftKeyClick(event: MouseEvent, key: TerminalSoftKeyDefinition): void {
if (Date.now() - this.lastPointerFinishedAt < SYNTHETIC_CLICK_SUPPRESSION_MS) {
event.preventDefault();
return;
}
this.sendSoftKey(key, { refocus: this.refocusOnClick });
}
override render() {
return html`
<div class="terminal-soft-keys" role="toolbar" aria-label="Terminal soft keys">
${TERMINAL_SOFT_KEYS.map((key) => html`
<button
type="button"
class="soft-key"
title=${key.title}
aria-label=${key.ariaLabel}
@pointerdown=${(event: PointerEvent) => { this.onSoftKeyPointerDown(event, key); }}
@pointermove=${(event: PointerEvent) => { this.onSoftKeyPointerMove(event); }}
@pointerup=${(event: PointerEvent) => { this.onSoftKeyPointerUp(event, key); }}
@pointercancel=${(event: PointerEvent) => { this.onSoftKeyPointerCancel(event); }}
@click=${(event: MouseEvent) => { this.onSoftKeyClick(event, key); }}
>${key.label}</button>
`)}
</div>
`;
}
static override styles = css`
:host { flex: 0 0 auto; display: block; }
.terminal-soft-keys { display: flex; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; touch-action: pan-x; }
.terminal-soft-keys::-webkit-scrollbar { display: none; }
button { display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; max-width: none; min-height: 34px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 6px 9px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; cursor: pointer; touch-action: pan-x; -webkit-touch-callout: none; user-select: none; }
button:disabled { opacity: .5; cursor: not-allowed; }
`;
}
interface SoftKeyPointerStart {
pointerId: number;
key: TerminalSoftKeyDefinition;
clientX: number;
clientY: number;
}
function pointerMovedBeyondTap(start: SoftKeyPointerStart, event: PointerEvent): boolean {
return Math.hypot(event.clientX - start.clientX, event.clientY - start.clientY) > SOFT_KEY_TAP_MOVE_THRESHOLD_PX;
}
+5 -1
View File
@@ -49,7 +49,9 @@ export class WorkspaceList extends LitElement {
return html`
<section>
<h2>${this.renderHeading()}</h2>
${this.collapsed ? null : this.workspaces.map((workspace) => {
${this.collapsed ? null : html`
<div class="list-body">
${this.workspaces.map((workspace) => {
const label = workspacePrimaryLabel(workspace);
const items = this.workspaceLabelItems(workspace);
return html`
@@ -67,6 +69,8 @@ export class WorkspaceList extends LitElement {
</div>
`;
})}
</div>
`}
</section>
`;
}
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { actionMenuPanelStyle } from "./actionMenu";
describe("actionMenuPanelStyle", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("can constrain menus to the viewport for compact shadow-root controls", () => {
vi.stubGlobal("window", { innerWidth: 400, innerHeight: 800 });
vi.stubGlobal("HTMLElement", FakeHTMLElement);
const target = new FakeHTMLElement({ top: 10, right: 390, bottom: 46, left: 354 });
expect(actionMenuPanelStyle(target, { constrainTo: "viewport" })).toBe("top: 46px; max-height: 754px; right: 10px; max-width: 390px;");
});
});
class FakeHTMLElement extends EventTarget {
constructor(private readonly rect: { top: number; right: number; bottom: number; left: number }) {
super();
}
getBoundingClientRect(): { top: number; right: number; bottom: number; left: number } {
return this.rect;
}
}
+10 -2
View File
@@ -8,10 +8,14 @@ interface ActionMenuRect {
left: number;
}
export function actionMenuPanelStyle(target: EventTarget | null): string {
interface ActionMenuPanelStyleOptions {
constrainTo?: "host" | "viewport";
}
export function actionMenuPanelStyle(target: EventTarget | null, options: ActionMenuPanelStyleOptions = {}): string {
if (typeof HTMLElement === "undefined" || typeof window === "undefined" || !(target instanceof HTMLElement)) return "";
const trigger = target.getBoundingClientRect();
const bounds = actionMenuBounds(target);
const bounds = options.constrainTo === "viewport" ? viewportBounds() : actionMenuBounds(target);
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const leftBound = Math.max(0, bounds.left);
@@ -35,6 +39,10 @@ export function actionMenuPanelStyle(target: EventTarget | null): string {
function actionMenuBounds(target: HTMLElement): ActionMenuRect {
const root = target.getRootNode();
if (typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot && root.host instanceof HTMLElement) return root.host.getBoundingClientRect();
return viewportBounds();
}
function viewportBounds(): ActionMenuRect {
return { top: 0, right: window.innerWidth, bottom: window.innerHeight, left: 0 };
}
@@ -0,0 +1,171 @@
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import type { Machine, Project, SessionInfo, Workspace } from "../../api";
import type { NavigationSection } from "../../appShell/navigationState";
@customElement("app-context-bar")
export class AppContextBar extends LitElement {
@property({ attribute: false }) machine?: Machine;
@property({ attribute: false }) project?: Project;
@property({ attribute: false }) workspace?: Workspace;
@property({ attribute: false }) session?: SessionInfo;
@property({ attribute: false }) refreshControl: unknown;
@property({ attribute: false }) onOpenSection?: (section: NavigationSection) => void;
@query(".context-items") private contextItems?: HTMLElement | null;
@state() private canScrollLeft = false;
@state() private canScrollRight = false;
private observedContextItems: HTMLElement | undefined;
private contextItemsResizeObserver: ResizeObserver | undefined;
override disconnectedCallback(): void {
this.contextItemsResizeObserver?.disconnect();
this.contextItemsResizeObserver = undefined;
this.observedContextItems = undefined;
super.disconnectedCallback();
}
override firstUpdated(): void {
this.observeContextItems();
this.updateScrollState();
}
override updated(): void {
this.observeContextItems();
this.updateScrollState();
}
override render() {
const machineLabel = machineContextLabel(this.machine);
const projectLabel = projectContextLabel(this.project);
const workspaceLabel = workspaceContextLabel(this.workspace);
const sessionLabel = sessionContextLabel(this.session);
return html`
<nav class=${this.contextBarClass()} aria-label="Current location">
<span class="context-bar-label">Location</span>
<ol class="context-items" @scroll=${this.onContextScroll}>
<li class="context-item">
<button type="button" class=${this.machine === undefined ? "context-chip empty" : "context-chip"} title=${machineContextTitle(this.machine)} aria-label=${`Machine: ${machineLabel}. Open machine selection.`} @click=${() => { this.onOpenSection?.("machines"); }}>
<span class="context-kind">Machine</span>
<span class="context-value">${machineLabel}</span>
</button>
</li>
<li class="context-item">
<button type="button" class=${this.project === undefined ? "context-chip empty" : "context-chip"} title=${projectContextTitle(this.project)} aria-label=${`Project: ${projectLabel}. Open project selection.`} @click=${() => { this.onOpenSection?.("projects"); }}>
<span class="context-kind">Project</span>
<span class="context-value">${projectLabel}</span>
</button>
</li>
<li class="context-item">
<button type="button" class=${this.workspace === undefined ? "context-chip empty" : "context-chip"} title=${workspaceContextTitle(this.workspace)} aria-label=${`Workspace: ${workspaceLabel}. Open workspace selection.`} @click=${() => { this.onOpenSection?.("workspaces"); }}>
<span class="context-kind">Workspace</span>
<span class="context-value">${workspaceLabel}</span>
</button>
</li>
<li class="context-item">
<button type="button" class=${this.session === undefined ? "context-chip empty" : "context-chip"} title=${sessionContextTitle(this.session)} aria-label=${`Session: ${sessionLabel}. Open session selection.`} @click=${() => { this.onOpenSection?.("sessions"); }}>
<span class="context-kind">Session</span>
<span class="context-value">${sessionLabel}</span>
</button>
</li>
</ol>
${this.refreshControl === undefined ? null : html`<div class="context-actions">${this.refreshControl}</div>`}
</nav>
`;
}
private contextBarClass(): string {
const classes = ["context-bar"];
if (this.refreshControl !== undefined) classes.push("has-context-actions");
if (this.canScrollLeft) classes.push("can-scroll-left");
if (this.canScrollRight) classes.push("can-scroll-right");
return classes.join(" ");
}
private observeContextItems(): void {
const contextItems = this.contextItemsElement();
if (this.observedContextItems === contextItems) return;
this.contextItemsResizeObserver?.disconnect();
this.observedContextItems = contextItems;
this.contextItemsResizeObserver = undefined;
if (contextItems === undefined || typeof ResizeObserver === "undefined") return;
this.contextItemsResizeObserver = new ResizeObserver(() => {
this.updateScrollState();
});
this.contextItemsResizeObserver.observe(contextItems);
}
private updateScrollState(): void {
const contextItems = this.contextItemsElement();
const maxScrollLeft = contextItems === undefined ? 0 : Math.max(0, contextItems.scrollWidth - contextItems.clientWidth);
const canScrollLeft = contextItems !== undefined && contextItems.scrollLeft > 1;
const canScrollRight = contextItems !== undefined && maxScrollLeft - contextItems.scrollLeft > 1;
if (this.canScrollLeft !== canScrollLeft) this.canScrollLeft = canScrollLeft;
if (this.canScrollRight !== canScrollRight) this.canScrollRight = canScrollRight;
}
private contextItemsElement(): HTMLElement | undefined {
const contextItems = this.contextItems;
return contextItems instanceof HTMLElement ? contextItems : undefined;
}
private readonly onContextScroll = () => {
this.updateScrollState();
};
static override styles = css`
:host { flex: 0 0 auto; min-width: 0; }
.context-bar { position: relative; flex: 0 0 auto; min-width: 0; display: flex; align-items: center; gap: 0; padding: 6px 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); }
.context-bar::before, .context-bar::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
.context-bar::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
.context-bar::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
.context-bar.can-scroll-left::before, .context-bar.can-scroll-right::after { opacity: 1; }
.context-bar-label { display: none; }
.context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 5px; margin: 0; padding: 0 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px; scrollbar-width: thin; }
.context-bar.has-context-actions .context-items { padding-right: 52px; scroll-padding-inline: 8px 52px; }
.context-item { flex: 0 0 auto; min-width: 0; display: flex; }
.context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; padding: 0 8px 0 0; pointer-events: none; }
.context-actions::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; z-index: 0; width: 26px; background: var(--pi-bg); pointer-events: none; }
app-refresh-control { pointer-events: auto; }
.context-chip { flex: 0 0 auto; min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 4px 8px; font: inherit; text-align: left; }
.context-chip:hover { background: var(--pi-surface-hover); }
.context-chip:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
.context-chip.empty { border-style: dashed; color: var(--pi-muted); }
.context-kind { display: none; }
.context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; }
button { cursor: pointer; }
`;
}
function machineContextLabel(machine: Machine | undefined): string {
return machine === undefined ? "No machine" : `${machine.name}${machine.kind === "remote" ? " · remote" : ""}`;
}
function machineContextTitle(machine: Machine | undefined): string {
return machine === undefined ? "No machine selected" : machine.baseUrl ?? machine.name;
}
function projectContextLabel(project: Project | undefined): string {
return project?.name ?? "No project";
}
function projectContextTitle(project: Project | undefined): string {
return project === undefined ? "No project selected" : `${project.name}${project.path}`;
}
function workspaceContextLabel(workspace: Workspace | undefined): string {
return workspace === undefined ? "No workspace" : `${workspace.label}${workspace.isMain ? " · main" : ""} · ${workspace.path}`;
}
function workspaceContextTitle(workspace: Workspace | undefined): string {
return workspace === undefined ? "No workspace selected" : `${workspace.label}${workspace.isMain ? " · main" : ""}${workspace.path}`;
}
function sessionContextLabel(session: SessionInfo | undefined): string {
const name = session?.name?.trim();
const firstMessage = session?.firstMessage.trim();
return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session?.id.slice(0, 8) ?? "No session";
}
function sessionContextTitle(session: SessionInfo | undefined): string {
return session === undefined ? "No session selected" : session.path;
}
@@ -0,0 +1,110 @@
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import type { AppState } from "../../appState";
export interface AppMobileMainTab {
id: AppState["mainView"];
label: unknown;
className?: string | undefined;
}
@customElement("app-mobile-main-tabs")
export class AppMobileMainTabs extends LitElement {
@property({ attribute: false }) tabs: AppMobileMainTab[] = [];
@property({ attribute: false }) selectedView: AppState["mainView"] = "chat";
@property({ attribute: false }) onSelect?: (view: AppState["mainView"]) => void;
@query(".mobile-tabs") private mobileTabs?: HTMLElement | null;
@state() private canScrollLeft = false;
@state() private canScrollRight = false;
private observedMobileTabs: HTMLElement | undefined;
private mobileTabsResizeObserver: ResizeObserver | undefined;
override disconnectedCallback(): void {
this.mobileTabsResizeObserver?.disconnect();
this.mobileTabsResizeObserver = undefined;
this.observedMobileTabs = undefined;
super.disconnectedCallback();
}
override firstUpdated(): void {
this.observeMobileTabs();
this.updateScrollState();
}
override updated(): void {
this.observeMobileTabs();
this.updateScrollState();
}
override render() {
return html`
<div class=${this.frameClass()}>
<div class="mobile-tabs" @scroll=${this.onMobileTabsScroll}>
${this.tabs.map((tab) => html`
<button class=${this.tabClass(tab)} @click=${() => { this.onSelect?.(tab.id); }}>${tab.label}</button>
`)}
</div>
</div>
`;
}
private frameClass(): string {
return `mobile-tabs-frame${this.canScrollLeft ? " can-scroll-left" : ""}${this.canScrollRight ? " can-scroll-right" : ""}`;
}
private tabClass(tab: AppMobileMainTab): string {
return [
...(tab.className === undefined ? [] : [tab.className]),
...(this.selectedView === tab.id ? ["selected"] : []),
].join(" ");
}
private observeMobileTabs(): void {
const mobileTabs = this.mobileTabsElement();
if (this.observedMobileTabs === mobileTabs) return;
this.mobileTabsResizeObserver?.disconnect();
this.observedMobileTabs = mobileTabs;
this.mobileTabsResizeObserver = undefined;
if (mobileTabs === undefined || typeof ResizeObserver === "undefined") return;
this.mobileTabsResizeObserver = new ResizeObserver(() => {
this.updateScrollState();
});
this.mobileTabsResizeObserver.observe(mobileTabs);
}
private updateScrollState(): void {
const mobileTabs = this.mobileTabsElement();
const maxScrollLeft = mobileTabs === undefined ? 0 : Math.max(0, mobileTabs.scrollWidth - mobileTabs.clientWidth);
const canScrollLeft = mobileTabs !== undefined && mobileTabs.scrollLeft > 1;
const canScrollRight = mobileTabs !== undefined && maxScrollLeft - mobileTabs.scrollLeft > 1;
if (this.canScrollLeft !== canScrollLeft) this.canScrollLeft = canScrollLeft;
if (this.canScrollRight !== canScrollRight) this.canScrollRight = canScrollRight;
}
private mobileTabsElement(): HTMLElement | undefined {
const mobileTabs = this.mobileTabs;
return mobileTabs instanceof HTMLElement ? mobileTabs : undefined;
}
private readonly onMobileTabsScroll = () => {
this.updateScrollState();
};
static override styles = css`
:host { flex: 0 0 auto; min-width: 0; }
.mobile-tabs-frame { position: relative; display: flex; flex: 0 0 auto; min-width: 0; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg); }
.mobile-tabs-frame::before, .mobile-tabs-frame::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
.mobile-tabs-frame::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
.mobile-tabs-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
.mobile-tabs-frame.can-scroll-left::before, .mobile-tabs-frame.can-scroll-right::after { opacity: 1; }
.mobile-tabs { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
.mobile-tabs button { flex: 0 0 auto; white-space: nowrap; }
.navigation-tab { display: none; }
.mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
.tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
@media (max-width: 760px) {
.navigation-tab { display: block; }
}
`;
}
@@ -0,0 +1,132 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api";
import type { WorkspaceLabelItem } from "../../plugins/types";
import "../MachineList";
import "../ProjectList";
import "../WorkspaceList";
import "../SessionList";
@customElement("app-navigation-panel")
export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) machines: Machine[] = [];
@property({ attribute: false }) selectedMachine?: Machine;
@property({ attribute: false }) machineStatuses: Record<string, MachineHealth> = {};
@property({ attribute: false }) projects: Project[] = [];
@property({ attribute: false }) selectedProject?: Project;
@property({ attribute: false }) workspaces: Workspace[] = [];
@property({ attribute: false }) selectedWorkspace?: Workspace;
@property({ attribute: false }) sessions: SessionInfo[] = [];
@property({ attribute: false }) selectedSession?: SessionInfo;
@property({ attribute: false }) workspaceActivities: Record<string, WorkspaceActivity> = {};
@property({ attribute: false }) sessionActivities: Record<string, SessionActivity> = {};
@property({ attribute: false }) sessionStatuses: Record<string, SessionStatus> = {};
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
@property({ attribute: false }) deletingWorkspaceIds: string[] = [];
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
@property({ attribute: false }) refreshControl: unknown;
@property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean }) machinesCollapsed = false;
@property({ type: Boolean }) projectsCollapsed = false;
@property({ type: Boolean }) workspacesCollapsed = false;
@property({ type: Boolean }) sessionsCollapsed = false;
@property({ type: Boolean }) canStartSession = false;
@property({ attribute: false }) onShowActions?: () => void;
@property({ attribute: false }) onToggleMachines?: () => void;
@property({ attribute: false }) onToggleProjects?: () => void;
@property({ attribute: false }) onToggleWorkspaces?: () => void;
@property({ attribute: false }) onToggleSessions?: () => void;
@property({ attribute: false }) onSelectProject?: (project: Project) => void | Promise<void>;
@property({ attribute: false }) onCloseProject?: (project: Project) => void | Promise<void>;
@property({ attribute: false }) onSelectWorkspace?: (workspace: Workspace) => void | Promise<void>;
@property({ attribute: false }) onDeleteWorkspace?: (workspace: Workspace) => void | Promise<void>;
@property({ attribute: false }) onStartSession?: () => void | Promise<void>;
@property({ attribute: false }) onSelectSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onArchiveSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onArchiveSessionWithDescendants?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onRestoreSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onDeleteCachedNewSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
override render() {
return html`
<header>
<strong>PI WEB</strong>
<div class="header-actions">
${this.refreshControl}
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.onShowActions?.(); }}>Actions</button>
</div>
</header>
<machine-list
.machines=${this.machines}
.selected=${this.selectedMachine}
.statuses=${this.machineStatuses}
.collapsible=${this.collapsible}
.collapsed=${this.machinesCollapsed}
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
></machine-list>
<project-list
.projects=${this.projects}
.selected=${this.selectedProject}
.activities=${this.workspaceActivities}
.workspacesByProjectId=${this.workspacesByProjectId}
.collapsible=${this.collapsible}
.collapsed=${this.projectsCollapsed}
.onToggleCollapsed=${() => { this.onToggleProjects?.(); }}
.onSelect=${(project: Project) => this.onSelectProject?.(project)}
.onClose=${(project: Project) => this.onCloseProject?.(project)}
></project-list>
<workspace-list
.workspaces=${this.workspaces}
.selected=${this.selectedWorkspace}
.activities=${this.workspaceActivities}
.deletingWorkspaceIds=${this.deletingWorkspaceIds}
.collapsible=${this.collapsible}
.collapsed=${this.workspacesCollapsed}
.workspaceLabelItems=${this.workspaceLabelItems}
.onToggleCollapsed=${() => { this.onToggleWorkspaces?.(); }}
.onSelect=${(workspace: Workspace) => this.onSelectWorkspace?.(workspace)}
.onDelete=${(workspace: Workspace) => this.onDeleteWorkspace?.(workspace)}
></workspace-list>
<session-list
.sessions=${this.sessions}
.statuses=${this.sessionStatuses}
.activities=${this.sessionActivities}
.selected=${this.selectedSession}
.canStart=${this.canStartSession}
.collapsible=${this.collapsible}
.collapsed=${this.sessionsCollapsed}
.onToggleCollapsed=${() => { this.onToggleSessions?.(); }}
.onArchivedCollapsed=${() => this.onArchivedCollapsed?.()}
.onStart=${() => this.onStartSession?.()}
.onSelect=${(session: SessionInfo) => this.onSelectSession?.(session)}
.onArchive=${(session: SessionInfo) => this.onArchiveSession?.(session)}
.onArchiveWithDescendants=${(session: SessionInfo) => this.onArchiveSessionWithDescendants?.(session)}
.onRestore=${(session: SessionInfo) => this.onRestoreSession?.(session)}
.onDelete=${(session: SessionInfo) => this.onDeleteCachedNewSession?.(session)}
.onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)}
></session-list>
`;
}
static override styles = css`
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
:host([collapsible]) { flex: 1 1 auto; }
header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }
.header-actions { display: flex; align-items: center; gap: 8px; }
machine-list, project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); }
session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; }
:host([collapsible]) machine-list,
:host([collapsible]) project-list,
:host([collapsible]) workspace-list,
:host([collapsible]) session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; }
:host([collapsible]) machine-list[collapsed],
:host([collapsible]) project-list[collapsed],
:host([collapsible]) workspace-list[collapsed],
:host([collapsible]) session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
`;
}
@@ -0,0 +1,58 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
export type PanelEdgeSide = "navigation" | "workspace";
@customElement("app-panel-edge-control")
export class AppPanelEdgeControl extends LitElement {
@property({ reflect: true }) side: PanelEdgeSide = "navigation";
@property({ type: Boolean, reflect: true }) collapsed = false;
@property() controls = "";
@property() expandLabel = "Expand panel";
@property() collapseLabel = "Collapse panel";
@property({ attribute: false }) onToggle?: () => void;
override render() {
const label = this.collapsed ? this.expandLabel : this.collapseLabel;
return html`
<button
type="button"
class="edge-button"
title=${label}
aria-label=${label}
aria-controls=${this.controls}
aria-expanded=${String(!this.collapsed)}
@click=${() => { this.onToggle?.(); }}
>${this.renderIcon()}</button>
`;
}
private renderIcon() {
const direction = this.iconDirection();
const path = direction === "left" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6";
return html`<svg class="edge-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d=${path}/></svg>`;
}
private iconDirection(): "left" | "right" {
if (this.side === "navigation") return this.collapsed ? "right" : "left";
return this.collapsed ? "left" : "right";
}
static override styles = css`
:host { min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; }
:host([side="navigation"]) { grid-column: 2; }
:host([side="workspace"]) { grid-column: 4; }
.edge-button { position: relative; z-index: 1; box-sizing: border-box; display: grid; place-items: center; width: 18px; height: 48px; padding: 0; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-bg); color: var(--pi-muted); opacity: .75; cursor: pointer; }
.edge-button:hover, .edge-button:focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); opacity: 1; }
:host([side="navigation"][collapsed]) .edge-button { transform: translateX(calc(50% - .5px)); }
:host([side="workspace"][collapsed]) .edge-button { transform: translateX(calc(-50% + .5px)); }
.edge-icon { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
@media (max-width: 1180px) {
:host([side="navigation"]) { grid-row: 1 / 3; }
:host([side="workspace"]) { display: none; }
}
@media (max-width: 760px) {
:host([side="navigation"]) { display: none; }
}
`;
}
@@ -0,0 +1,151 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { actionMenuPanelStyle } from "../actionMenu";
const REFRESH_LONG_PRESS_MS = 550;
@customElement("app-refresh-control")
export class AppRefreshControl extends LitElement {
@property({ type: Boolean }) isRefreshing = false;
@property({ attribute: false }) onRefresh?: () => void | Promise<void>;
@property({ attribute: false }) onReload?: () => void;
@state() private menuOpen = false;
@state() private menuStyle = "";
private longPressTimer: number | undefined;
private suppressNextClick = false;
override connectedCallback(): void {
super.connectedCallback();
document.addEventListener("click", this.onDocumentClick);
document.addEventListener("keydown", this.onDocumentKeyDown);
}
override disconnectedCallback(): void {
document.removeEventListener("click", this.onDocumentClick);
document.removeEventListener("keydown", this.onDocumentKeyDown);
this.clearLongPressTimer();
super.disconnectedCallback();
}
override render() {
const label = this.isRefreshing ? "Refreshing app data. Long-press for reload options." : "Refresh app data. Long-press for reload options.";
return html`
<button
class=${`app-refresh-button${this.isRefreshing ? " refreshing" : ""}`}
title=${label}
aria-label=${label}
aria-haspopup="menu"
aria-expanded=${String(this.menuOpen)}
aria-busy=${String(this.isRefreshing)}
@click=${this.onRefreshClick}
@contextmenu=${this.onRefreshContextMenu}
@pointerdown=${this.onRefreshPointerDown}
@pointerup=${() => { this.clearLongPressTimer(); }}
@pointercancel=${() => { this.clearLongPressTimer(); }}
@pointerleave=${() => { this.clearLongPressTimer(); }}
>${this.renderRefreshIcon()}</button>
${this.renderMenu()}
`;
}
private renderMenu() {
if (!this.menuOpen) return null;
return html`
<div class="app-refresh-menu" role="menu" style=${this.menuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
<button role="menuitem" @click=${() => { this.refresh(); }}>Refresh app data</button>
<button role="menuitem" @click=${() => { this.reload(); }}>Full page reload</button>
</div>
`;
}
private renderRefreshIcon() {
return html`
<svg class="app-refresh-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M20 6v5h-5"></path>
<path d="M4 18v-5h5"></path>
<path d="M18.2 9A7 7 0 0 0 6.1 6.8L4 9"></path>
<path d="M5.8 15a7 7 0 0 0 12.1 2.2L20 15"></path>
</svg>
`;
}
private readonly onRefreshClick = (event: MouseEvent): void => {
event.stopPropagation();
if (this.suppressNextClick) {
this.suppressNextClick = false;
return;
}
this.refresh();
};
private readonly onRefreshPointerDown = (event: PointerEvent): void => {
if (!event.isPrimary || event.button !== 0) return;
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return;
this.clearLongPressTimer();
this.suppressNextClick = false;
this.longPressTimer = window.setTimeout(() => {
this.longPressTimer = undefined;
this.suppressNextClick = true;
this.openMenu(target);
}, REFRESH_LONG_PRESS_MS);
};
private readonly onRefreshContextMenu = (event: MouseEvent): void => {
event.preventDefault();
event.stopPropagation();
this.clearLongPressTimer();
this.suppressNextClick = true;
this.openMenu(event.currentTarget);
};
private readonly onDocumentClick = (event: MouseEvent): void => {
if (event.composedPath().includes(this)) return;
this.closeMenu();
};
private readonly onDocumentKeyDown = (event: KeyboardEvent): void => {
if (event.key !== "Escape" || !this.menuOpen) return;
event.preventDefault();
event.stopPropagation();
this.closeMenu();
};
private openMenu(target: EventTarget | null): void {
this.menuStyle = actionMenuPanelStyle(target, { constrainTo: "viewport" });
this.menuOpen = true;
}
private closeMenu(): void {
this.menuOpen = false;
this.suppressNextClick = false;
}
private refresh(): void {
this.closeMenu();
void this.onRefresh?.();
}
private reload(): void {
this.closeMenu();
this.onReload?.();
}
private clearLongPressTimer(): void {
if (this.longPressTimer === undefined) return;
window.clearTimeout(this.longPressTimer);
this.longPressTimer = undefined;
}
static override styles = css`
:host { position: relative; z-index: 1; display: flex; align-items: center; pointer-events: auto; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
:host, :host * { -webkit-user-select: none; user-select: none; }
.app-refresh-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 0; line-height: 1; cursor: pointer; touch-action: manipulation; -webkit-touch-callout: none; }
.app-refresh-icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
.app-refresh-button.refreshing .app-refresh-icon { animation: app-refresh-spin .8s linear infinite; }
.app-refresh-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(170px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); overflow-wrap: anywhere; }
.app-refresh-menu button { display: block; width: 100%; border: 0; border-radius: 8px; background: transparent; color: var(--pi-text); padding: 7px 9px; text-align: left; white-space: normal; overflow-wrap: anywhere; cursor: pointer; }
.app-refresh-menu button:hover, .app-refresh-menu button:focus { background: var(--pi-selection-bg); }
@keyframes app-refresh-spin { to { transform: rotate(360deg); } }
`;
}
+46 -18
View File
@@ -50,14 +50,20 @@ export interface CompletionItem {
}
export const appStyles = css`
:host { display: block; height: 100dvh; box-sizing: border-box; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); color: var(--pi-text); background: var(--pi-bg); font: 14px system-ui, sans-serif; }
.shell { display: grid; grid-template-columns: 340px minmax(420px, 1fr) minmax(360px, 42vw); height: 100%; min-height: 0; }
aside { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--pi-border); overflow: hidden; }
/* Mobile browsers already subtract browser controls from 100dvh; reserve bottom safe area only in standalone PWA modes. */
:host { --pi-app-safe-area-bottom: 0px; position: fixed; top: 0; right: 0; left: 0; display: block; height: 100dvh; box-sizing: border-box; overflow: hidden; padding: env(safe-area-inset-top) env(safe-area-inset-right) var(--pi-app-safe-area-bottom) env(safe-area-inset-left); color: var(--pi-text); background: var(--pi-bg); font: 14px system-ui, sans-serif; }
:host([pwa-display-mode]) { --pi-app-safe-area-bottom: env(safe-area-inset-bottom); }
@media (display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui) {
:host { --pi-app-safe-area-bottom: env(safe-area-inset-bottom); }
}
.shell { --navigation-panel-width: 340px; --workspace-panel-width: minmax(360px, 42vw); display: grid; grid-template-columns: var(--navigation-panel-width) 1px minmax(420px, 1fr) 1px var(--workspace-panel-width); height: 100%; min-height: 0; }
aside { grid-column: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
aside app-navigation-panel { flex: 1 1 auto; min-height: 0; }
header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }
.header-actions { display: flex; align-items: center; gap: 8px; }
project-list, workspace-list { flex: 0 0 auto; max-height: 26%; overflow: auto; border-bottom: 1px solid var(--pi-border-muted); }
session-list { flex: 1 1 auto; min-height: 0; overflow: auto; }
main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); }
session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; }
main { grid-column: 3; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.context-bar { position: relative; flex: 0 0 auto; min-width: 0; display: none; align-items: center; gap: 0; padding: 6px 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); }
.context-bar::before, .context-bar::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
.context-bar::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
@@ -83,6 +89,7 @@ export const appStyles = css`
.context-chip.empty { border-style: dashed; color: var(--pi-muted); }
.context-kind { display: none; }
.context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; }
app-mobile-main-tabs { display: none; }
.mobile-tabs-frame { position: relative; display: none; flex: 0 0 auto; min-width: 0; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg); }
.mobile-tabs-frame::before, .mobile-tabs-frame::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
.mobile-tabs-frame::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
@@ -93,31 +100,51 @@ export const appStyles = css`
.mobile-navigation-tab, .mobile-navigation-panel { display: none; }
.mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
.tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
workspace-panel { min-width: 0; min-height: 0; border-left: 1px solid var(--pi-border); overflow: hidden; }
.navigation-panel-edge, .workspace-panel-edge { min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; }
.navigation-panel-edge { grid-column: 2; }
.workspace-panel-edge { grid-column: 4; }
.navigation-panel-edge-button, .workspace-panel-edge-button { position: relative; z-index: 1; box-sizing: border-box; display: grid; place-items: center; width: 18px; height: 48px; padding: 0; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-bg); color: var(--pi-muted); opacity: .75; cursor: pointer; }
.navigation-panel-edge-button:hover, .navigation-panel-edge-button:focus-visible, .workspace-panel-edge-button:hover, .workspace-panel-edge-button:focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); opacity: 1; }
.shell.navigation-panel-collapsed .navigation-panel-edge-button { transform: translateX(calc(50% - .5px)); }
.shell.workspace-panel-collapsed .workspace-panel-edge-button { transform: translateX(calc(-50% + .5px)); }
.navigation-panel-edge-icon, .workspace-panel-edge-icon { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
workspace-panel { grid-column: 5; min-width: 0; min-height: 0; overflow: hidden; }
@media (min-width: 1181px) {
.shell.navigation-panel-collapsed { --navigation-panel-width: 0px; }
.shell.navigation-panel-collapsed > aside { display: none; }
.shell.workspace-panel-collapsed { --workspace-panel-width: 0px; }
.shell.workspace-panel-collapsed > workspace-panel { display: none; }
}
@media (max-width: 1180px) {
.shell { grid-template-columns: 340px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
.shell { grid-template-columns: var(--navigation-panel-width) 1px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
.shell.navigation-panel-collapsed { --navigation-panel-width: 0px; }
.shell.navigation-panel-collapsed > aside { display: none; }
aside { grid-row: 1 / 3; }
main { grid-column: 2; grid-row: 1 / 3; }
.navigation-panel-edge { grid-row: 1 / 3; }
main { grid-column: 3; grid-row: 1 / 3; }
app-mobile-main-tabs { display: block; flex: 0 0 auto; min-width: 0; }
.mobile-tabs-frame { display: flex; }
.shell.workspace-view main { grid-row: 1; min-height: auto; }
.shell.workspace-view > workspace-panel { grid-column: 2; grid-row: 2; display: flex; border-left: 0; }
.shell.workspace-view > workspace-panel { grid-column: 3; grid-row: 2; display: flex; border-left: 0; }
.shell:not(.workspace-view) > workspace-panel { display: none; }
.workspace-panel-edge { display: none; }
main.workspace-view chat-view, main.workspace-view prompt-editor, main.workspace-view status-bar,
main.workspace-view .empty { display: none; }
main.workspace-view { overflow: hidden; }
}
@media (max-width: 760px) {
.shell { grid-template-columns: minmax(0, 1fr); }
aside { display: none; }
aside, .navigation-panel-edge { display: none; }
main, .shell.workspace-view > workspace-panel { grid-column: 1; }
.context-bar { display: flex; }
.mobile-navigation-tab { display: block; }
main.navigation-view chat-view, main.navigation-view prompt-editor, main.navigation-view status-bar,
main.navigation-view .empty { display: none; }
main.navigation-view .mobile-navigation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
main.navigation-view .mobile-navigation-panel app-navigation-panel { flex: 1 1 auto; min-height: 0; }
main.navigation-view .mobile-navigation-panel project-list,
main.navigation-view .mobile-navigation-panel workspace-list,
main.navigation-view .mobile-navigation-panel session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: auto; }
main.navigation-view .mobile-navigation-panel session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; }
main.navigation-view .mobile-navigation-panel project-list[collapsed],
main.navigation-view .mobile-navigation-panel workspace-list[collapsed],
main.navigation-view .mobile-navigation-panel session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; }
@@ -140,7 +167,7 @@ export const workspacePanelStyles = css`
.workspace-header-scroll-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
.workspace-header-scroll-frame.can-scroll-left::before, .workspace-header-scroll-frame.can-scroll-right::after { opacity: 1; }
.workspace-header-strip { display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
.tabs { flex: 0 0 auto; display: flex; gap: 6px; }
.tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; }
.tabs button { flex: 0 0 auto; white-space: nowrap; }
button { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; }
button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
@@ -183,10 +210,11 @@ export const workspacePanelStyles = css`
`;
export const listStyles = css`
:host { display: block; color: var(--pi-text); font: 14px system-ui, sans-serif; }
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; }
:host([collapsed]) { flex: 0 0 auto; min-height: auto; overflow: hidden; }
section { padding: 10px; }
h2 { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
section { box-sizing: border-box; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; padding: 10px; }
h2 { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
.list-body { flex: 1 1 auto; min-height: 0; overflow: auto; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
section > button { display: block; width: 100%; text-align: left; margin: 6px 0; }
.subheading { margin-top: 14px; }
@@ -242,11 +270,11 @@ export const listStyles = css`
`;
export const chatStyles = css`
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; }
:host { position: relative; z-index: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; }
.chat-wrap { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; }
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; }
.scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; }
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 3; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); }
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 20; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); }
.activity-dock.active { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-bg-overlay); }
.activity-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; }
@@ -62,7 +62,7 @@ export class SessionController {
this.socket.close();
this.catchupStreamSessionId = undefined;
this.clearPendingTranscriptEvents();
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
}
deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) {
+3
View File
@@ -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" });
});
});
+3 -2
View File
@@ -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("!@");
}
+19 -1
View File
@@ -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);
}
+44 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { Workspace } from "../api";
import type { SessionInfo, Workspace } from "../api";
import { initialAppState, type AppState } from "../appState";
import { markCachedNewSessionInfo } from "../cachedNewSessions";
import { corePlugin } from "./core";
import { PluginRegistry } from "./registry";
import { themePackPlugin } from "./themes";
@@ -38,6 +39,7 @@ function createContext(statePatch: Partial<AppState> = {}) {
deleteWorkspace: vi.fn(() => { calls.push("deleteWorkspace"); }),
startSession: vi.fn(() => { calls.push("startSession"); }),
archiveSession: vi.fn(() => { calls.push("archiveSession"); }),
deleteCachedNewSession: vi.fn(() => { calls.push("deleteCachedNewSession"); }),
stopActiveWork: vi.fn(() => { calls.push("stopActiveWork"); }),
};
return { context, calls };
@@ -102,6 +104,34 @@ describe("PluginRegistry", () => {
expect(calls).toEqual(["deleteWorkspace"]);
});
it("offers archive only for persisted sessions and delete only for browser-cached new sessions", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
const persistedActions = registry.getActions(createContext({ selectedSession: testSession() }).context);
expect(persistedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true);
expect(persistedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
const cachedActions = registry.getActions(createContext({ selectedSession: markCachedNewSessionInfo(testSession()) }).context);
expect(cachedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(cachedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true);
const archivedActions = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" } }).context);
expect(archivedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(archivedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
});
it("routes browser-cached new session delete through the runtime context", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
const { context, calls } = createContext({ selectedSession: markCachedNewSessionInfo(testSession()) });
const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.delete");
if (action !== undefined) void action.run();
expect(calls).toEqual(["deleteCachedNewSession"]);
});
it("routes refresh current to the active core workspace panel", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
@@ -237,6 +267,19 @@ function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false, ...patch };
}
function testSession(patch: Partial<SessionInfo> = {}): SessionInfo {
return {
id: "s1",
path: "/tmp/s1.jsonl",
cwd: "/tmp/project",
created: "2026-05-20T00:00:00.000Z",
modified: "2026-05-20T00:00:00.000Z",
messageCount: 1,
firstMessage: "Hello",
...patch,
};
}
function testThemeTokens(): ThemeTokens {
return {
"--pi-bg": "#000000",
+1
View File
@@ -70,6 +70,7 @@ export interface PluginRuntimeContext {
deleteWorkspace: (workspace?: Workspace) => void | Promise<void>;
startSession: () => void | Promise<void>;
archiveSession: () => void | Promise<void>;
deleteCachedNewSession: () => void | Promise<void>;
stopActiveWork: () => void | Promise<void>;
}
+43
View File
@@ -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");
});
});
+98
View File
@@ -0,0 +1,98 @@
export type TerminalSoftKeyId =
| "escape"
| "tab"
| "ctrl-c"
| "ctrl-d"
| "ctrl-z"
| "ctrl-l"
| "ctrl-r"
| "ctrl-u"
| "ctrl-w"
| "arrow-left"
| "arrow-up"
| "arrow-down"
| "arrow-right"
| "home"
| "end"
| "page-up"
| "page-down"
| "delete"
| "backspace"
| "meta-backward-word"
| "meta-forward-word";
export interface TerminalModesSnapshot {
applicationCursorKeysMode: boolean;
}
export interface TerminalSoftKeyDefinition {
id: TerminalSoftKeyId;
label: string;
ariaLabel: string;
title: string;
}
export const TERMINAL_SOFT_KEYS: readonly TerminalSoftKeyDefinition[] = [
{ id: "escape", label: "Esc", ariaLabel: "Escape", title: "Send Escape" },
{ id: "tab", label: "Tab", ariaLabel: "Tab", title: "Send Tab" },
{ id: "ctrl-c", label: "Ctrl+C", ariaLabel: "Control C", title: "Interrupt the foreground process" },
{ id: "ctrl-d", label: "Ctrl+D", ariaLabel: "Control D", title: "Send EOF / close input" },
{ id: "ctrl-z", label: "Ctrl+Z", ariaLabel: "Control Z", title: "Suspend the foreground process" },
{ id: "ctrl-l", label: "Ctrl+L", ariaLabel: "Control L", title: "Clear / redraw the terminal" },
{ id: "ctrl-r", label: "Ctrl+R", ariaLabel: "Control R", title: "Reverse search history" },
{ id: "ctrl-u", label: "Ctrl+U", ariaLabel: "Control U", title: "Delete to the start of the line" },
{ id: "ctrl-w", label: "Ctrl+W", ariaLabel: "Control W", title: "Delete the previous word" },
{ id: "arrow-left", label: "←", ariaLabel: "Left arrow", title: "Move left" },
{ id: "arrow-up", label: "↑", ariaLabel: "Up arrow", title: "Move up / previous command" },
{ id: "arrow-down", label: "↓", ariaLabel: "Down arrow", title: "Move down / next command" },
{ id: "arrow-right", label: "→", ariaLabel: "Right arrow", title: "Move right" },
{ id: "home", label: "Home", ariaLabel: "Home", title: "Move to the start" },
{ id: "end", label: "End", ariaLabel: "End", title: "Move to the end" },
{ id: "page-up", label: "PgUp", ariaLabel: "Page up", title: "Page up" },
{ id: "page-down", label: "PgDn", ariaLabel: "Page down", title: "Page down" },
{ id: "delete", label: "Del", ariaLabel: "Delete", title: "Delete forward" },
{ id: "backspace", label: "⌫", ariaLabel: "Backspace", title: "Backspace" },
{ id: "meta-backward-word", label: "M-B", ariaLabel: "Meta B", title: "Move backward one word" },
{ id: "meta-forward-word", label: "M-F", ariaLabel: "Meta F", title: "Move forward one word" },
];
const ESC = "\x1b";
const DEL = "\x7f";
export function terminalSoftKeySequence(key: TerminalSoftKeyId, modes?: TerminalModesSnapshot): string {
switch (key) {
case "escape": return ESC;
case "tab": return "\t";
case "ctrl-c": return controlSequence("c");
case "ctrl-d": return controlSequence("d");
case "ctrl-z": return controlSequence("z");
case "ctrl-l": return controlSequence("l");
case "ctrl-r": return controlSequence("r");
case "ctrl-u": return controlSequence("u");
case "ctrl-w": return controlSequence("w");
case "arrow-left": return cursorSequence("D", modes);
case "arrow-up": return cursorSequence("A", modes);
case "arrow-down": return cursorSequence("B", modes);
case "arrow-right": return cursorSequence("C", modes);
case "home": return cursorEndpointSequence("H", modes);
case "end": return cursorEndpointSequence("F", modes);
case "page-up": return `${ESC}[5~`;
case "page-down": return `${ESC}[6~`;
case "delete": return `${ESC}[3~`;
case "backspace": return DEL;
case "meta-backward-word": return `${ESC}b`;
case "meta-forward-word": return `${ESC}f`;
}
}
function controlSequence(letter: string): string {
return String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64);
}
function cursorSequence(code: "A" | "B" | "C" | "D", modes: TerminalModesSnapshot | undefined): string {
return modes?.applicationCursorKeysMode === true ? `${ESC}O${code}` : `${ESC}[${code}`;
}
function cursorEndpointSequence(code: "F" | "H", modes: TerminalModesSnapshot | undefined): string {
return modes?.applicationCursorKeysMode === true ? `${ESC}O${code}` : `${ESC}[${code}`;
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
parseTerminalSoftKeysPreference,
readTerminalSoftKeysPreference,
terminalSoftKeysEnabled,
TERMINAL_SOFT_KEYS_STORAGE_KEY,
writeTerminalSoftKeysPreference,
} from "./terminalSoftKeysPreference";
describe("terminal soft key preferences", () => {
it("uses stored preferences before environment defaults", () => {
expect(terminalSoftKeysEnabled(true, false)).toBe(true);
expect(terminalSoftKeysEnabled(false, true)).toBe(false);
expect(terminalSoftKeysEnabled(undefined, true)).toBe(true);
expect(terminalSoftKeysEnabled(undefined, false)).toBe(false);
});
it("parses boolean local storage values", () => {
expect(parseTerminalSoftKeysPreference("true")).toBe(true);
expect(parseTerminalSoftKeysPreference("false")).toBe(false);
expect(parseTerminalSoftKeysPreference(null)).toBeUndefined();
expect(parseTerminalSoftKeysPreference("yes")).toBeUndefined();
});
it("reads and writes the stored preference", () => {
const storage = new FakeStorage();
expect(readTerminalSoftKeysPreference(storage)).toBeUndefined();
writeTerminalSoftKeysPreference(true, storage);
expect(storage.value(TERMINAL_SOFT_KEYS_STORAGE_KEY)).toBe("true");
expect(readTerminalSoftKeysPreference(storage)).toBe(true);
writeTerminalSoftKeysPreference(false, storage);
expect(storage.value(TERMINAL_SOFT_KEYS_STORAGE_KEY)).toBe("false");
expect(readTerminalSoftKeysPreference(storage)).toBe(false);
});
it("ignores storage failures", () => {
const storage = new ThrowingStorage();
expect(readTerminalSoftKeysPreference(storage)).toBeUndefined();
expect(() => { writeTerminalSoftKeysPreference(true, storage); }).not.toThrow();
});
});
class FakeStorage {
private readonly values = new Map<string, string>();
getItem(key: string): string | null {
return this.values.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.values.set(key, value);
}
value(key: string): string | undefined {
return this.values.get(key);
}
}
class ThrowingStorage {
getItem(): string | null {
throw new Error("blocked");
}
setItem(): void {
throw new Error("blocked");
}
}
@@ -0,0 +1,57 @@
export const TERMINAL_SOFT_KEYS_STORAGE_KEY = "pi-web.terminal.softKeys";
export const TERMINAL_SOFT_KEYS_DEFAULT_ENVIRONMENT_MEDIA = "(pointer: coarse), (max-width: 760px)";
export type TerminalSoftKeysStorage = Pick<Storage, "getItem" | "setItem">;
export function terminalSoftKeysEnabled(preference: boolean | undefined, defaultEnabled: boolean): boolean {
return preference ?? defaultEnabled;
}
export function parseTerminalSoftKeysPreference(value: string | null): boolean | undefined {
if (value === "true") return true;
if (value === "false") return false;
return undefined;
}
export function createTerminalSoftKeysDefaultEnvironmentMedia(): MediaQueryList | undefined {
return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(TERMINAL_SOFT_KEYS_DEFAULT_ENVIRONMENT_MEDIA) : undefined;
}
export function isTerminalSoftKeysDefaultEnvironment(media: MediaQueryList | undefined): boolean {
return media?.matches === true;
}
export function initialTerminalSoftKeysEnabled(media = createTerminalSoftKeysDefaultEnvironmentMedia()): boolean {
return terminalSoftKeysEnabled(readTerminalSoftKeysPreference(), isTerminalSoftKeysDefaultEnvironment(media));
}
export function hasTerminalSoftKeysPreference(): boolean {
return readTerminalSoftKeysPreference() !== undefined;
}
export function readTerminalSoftKeysPreference(storage = browserStorage()): boolean | undefined {
if (storage === undefined) return undefined;
try {
return parseTerminalSoftKeysPreference(storage.getItem(TERMINAL_SOFT_KEYS_STORAGE_KEY));
} catch {
return undefined;
}
}
export function writeTerminalSoftKeysPreference(enabled: boolean, storage = browserStorage()): void {
if (storage === undefined) return;
try {
storage.setItem(TERMINAL_SOFT_KEYS_STORAGE_KEY, String(enabled));
} catch {
// Ignore storage failures; the per-page toggle still works for this session.
}
}
function browserStorage(): TerminalSoftKeysStorage | undefined {
if (typeof window === "undefined") return undefined;
try {
return window.localStorage;
} catch {
return undefined;
}
}
+239
View File
@@ -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<void> {
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<PiWebVersionResponse> {
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<RunningVersionInfo> {
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<RunningVersionInfo> {
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<T>(promise: Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+5 -4
View File
@@ -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<FastifyInsta
});
app.get("/api/pi-web/status", async () => getPiWebStatus());
app.get("/api/pi-web/version", async () => getPiWebVersionStatus());
registerMachineRoutes(app, machines);
+103 -13
View File
@@ -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,14 +22,27 @@ 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: {
const daemon = daemonWithComponent({
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202605.7",
@@ -32,8 +50,6 @@ describe("PI WEB status", () => {
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<string> {
return await mkdtemp(join(tmpdir(), "pi-web-status-"));
}
async function installSystemdServiceFiles(home: string, names: string[]): Promise<void> {
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;
}
+190 -73
View File
@@ -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<ServiceId, NativeServiceRef> = {
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<PiWebStatusResponse> {
const web = await getPiWebComponentStatus("web");
const [installed, sessiond] = await Promise.all([
readInstalledPackageInfo(),
export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()): Promise<PiWebVersionResponse> {
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<PiWebStatusResponse> {
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<string> {
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<ServiceId> {
return new Set(startServiceOrder.filter((id) => existsSync(serviceFilePath(backend, serviceRefs[id]))));
}
function installedServiceRefs(installed: Set<ServiceId>, 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<PiWebStatusMessage, "command"> | 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];
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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<string, string>; body: string }>;
+81 -2
View File
@@ -45,6 +45,7 @@ function sessionRecord(id: string, cwd = "/workspace") {
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
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<TestSession> = {})
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<TestSession> = {})
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<void>((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" } });
+133 -23
View File
@@ -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<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived">;
interface PiSessionListEntry {
id: string;
@@ -180,6 +187,8 @@ export class PiSessionService {
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
private readonly heartbeat: NodeJS.Timeout;
private readonly commandService: SessionCommandService<PiAgentSession>;
private readonly compactionPromptQueues = new Map<string, QueuedPrompt[]>();
private readonly compactionDrainTimers = new Map<string, NodeJS.Timeout>();
private readonly authLossWarnings = new Set<string>();
private readonly archiveStore: SessionArchiveRepository;
private readonly agentDir: string;
@@ -222,9 +231,11 @@ export class PiSessionService {
async dispose(): Promise<void> {
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<void> {
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<void> {
@@ -416,7 +445,7 @@ export class PiSessionService {
async archive(sessionId: string): Promise<void> {
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<void> {
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<PiSessionRuntime>): 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,
];
}
+1 -1
View File
@@ -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";
@@ -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<string> {
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" },
]);
});
});
+89 -12
View File
@@ -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<ClientFileSuggestion[]> {
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<ClientFileSuggestion[]> {
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<Cli
.slice(0, 80);
}
async function listGitFiles(cwd: string): Promise<ClientFileSuggestion[]> {
async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
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<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
return withDirectories(lines(await git(cwd, ["ls-files"], exec)), "tracked");
}
async function listGitFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
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<ClientFileSuggestion[]> {
];
}
async function listPlainFiles(cwd: string): Promise<ClientFileSuggestion[]> {
const { stdout } = await execFileAsync("rg", ["--files"], { cwd, maxBuffer: 1024 * 1024 * 8 });
async function listPlainFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>, includeIgnored: boolean): Promise<ClientFileSuggestion[]> {
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<string> {
const { stdout } = await execFileAsync("git", args, { cwd, env: sanitizedGitEnv(), maxBuffer: 1024 * 1024 * 8 });
async function filesystemFiles(cwd: string): Promise<string[]> {
const paths: string[] = [];
await collectFilesystemFiles(cwd, "", paths, false);
return paths;
}
async function collectFilesystemFiles(cwd: string, relativeDirectory: string, paths: string[], optionalDirectory: boolean): Promise<void> {
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<boolean> {
if (!symbolicLink) return false;
try {
return (await stat(join(cwd, relativePath))).isFile();
} catch {
return false;
}
}
async function git(cwd: string, args: string[], exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<string> {
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);
}
@@ -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"],
+3 -3
View File
@@ -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<FileTreeEntry> => {
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 };
}
@@ -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");
+9 -5
View File
@@ -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[];
}
+58
View File
@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}