Archived
Merge remote-tracking branch 'origin/main' into investigate/issue-12-session-dir
# Conflicts: # src/client/src/api.ts # src/client/src/api/clients.ts # src/client/src/api/federatedRouteContract.test.ts # src/server/sessions/piSessionService.ts # src/server/sessions/sessionRoutes.ts
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Replace add-machine browser prompts with a PI WEB form that asks for the remote URL first, suggests a machine name, and supports an optional bearer token.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Initialize Pi extensions for web-managed sessions so `session_start` handlers, extension resources, and startup-dependent tools run correctly.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Document built-in PI WEB plugins, including configuration guidance for Workspace Tasks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Reduce desktop navigation crowding by moving machine switching into a compact header control and removing automatic desktop section collapse.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Delete workspaces through a server-side operation that closes target workspace terminals before running the worktree removal command, preventing stale machine activity indicators.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Make navigation sections collapsible on desktop and auto-collapse completed context sections after selections.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Document machine federation across the website and add a Fleet guide for setup, trust model, remote plugins, and troubleshooting.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add a keyboard shortcuts settings editor with manual entry, recording, disabling, reset-to-default controls, and conflict/shadowing indicators.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Let Firefox copy only the selected chat text instead of replacing selections with the full message.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Fix workspace selection in the web UI so local machine project and session loading no longer fails with `api is not defined`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Make the app refresh control perform a full page reload directly instead of opening refresh-data options.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Simplify the mobile location breadcrumb by hiding the machine crumb when there is only one configured machine and removing activity indicators from breadcrumb items.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Hide the Machines navigation section when only one machine is configured, align Machines list spacing with the other navigation sections, and add a remove action to remote machine rows.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add keyboard-first navigation for focusing Machines, Projects, Workspaces, Sessions, and the chat composer.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Reduce repeated machine and workspace details in the chat status bar and workspace tool header, keeping compact session metrics right-aligned.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Show machine activity indicators when sessions or terminals are active on any workspace for that machine.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add machine-scoped local project, workspace, file, and git API aliases as the next step toward machine federation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Allow PI WEB plugins to mark themselves as machine-specific so the gateway copy stays local-only and remote machines can provide their own status/plugin UI.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Make the mobile Actions entry available from the top context controls and remove the redundant PI WEB navigation header on mobile.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Keep the documentation site's GitHub and theme controls visible in mobile portrait layouts.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Use compact icons, initials, and inline badges for the mobile main tab bar so tabs are easier to fit without losing horizontal scrolling; let workspace panel plugins provide custom SVG tab icons; and add icons for bundled Info, Updates, and Tasks plugin panels.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Fall back to the local machine when a bookmarked or restored remote machine is offline, and clear stale remote workspace route state.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Keep workspace tool tab icons visible in the desktop workspace panel and collapse tab names only in compact panel widths.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Preserve machine, workspace, session, and terminal navigation memory across reloads within each browser tab.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add plugin enablement settings so discovered PI WEB plugins can be disabled before the browser imports them.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add documented plugin context helpers for machine-scoped workspace files and terminal commands, generate plugin API declarations from source, and move bundled plugins away from direct PI WEB API calls.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Keep archived parent sessions visible in the current session tree while they still have unarchived children.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Clarify plugin docs and website copy around private PI WEB APIs and the supported helper surface.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Keep the PWA refresh control menu visible above mobile tab navigation and workspace tab content.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Remember each machine's last selected project, workspace, session, and workspace tool when switching machines in the web UI.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add remote machine federation so PI WEB can register trusted remote runtimes and proxy their projects, workspaces, sessions, files, git state, activity, and terminals through the current web server.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Load trusted PI WEB plugins from selected federated machines with machine-scoped actions, workspace panels, labels, proxied plugin assets, and gateway-preferred duplicate handling.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Clean up the workspace panel plugin context by removing the legacy `openTerminal` alias and moving render invalidation to `context.host.requestRender()`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add draggable, persistent side panel resizing for the web UI navigation and workspace panels, including reset actions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Align the desktop machine badge status to the right edge of the badge.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add safe bulk session actions for archiving current sessions and permanently deleting archived sessions, with runtime capability checks for remote compatibility.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add a deep-linked Settings UI for editing the active PI WEB config file and viewing registered keyboard shortcuts.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add shortcut preferences to the PI WEB config schema so keyboard shortcuts can be overridden or disabled by action id.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Keep machine/session navigation snappy by deferring expensive Pi-Web status refreshes and caching status checks.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add the first machine registry API and show the synthesized Local machine in the web UI as the foundation for machine federation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Pin navigation activity indicators to the top-right of list chips so active projects, workspaces, and sessions no longer shift their labels.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Rename the built-in Updates plugin id from `pi-web` to `updates` for clearer plugin configuration.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add workspace file and render helpers to plugin workspace label callbacks so labels can load workspace-scoped metadata without hidden panels.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Prevent redundant Workspace Tasks panel re-renders from resetting mobile scroll position or replacing task buttons mid-click, and show feedback for stale, cancelled, or already-starting tasks.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Bundle Workspace Tasks with PI WEB as a built-in plugin for running `.pi-web/tasks.json` commands in workspace terminals.
|
||||
@@ -1,5 +1,37 @@
|
||||
# @jmfederico/pi-web
|
||||
|
||||
## 1.202606.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 93b50e6: Replace add-machine browser prompts with a PI WEB form that asks for the remote URL first, suggests a machine name, and supports an optional bearer token.
|
||||
- 08f69d0: Document built-in PI WEB plugins, including configuration guidance for Workspace Tasks.
|
||||
- 9c3dafc: Delete workspaces through a server-side operation that closes target workspace terminals before running the worktree removal command, preventing stale machine activity indicators.
|
||||
- 159f533: Fix workspace selection in the web UI so local machine project and session loading no longer fails with `api is not defined`.
|
||||
- 82ba2e0: Prevent malformed session prompt API calls from crashing the session daemon.
|
||||
- f2d211d: Harden remote machine plugin asset proxying so plugin asset URLs cannot escape the remote plugin directory.
|
||||
- ccd4a76: Hide the Machines navigation section when only one machine is configured, align Machines list spacing with the other navigation sections, and add a remove action to remote machine rows.
|
||||
- 193c9d0: Show machine activity indicators when sessions or terminals are active on any workspace for that machine.
|
||||
- b5f8810: Add machine-scoped local project, workspace, file, and git API aliases as the next step toward machine federation.
|
||||
- 4495a26: Make the mobile Actions entry available from the top context controls and remove the redundant PI WEB navigation header on mobile.
|
||||
- 4548e5c: Use compact icons, initials, and inline badges for the mobile main tab bar so tabs are easier to fit without losing horizontal scrolling; let workspace panel plugins provide custom SVG tab icons; and add icons for bundled Info, Updates, and Tasks plugin panels.
|
||||
- e352dce: Fall back to the local machine when a bookmarked or restored remote machine is offline, and clear stale remote workspace route state.
|
||||
- bd8d1f1: Keep workspace tool tab icons visible in the desktop workspace panel and collapse tab names only in compact panel widths.
|
||||
- 30fb960: Preserve machine, workspace, session, and terminal navigation memory across reloads within each browser tab.
|
||||
- 08f69d0: Add plugin enablement settings so discovered PI WEB plugins can be disabled before the browser imports them.
|
||||
- e3533eb: Add documented plugin context helpers for machine-scoped workspace files and terminal commands, generate plugin API declarations from source, and move bundled plugins away from direct PI WEB API calls.
|
||||
- 8cd2bba: Keep the PWA refresh control menu visible above mobile tab navigation and workspace tab content.
|
||||
- b3bb732: Remember each machine's last selected project, workspace, session, and workspace tool when switching machines in the web UI.
|
||||
- a142f5e: Add remote machine federation so PI WEB can register trusted remote runtimes and proxy their projects, workspaces, sessions, files, git state, activity, and terminals through the current web server.
|
||||
- b9be7de: Load trusted PI WEB plugins from selected federated machines with machine-scoped actions, workspace panels, labels, proxied plugin assets, and gateway-preferred duplicate handling.
|
||||
- f1c8f1f: Clean up the workspace panel plugin context by moving render invalidation to `context.host.requestRender()` and deprecating the legacy runtime-only `openTerminal` alias in favor of `context.terminal.open()`.
|
||||
- 4495a26: Add a deep-linked Settings UI for editing the active PI WEB config file and viewing registered keyboard shortcuts.
|
||||
- a58c211: Add shortcut preferences to the PI WEB config schema so keyboard shortcuts can be overridden or disabled by action id.
|
||||
- 0405b38: Add the first machine registry API and show the synthesized Local machine in the web UI as the foundation for machine federation.
|
||||
- 4bc0010: Add workspace file and render helpers to plugin workspace label callbacks so labels can load workspace-scoped metadata without hidden panels.
|
||||
- 08f69d0: Prevent redundant Workspace Tasks panel re-renders from resetting mobile scroll position or replacing task buttons mid-click, and show feedback for stale, cancelled, or already-starting tasks.
|
||||
- 08f69d0: Bundle Workspace Tasks with PI WEB as a built-in plugin for running `.pi-web/tasks.json` commands in workspace terminals.
|
||||
|
||||
## 1.202606.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -104,7 +104,7 @@ PI WEB keeps its own state intentionally small:
|
||||
|
||||
## Machine federation
|
||||
|
||||
The Machines section lets one PI WEB instance act as a gateway to other PI WEB runtimes. Register a remote machine from **Actions → Add Machine** with the remote PI WEB base URL, for example a Tailscale, WireGuard, SSH tunnel, or trusted reverse-proxy URL. The browser continues talking to the local PI WEB origin; project, workspace, file, git, session, activity, and terminal HTTP/WebSocket traffic is proxied server-to-server.
|
||||
The Machines section lets one PI WEB instance act as a gateway to other PI WEB runtimes. Register a remote machine from **Actions → Add Machine** with the remote PI WEB base URL, for example a URL reachable over NetBird, Tailscale, WireGuard, an SSH tunnel, or a trusted reverse proxy. The browser continues talking to the local PI WEB origin; project, workspace, file, git, session, activity, and terminal HTTP/WebSocket traffic is proxied server-to-server. See the [Fleet guide](https://pi-web.dev/machines.html) for setup, trust model, and troubleshooting details.
|
||||
|
||||
Remote model-provider credentials and OAuth state stay on the target machine. API-key provider configuration can be proxied, but OAuth login should be completed by opening the remote PI WEB directly. Register remote machines only when you trust the endpoint and the network path: adding a machine gives this PI WEB server permission to contact that URL with the optional bearer token you configured.
|
||||
|
||||
@@ -112,7 +112,7 @@ Remote model-provider credentials and OAuth state stay on the target machine. AP
|
||||
|
||||
PI WEB production installs can load trusted local UI plugins without rebuilding PI WEB. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata, using documented context helpers for workspace files and terminals. They do not run in the session daemon and are not sandboxed.
|
||||
|
||||
The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module`, plus a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, `pi-web-plugins/updates` demonstrates a dynamic status panel, and built-in [Workspace Tasks](docs/plugins.md#workspace-tasks) adds a workspace tab for running configured shell commands in PI WEB terminals.
|
||||
The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module` plus optional `machineSpecific` metadata, and a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, `pi-web-plugins/updates` demonstrates a dynamic status panel, and built-in [Workspace Tasks](docs/plugins.md#workspace-tasks) adds a workspace tab for running configured shell commands in PI WEB terminals.
|
||||
|
||||
A useful prompt for AI agents:
|
||||
|
||||
|
||||
+23
-17
@@ -28,23 +28,28 @@
|
||||
<nav class="container nav" aria-label="Main navigation">
|
||||
<a class="brand" href="/" aria-label="PI WEB home">PI WEB</a>
|
||||
<div class="nav-links">
|
||||
<a href="/remote-first.html">Remote-first</a>
|
||||
<a href="/install.html">Install</a>
|
||||
<a href="/plugins.html">Plugins</a>
|
||||
<a href="/faq.html">FAQ</a>
|
||||
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
|
||||
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
|
||||
<span data-theme-icon aria-hidden="true">◐</span>
|
||||
<span data-theme-label>Theme</span>
|
||||
</button>
|
||||
<div class="nav-pages">
|
||||
<a href="/remote-first.html">Remote-first</a>
|
||||
<a href="/machines.html">Fleet</a>
|
||||
<a href="/install.html">Install</a>
|
||||
<a href="/plugins.html">Plugins</a>
|
||||
<a href="/faq.html">FAQ</a>
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
|
||||
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
|
||||
<span data-theme-icon aria-hidden="true">◐</span>
|
||||
<span data-theme-label>Theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -75,6 +80,7 @@
|
||||
<div class="container footer-inner">
|
||||
<span>PI WEB · remote control for persistent Pi Coding Agent sessions.</span>
|
||||
<div class="footer-links">
|
||||
<a href="/machines.html">Fleet</a>
|
||||
<a href="/install.html">Install</a>
|
||||
<a href="/plugins.html">Plugins</a>
|
||||
<a href="/faq.html">FAQ</a>
|
||||
|
||||
+41
-17
@@ -27,23 +27,28 @@
|
||||
<nav class="container nav" aria-label="Main navigation">
|
||||
<a class="brand" href="./" aria-label="PI WEB home">PI WEB</a>
|
||||
<div class="nav-links">
|
||||
<a href="remote-first.html">Remote-first</a>
|
||||
<a href="install.html">Install</a>
|
||||
<a href="plugins.html">Plugins</a>
|
||||
<a href="faq.html" aria-current="page">FAQ</a>
|
||||
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
|
||||
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
|
||||
<span data-theme-icon aria-hidden="true">◐</span>
|
||||
<span data-theme-label>Theme</span>
|
||||
</button>
|
||||
<div class="nav-pages">
|
||||
<a href="remote-first.html">Remote-first</a>
|
||||
<a href="machines.html">Fleet</a>
|
||||
<a href="install.html">Install</a>
|
||||
<a href="plugins.html">Plugins</a>
|
||||
<a href="faq.html" aria-current="page">FAQ</a>
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
|
||||
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
|
||||
<span data-theme-icon aria-hidden="true">◐</span>
|
||||
<span data-theme-label>Theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -71,6 +76,7 @@
|
||||
<a href="#systemd-not-found">User service manager is unavailable</a>
|
||||
<a href="#cannot-open">I cannot open the web UI</a>
|
||||
<a href="#public-internet">Can I expose this publicly?</a>
|
||||
<a href="#remote-machines">How do remote machines work?</a>
|
||||
<a href="#laptop-or-server">Laptop or server?</a>
|
||||
<a href="#plugins">Can I use local plugins?</a>
|
||||
<a href="#sessions-stop">Sessions stop unexpectedly</a>
|
||||
@@ -189,6 +195,23 @@
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article id="remote-machines" class="faq-item">
|
||||
<h2>How do I connect another PI WEB machine?</h2>
|
||||
<p>
|
||||
Install and run PI WEB on the target machine, make its PI WEB URL reachable from the gateway through a
|
||||
trusted path, then open the gateway and choose <strong>Actions → Add Machine</strong>. The browser keeps
|
||||
talking to the gateway while projects, sessions, files, and terminals run on the selected remote runtime.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Use NetBird, Tailscale, WireGuard, private LAN, SSH tunnel, or an authenticated reverse proxy.</li>
|
||||
<li>Remote Pi auth, OAuth state, sessions, terminals, repositories, and plugins stay on the target machine.</li>
|
||||
<li>API-key provider configuration can be proxied, but OAuth login should be completed by opening the remote PI WEB directly.</li>
|
||||
<li>Remote plugins are trusted browser code and only appear while that machine is selected.</li>
|
||||
<li>If the remote reports offline, check that the gateway server can reach the remote URL and try <strong>Actions → Refresh Selected Machine</strong>.</li>
|
||||
</ul>
|
||||
<p><a href="machines.html">Read the fleet guide →</a></p>
|
||||
</article>
|
||||
|
||||
<article id="laptop-or-server" class="faq-item">
|
||||
<h2>Should I run PI WEB on my laptop or a server?</h2>
|
||||
<p>
|
||||
@@ -251,6 +274,7 @@
|
||||
<div class="footer-links">
|
||||
<a href="./">Home</a>
|
||||
<a href="remote-first.html">Remote-first</a>
|
||||
<a href="machines.html">Fleet</a>
|
||||
<a href="install.html">Install</a>
|
||||
<a href="plugins.html">Plugins</a>
|
||||
<a href="https://github.com/jmfederico/pi-web/issues">Issues</a>
|
||||
|
||||
+23
-17
@@ -34,23 +34,28 @@
|
||||
<nav class="container nav" aria-label="Main navigation">
|
||||
<a class="brand" href="./" aria-label="PI WEB home">PI WEB</a>
|
||||
<div class="nav-links">
|
||||
<a href="remote-first.html">Remote-first</a>
|
||||
<a href="install.html">Install</a>
|
||||
<a href="plugins.html">Plugins</a>
|
||||
<a href="faq.html">FAQ</a>
|
||||
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
|
||||
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
|
||||
<span data-theme-icon aria-hidden="true">◐</span>
|
||||
<span data-theme-label>Theme</span>
|
||||
</button>
|
||||
<div class="nav-pages">
|
||||
<a href="remote-first.html">Remote-first</a>
|
||||
<a href="machines.html">Fleet</a>
|
||||
<a href="install.html">Install</a>
|
||||
<a href="plugins.html">Plugins</a>
|
||||
<a href="faq.html">FAQ</a>
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
|
||||
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
|
||||
<span data-theme-icon aria-hidden="true">◐</span>
|
||||
<span data-theme-label>Theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -298,6 +303,7 @@
|
||||
<span>PI WEB · remote control for persistent Pi Coding Agent sessions.</span>
|
||||
<div class="footer-links">
|
||||
<a href="remote-first.html">Remote-first</a>
|
||||
<a href="machines.html">Fleet</a>
|
||||
<a href="install.html">Install</a>
|
||||
<a href="plugins.html">Plugins</a>
|
||||
<a href="faq.html">FAQ</a>
|
||||
|
||||
+42
-18
@@ -27,23 +27,28 @@
|
||||
<nav class="container nav" aria-label="Main navigation">
|
||||
<a class="brand" href="./" aria-label="PI WEB home">PI WEB</a>
|
||||
<div class="nav-links">
|
||||
<a href="remote-first.html">Remote-first</a>
|
||||
<a href="install.html" aria-current="page">Install</a>
|
||||
<a href="plugins.html">Plugins</a>
|
||||
<a href="faq.html">FAQ</a>
|
||||
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
|
||||
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
|
||||
<span data-theme-icon aria-hidden="true">◐</span>
|
||||
<span data-theme-label>Theme</span>
|
||||
</button>
|
||||
<div class="nav-pages">
|
||||
<a href="remote-first.html">Remote-first</a>
|
||||
<a href="machines.html">Fleet</a>
|
||||
<a href="install.html" aria-current="page">Install</a>
|
||||
<a href="plugins.html">Plugins</a>
|
||||
<a href="faq.html">FAQ</a>
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
|
||||
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
|
||||
<span data-theme-icon aria-hidden="true">◐</span>
|
||||
<span data-theme-label>Theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -70,6 +75,7 @@
|
||||
<a href="#pi-package">Install through Pi</a>
|
||||
<a href="#manual-run">WSL / manual run</a>
|
||||
<a href="#remote-access">Remote access</a>
|
||||
<a href="#federated-machines">Federated machines</a>
|
||||
<a href="#manage-services">Manage services</a>
|
||||
<a href="#configure">Configure</a>
|
||||
<a href="#uninstall">Uninstall</a>
|
||||
@@ -189,11 +195,28 @@
|
||||
<div class="callout danger">
|
||||
PI WEB is designed for trusted users and trusted server paths. Do not expose it directly to the public
|
||||
internet. If you use a VPN or private network, bind PI WEB to the server's VPN/private IP, such as a
|
||||
Tailscale/WireGuard/LAN address, and make sure that network policy limits access. Avoid
|
||||
NetBird/Tailscale/WireGuard/LAN address, and make sure that network policy limits access. Avoid
|
||||
<code>0.0.0.0</code> unless a firewall, VPN, or authenticated reverse proxy strictly controls the port.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="federated-machines">
|
||||
<h2>Federated machines</h2>
|
||||
<p>
|
||||
To use one PI WEB instance as a gateway to others, install and run PI WEB on each target machine first.
|
||||
Make each target reachable from the gateway through a trusted path, then open the gateway and use
|
||||
<strong>Actions → Add Machine</strong> with the target PI WEB base URL.
|
||||
</p>
|
||||
<p>
|
||||
The remote URL can be reachable through NetBird, Tailscale, WireGuard, LAN, an SSH tunnel, or an authenticated reverse proxy. The
|
||||
browser keeps using the gateway origin while projects, sessions, files, and terminals run on the selected
|
||||
remote runtime.
|
||||
</p>
|
||||
<div class="doc-actions">
|
||||
<a class="button" href="machines.html">Read the fleet guide</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
@@ -291,6 +314,7 @@
|
||||
<div class="footer-links">
|
||||
<a href="./">Home</a>
|
||||
<a href="remote-first.html">Remote-first</a>
|
||||
<a href="machines.html">Fleet</a>
|
||||
<a href="plugins.html">Plugins</a>
|
||||
<a href="faq.html">FAQ</a>
|
||||
<a href="https://github.com/jmfederico/pi-web">GitHub</a>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PI WEB fleet</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Connect trusted PI WEB runtimes through machine federation so one browser control plane can supervise local and remote projects, sessions, files, git state, terminals, and plugins."
|
||||
/>
|
||||
<meta property="og:title" content="PI WEB fleet" />
|
||||
<meta property="og:image" content="assets/pi-web-banner.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
|
||||
<script>
|
||||
(() => {
|
||||
const theme = window.localStorage.getItem("pi-web-theme");
|
||||
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
|
||||
})();
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<nav class="container nav" aria-label="Main navigation">
|
||||
<a class="brand" href="./" aria-label="PI WEB home">PI WEB</a>
|
||||
<div class="nav-links">
|
||||
<div class="nav-pages">
|
||||
<a href="remote-first.html">Remote-first</a>
|
||||
<a href="machines.html" aria-current="page">Fleet</a>
|
||||
<a href="install.html">Install</a>
|
||||
<a href="plugins.html">Plugins</a>
|
||||
<a href="faq.html">FAQ</a>
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
|
||||
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
|
||||
<span data-theme-icon aria-hidden="true">◐</span>
|
||||
<span data-theme-label>Theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="page-hero">
|
||||
<div class="container">
|
||||
<p class="eyebrow"><span class="pulse"></span> PI WEB fleet</p>
|
||||
<h1>Connect trusted PI WEB runtimes when one machine is not enough.</h1>
|
||||
<p>
|
||||
Most PI WEB setups only need one runtime. When you do have more than one, machine federation lets the PI WEB
|
||||
instance you opened act as a gateway to other trusted runtimes while each machine keeps its own repositories,
|
||||
credentials, sessions, and plugins.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section compact">
|
||||
<div class="container doc-layout">
|
||||
<aside class="toc" aria-label="Machines page contents">
|
||||
<strong>On this page</strong>
|
||||
<a href="#what-it-is">What Fleet does</a>
|
||||
<a href="#when-to-use">When to use it</a>
|
||||
<a href="#prepare-machines">Set up another runtime</a>
|
||||
<a href="#add-machine">Add a machine</a>
|
||||
<a href="#what-is-proxied">What works remotely</a>
|
||||
<a href="#auth-credentials">Credentials</a>
|
||||
<a href="#plugins">Plugins</a>
|
||||
<a href="#trust-model">Trust boundaries</a>
|
||||
<a href="#storage-troubleshooting">Troubleshooting</a>
|
||||
</aside>
|
||||
|
||||
<div class="doc-content">
|
||||
<section id="what-it-is">
|
||||
<h2>What Fleet does</h2>
|
||||
<p>
|
||||
A machine is a PI WEB runtime endpoint. The local machine is synthesized automatically. Remote machines
|
||||
are opt-in PI WEB runtimes that you register with a base URL and, optionally, a bearer token.
|
||||
</p>
|
||||
<p>
|
||||
After registration, the browser keeps talking to the current PI WEB origin. The gateway contacts the
|
||||
selected remote PI WEB server and routes that machine's projects, workspaces, sessions, files, git state,
|
||||
activity, and terminals to the browser UI.
|
||||
</p>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
<strong>Fleet shape</strong>
|
||||
<button class="copy-button" data-copy="#federated-shape">Copy</button>
|
||||
</div>
|
||||
<pre id="federated-shape"><code>Browser
|
||||
↓
|
||||
PI WEB gateway you opened
|
||||
├─ [ Local runtime ]
|
||||
│
|
||||
├─ [ Remote PI WEB runtime A ]
|
||||
│ ↓ selected machine
|
||||
│ projects, workspaces, sessions, terminals, plugins
|
||||
│
|
||||
└─ [ Remote PI WEB runtime B ]</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="when-to-use">
|
||||
<h2>When to use it</h2>
|
||||
<p>
|
||||
Federation is useful when you have more than one place where agents should work, but you want one stable
|
||||
browser entrypoint.
|
||||
</p>
|
||||
<div class="cards">
|
||||
<article class="card">
|
||||
<div class="card-icon">▦</div>
|
||||
<h3>Many dev boxes</h3>
|
||||
<p>Register a workstation, home server, cloud VM, or client-specific host without moving repositories.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<div class="card-icon">⇄</div>
|
||||
<h3>One gateway</h3>
|
||||
<p>Open one trusted PI WEB URL and switch machines instead of juggling browser tabs and tunnels.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<div class="card-icon">⌁</div>
|
||||
<h3>Local ownership</h3>
|
||||
<p>Each target machine keeps its own Pi auth, sessions, worktrees, terminal state, and plugins.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="prepare-machines">
|
||||
<h2>Set up another runtime</h2>
|
||||
<p>
|
||||
Install and run PI WEB on every machine you want to register. The remote URL must be reachable from the
|
||||
gateway server, not just from your browser.
|
||||
</p>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
<strong>Install on each target</strong>
|
||||
<button class="copy-button" data-copy="#target-install">Copy</button>
|
||||
</div>
|
||||
<pre id="target-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</code></pre>
|
||||
</div>
|
||||
<p>
|
||||
Prefer a private path such as NetBird, Tailscale, WireGuard, private LAN, SSH tunnel, or an authenticated reverse
|
||||
proxy. If the remote is behind a path prefix, include that prefix in the machine URL, for example
|
||||
<code>https://devbox.example.test/pi-web</code>.
|
||||
</p>
|
||||
<div class="callout danger">
|
||||
Do not expose PI WEB directly to the public internet. Register machines only over trusted network paths
|
||||
and only when you trust the endpoint.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="add-machine">
|
||||
<h2>Add a machine</h2>
|
||||
<ol>
|
||||
<li>Open the PI WEB instance you want to use as the gateway.</li>
|
||||
<li>Open <strong>Actions → Add Machine</strong>.</li>
|
||||
<li>Enter the remote PI WEB base URL, including <code>http://</code> or <code>https://</code>.</li>
|
||||
<li>Accept the suggested name or enter a friendlier sidebar label.</li>
|
||||
<li>Paste an optional bearer token if the remote endpoint requires one.</li>
|
||||
</ol>
|
||||
<p>
|
||||
The Machines section appears when there is more than one machine. Select a machine, then add projects,
|
||||
workspaces, and sessions on that selected machine.
|
||||
</p>
|
||||
<div class="callout">
|
||||
Removing a remote machine only removes it from this gateway's registry. It does not stop the remote PI WEB
|
||||
service or delete projects, workspaces, sessions, or credentials on the target machine.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="what-is-proxied">
|
||||
<h2>What works remotely</h2>
|
||||
<p>
|
||||
After you select a machine, the rest of the app works in that machine's scope. The gateway routes the
|
||||
selected-machine work to the target PI WEB runtime.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Projects and workspaces.</li>
|
||||
<li>Files, previews, git status, and diffs.</li>
|
||||
<li>Pi sessions, transcripts, prompts, model controls, and commands.</li>
|
||||
<li>Activity indicators and realtime updates.</li>
|
||||
<li>Terminals and terminal command runs.</li>
|
||||
<li>Remote plugins from the selected machine.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="auth-credentials">
|
||||
<h2>Credentials stay on the target machine</h2>
|
||||
<p>
|
||||
Model-provider credentials, Pi configuration, OAuth state, repositories, and active session runtimes stay
|
||||
on the selected target machine. The gateway does not copy them into its own Pi configuration.
|
||||
</p>
|
||||
<ul>
|
||||
<li>API-key provider configuration can be proxied through the gateway.</li>
|
||||
<li>OAuth login should be completed by opening the remote PI WEB directly.</li>
|
||||
<li>The optional machine bearer token is stored by the gateway and sent to the remote while proxying requests.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Use <strong>Actions → Open Selected Machine PI WEB</strong> when you need to authenticate directly on a
|
||||
remote machine or inspect it outside the gateway.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="plugins">
|
||||
<h2>Plugins follow the selected machine</h2>
|
||||
<p>
|
||||
When you select a remote machine, PI WEB tries to load that machine's discovered plugins through the
|
||||
gateway. Remote plugin actions, workspace panels, and workspace labels only appear while that machine is
|
||||
selected, and documented file and terminal helpers run against that machine.
|
||||
</p>
|
||||
<p>
|
||||
Remote plugins are still trusted browser-side code. Only federate machines whose PI WEB plugins you are
|
||||
comfortable loading in the browser.
|
||||
</p>
|
||||
<p><a href="plugins.html#remote-machine-plugins">Read the remote machine plugin notes →</a></p>
|
||||
</section>
|
||||
|
||||
<section id="trust-model">
|
||||
<h2>Trust boundaries</h2>
|
||||
<p>
|
||||
Machine federation is for trusted users, trusted PI WEB servers, and trusted network paths. Adding a
|
||||
machine gives the gateway permission to contact that URL and forward user-initiated PI WEB traffic to it.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Use private networking, an SSH tunnel, or an authenticated reverse proxy.</li>
|
||||
<li>Do not include credentials in the machine URL itself.</li>
|
||||
<li>Use bearer tokens or proxy authentication when a remote endpoint needs an extra gate.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="storage-troubleshooting">
|
||||
<h2>Troubleshooting</h2>
|
||||
<p>
|
||||
Remote machine records are stored on the gateway in <code>~/.pi-web/machines.json</code>. If
|
||||
<code>PI_WEB_DATA_DIR</code> is set, they live under <code>$PI_WEB_DATA_DIR/machines.json</code>.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Use <strong>Actions → Refresh Selected Machine</strong> to re-check health.</li>
|
||||
<li>Make sure the remote URL is reachable from the gateway server.</li>
|
||||
<li>Open the remote PI WEB directly to verify it is running and to complete provider login flows.</li>
|
||||
<li>Check gateway logs with <code>pi-web logs</code> for proxy timeouts or upstream errors.</li>
|
||||
</ul>
|
||||
<div class="doc-actions">
|
||||
<a class="button primary" href="install.html#remote-access">Review remote access setup</a>
|
||||
<a class="button" href="faq.html#remote-machines">Read remote machine FAQ</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container footer-inner">
|
||||
<span>PI WEB fleet</span>
|
||||
<div class="footer-links">
|
||||
<a href="./">Home</a>
|
||||
<a href="remote-first.html">Remote-first</a>
|
||||
<a href="install.html">Install</a>
|
||||
<a href="plugins.html">Plugins</a>
|
||||
<a href="faq.html">FAQ</a>
|
||||
<a href="https://github.com/jmfederico/pi-web">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="site.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+43
-27
@@ -27,23 +27,28 @@
|
||||
<nav class="container nav" aria-label="Main navigation">
|
||||
<a class="brand" href="./" aria-label="PI WEB home">PI WEB</a>
|
||||
<div class="nav-links">
|
||||
<a href="remote-first.html">Remote-first</a>
|
||||
<a href="install.html">Install</a>
|
||||
<a href="plugins.html" aria-current="page">Plugins</a>
|
||||
<a href="faq.html">FAQ</a>
|
||||
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
|
||||
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
|
||||
<span data-theme-icon aria-hidden="true">◐</span>
|
||||
<span data-theme-label>Theme</span>
|
||||
</button>
|
||||
<div class="nav-pages">
|
||||
<a href="remote-first.html">Remote-first</a>
|
||||
<a href="machines.html">Fleet</a>
|
||||
<a href="install.html">Install</a>
|
||||
<a href="plugins.html" aria-current="page">Plugins</a>
|
||||
<a href="faq.html">FAQ</a>
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
|
||||
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
|
||||
<span data-theme-icon aria-hidden="true">◐</span>
|
||||
<span data-theme-label>Theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -89,7 +94,9 @@
|
||||
</ul>
|
||||
<p>
|
||||
Plugins cannot extend the session daemon or add server-side hooks. They run in the browser UI only.
|
||||
PI WEB's internal API routes are not plugin API; use documented context helpers instead.
|
||||
Use documented context helpers for the stable API surface. Daring plugins can reach into PI WEB
|
||||
internals, but those private surfaces are experimental: they may graduate into stable helpers, change
|
||||
shape, or disappear.
|
||||
</p>
|
||||
<div class="callout">
|
||||
The plugin API is intentionally limited and actively developed. Feedback is appreciated: if an extension
|
||||
@@ -184,7 +191,8 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
<h3>Updates</h3>
|
||||
<p>
|
||||
<strong>Updates</strong> adds a conditional <strong>Updates</strong> workspace tab with PI WEB update,
|
||||
restart, and installed-service guidance. It is built into PI WEB and enabled by default.
|
||||
restart, and installed-service guidance. It is built into PI WEB, enabled by default, and uses the
|
||||
selected machine's plugin copy when machine federation is active.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Plugin id: <code>updates</code></li>
|
||||
@@ -295,14 +303,14 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
<section id="remote-machine-plugins">
|
||||
<h2>Remote machine plugins</h2>
|
||||
<p>
|
||||
With machine federation, PI WEB also loads discovered plugins from the selected remote machine. Remote
|
||||
plugins are trusted browser-side code like local plugins, but their actions, workspace panels, and
|
||||
workspace labels only appear while that machine is selected.
|
||||
With <a href="machines.html">machine federation</a>, PI WEB also loads discovered plugins from the selected
|
||||
remote machine. Remote plugins are trusted browser-side code like local plugins, but their actions,
|
||||
workspace panels, and workspace labels only appear while that machine is selected.
|
||||
</p>
|
||||
<ul>
|
||||
<li>File and terminal helpers run against the selected remote machine.</li>
|
||||
<li>Remote plugin code is loaded best-effort through the current gateway and cached for the page lifetime.</li>
|
||||
<li>If the gateway already has an enabled plugin with the same original id, the gateway plugin wins and the remote duplicate stays hidden.</li>
|
||||
<li>If the gateway and remote machine both have an enabled plugin with the same original id, <code>machineSpecific</code> metadata decides whether the gateway copy is reused or only the selected-machine copy can appear.</li>
|
||||
<li>Remote theme contributions are ignored for now because themes are app-wide.</li>
|
||||
<li>Mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible.</li>
|
||||
</ul>
|
||||
@@ -310,6 +318,11 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable
|
||||
one, open that machine directly or update its config file.
|
||||
</p>
|
||||
<p>
|
||||
Plugin package metadata can set <code>machineSpecific: true</code>. Use it for plugins like Updates whose
|
||||
UI should come from the selected PI WEB instance; on remote machines, the gateway copy is hidden unless
|
||||
the remote machine exposes its own copy.
|
||||
</p>
|
||||
<p>
|
||||
For portable plugin assets, prefer URLs relative to the plugin module, such as
|
||||
<code>new URL("./asset.json", import.meta.url)</code>. If a remote plugin constructs absolute asset URLs,
|
||||
@@ -347,8 +360,8 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
</p>
|
||||
<p>
|
||||
If you want to understand the API yourself, ask an agent to read that file and explain the relevant
|
||||
extension points for the plugin you want. The Markdown reference also marks which context fields are
|
||||
stable and which PI WEB internals should be avoided unless necessary.
|
||||
public extension points for the plugin you want. Private PI WEB internals exist for the daring; treat
|
||||
them as experimental surfaces that may graduate into stable helpers, change shape, or disappear.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -376,8 +389,10 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
<section id="trust">
|
||||
<h2>Trust model</h2>
|
||||
<p>
|
||||
Plugins are not sandboxed. They run as JavaScript in the browser, can call PI WEB APIs available to the
|
||||
current session, and can render arbitrary UI. Install only plugins you trust.
|
||||
Plugins are not sandboxed. They run as JavaScript in the browser, can use browser APIs, call documented
|
||||
PI WEB helpers, and render arbitrary UI. If a plugin reaches beyond those helpers into private PI WEB
|
||||
internals, treat that dependency as experimental: it may become stable, change shape, or disappear.
|
||||
Install only plugins you trust.
|
||||
</p>
|
||||
<p>
|
||||
Plugins do not run inside the session daemon and do not provide server-side hooks.
|
||||
@@ -394,6 +409,7 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
<div class="footer-links">
|
||||
<a href="./">Home</a>
|
||||
<a href="remote-first.html">Remote-first</a>
|
||||
<a href="machines.html">Fleet</a>
|
||||
<a href="install.html">Install</a>
|
||||
<a href="faq.html">FAQ</a>
|
||||
<a href="https://github.com/jmfederico/pi-web">GitHub</a>
|
||||
|
||||
+24
-26
@@ -22,7 +22,7 @@ Plugins run as JavaScript in the browser app. Treat them as trusted code:
|
||||
- they can render arbitrary Lit templates/custom elements in plugin contribution areas;
|
||||
- they should not be installed from untrusted sources.
|
||||
|
||||
PI WEB's `/api/...` HTTP and WebSocket endpoints are internal implementation details. Plugin code should not fetch PI WEB API endpoints directly; use the documented context helpers instead.
|
||||
PI WEB's `/api/...` HTTP and WebSocket endpoints are internal implementation details. Plugin code should use the documented context helpers instead. Daring plugins can still reach private routes or runtime objects because they run in the browser, but those private surfaces are experimental: they may graduate into stable helpers, change shape, or disappear.
|
||||
|
||||
## What to ask AI to build
|
||||
|
||||
@@ -131,17 +131,22 @@ Reload the PI WEB browser tab. PI WEB serves plugin modules with an mtime-based
|
||||
|
||||
## Remote machine plugins
|
||||
|
||||
When machine federation is enabled, PI WEB also loads discovered plugins from the selected remote machine. Remote plugins are trusted browser-side code like local plugins, but their contributions are machine-scoped:
|
||||
When [machine federation](https://pi-web.dev/machines.html) is enabled, PI WEB also loads discovered plugins from the selected remote machine. Remote plugins are trusted browser-side code like local plugins, but their contributions are machine-scoped:
|
||||
|
||||
- actions, workspace panels, and workspace labels only appear while that machine is selected;
|
||||
- plugin file and terminal helpers run against that machine;
|
||||
- plugin code is loaded best-effort through the current gateway and cached for the browser page lifetime;
|
||||
- if the gateway already has an enabled plugin with the same original id, the gateway plugin wins and the remote duplicate stays hidden;
|
||||
- if the gateway and remote machine both have an enabled plugin with the same original id, `machineSpecific` metadata decides whether the gateway copy is reused or only the selected machine's copy can appear;
|
||||
- remote theme contributions are ignored for now because themes are app-wide;
|
||||
- mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible.
|
||||
|
||||
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable a remote machine plugin, open that machine directly or update its config file.
|
||||
|
||||
Plugin package metadata may set `machineSpecific: true` when the plugin's meaning is tied to the selected PI WEB machine:
|
||||
|
||||
- Omitted or `false`: use the gateway copy when the same plugin id is also present on a remote machine. This is best for portable UI plugins whose helpers already route through the selected machine.
|
||||
- `true`: the gateway copy only appears for the local machine. When a remote machine is selected, only that remote machine's copy can appear; if the remote machine does not expose the plugin, the plugin is hidden. This is best for plugins that report machine-local PI WEB status or depend on machine-local plugin code.
|
||||
|
||||
For portable plugin assets, prefer URLs relative to the plugin module, for example:
|
||||
|
||||
```js
|
||||
@@ -185,7 +190,7 @@ Built-in plugins can be managed from **Settings → Plugins** or with the top-le
|
||||
**Plugin id:** `updates`
|
||||
**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance.
|
||||
|
||||
Updates is enabled by default. To hide it, disable `updates` in **Settings → Plugins** or set:
|
||||
Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab only appears for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → Plugins** or set:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -286,7 +291,7 @@ A package can expose one or more PI WEB plugin modules. There is exactly one sup
|
||||
"piWeb": {
|
||||
"plugins": [
|
||||
{ "id": "review", "module": "dist/review.js" },
|
||||
{ "id": "dashboard", "module": "dist/dashboard.js" }
|
||||
{ "id": "dashboard", "module": "dist/dashboard.js", "machineSpecific": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -298,6 +303,7 @@ Rules:
|
||||
- Each entry must have an explicit `id` and `module`.
|
||||
- `id` must match `^[a-z][a-z0-9.-]*$`.
|
||||
- `module` must be a safe relative path inside the plugin package root.
|
||||
- `machineSpecific` is optional and must be a boolean; omit it for the default portable gateway behavior.
|
||||
- Duplicate plugin ids are not auto-renamed; later duplicates are skipped.
|
||||
- Legacy shortcuts such as `piWeb.plugin`, string entries in `piWeb.plugins`, `piWeb.id` fallback ids, and no-`package.json` fallbacks are not supported.
|
||||
|
||||
@@ -312,13 +318,14 @@ The manifest contains each discovered plugin module:
|
||||
"id": "my-plugin",
|
||||
"module": "/pi-web-plugins/my-plugin/pi-web-plugin.js?v=1234567890",
|
||||
"source": "local",
|
||||
"scope": "local"
|
||||
"scope": "local",
|
||||
"machineSpecific": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`source` describes where the plugin came from (`bundled`, `local`, or the Pi package source). `scope` is `bundled`, `local`, `user`, or `project`.
|
||||
`source` describes where the plugin came from (`bundled`, `local`, or the Pi package source). `scope` is `bundled`, `local`, `user`, or `project`. `machineSpecific` controls whether the gateway copy is valid for remote machines or only each selected machine's own copy can appear.
|
||||
|
||||
A plugin can fetch its own static assets with URLs under:
|
||||
|
||||
@@ -450,12 +457,12 @@ interface PluginRuntimeContext {
|
||||
Notes:
|
||||
|
||||
- `state` is a snapshot of current UI state when actions are built.
|
||||
- The stable state fields are `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`.
|
||||
- Other `state` fields may exist at runtime, but they are PI WEB internals and can change quickly.
|
||||
- The stable state fields are `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`. `state.piWebStatus` describes the currently selected machine's PI WEB runtime, or the gateway/local runtime when the local machine is selected.
|
||||
- Other `state` fields may exist at runtime, but they are private PI WEB internals that may graduate into stable helpers, change shape, or disappear.
|
||||
- `enabled` is evaluated when the action palette asks for actions.
|
||||
- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`.
|
||||
- `openTerminal()` switches to the built-in terminal panel. Pass `{ terminalId }` to deep-link to a specific terminal.
|
||||
- Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. Unstable runtime fields are intentionally omitted from these types; if a plugin author chooses to depend on them, they must explicitly import unstable types from `@jmfederico/pi-web/plugin-api/unstable` and type-assert the context in their own code.
|
||||
- Only fields documented here and declared in `plugin-api.d.ts` are stable public plugin API. Anything else is experimental: it may become public API later, change shape, or disappear.
|
||||
|
||||
#### Keyboard shortcuts
|
||||
|
||||
@@ -534,6 +541,8 @@ interface WorkspacePanelContext {
|
||||
|
||||
`machine`, `workspace`, `files`, `terminal`, and `host` are documented as stable for panel callbacks. Use `terminal.open()` to switch to the built-in terminal panel; pass `{ terminalId }` to deep-link to a specific terminal. Call `host.requestRender()` when async plugin-owned state changes should make PI WEB re-evaluate panel callbacks such as `badge`, `visible`, or `render`.
|
||||
|
||||
For compatibility, PI WEB still provides the old `context.openTerminal()` workspace-panel helper at runtime. It is deprecated, intentionally omitted from the public TypeScript declarations, and planned for removal in v2. Existing JavaScript plugins keep working, while typed plugins should migrate to `context.terminal.open()`.
|
||||
|
||||
Useful workspace and machine shapes:
|
||||
|
||||
```ts
|
||||
@@ -761,22 +770,11 @@ render: ({ terminal }) => html`
|
||||
|
||||
Review command strings carefully. They are trusted shell commands executed in the workspace terminal.
|
||||
|
||||
## Internal PI WEB APIs and explicit unstable opt-in
|
||||
## Private and experimental PI WEB APIs
|
||||
|
||||
PI WEB's `/api/...` HTTP and WebSocket routes are private implementation details. Plugin code should not fetch PI WEB API endpoints directly because those URLs, response shapes, and machine-federation routing rules can change.
|
||||
PI WEB's `/api/...` HTTP and WebSocket routes and runtime-only fields are private implementation details. They exist because plugins are trusted browser code, and because some capabilities may be evaluated there before they are designed as stable helpers.
|
||||
|
||||
If a plugin author deliberately chooses to depend on an unstable runtime field while a public helper is still being designed, make that decision explicit in code with a type-only unstable import and a local type assertion:
|
||||
|
||||
```ts
|
||||
import type { WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api";
|
||||
import type { UnstableWorkspacePanelContext } from "@jmfederico/pi-web/plugin-api/unstable";
|
||||
|
||||
function unstableContext(context: WorkspacePanelContext) {
|
||||
return context as WorkspacePanelContext & UnstableWorkspacePanelContext;
|
||||
}
|
||||
```
|
||||
|
||||
Unstable APIs are not covered by the v1 compatibility promise. Prefer documented helpers whenever they exist.
|
||||
That is allowed, but outside the v1 compatibility promise: URLs, response shapes, runtime fields, and machine-federation routing may graduate into stable APIs, change shape, or disappear. The stable public plugin API is only the documented helpers and declarations in `plugin-api.d.ts`. Prefer those whenever they exist; if you rely on private surfaces, keep the dependency local to the plugin and expect to revisit it after PI WEB upgrades.
|
||||
|
||||
## Async data and caching
|
||||
|
||||
@@ -793,7 +791,7 @@ PI WEB does not provide a plugin cache/invalidation framework. Keep host callbac
|
||||
If you are an AI agent building or editing a PI WEB plugin, follow this checklist:
|
||||
|
||||
1. Create or update a plugin folder with `package.json` and a JavaScript module such as `pi-web-plugin.js`.
|
||||
2. Use the single supported package metadata shape: `piWeb.plugins` array with `{ id, module }` entries.
|
||||
2. Use the single supported package metadata shape: `piWeb.plugins` array with `{ id, module, machineSpecific? }` entries.
|
||||
3. Default-export `{ apiVersion: 1, name, activate }` from the module.
|
||||
4. Return `{ contributions: { actions, workspacePanels, workspaceLabels } }` from `activate()`.
|
||||
5. Use ids matching `^[a-z][a-z0-9.-]*$`.
|
||||
@@ -804,7 +802,7 @@ If you are an AI agent building or editing a PI WEB plugin, follow this checklis
|
||||
10. Add workspace labels for compact inline metadata.
|
||||
11. Return arrays from workspace label `items()`; return an empty array to render nothing.
|
||||
12. Use documented context helpers first: `files`, `terminal`, `host.requestRender`, `workspace`, `machine`, `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`.
|
||||
13. Do not fetch PI WEB `/api/...` endpoints directly. If an unstable runtime field is intentionally required, import the type from `@jmfederico/pi-web/plugin-api/unstable` and type-assert locally.
|
||||
13. Do not fetch PI WEB `/api/...` endpoints directly unless you intentionally accept private API churn; prefer documented helpers.
|
||||
14. Treat plugins as trusted code and avoid reading or displaying secrets unless intentional.
|
||||
15. After local edits, tell the user to hard reload the browser and check the console for plugin errors.
|
||||
|
||||
|
||||
+23
-17
@@ -30,23 +30,28 @@
|
||||
<nav class="container nav" aria-label="Main navigation">
|
||||
<a class="brand" href="./" aria-label="PI WEB home">PI WEB</a>
|
||||
<div class="nav-links">
|
||||
<a href="remote-first.html" aria-current="page">Remote-first</a>
|
||||
<a href="install.html">Install</a>
|
||||
<a href="plugins.html">Plugins</a>
|
||||
<a href="faq.html">FAQ</a>
|
||||
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
|
||||
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
|
||||
<span data-theme-icon aria-hidden="true">◐</span>
|
||||
<span data-theme-label>Theme</span>
|
||||
</button>
|
||||
<div class="nav-pages">
|
||||
<a href="remote-first.html" aria-current="page">Remote-first</a>
|
||||
<a href="machines.html">Fleet</a>
|
||||
<a href="install.html">Install</a>
|
||||
<a href="plugins.html">Plugins</a>
|
||||
<a href="faq.html">FAQ</a>
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="PI WEB on GitHub">
|
||||
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
|
||||
<span data-theme-icon aria-hidden="true">◐</span>
|
||||
<span data-theme-label>Theme</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -182,6 +187,7 @@
|
||||
<span>PI WEB · remote-first control for persistent AI agents.</span>
|
||||
<div class="footer-links">
|
||||
<a href="./">Home</a>
|
||||
<a href="machines.html">Fleet</a>
|
||||
<a href="install.html">Install</a>
|
||||
<a href="plugins.html">Plugins</a>
|
||||
<a href="faq.html">FAQ</a>
|
||||
|
||||
+43
-23
@@ -197,12 +197,27 @@ video {
|
||||
background: var(--brand-2);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
.nav-links,
|
||||
.nav-pages,
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-pages {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.nav-links a,
|
||||
@@ -856,6 +871,7 @@ code .comment,
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -1062,11 +1078,7 @@ html[data-theme="light"] .comment {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.container {
|
||||
width: min(100% - 28px, 1120px);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.nav {
|
||||
position: relative;
|
||||
align-items: stretch;
|
||||
@@ -1082,17 +1094,23 @@ html[data-theme="light"] .comment {
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow: visible;
|
||||
width: 100%;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.nav-links a:not(.github-link) {
|
||||
.nav-pages {
|
||||
flex: 1 1 auto;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.nav-pages a {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.nav-links::-webkit-scrollbar {
|
||||
.nav-pages::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1102,28 +1120,30 @@ html[data-theme="light"] .comment {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.nav-links .github-link,
|
||||
.nav-links .theme-toggle {
|
||||
.nav-actions {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.nav-actions .github-link,
|
||||
.nav-actions .theme-toggle {
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
min-width: 38px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-links .github-link {
|
||||
right: 44px;
|
||||
}
|
||||
|
||||
.nav-links .theme-toggle {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.nav-links .github-link span,
|
||||
.nav-links .theme-toggle [data-theme-label] {
|
||||
.nav-actions .github-link span,
|
||||
.nav-actions .theme-toggle [data-theme-label] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.container {
|
||||
width: min(100% - 28px, 1120px);
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding-top: 52px;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@jmfederico/pi-web",
|
||||
"version": "1.202606.0",
|
||||
"version": "1.202606.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@jmfederico/pi-web",
|
||||
"version": "1.202606.0",
|
||||
"version": "1.202606.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jmfederico/pi-web",
|
||||
"version": "1.202606.0",
|
||||
"version": "1.202606.1",
|
||||
"description": "Remote web UI and browser control plane for persistent Pi Coding Agent sessions.",
|
||||
"license": "MIT",
|
||||
"author": "Federico Jaramillo Martinez",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"private": true,
|
||||
"piWeb": {
|
||||
"plugins": [
|
||||
{ "id": "updates", "module": "pi-web-plugin.js" }
|
||||
{ "id": "updates", "module": "pi-web-plugin.js", "machineSpecific": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||
import { terminalsApi, workspacesApi } from "./clients";
|
||||
import { machinesApi, piWebApi, terminalsApi, workspacesApi } from "./clients";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "w/1",
|
||||
@@ -29,6 +30,36 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("machine-scoped runtime API", () => {
|
||||
it("reads machine PI WEB status through the gateway route", async () => {
|
||||
const fetchMock = stubJsonFetch({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "PI WEB", available: true, stale: false },
|
||||
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false },
|
||||
},
|
||||
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
|
||||
commands: {},
|
||||
messages: [],
|
||||
});
|
||||
|
||||
await piWebApi.piWebStatus("remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status");
|
||||
});
|
||||
|
||||
it("reads machine runtime through the gateway route", async () => {
|
||||
const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
|
||||
|
||||
await machinesApi.runtime("remote a");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/runtime");
|
||||
});
|
||||
});
|
||||
|
||||
describe("machine-scoped terminal command-run API", () => {
|
||||
it("deletes workspaces through the selected machine scope", async () => {
|
||||
const fetchMock = stubJsonFetch(commandRun);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
parseAuthProvidersResponse,
|
||||
parseClosed,
|
||||
parseCommandResult,
|
||||
parseDeleted,
|
||||
parseDetached,
|
||||
parseFileContentResponse,
|
||||
parseFileSuggestion,
|
||||
@@ -16,12 +17,14 @@ import {
|
||||
parseGitStatusResponse,
|
||||
parseMachine,
|
||||
parseMachineHealth,
|
||||
parseMachineRuntime,
|
||||
parseMachinesResponse,
|
||||
parseMessagePage,
|
||||
parseModelSelectionResponse,
|
||||
parseOAuthFlowState,
|
||||
parsePiWebConfigResponse,
|
||||
parsePiWebPluginsResponse,
|
||||
parsePiWebRuntimeResponse,
|
||||
parsePiWebStatusResponse,
|
||||
parseProject,
|
||||
parseRestored,
|
||||
@@ -39,8 +42,12 @@ import { machineGitDiffUrl, messageUrl } from "./urls";
|
||||
|
||||
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
|
||||
|
||||
function sessionBaseUrl(session: SessionRef, machineId = "local"): string {
|
||||
return `${machinePrefix(machineId)}/sessions/${encodeURIComponent(session.id)}`;
|
||||
}
|
||||
|
||||
function sessionUrl(session: SessionRef, endpoint: string, machineId = "local"): string {
|
||||
return `${machinePrefix(machineId)}/sessions/${encodeURIComponent(session.id)}/${endpoint}`;
|
||||
return `${sessionBaseUrl(session, machineId)}/${endpoint}`;
|
||||
}
|
||||
|
||||
function sessionQueryUrl(session: SessionRef, endpoint: string, machineId = "local"): string {
|
||||
@@ -49,7 +56,8 @@ function sessionQueryUrl(session: SessionRef, endpoint: string, machineId = "loc
|
||||
}
|
||||
|
||||
export const piWebApi = {
|
||||
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
|
||||
piWebStatus: (machineId = "local") => request(machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`, parsePiWebStatusResponse),
|
||||
piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
|
||||
};
|
||||
|
||||
export const machinesApi = {
|
||||
@@ -57,6 +65,7 @@ export const machinesApi = {
|
||||
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("/api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
|
||||
deleteMachine: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
|
||||
health: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
|
||||
runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
|
||||
};
|
||||
|
||||
export const configApi = {
|
||||
@@ -107,6 +116,7 @@ export const sessionsApi = {
|
||||
archive: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "archive", machineId), parseArchived, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
archiveWithDescendants: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "archive-tree", machineId), parseArchived, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
restore: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
deleteArchived: (session: SessionRef, machineId = "local") => request(`${sessionBaseUrl(session, machineId)}?${new URLSearchParams({ cwd: session.cwd }).toString()}`, parseDeleted, { method: "DELETE" }),
|
||||
detachParent: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
|
||||
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Workspace } from "../../../shared/apiTypes";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
|
||||
import { activityApi, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { activityApi, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
|
||||
import { workspaceImagePreviewUrl } from "./urls";
|
||||
|
||||
@@ -27,6 +27,7 @@ describe("federated route contract", () => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await Promise.all([
|
||||
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
|
||||
ignoreParseFailure(activityApi.workspaceActivity(machineId)),
|
||||
ignoreParseFailure(projectsApi.projects(machineId)),
|
||||
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
|
||||
@@ -59,6 +60,7 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(sessionsApi.archive(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.archiveWithDescendants(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.restore(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.deleteArchived(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.detachParent(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
|
||||
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspaceActivityResponse } from "./parsers";
|
||||
|
||||
describe("API parsers", () => {
|
||||
it("parses PI WEB config responses", () => {
|
||||
@@ -18,11 +19,23 @@ describe("API parsers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses PI WEB runtime responses", () => {
|
||||
expect(parsePiWebRuntimeResponse({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
|
||||
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
|
||||
});
|
||||
|
||||
it("parses PI WEB plugin status responses", () => {
|
||||
expect(parsePiWebPluginsResponse({
|
||||
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }],
|
||||
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }],
|
||||
})).toEqual({
|
||||
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }],
|
||||
plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import { isPiWebCapability } from "../../../shared/capabilities";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
@@ -95,6 +96,21 @@ export function parseMachineHealth(value: unknown): MachineHealth {
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMachineRuntime(value: unknown): MachineRuntime {
|
||||
const record = requireRecord(value);
|
||||
const error = optionalString(record, "error");
|
||||
return {
|
||||
machineId: requireString(record, "machineId"),
|
||||
ok: requireBoolean(record, "ok"),
|
||||
checkedAt: requireString(record, "checkedAt"),
|
||||
...optionalField("packageName", optionalString(record, "packageName")),
|
||||
...optionalField("generatedAt", optionalString(record, "generatedAt")),
|
||||
...(record["components"] === undefined ? {} : { components: parsePiWebRuntimeComponents(record["components"]) }),
|
||||
...(record["capabilities"] === undefined ? {} : { capabilities: parsePiWebCapabilities(record["capabilities"]) }),
|
||||
...(error === undefined ? {} : { error }),
|
||||
};
|
||||
}
|
||||
|
||||
function requireMachineKind(record: Record<string, unknown>, key: string): MachineKind {
|
||||
const value = requireString(record, key);
|
||||
if (value !== "local" && value !== "remote") throw new Error(`Expected machine kind field: ${key}`);
|
||||
@@ -476,6 +492,7 @@ function parsePiWebPluginInfo(value: unknown): PiWebPluginInfo {
|
||||
module: requireString(record, "module"),
|
||||
source: requireString(record, "source"),
|
||||
scope: parsePiWebPluginScope(record["scope"]),
|
||||
machineSpecific: parseOptionalBoolean(record["machineSpecific"], "machineSpecific") ?? false,
|
||||
enabled: requireBoolean(record, "enabled"),
|
||||
};
|
||||
}
|
||||
@@ -485,6 +502,12 @@ function parsePiWebPluginScope(value: unknown): PiWebPluginScope {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOptionalBoolean(value: unknown, key: string): boolean | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "boolean") throw new Error(`Expected optional boolean field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
@@ -497,11 +520,38 @@ export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePiWebRuntimeResponse(value: unknown): PiWebRuntimeResponse {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
packageName: requireString(record, "packageName"),
|
||||
generatedAt: requireString(record, "generatedAt"),
|
||||
components: parsePiWebRuntimeComponents(record["components"]),
|
||||
capabilities: parsePiWebCapabilities(record["capabilities"]),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiWebComponents(value: unknown): PiWebStatusResponse["components"] {
|
||||
const record = requireRecord(value);
|
||||
return { web: parsePiWebComponentStatus(record["web"]), sessiond: parsePiWebComponentStatus(record["sessiond"]) };
|
||||
}
|
||||
|
||||
function parsePiWebRuntimeComponents(value: unknown): PiWebRuntimeResponse["components"] {
|
||||
const record = requireRecord(value);
|
||||
return { web: parsePiWebRuntimeComponent(record["web"]), sessiond: parsePiWebRuntimeComponent(record["sessiond"]) };
|
||||
}
|
||||
|
||||
function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
component: parsePiWebServiceComponent(record["component"]),
|
||||
label: requireString(record, "label"),
|
||||
...optionalField("runtimeVersion", optionalString(record, "runtimeVersion")),
|
||||
available: requireBoolean(record, "available"),
|
||||
capabilities: parsePiWebCapabilities(record["capabilities"]),
|
||||
...optionalField("error", optionalString(record, "error")),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
@@ -571,6 +621,11 @@ function parsePiWebServiceComponent(value: unknown): PiWebServiceComponent {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePiWebCapabilities(value: unknown): PiWebCapability[] {
|
||||
if (!Array.isArray(value) || !value.every(isPiWebCapability)) throw new Error("Invalid PI WEB capabilities");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePiWebStatusSeverity(value: unknown): PiWebStatusSeverity {
|
||||
if (value !== "info" && value !== "warning" && value !== "error") throw new Error("Invalid PI WEB status severity");
|
||||
return value;
|
||||
@@ -638,6 +693,12 @@ export function parseRestored(value: unknown): { restored: true } {
|
||||
return { restored: true };
|
||||
}
|
||||
|
||||
export function parseDeleted(value: unknown): { deleted: true } {
|
||||
const record = requireRecord(value);
|
||||
if (record["deleted"] !== true) throw new Error("Expected deleted response");
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
export function parseDetached(value: unknown): { detached: true } {
|
||||
const record = requireRecord(value);
|
||||
if (record["detached"] !== true) throw new Error("Expected detached response");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultNavigationSection, expandedNavigationSection, isNavigationSectionCollapsed, toggleNavigationSection } from "./navigationState";
|
||||
import { defaultNavigationSection, expandedNavigationSection, isNavigationSectionCollapsed, toggleCollapsedNavigationSection, toggleNavigationSection } from "./navigationState";
|
||||
|
||||
describe("navigationState", () => {
|
||||
it("defaults to the first incomplete selection section", () => {
|
||||
@@ -16,15 +16,22 @@ describe("navigationState", () => {
|
||||
expect(expandedNavigationSection("none", state)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("only collapses sections in mobile navigation layouts", () => {
|
||||
it("uses the mobile accordion state on mobile 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", () => {
|
||||
it("uses independent collapsed sections on desktop layouts", () => {
|
||||
const state = { selectedProject: {}, selectedWorkspace: {} };
|
||||
|
||||
expect(isNavigationSectionCollapsed("projects", { isMobileLayout: false, expanded: "sessions", state })).toBe(false);
|
||||
expect(isNavigationSectionCollapsed("projects", { isMobileLayout: false, expanded: "sessions", state, collapsedSections: ["projects"] })).toBe(true);
|
||||
expect(isNavigationSectionCollapsed("sessions", { isMobileLayout: false, expanded: "sessions", state, collapsedSections: ["projects"] })).toBe(false);
|
||||
});
|
||||
|
||||
it("toggles the effective mobile section, including the implicit default section", () => {
|
||||
const state = { selectedProject: undefined, selectedWorkspace: undefined };
|
||||
|
||||
expect(toggleNavigationSection(undefined, "projects", { isMobileLayout: true, state })).toBe("none");
|
||||
@@ -37,4 +44,11 @@ describe("navigationState", () => {
|
||||
|
||||
expect(toggleNavigationSection("projects", "projects", { isMobileLayout: false, state })).toBe("projects");
|
||||
});
|
||||
|
||||
it("toggles desktop sections independently", () => {
|
||||
expect(toggleCollapsedNavigationSection([], "projects")).toEqual(["projects"]);
|
||||
expect(toggleCollapsedNavigationSection(["machines", "projects"], "projects")).toEqual(["machines"]);
|
||||
expect(toggleCollapsedNavigationSection(["sessions"], "machines")).toEqual(["machines", "sessions"]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
|
||||
export type NavigationSection = "machines" | "projects" | "workspaces" | "sessions";
|
||||
export const NAVIGATION_SECTION_ORDER = ["machines", "projects", "workspaces", "sessions"] as const;
|
||||
export type NavigationSection = (typeof NAVIGATION_SECTION_ORDER)[number];
|
||||
export type ExpandedNavigationSection = NavigationSection | "none" | undefined;
|
||||
|
||||
export interface NavigationSelectionState {
|
||||
@@ -19,8 +20,9 @@ export function expandedNavigationSection(expanded: ExpandedNavigationSection, s
|
||||
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 isNavigationSectionCollapsed(section: NavigationSection, options: { isMobileLayout: boolean; expanded: ExpandedNavigationSection; state: NavigationSelectionState; collapsedSections?: readonly NavigationSection[] | undefined }): boolean {
|
||||
if (options.isMobileLayout) return expandedNavigationSection(options.expanded, options.state) !== section;
|
||||
return options.collapsedSections?.includes(section) ?? false;
|
||||
}
|
||||
|
||||
export function toggleNavigationSection(expanded: ExpandedNavigationSection, section: NavigationSection, options: { isMobileLayout: boolean; state: NavigationSelectionState }): ExpandedNavigationSection {
|
||||
@@ -32,8 +34,20 @@ export function expandNavigationSection(expanded: ExpandedNavigationSection, sec
|
||||
return isMobileLayout ? section : expanded;
|
||||
}
|
||||
|
||||
export class MobileNavigationController implements ReactiveController {
|
||||
export function toggleCollapsedNavigationSection(collapsedSections: readonly NavigationSection[], section: NavigationSection): NavigationSection[] {
|
||||
const collapsed = new Set(collapsedSections);
|
||||
if (collapsed.has(section)) collapsed.delete(section);
|
||||
else collapsed.add(section);
|
||||
return orderedNavigationSections(collapsed);
|
||||
}
|
||||
|
||||
export function nextNavigationSection(section: NavigationSection): NavigationSection | undefined {
|
||||
return NAVIGATION_SECTION_ORDER[NAVIGATION_SECTION_ORDER.indexOf(section) + 1];
|
||||
}
|
||||
|
||||
export class NavigationSectionsController implements ReactiveController {
|
||||
private expanded: ExpandedNavigationSection;
|
||||
private collapsedSections: readonly NavigationSection[] = [];
|
||||
|
||||
hostConnected(): void {
|
||||
return;
|
||||
@@ -56,15 +70,30 @@ export class MobileNavigationController implements ReactiveController {
|
||||
isMobileLayout: this.isMobileLayout(),
|
||||
expanded: this.expanded,
|
||||
state: this.getState(),
|
||||
collapsedSections: this.collapsedSections,
|
||||
});
|
||||
}
|
||||
|
||||
toggle(section: NavigationSection): void {
|
||||
this.setExpanded(toggleNavigationSection(this.expanded, section, { isMobileLayout: this.isMobileLayout(), state: this.getState() }));
|
||||
if (this.isMobileLayout()) {
|
||||
this.setExpanded(toggleNavigationSection(this.expanded, section, { isMobileLayout: true, state: this.getState() }));
|
||||
return;
|
||||
}
|
||||
this.setCollapsedSections(toggleCollapsedNavigationSection(this.collapsedSections, section));
|
||||
}
|
||||
|
||||
expand(section: NavigationSection): void {
|
||||
this.setExpanded(expandNavigationSection(this.expanded, section, this.isMobileLayout()));
|
||||
if (this.isMobileLayout()) {
|
||||
this.setExpanded(expandNavigationSection(this.expanded, section, true));
|
||||
return;
|
||||
}
|
||||
this.setCollapsedSections(this.collapsedSections.filter((collapsedSection) => collapsedSection !== section));
|
||||
}
|
||||
|
||||
advanceAfterSelection(section: NavigationSection): void {
|
||||
if (!this.isMobileLayout()) return;
|
||||
const next = nextNavigationSection(section);
|
||||
if (next !== undefined) this.expand(next);
|
||||
}
|
||||
|
||||
open(section: NavigationSection, openNavigationView: () => void): void {
|
||||
@@ -78,4 +107,19 @@ export class MobileNavigationController implements ReactiveController {
|
||||
this.expanded = expanded;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
private setCollapsedSections(collapsedSections: readonly NavigationSection[]): void {
|
||||
if (navigationSectionListsEqual(this.collapsedSections, collapsedSections)) return;
|
||||
this.collapsedSections = collapsedSections;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
function orderedNavigationSections(sections: Iterable<NavigationSection>): NavigationSection[] {
|
||||
const sectionSet = new Set(sections);
|
||||
return NAVIGATION_SECTION_ORDER.filter((section) => sectionSet.has(section));
|
||||
}
|
||||
|
||||
function navigationSectionListsEqual(first: readonly NavigationSection[], second: readonly NavigationSection[]): boolean {
|
||||
return first.length === second.length && first.every((section, index) => section === second[index]);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,18 @@ export class PanelCollapseController implements ReactiveController {
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
expandNavigationPanel(): void {
|
||||
if (!this.navigationPanelCollapsed) return;
|
||||
this.navigationPanelCollapsed = false;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
expandWorkspacePanel(): void {
|
||||
if (!this.workspacePanelCollapsed) return;
|
||||
this.workspacePanelCollapsed = false;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
shellClass(mainView: AppState["mainView"]): string {
|
||||
return [
|
||||
"shell",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clampPanelWidth,
|
||||
PANEL_SIZE_STORAGE_KEY,
|
||||
panelResizeDelta,
|
||||
panelWidthFromDrag,
|
||||
panelWidthFromKeyboard,
|
||||
readStoredPanelSizes,
|
||||
writeStoredPanelSizes,
|
||||
} from "./panelResizeController";
|
||||
|
||||
describe("panel resize behavior", () => {
|
||||
it("resizes left and right panels in opposite drag directions", () => {
|
||||
expect(panelResizeDelta("navigation", 100, 140)).toBe(40);
|
||||
expect(panelWidthFromDrag("navigation", 300, 100, 140)).toBe(340);
|
||||
|
||||
expect(panelResizeDelta("workspace", 100, 140)).toBe(-40);
|
||||
expect(panelWidthFromDrag("workspace", 500, 100, 140)).toBe(460);
|
||||
});
|
||||
|
||||
it("clamps panel widths to broad fallback bounds", () => {
|
||||
expect(clampPanelWidth("navigation", 50)).toBe(180);
|
||||
expect(clampPanelWidth("navigation", 9000)).toBe(4096);
|
||||
expect(clampPanelWidth("workspace", 50)).toBe(240);
|
||||
expect(clampPanelWidth("workspace", 9000)).toBe(4096);
|
||||
});
|
||||
|
||||
it("supports narrower viewport-aware constraints", () => {
|
||||
const constraints = { minWidth: 200, maxWidth: 900, defaultWidth: 340, keyboardStep: 24, largeKeyboardStep: 72 };
|
||||
|
||||
expect(clampPanelWidth("navigation", 100, constraints)).toBe(200);
|
||||
expect(clampPanelWidth("navigation", 1200, constraints)).toBe(900);
|
||||
});
|
||||
|
||||
it("supports keyboard resizing in panel-relative directions", () => {
|
||||
expect(panelWidthFromKeyboard("navigation", 300, "ArrowRight")).toBe(324);
|
||||
expect(panelWidthFromKeyboard("navigation", 300, "ArrowLeft")).toBe(276);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "ArrowLeft")).toBe(524);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "ArrowRight")).toBe(476);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "Home")).toBe(240);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "End")).toBe(4096);
|
||||
expect(panelWidthFromKeyboard("workspace", 500, "Enter")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads, writes, and clears stored panel widths", () => {
|
||||
const storage = new FakeStorage();
|
||||
|
||||
expect(readStoredPanelSizes(storage)).toEqual({});
|
||||
writeStoredPanelSizes({ navigationPanelWidth: 260, workspacePanelWidth: 640 }, storage);
|
||||
|
||||
expect(JSON.parse(storage.value(PANEL_SIZE_STORAGE_KEY) ?? "{}")).toEqual({
|
||||
version: 1,
|
||||
navigationPanelWidth: 260,
|
||||
workspacePanelWidth: 640,
|
||||
});
|
||||
expect(readStoredPanelSizes(storage)).toEqual({ navigationPanelWidth: 260, workspacePanelWidth: 640 });
|
||||
|
||||
writeStoredPanelSizes({}, storage);
|
||||
expect(storage.value(PANEL_SIZE_STORAGE_KEY)).toBeUndefined();
|
||||
expect(readStoredPanelSizes(storage)).toEqual({});
|
||||
});
|
||||
|
||||
it("clamps stored panel widths and ignores invalid values", () => {
|
||||
const storage = new FakeStorage({
|
||||
[PANEL_SIZE_STORAGE_KEY]: JSON.stringify({ version: 1, navigationPanelWidth: 9999, workspacePanelWidth: "wide" }),
|
||||
});
|
||||
|
||||
expect(readStoredPanelSizes(storage)).toEqual({ navigationPanelWidth: 4096 });
|
||||
});
|
||||
|
||||
it("ignores storage failures", () => {
|
||||
const storage = new ThrowingStorage();
|
||||
|
||||
expect(readStoredPanelSizes(storage)).toEqual({});
|
||||
expect(() => { writeStoredPanelSizes({ navigationPanelWidth: 260 }, storage); }).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
class FakeStorage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
constructor(seed: Record<string, string> = {}) {
|
||||
for (const [key, value] of Object.entries(seed)) this.values.set(key, value);
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
removeItem(): void {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
|
||||
export type ResizablePanelSide = "navigation" | "workspace";
|
||||
|
||||
export interface PanelResizeConstraints {
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
defaultWidth: number;
|
||||
keyboardStep: number;
|
||||
largeKeyboardStep: number;
|
||||
}
|
||||
|
||||
export interface PanelSizePreferences {
|
||||
navigationPanelWidth?: number;
|
||||
workspacePanelWidth?: number;
|
||||
}
|
||||
|
||||
export interface PanelResizeControllerOptions {
|
||||
storage?: PanelSizeStorage;
|
||||
}
|
||||
|
||||
export interface PanelResizeOptions {
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
export interface PanelResetOptions {
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
export interface PanelKeyboardResizeOptions {
|
||||
largeStep?: boolean;
|
||||
constraints?: PanelResizeConstraints;
|
||||
}
|
||||
|
||||
export type PanelSizeStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
||||
export type PanelResizeConstraintsBySide = Partial<Record<ResizablePanelSide, PanelResizeConstraints>>;
|
||||
|
||||
export const PANEL_SIZE_STORAGE_KEY = "pi-web:panel-sizes:v1";
|
||||
export const PANEL_RESIZE_CONSTRAINTS = {
|
||||
navigation: { minWidth: 180, maxWidth: 4096, defaultWidth: 340, keyboardStep: 24, largeKeyboardStep: 72 },
|
||||
workspace: { minWidth: 240, maxWidth: 4096, defaultWidth: 480, keyboardStep: 24, largeKeyboardStep: 72 },
|
||||
} as const satisfies Record<ResizablePanelSide, PanelResizeConstraints>;
|
||||
|
||||
interface StoredPanelSizeEnvelope {
|
||||
version: 1;
|
||||
navigationPanelWidth?: number;
|
||||
workspacePanelWidth?: number;
|
||||
}
|
||||
|
||||
export class PanelResizeController implements ReactiveController {
|
||||
private readonly storage: PanelSizeStorage | undefined;
|
||||
private panelSizes: PanelSizePreferences;
|
||||
|
||||
constructor(private readonly host: ReactiveControllerHost, options: PanelResizeControllerOptions = {}) {
|
||||
host.addController(this);
|
||||
this.storage = options.storage ?? browserPanelSizeStorage();
|
||||
this.panelSizes = readStoredPanelSizes(this.storage);
|
||||
}
|
||||
|
||||
hostConnected(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
constraints(side: ResizablePanelSide): PanelResizeConstraints {
|
||||
return panelResizeConstraints(side);
|
||||
}
|
||||
|
||||
panelWidth(side: ResizablePanelSide, measuredWidth?: number): number {
|
||||
return clampPanelWidth(side, measuredWidth ?? this.storedPanelWidth(side) ?? this.constraints(side).defaultWidth);
|
||||
}
|
||||
|
||||
resizePanel(side: ResizablePanelSide, width: number, options: PanelResizeOptions = {}): void {
|
||||
const nextWidth = clampPanelWidth(side, width);
|
||||
if (this.storedPanelWidth(side) === nextWidth) return;
|
||||
this.panelSizes = panelSizesWithWidth(this.panelSizes, side, nextWidth);
|
||||
if (options.persist !== false) this.persistPanelSizes();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
resetPanel(side: ResizablePanelSide, options: PanelResetOptions = {}): void {
|
||||
if (this.storedPanelWidth(side) === undefined) return;
|
||||
this.panelSizes = panelSizesWithoutSide(this.panelSizes, side);
|
||||
if (options.persist !== false) this.persistPanelSizes();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
resetPanels(options: PanelResetOptions = {}): void {
|
||||
if (this.panelSizes.navigationPanelWidth === undefined && this.panelSizes.workspacePanelWidth === undefined) return;
|
||||
this.panelSizes = {};
|
||||
if (options.persist !== false) this.persistPanelSizes();
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
persistPanelSizes(): void {
|
||||
writeStoredPanelSizes(this.panelSizes, this.storage);
|
||||
}
|
||||
|
||||
shellStyle(constraintsBySide: PanelResizeConstraintsBySide = {}): string {
|
||||
const declarations: string[] = [];
|
||||
if (this.panelSizes.navigationPanelWidth !== undefined) {
|
||||
declarations.push(`--navigation-panel-size: ${formatPanelWidth(clampPanelWidth("navigation", this.panelSizes.navigationPanelWidth, constraintsBySide.navigation))};`);
|
||||
}
|
||||
if (this.panelSizes.workspacePanelWidth !== undefined) {
|
||||
declarations.push(`--workspace-panel-size: ${formatPanelWidth(clampPanelWidth("workspace", this.panelSizes.workspacePanelWidth, constraintsBySide.workspace))};`);
|
||||
}
|
||||
return declarations.join(" ");
|
||||
}
|
||||
|
||||
private storedPanelWidth(side: ResizablePanelSide): number | undefined {
|
||||
return side === "navigation" ? this.panelSizes.navigationPanelWidth : this.panelSizes.workspacePanelWidth;
|
||||
}
|
||||
}
|
||||
|
||||
export function panelResizeConstraints(side: ResizablePanelSide): PanelResizeConstraints {
|
||||
return PANEL_RESIZE_CONSTRAINTS[side];
|
||||
}
|
||||
|
||||
export function panelResizeDelta(side: ResizablePanelSide, startClientX: number, currentClientX: number): number {
|
||||
return side === "navigation" ? currentClientX - startClientX : startClientX - currentClientX;
|
||||
}
|
||||
|
||||
export function panelWidthFromDrag(side: ResizablePanelSide, startWidth: number, startClientX: number, currentClientX: number, constraints = panelResizeConstraints(side)): number {
|
||||
return clampPanelWidth(side, startWidth + panelResizeDelta(side, startClientX, currentClientX), constraints);
|
||||
}
|
||||
|
||||
export function panelWidthFromKeyboard(side: ResizablePanelSide, currentWidth: number, key: string, options: PanelKeyboardResizeOptions = {}): number | undefined {
|
||||
const constraints = options.constraints ?? panelResizeConstraints(side);
|
||||
if (key === "Home") return constraints.minWidth;
|
||||
if (key === "End") return constraints.maxWidth;
|
||||
|
||||
const step = options.largeStep === true ? constraints.largeKeyboardStep : constraints.keyboardStep;
|
||||
const delta = keyboardResizeDelta(side, key, step);
|
||||
if (delta === undefined) return undefined;
|
||||
return clampPanelWidth(side, currentWidth + delta, constraints);
|
||||
}
|
||||
|
||||
export function clampPanelWidth(side: ResizablePanelSide, width: number, constraints = panelResizeConstraints(side)): number {
|
||||
if (!Number.isFinite(width)) return constraints.defaultWidth;
|
||||
return Math.round(Math.min(Math.max(width, constraints.minWidth), constraints.maxWidth));
|
||||
}
|
||||
|
||||
export function readStoredPanelSizes(storage: PanelSizeStorage | undefined = browserPanelSizeStorage()): PanelSizePreferences {
|
||||
try {
|
||||
const raw = storage?.getItem(PANEL_SIZE_STORAGE_KEY);
|
||||
if (raw === undefined || raw === null || raw === "") return {};
|
||||
const value: unknown = JSON.parse(raw);
|
||||
return parseStoredPanelSizes(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredPanelSizes(panelSizes: PanelSizePreferences, storage: PanelSizeStorage | undefined = browserPanelSizeStorage()): void {
|
||||
if (storage === undefined) return;
|
||||
try {
|
||||
if (panelSizes.navigationPanelWidth === undefined && panelSizes.workspacePanelWidth === undefined) {
|
||||
storage.removeItem(PANEL_SIZE_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
const envelope: StoredPanelSizeEnvelope = { version: 1 };
|
||||
if (panelSizes.navigationPanelWidth !== undefined) envelope.navigationPanelWidth = clampPanelWidth("navigation", panelSizes.navigationPanelWidth);
|
||||
if (panelSizes.workspacePanelWidth !== undefined) envelope.workspacePanelWidth = clampPanelWidth("workspace", panelSizes.workspacePanelWidth);
|
||||
storage.setItem(PANEL_SIZE_STORAGE_KEY, JSON.stringify(envelope));
|
||||
} catch {
|
||||
// Ignore localStorage quota/privacy errors; the resized layout still applies in memory for this tab.
|
||||
}
|
||||
}
|
||||
|
||||
function parseStoredPanelSizes(value: unknown): PanelSizePreferences {
|
||||
if (!isRecord(value) || value["version"] !== 1) return {};
|
||||
const panelSizes: PanelSizePreferences = {};
|
||||
const navigationWidth = parseStoredPanelWidth(value["navigationPanelWidth"]);
|
||||
const workspaceWidth = parseStoredPanelWidth(value["workspacePanelWidth"]);
|
||||
if (navigationWidth !== undefined) panelSizes.navigationPanelWidth = clampPanelWidth("navigation", navigationWidth);
|
||||
if (workspaceWidth !== undefined) panelSizes.workspacePanelWidth = clampPanelWidth("workspace", workspaceWidth);
|
||||
return panelSizes;
|
||||
}
|
||||
|
||||
function parseStoredPanelWidth(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function panelSizesWithWidth(panelSizes: PanelSizePreferences, side: ResizablePanelSide, width: number): PanelSizePreferences {
|
||||
if (side === "navigation") return { ...panelSizes, navigationPanelWidth: width };
|
||||
return { ...panelSizes, workspacePanelWidth: width };
|
||||
}
|
||||
|
||||
function panelSizesWithoutSide(panelSizes: PanelSizePreferences, side: ResizablePanelSide): PanelSizePreferences {
|
||||
if (side === "navigation") {
|
||||
return panelSizes.workspacePanelWidth === undefined ? {} : { workspacePanelWidth: panelSizes.workspacePanelWidth };
|
||||
}
|
||||
return panelSizes.navigationPanelWidth === undefined ? {} : { navigationPanelWidth: panelSizes.navigationPanelWidth };
|
||||
}
|
||||
|
||||
function keyboardResizeDelta(side: ResizablePanelSide, key: string, step: number): number | undefined {
|
||||
if (side === "navigation") {
|
||||
if (key === "ArrowRight") return step;
|
||||
if (key === "ArrowLeft") return -step;
|
||||
return undefined;
|
||||
}
|
||||
if (key === "ArrowLeft") return step;
|
||||
if (key === "ArrowRight") return -step;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatPanelWidth(width: number): string {
|
||||
return `${String(Math.round(width))}px`;
|
||||
}
|
||||
|
||||
function browserPanelSizeStorage(): PanelSizeStorage | undefined {
|
||||
try {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { ChatLine } from "./components/shared";
|
||||
import type { QualifiedContributionId } from "./plugins/ids";
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface AppState {
|
||||
selectedMachine: Machine | undefined;
|
||||
isLoadingMachines: boolean;
|
||||
machineStatuses: Record<string, MachineHealth>;
|
||||
machineRuntimes: Record<string, MachineRuntime>;
|
||||
projects: Project[];
|
||||
workspaces: Workspace[];
|
||||
sessions: SessionInfo[];
|
||||
@@ -102,6 +103,7 @@ export function initialAppState(): AppState {
|
||||
selectedMachine: undefined,
|
||||
isLoadingMachines: false,
|
||||
machineStatuses: {},
|
||||
machineRuntimes: {},
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
sessions: [],
|
||||
|
||||
@@ -9,7 +9,7 @@ export class FormattedText extends LitElement {
|
||||
@property() text = "";
|
||||
|
||||
override render() {
|
||||
return html`<div class="formatted" @click=${this.onFormattedClick} @copy=${this.onFormattedCopy}>${unsafeHTML(toSafeMarkdownHtml(this.text))}</div>`;
|
||||
return html`<div class="formatted" @click=${this.onFormattedClick}>${unsafeHTML(toSafeMarkdownHtml(this.text))}</div>`;
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
@@ -48,13 +48,6 @@ export class FormattedText extends LitElement {
|
||||
void this.copyCode(code.textContent, button);
|
||||
};
|
||||
|
||||
private readonly onFormattedCopy = (event: ClipboardEvent): void => {
|
||||
if (event.clipboardData === null || !hasSelectedText(currentSelection(this))) return;
|
||||
event.clipboardData.setData("text/plain", this.text);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
private async copyCode(text: string, button: HTMLButtonElement): Promise<void> {
|
||||
const ok = await writeClipboard(text);
|
||||
this.setCopyButtonState(button, ok ? "copied" : "failed");
|
||||
@@ -74,20 +67,6 @@ export class FormattedText extends LitElement {
|
||||
static override styles = formattedTextStyles;
|
||||
}
|
||||
|
||||
function currentSelection(element: Element): Selection | null {
|
||||
const root = element.getRootNode();
|
||||
if (typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot && hasRootSelection(root)) return root.getSelection();
|
||||
return element.ownerDocument.getSelection();
|
||||
}
|
||||
|
||||
function hasRootSelection(root: Node): root is Node & { getSelection: () => Selection | null } {
|
||||
return "getSelection" in root && typeof root.getSelection === "function";
|
||||
}
|
||||
|
||||
function hasSelectedText(selection: Selection | null): boolean {
|
||||
return selection !== null && !selection.isCollapsed && selection.toString() !== "";
|
||||
}
|
||||
|
||||
async function writeClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
@@ -3,12 +3,13 @@ import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { Machine, MachineHealth, WorkspaceActivity } from "../api";
|
||||
import { machineActivityIndicator } from "../workspaceActivity";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { renderActivityIndicator } from "./activityBadge";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||
import { renderActionActivityIndicator } from "./activityBadge";
|
||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
||||
import { listStyles } from "./shared";
|
||||
|
||||
@customElement("machine-list")
|
||||
export class MachineList extends LitElement {
|
||||
export class MachineList extends LitElement implements KeyboardNavigableSection {
|
||||
@property({ attribute: false }) machines: Machine[] = [];
|
||||
@property({ attribute: false }) selected?: Machine;
|
||||
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
|
||||
@@ -18,6 +19,8 @@ export class MachineList extends LitElement {
|
||||
@property({ attribute: false }) onSelect?: (machine: Machine) => void;
|
||||
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
||||
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
|
||||
@state() private openMenuMachineId: string | undefined;
|
||||
@state() private menuStyle = "";
|
||||
|
||||
@@ -41,6 +44,11 @@ export class MachineList extends LitElement {
|
||||
if (changed.has("collapsed") && this.collapsed) this.openMenuMachineId = undefined;
|
||||
}
|
||||
|
||||
async focusSelectedOrFirst(): Promise<boolean> {
|
||||
await this.updateComplete;
|
||||
return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle" });
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<section>
|
||||
@@ -67,7 +75,8 @@ export class MachineList extends LitElement {
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleMachineKeydown(event, machine); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
<span class="action-name machine-primary">${this.renderActivity(machine)}<span class="machine-primary-label">${machine.name}</span></span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
|
||||
<span class="action-name machine-primary"><span class="machine-primary-label">${machine.name}</span></span><small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${statusLabel}</small>
|
||||
${this.renderActivity(machine)}
|
||||
</div>
|
||||
${hasRemoveAction ? this.renderMachineMenu(machine) : null}
|
||||
</div>
|
||||
@@ -78,7 +87,7 @@ export class MachineList extends LitElement {
|
||||
const status = this.statuses[machine.id]?.status ?? machine.status;
|
||||
if (status === "offline" || status === "error") return undefined;
|
||||
const kind = machineActivityIndicator(this.activities[machine.id]);
|
||||
return renderActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active");
|
||||
return renderActionActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active");
|
||||
}
|
||||
|
||||
private renderMachineMenu(machine: Machine) {
|
||||
@@ -107,7 +116,7 @@ export class MachineList extends LitElement {
|
||||
if (!this.collapsible) return "Machines";
|
||||
const selectedSummary = this.selected?.name ?? "No machine selected";
|
||||
const selectedTitle = this.selected?.baseUrl ?? selectedSummary;
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.machines.length}</small></button>`;
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Machines</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span><small class="section-count">${this.machines.length}</small></button>`;
|
||||
}
|
||||
|
||||
private toggleMenu(machineId: string, target: EventTarget | null): void {
|
||||
@@ -131,7 +140,11 @@ export class MachineList extends LitElement {
|
||||
this.openMenuMachineId = undefined;
|
||||
return;
|
||||
}
|
||||
activateSelectableRowFromKeyboard(event, () => this.onSelect?.(machine));
|
||||
handleSelectableRowKeyboard(event, {
|
||||
activate: () => this.onSelect?.(machine),
|
||||
nextSection: this.onFocusNextSection === undefined ? undefined : () => { void this.onFocusNextSection?.(); },
|
||||
cancel: this.onCancelKeyboardNavigation === undefined ? undefined : () => { void this.onCancelKeyboardNavigation?.(); },
|
||||
});
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
@@ -139,7 +152,6 @@ export class MachineList extends LitElement {
|
||||
css`
|
||||
.machine-row.no-actions .action-main { border-radius: 8px; }
|
||||
.machine-primary { display: flex; align-items: baseline; gap: 6px; }
|
||||
.machine-primary .activity-indicator { flex: 0 0 auto; margin-right: 0; }
|
||||
.machine-primary-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.machine-menu-panel button.danger { color: var(--pi-danger); }
|
||||
.machine-menu-panel button.danger:hover, .machine-menu-panel button.danger:focus { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { Machine, MachineHealth, MachineStatus, WorkspaceActivity } from "../api";
|
||||
import { machineActivityIndicator } from "../workspaceActivity";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { renderActivityIndicator } from "./activityBadge";
|
||||
import { canRemoveMachine } from "./MachineList";
|
||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||
|
||||
@customElement("machine-switcher")
|
||||
export class MachineSwitcher extends LitElement implements KeyboardNavigableSection {
|
||||
@property({ attribute: false }) machines: Machine[] = [];
|
||||
@property({ attribute: false }) selected?: Machine;
|
||||
@property({ attribute: false }) statuses: Record<string, MachineHealth> = {};
|
||||
@property({ attribute: false }) activities: Record<string, Record<string, WorkspaceActivity>> = {};
|
||||
@property({ attribute: false }) onSelect?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onRemove?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
|
||||
@state() private open = false;
|
||||
@state() private menuStyle = "";
|
||||
@state() private openActionsMachineId: string | undefined;
|
||||
@state() private actionMenuStyle = "";
|
||||
|
||||
private readonly onDocumentClick = (event: MouseEvent) => {
|
||||
if (event.composedPath().includes(this)) return;
|
||||
this.open = false;
|
||||
this.openActionsMachineId = undefined;
|
||||
};
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
document.addEventListener("click", this.onDocumentClick);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
document.removeEventListener("click", this.onDocumentClick);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected override updated(changed: PropertyValues<this>): void {
|
||||
if (changed.has("machines") && this.open && this.selectedMachine() === undefined) this.open = false;
|
||||
if (changed.has("machines") && this.openActionsMachineId !== undefined && !this.machines.some((machine) => machine.id === this.openActionsMachineId)) this.openActionsMachineId = undefined;
|
||||
}
|
||||
|
||||
async focusSelectedOrFirst(): Promise<boolean> {
|
||||
const button = this.switcherButton();
|
||||
if (button === null) return false;
|
||||
return await this.openMenuAndFocusOption(button);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const selected = this.selectedMachine();
|
||||
if (selected === undefined) return null;
|
||||
const status = machineStatus(selected, this.statuses);
|
||||
const label = selected.name;
|
||||
return html`
|
||||
<div class="machine-switcher">
|
||||
<button
|
||||
type="button"
|
||||
class="machine-switcher-button"
|
||||
title=${machineTitle(selected)}
|
||||
aria-label=${`Machine: ${label}. Switch machine.`}
|
||||
aria-expanded=${String(this.open)}
|
||||
@click=${(event: MouseEvent) => { this.toggleMenu(event.currentTarget); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleSwitcherButtonKeydown(event); }}
|
||||
>
|
||||
${this.renderActivity(selected)}
|
||||
<span class="machine-switcher-text">
|
||||
<span class="machine-switcher-kicker">Machine</span>
|
||||
<span class="machine-switcher-label">${label}</span>
|
||||
</span>
|
||||
<span class=${`machine-status ${status}`}>${machineStatusLabel(status)}</span>
|
||||
<span class="machine-chevron" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
${this.open ? html`
|
||||
<div class="machine-switcher-menu" style=${this.menuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
|
||||
${this.machines.map((machine) => this.renderMachineOption(machine))}
|
||||
</div>
|
||||
` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMachineOption(machine: Machine): TemplateResult {
|
||||
const selected = this.selected?.id === machine.id;
|
||||
const status = machineStatus(machine, this.statuses);
|
||||
const hasActions = canRemoveMachine(machine) && this.onRemove !== undefined;
|
||||
const actionsOpen = this.openActionsMachineId === machine.id;
|
||||
return html`
|
||||
<div class=${`machine-option ${selected ? "selected" : ""} ${hasActions ? "" : "no-actions"}`}>
|
||||
<button
|
||||
type="button"
|
||||
class="machine-option-main"
|
||||
title=${machineTitle(machine)}
|
||||
data-machine-id=${machine.id}
|
||||
@click=${() => { this.select(machine); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleMachineOptionKeydown(event); }}
|
||||
>
|
||||
<span class="machine-option-name">${this.renderActivity(machine)}<span>${machine.name}</span></span>
|
||||
<small>${machine.kind === "local" ? "Local Pi Web" : machine.baseUrl ?? "Remote Pi Web"} · ${machineStatusLabel(status)}</small>
|
||||
</button>
|
||||
${hasActions ? html`
|
||||
<div class="machine-option-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="machine-option-actions-toggle"
|
||||
title="Machine actions"
|
||||
aria-label=${`Actions for ${machine.name}`}
|
||||
aria-expanded=${String(actionsOpen)}
|
||||
@click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleActionsMenu(machine.id, event.currentTarget); }}
|
||||
>⋯</button>
|
||||
${actionsOpen ? html`
|
||||
<div class="machine-option-actions-panel" style=${this.actionMenuStyle} @click=${(event: MouseEvent) => { event.stopPropagation(); }}>
|
||||
<button class="danger" title=${`Remove ${machine.name}`} @click=${() => { this.removeMachine(machine); }}>Remove</button>
|
||||
</div>
|
||||
` : null}
|
||||
</div>
|
||||
` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderActivity(machine: Machine): TemplateResult | undefined {
|
||||
const status = machineStatus(machine, this.statuses);
|
||||
if (status === "offline" || status === "error") return undefined;
|
||||
const kind = machineActivityIndicator(this.activities[machine.id]);
|
||||
return renderActivityIndicator(kind, kind === "terminal" ? "Machine terminal active" : "Machine active");
|
||||
}
|
||||
|
||||
private selectedMachine(): Machine | undefined {
|
||||
return this.selected ?? this.machines.find((machine) => machine.id === "local") ?? this.machines[0];
|
||||
}
|
||||
|
||||
private switcherButton(): HTMLElement | null {
|
||||
return this.renderRoot.querySelector<HTMLElement>(".machine-switcher-button");
|
||||
}
|
||||
|
||||
private handleSwitcherButtonKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void this.openMenuAndFocusOption(event.currentTarget);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowRight" && this.onFocusNextSection !== undefined) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void this.onFocusNextSection();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && this.onCancelKeyboardNavigation !== undefined) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void this.onCancelKeyboardNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
private handleMachineOptionKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === "ArrowUp") {
|
||||
this.focusRelativeMachineOption(event.currentTarget, -1, event);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
this.focusRelativeMachineOption(event.currentTarget, 1, event);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Home") {
|
||||
this.focusIndexedMachineOption(0, event);
|
||||
return;
|
||||
}
|
||||
if (event.key === "End") {
|
||||
this.focusIndexedMachineOption(-1, event);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowRight" && this.onFocusNextSection !== undefined) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.open = false;
|
||||
void this.onFocusNextSection();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowLeft" || event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.open = false;
|
||||
void this.updateComplete.then(() => { this.focusSwitcherButton(); });
|
||||
}
|
||||
}
|
||||
|
||||
private toggleMenu(target: EventTarget | null): void {
|
||||
this.menuStyle = machineSwitcherMenuStyle(target);
|
||||
this.open = !this.open;
|
||||
this.openActionsMachineId = undefined;
|
||||
}
|
||||
|
||||
private focusSwitcherButton(): boolean {
|
||||
const button = this.switcherButton();
|
||||
if (button === null) return false;
|
||||
button.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
private async openMenuAndFocusOption(target: EventTarget | null): Promise<boolean> {
|
||||
this.menuStyle = machineSwitcherMenuStyle(target);
|
||||
this.open = true;
|
||||
this.openActionsMachineId = undefined;
|
||||
await this.updateComplete;
|
||||
return this.focusSelectedMachineOption();
|
||||
}
|
||||
|
||||
private focusSelectedMachineOption(): boolean {
|
||||
const selected = this.renderRoot.querySelector<HTMLElement>(".machine-option.selected .machine-option-main");
|
||||
const first = this.machineOptionButtons()[0];
|
||||
const target = selected ?? first;
|
||||
if (target === undefined) return false;
|
||||
target.focus();
|
||||
target.scrollIntoView({ block: "nearest" });
|
||||
return true;
|
||||
}
|
||||
|
||||
private focusRelativeMachineOption(target: EventTarget | null, delta: number, event: KeyboardEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const buttons = this.machineOptionButtons();
|
||||
if (buttons.length === 0 || !(target instanceof HTMLElement)) return;
|
||||
const index = buttons.indexOf(target);
|
||||
if (index < 0) return;
|
||||
this.focusMachineOptionAt(index + delta);
|
||||
}
|
||||
|
||||
private focusIndexedMachineOption(index: number, event: KeyboardEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.focusMachineOptionAt(index < 0 ? this.machineOptionButtons().length - 1 : index);
|
||||
}
|
||||
|
||||
private focusMachineOptionAt(index: number): void {
|
||||
const buttons = this.machineOptionButtons();
|
||||
const target = buttons[Math.min(Math.max(index, 0), buttons.length - 1)];
|
||||
target?.focus();
|
||||
target?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
private machineOptionButtons(): HTMLElement[] {
|
||||
return Array.from(this.renderRoot.querySelectorAll<HTMLElement>(".machine-option-main"));
|
||||
}
|
||||
|
||||
private toggleActionsMenu(machineId: string, target: EventTarget | null): void {
|
||||
if (this.openActionsMachineId === machineId) {
|
||||
this.openActionsMachineId = undefined;
|
||||
return;
|
||||
}
|
||||
this.actionMenuStyle = actionMenuPanelStyle(target, { constrainTo: "viewport" });
|
||||
this.openActionsMachineId = machineId;
|
||||
}
|
||||
|
||||
private select(machine: Machine): void {
|
||||
this.open = false;
|
||||
this.openActionsMachineId = undefined;
|
||||
void this.onSelect?.(machine);
|
||||
}
|
||||
|
||||
private removeMachine(machine: Machine): void {
|
||||
this.open = false;
|
||||
this.openActionsMachineId = undefined;
|
||||
void this.onRemove?.(machine);
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { min-width: 0; display: block; }
|
||||
.machine-switcher { min-width: 0; }
|
||||
.machine-switcher-button { box-sizing: border-box; width: 100%; min-width: 0; display: flex; align-items: center; gap: 6px; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 8px; cursor: pointer; text-align: left; }
|
||||
.machine-switcher-button:hover, .machine-switcher-button:focus-visible { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
.machine-switcher-text { flex: 1 1 auto; min-width: 0; display: grid; gap: 1px; }
|
||||
.machine-switcher-kicker { color: var(--pi-muted); font-size: 10px; line-height: 1; text-transform: uppercase; letter-spacing: .02em; }
|
||||
.machine-switcher-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; line-height: 1.2; }
|
||||
.machine-status { flex: 0 0 auto; color: var(--pi-muted); font-size: 11px; }
|
||||
.machine-status.online { color: var(--pi-success); }
|
||||
.machine-status.offline, .machine-status.error { color: var(--pi-danger); }
|
||||
.machine-chevron { flex: 0 0 auto; color: var(--pi-muted); font-size: 11px; }
|
||||
.activity-indicator { flex: 0 0 auto; display: inline-block; width: 7px; height: 7px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; }
|
||||
.activity-indicator.session { border-radius: 50%; background: var(--pi-success); }
|
||||
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
|
||||
.machine-switcher-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(280px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); }
|
||||
.machine-option { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px; align-items: stretch; margin: 2px 0; }
|
||||
.machine-option.no-actions { grid-template-columns: minmax(0, 1fr); }
|
||||
.machine-option-main, .machine-option-actions-toggle, .machine-option-actions-panel button { border: 0; border-radius: 7px; background: transparent; color: var(--pi-text); cursor: pointer; }
|
||||
.machine-option-main { min-width: 0; display: grid; gap: 2px; padding: 7px 8px; text-align: left; }
|
||||
.machine-option-name { min-width: 0; display: flex; align-items: baseline; gap: 6px; }
|
||||
.machine-option-name span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.machine-option-main small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-muted); }
|
||||
.machine-option-actions { position: relative; align-self: stretch; }
|
||||
.machine-option-actions-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: var(--pi-muted); }
|
||||
.machine-option-actions-panel { position: fixed; z-index: 10001; box-sizing: border-box; min-width: min(120px, 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); }
|
||||
.machine-option-actions-panel button { display: block; width: 100%; padding: 7px 9px; text-align: left; white-space: nowrap; }
|
||||
.machine-option-actions-panel button.danger { color: var(--pi-danger); }
|
||||
.machine-option-main:hover, .machine-option-main:focus-visible, .machine-option-actions-toggle:hover, .machine-option-actions-toggle:focus-visible, .machine-option.selected .machine-option-main { background: var(--pi-selection-bg); }
|
||||
.machine-option-actions-panel button:hover, .machine-option-actions-panel button:focus-visible { background: var(--pi-selection-bg); }
|
||||
.machine-option-actions-panel button.danger:hover, .machine-option-actions-panel button.danger:focus-visible { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
|
||||
@keyframes pulse { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
|
||||
`;
|
||||
}
|
||||
|
||||
export function shouldShowMachineSwitcher(machines: readonly Machine[]): boolean {
|
||||
return machines.length > 1;
|
||||
}
|
||||
|
||||
function machineStatus(machine: Machine, statuses: Record<string, MachineHealth>): MachineStatus {
|
||||
return statuses[machine.id]?.status ?? machine.status ?? "unknown";
|
||||
}
|
||||
|
||||
function machineStatusLabel(status: MachineStatus): string {
|
||||
return status === "online" ? "online" : status === "offline" ? "offline" : status === "error" ? "error" : "unknown";
|
||||
}
|
||||
|
||||
function machineTitle(machine: Machine): string {
|
||||
return machine.baseUrl ?? machine.name;
|
||||
}
|
||||
|
||||
function machineSwitcherMenuStyle(target: EventTarget | null): string {
|
||||
if (typeof HTMLElement === "undefined" || typeof window === "undefined" || !(target instanceof HTMLElement)) return "";
|
||||
const trigger = target.getBoundingClientRect();
|
||||
const viewportPadding = 8;
|
||||
const menuWidth = Math.min(280, Math.max(0, window.innerWidth - viewportPadding * 2));
|
||||
const left = Math.min(Math.max(viewportPadding, trigger.left), Math.max(viewportPadding, window.innerWidth - viewportPadding - menuWidth));
|
||||
const availableBelow = Math.max(0, window.innerHeight - trigger.bottom - viewportPadding);
|
||||
return [`top: ${px(trigger.bottom)};`, `left: ${px(left)};`, `width: ${px(menuWidth)};`, `max-height: ${px(availableBelow)};`].join(" ");
|
||||
}
|
||||
|
||||
function px(value: number): string {
|
||||
return `${String(Math.round(value))}px`;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { configApi, piWebApi, terminalsApi, workspacesApi, type Machine, type Ma
|
||||
import type { AppAction } from "../actions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
|
||||
import { ActivityController } from "../controllers/activityController";
|
||||
import { AuthController } from "../controllers/authController";
|
||||
import { FileExplorerController } from "../controllers/fileExplorerController";
|
||||
@@ -27,14 +28,14 @@ import { loadExternalPlugins } from "../plugins/external";
|
||||
import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry";
|
||||
import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
||||
import { AppShellController } from "../appShell/appShellController";
|
||||
import { MobileNavigationController, type NavigationSection } from "../appShell/navigationState";
|
||||
import { NavigationSectionsController, type NavigationSection } from "../appShell/navigationState";
|
||||
import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController";
|
||||
import { PanelResizeController, type PanelResizeConstraints, type ResizablePanelSide } from "../appShell/panelResizeController";
|
||||
import { readRoute, writeRoute, type AppRoute } from "../route";
|
||||
import { readSettingsSection, writeSettingsSection, type SettingsSection } from "../settingsRoute";
|
||||
import { applyShortcutPreferences } from "../shortcutPreferences";
|
||||
import { applyActiveShortcutPreferences } from "../shortcutPreferences";
|
||||
import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime";
|
||||
import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionRunFilter } from "../workspaceDeletion";
|
||||
import { machineActivityIndicator } from "../workspaceActivity";
|
||||
import "./MachineList";
|
||||
import "./ProjectList";
|
||||
import "./WorkspaceList";
|
||||
@@ -56,13 +57,14 @@ import type { WorkspacePanelEmptyState } from "./WorkspacePanel";
|
||||
import "./appShell/AppContextBar";
|
||||
import "./appShell/AppMobileMainTabs";
|
||||
import type { AppMobileMainTab, AppMobileMainTabIcon } from "./appShell/AppMobileMainTabs";
|
||||
import "./appShell/AppNavigationPanel";
|
||||
import { shouldShowMachinesSection, type AppNavigationPanel, type NavigationFocusTarget } from "./appShell/AppNavigationPanel";
|
||||
import "./appShell/AppPanelEdgeControl";
|
||||
import "./appShell/AppRefreshControl";
|
||||
import { appStyles } from "./shared";
|
||||
|
||||
|
||||
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
|
||||
const PI_WEB_STATUS_DEFER_MS = 750;
|
||||
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
|
||||
const THEME_AUTO_ON_VALUE = "auto:on";
|
||||
const THEME_AUTO_OFF_VALUE = "auto:off";
|
||||
@@ -70,12 +72,18 @@ const THEME_OPTION_PREFIX = "theme:";
|
||||
const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files");
|
||||
const GIT_ROUTE_NAMESPACE = queryNamespace("core:workspace.git");
|
||||
const TERMINAL_ROUTE_NAMESPACE = queryNamespace("core:workspace.terminal");
|
||||
const MIN_RESIZABLE_CHAT_WIDTH_PX = 320;
|
||||
const PANEL_EDGE_COLUMNS_WIDTH_PX = 2;
|
||||
const DESKTOP_SIDE_BY_SIDE_MEDIA_QUERY = "(min-width: 1181px)";
|
||||
|
||||
@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("app-navigation-panel") private navigationPanel?: AppNavigationPanel;
|
||||
@query("#navigation-panel") private navigationPanelFrame?: HTMLElement;
|
||||
@query("#workspace-panel") private workspacePanelFrame?: HTMLElement;
|
||||
|
||||
private readonly sessions = new SessionController(
|
||||
() => this.state,
|
||||
@@ -128,7 +136,8 @@ export class PiWebApp extends LitElement {
|
||||
private readonly terminalSelection = new SessionStorageTerminalSelectionMemory();
|
||||
private readonly appShell = new AppShellController(this);
|
||||
private readonly panelCollapse = new PanelCollapseController(this);
|
||||
private readonly mobileNavigation = new MobileNavigationController(
|
||||
private readonly panelResize = new PanelResizeController(this);
|
||||
private readonly navigationSections = new NavigationSectionsController(
|
||||
this,
|
||||
() => this.state,
|
||||
() => this.appShell.isMobileNavigationLayout,
|
||||
@@ -136,6 +145,7 @@ export class PiWebApp extends LitElement {
|
||||
private readonly systemLightThemeMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(prefers-color-scheme: light)") : undefined;
|
||||
private terminalAutoStartWorkspaceId: string | undefined;
|
||||
private piWebStatusTimer: number | undefined;
|
||||
private piWebStatusDeferredTimer: number | undefined;
|
||||
private workspaceDeletionPollTimer: number | undefined;
|
||||
private refreshingWorkspaceDeletionRuns = false;
|
||||
private readonly handledWorkspaceDeletionRunIds = new Set<string>();
|
||||
@@ -163,7 +173,7 @@ export class PiWebApp extends LitElement {
|
||||
private readonly onFocus = () => {
|
||||
this.appShell.repairViewportPosition();
|
||||
void this.sessions.refreshSelectedSession();
|
||||
void this.refreshPiWebStatus();
|
||||
this.schedulePiWebStatusRefresh();
|
||||
void this.refreshMachineActivities();
|
||||
void this.refreshWorkspaceDeletionRuns();
|
||||
};
|
||||
@@ -171,7 +181,7 @@ export class PiWebApp extends LitElement {
|
||||
if (document.visibilityState === "visible") {
|
||||
this.appShell.repairViewportPosition();
|
||||
void this.sessions.refreshSelectedSession();
|
||||
void this.refreshPiWebStatus();
|
||||
this.schedulePiWebStatusRefresh();
|
||||
void this.refreshMachineActivities();
|
||||
void this.refreshWorkspaceDeletionRuns();
|
||||
}
|
||||
@@ -184,7 +194,8 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private readonly onKeyDown = (event: KeyboardEvent) => {
|
||||
if (this.keyboard.handle(event, this.getActions())) {
|
||||
if (this.settingsSection !== undefined) return;
|
||||
if (this.keyboard.handle(event, this.getDefaultActions(), { shortcuts: this.shortcutConfig })) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
@@ -204,12 +215,11 @@ export class PiWebApp extends LitElement {
|
||||
this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange);
|
||||
this.applyPreferredTheme(false);
|
||||
this.connectRealtime();
|
||||
this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS);
|
||||
void this.refreshPiWebStatus();
|
||||
this.piWebStatusTimer = window.setInterval(() => { this.schedulePiWebStatusRefresh(); }, PI_WEB_STATUS_REFRESH_MS);
|
||||
void this.refreshWorkspaceActivity();
|
||||
void this.loadClientConfig();
|
||||
void this.ensureGatewayPluginsLoaded();
|
||||
void this.loadProjectsAndRestoreRoute();
|
||||
void this.loadProjectsAndRestoreRoute().finally(() => { this.schedulePiWebStatusRefresh(); });
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
@@ -227,6 +237,7 @@ export class PiWebApp extends LitElement {
|
||||
this.git.dispose();
|
||||
if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer);
|
||||
this.piWebStatusTimer = undefined;
|
||||
this.clearScheduledPiWebStatusRefresh();
|
||||
if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer);
|
||||
this.workspaceDeletionPollTimer = undefined;
|
||||
super.disconnectedCallback();
|
||||
@@ -256,11 +267,28 @@ export class PiWebApp extends LitElement {
|
||||
await this.refreshWorkspaceDeletionRuns();
|
||||
}
|
||||
|
||||
private schedulePiWebStatusRefresh(delayMs = PI_WEB_STATUS_DEFER_MS): void {
|
||||
this.clearScheduledPiWebStatusRefresh();
|
||||
this.piWebStatusDeferredTimer = window.setTimeout(() => {
|
||||
this.piWebStatusDeferredTimer = undefined;
|
||||
void this.refreshPiWebStatus();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private clearScheduledPiWebStatusRefresh(): void {
|
||||
if (this.piWebStatusDeferredTimer === undefined) return;
|
||||
window.clearTimeout(this.piWebStatusDeferredTimer);
|
||||
this.piWebStatusDeferredTimer = undefined;
|
||||
}
|
||||
|
||||
private async refreshPiWebStatus(): Promise<void> {
|
||||
const machineId = selectedMachineId(this.state);
|
||||
try {
|
||||
this.setState({ piWebStatus: await piWebApi.piWebStatus() });
|
||||
const piWebStatus = await piWebApi.piWebStatus(machineId);
|
||||
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus });
|
||||
} catch (error) {
|
||||
console.warn("Failed to refresh PI WEB status", error);
|
||||
if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus: undefined });
|
||||
console.warn(`Failed to refresh PI WEB status for ${machineId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,12 +327,12 @@ export class PiWebApp extends LitElement {
|
||||
try {
|
||||
await Promise.all([
|
||||
this.sessions.refreshSelectedSession(),
|
||||
this.refreshPiWebStatus(),
|
||||
this.refreshMachineActivities(),
|
||||
this.loadClientConfig(),
|
||||
this.refreshWorkspaceDeletionRuns(),
|
||||
this.refreshCurrentWorkspaceSurface(),
|
||||
]);
|
||||
this.schedulePiWebStatusRefresh();
|
||||
} finally {
|
||||
this.isRefreshingApp = false;
|
||||
}
|
||||
@@ -328,6 +356,7 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private async restoreRouteFor(route: AppRoute, updateUrl: boolean, surface = this.readWorkspaceRouteSurface(route), restoredMainView?: AppState["mainView"]) {
|
||||
const machineBeforeRestore = selectedMachineId(this.state);
|
||||
const routeSurface = route.projectId === undefined || route.projectId === "" ? emptyWorkspaceRouteSurface() : surface;
|
||||
const restoreSeq = ++this.routeRestoreSeq;
|
||||
this.routeRestoreDepth += 1;
|
||||
@@ -371,6 +400,7 @@ export class PiWebApp extends LitElement {
|
||||
} finally {
|
||||
this.routeRestoreDepth = Math.max(0, this.routeRestoreDepth - 1);
|
||||
if (this.routeRestoreDepth === 0) this.restoringRouteTerminalId = undefined;
|
||||
if (selectedMachineId(this.state) !== machineBeforeRestore) this.schedulePiWebStatusRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,6 +744,7 @@ export class PiWebApp extends LitElement {
|
||||
this.realtime.close();
|
||||
this.connectRealtime();
|
||||
this.activeTerminalIds.clear();
|
||||
this.setState({ piWebStatus: undefined });
|
||||
this.git.updatePolling();
|
||||
void this.loadPluginsForSelectedMachine();
|
||||
}
|
||||
@@ -726,7 +757,6 @@ export class PiWebApp extends LitElement {
|
||||
private renderWorkspacePanel() {
|
||||
const workspace = this.state.selectedWorkspace;
|
||||
const panelContext = workspace === undefined ? undefined : this.createWorkspacePanelContext(workspace);
|
||||
const workspaceLabelItems = workspace === undefined ? [] : this.workspaceLabelItems(workspace);
|
||||
const emptyState = workspace === undefined ? this.workspacePanelEmptyState() : undefined;
|
||||
return html`
|
||||
<workspace-panel
|
||||
@@ -736,56 +766,134 @@ export class PiWebApp extends LitElement {
|
||||
.emptyState=${emptyState}
|
||||
.tool=${this.state.workspaceTool}
|
||||
.panels=${this.visibleWorkspacePanels()}
|
||||
.workspaceLabelItems=${workspaceLabelItems}
|
||||
.onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(tool); }}
|
||||
></workspace-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNavigationPanelEdgeControl() {
|
||||
const constraints = this.resizablePanelConstraints("navigation");
|
||||
return html`
|
||||
<app-panel-edge-control
|
||||
side="navigation"
|
||||
controls="navigation-panel"
|
||||
resizeLabel="Resize navigation panel"
|
||||
expandLabel="Expand navigation panel"
|
||||
collapseLabel="Collapse navigation panel"
|
||||
.collapsed=${this.panelCollapse.navigationPanelCollapsed}
|
||||
.resizable=${!this.appShell.isMobileNavigationLayout}
|
||||
.panelWidth=${this.panelResize.panelWidth("navigation")}
|
||||
.minWidth=${constraints.minWidth}
|
||||
.maxWidth=${constraints.maxWidth}
|
||||
.onToggle=${() => { this.panelCollapse.toggleNavigationPanel(); }}
|
||||
.onResizeStart=${() => this.startPanelResize("navigation")}
|
||||
.onResize=${(width: number) => { this.panelResize.resizePanel("navigation", width, { persist: false }); }}
|
||||
.onResizeEnd=${() => { this.panelResize.persistPanelSizes(); }}
|
||||
.onReset=${() => { this.resetResizablePanel("navigation"); }}
|
||||
></app-panel-edge-control>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWorkspacePanelEdgeControl() {
|
||||
const constraints = this.resizablePanelConstraints("workspace");
|
||||
return html`
|
||||
<app-panel-edge-control
|
||||
side="workspace"
|
||||
controls="workspace-panel"
|
||||
resizeLabel="Resize workspace panel"
|
||||
expandLabel="Expand workspace panel"
|
||||
collapseLabel="Collapse workspace panel"
|
||||
.collapsed=${this.panelCollapse.workspacePanelCollapsed}
|
||||
.resizable=${!this.appShell.isMobileNavigationLayout}
|
||||
.panelWidth=${this.panelResize.panelWidth("workspace")}
|
||||
.minWidth=${constraints.minWidth}
|
||||
.maxWidth=${constraints.maxWidth}
|
||||
.onToggle=${() => { this.panelCollapse.toggleWorkspacePanel(); }}
|
||||
.onResizeStart=${() => this.startPanelResize("workspace")}
|
||||
.onResize=${(width: number) => { this.panelResize.resizePanel("workspace", width, { persist: false }); }}
|
||||
.onResizeEnd=${() => { this.panelResize.persistPanelSizes(); }}
|
||||
.onReset=${() => { this.resetResizablePanel("workspace"); }}
|
||||
></app-panel-edge-control>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNavigationPanel(autoSwitchToChat: boolean) {
|
||||
const openChatAfter = (action: () => Promise<void>) => this.withChatScrollTransition(async () => {
|
||||
await action();
|
||||
if (autoSwitchToChat) this.setState({ mainView: "chat" });
|
||||
if (autoSwitchToChat) this.updateUrl();
|
||||
});
|
||||
private startPanelResize(side: ResizablePanelSide): number {
|
||||
if (side === "navigation") this.panelCollapse.expandNavigationPanel();
|
||||
else this.panelCollapse.expandWorkspacePanel();
|
||||
return this.measuredPanelWidth(side) ?? this.panelResize.panelWidth(side);
|
||||
}
|
||||
|
||||
private resizablePanelConstraints(side: ResizablePanelSide): PanelResizeConstraints {
|
||||
const constraints = this.panelResize.constraints(side);
|
||||
return {
|
||||
...constraints,
|
||||
maxWidth: this.resizablePanelMaxWidth(side, constraints),
|
||||
};
|
||||
}
|
||||
|
||||
private resizablePanelMaxWidth(side: ResizablePanelSide, constraints: PanelResizeConstraints): number {
|
||||
const shellWidth = this.getBoundingClientRect().width || (typeof window === "undefined" ? 0 : window.innerWidth);
|
||||
if (shellWidth <= 0) return constraints.maxWidth;
|
||||
|
||||
const otherPanelWidth = this.oppositeResizablePanelWidth(side);
|
||||
const maxWidth = Math.floor(shellWidth - otherPanelWidth - PANEL_EDGE_COLUMNS_WIDTH_PX - MIN_RESIZABLE_CHAT_WIDTH_PX);
|
||||
return Math.max(constraints.minWidth, Math.min(constraints.maxWidth, maxWidth));
|
||||
}
|
||||
|
||||
private oppositeResizablePanelWidth(side: ResizablePanelSide): number {
|
||||
const otherSide: ResizablePanelSide = side === "navigation" ? "workspace" : "navigation";
|
||||
if (this.isResizablePanelCollapsedOrStacked(otherSide)) return 0;
|
||||
return this.measuredPanelWidth(otherSide) ?? this.panelResize.panelWidth(otherSide);
|
||||
}
|
||||
|
||||
private isResizablePanelCollapsedOrStacked(side: ResizablePanelSide): boolean {
|
||||
if (side === "navigation") return this.panelCollapse.navigationPanelCollapsed;
|
||||
return this.panelCollapse.workspacePanelCollapsed || !this.isDesktopSideBySideLayout();
|
||||
}
|
||||
|
||||
private isDesktopSideBySideLayout(): boolean {
|
||||
if (typeof window === "undefined" || !("matchMedia" in window)) return true;
|
||||
return window.matchMedia(DESKTOP_SIDE_BY_SIDE_MEDIA_QUERY).matches;
|
||||
}
|
||||
|
||||
private measuredPanelWidth(side: ResizablePanelSide): number | undefined {
|
||||
const element = side === "navigation" ? this.navigationPanelFrame : this.workspacePanelFrame;
|
||||
const width = element?.getBoundingClientRect().width;
|
||||
return width === undefined || width <= 0 ? undefined : width;
|
||||
}
|
||||
|
||||
private resetResizablePanel(side: ResizablePanelSide): void {
|
||||
this.panelResize.resetPanel(side);
|
||||
}
|
||||
|
||||
private resetResizablePanels(): void {
|
||||
this.panelResize.resetPanels();
|
||||
}
|
||||
|
||||
private canDeleteArchivedSessions(): boolean {
|
||||
const runtime = this.selectedMachineRuntime();
|
||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived);
|
||||
}
|
||||
|
||||
private archivedDeleteUnavailableMessage(): string {
|
||||
const machineName = this.state.selectedMachine?.name ?? "this machine";
|
||||
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`;
|
||||
}
|
||||
|
||||
private selectedMachineRuntime() {
|
||||
return this.state.machineRuntimes[selectedMachineId(this.state)];
|
||||
}
|
||||
|
||||
private renderNavigationPanel() {
|
||||
return html`
|
||||
<app-navigation-panel
|
||||
.machines=${this.state.machines}
|
||||
.selectedMachine=${this.state.selectedMachine}
|
||||
.machineStatuses=${this.state.machineStatuses}
|
||||
.machineActivities=${this.state.machineActivities}
|
||||
.machinesCollapsed=${this.mobileNavigation.isCollapsed("machines")}
|
||||
.onToggleMachines=${() => { this.mobileNavigation.toggle("machines"); }}
|
||||
.onSelectMachine=${(machine: Machine) => this.withChatScrollTransition(async () => {
|
||||
this.mobileNavigation.expand("projects");
|
||||
await this.selectMachineWithMemory(machine);
|
||||
})}
|
||||
.machinesCollapsed=${this.navigationSections.isCollapsed("machines")}
|
||||
.onToggleMachines=${() => { this.navigationSections.toggle("machines"); }}
|
||||
.onSelectMachine=${(machine: Machine) => this.selectNavigationItem("machines", "projects", () => this.selectMachineWithMemory(machine))}
|
||||
.onRemoveMachine=${(machine: Machine) => { void this.removeMachine(machine); }}
|
||||
.projects=${this.state.projects}
|
||||
.selectedProject=${this.state.selectedProject}
|
||||
@@ -799,40 +907,78 @@ export class PiWebApp extends LitElement {
|
||||
.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")}
|
||||
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
|
||||
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
|
||||
.collapsible=${true}
|
||||
.compact=${this.appShell.isMobileNavigationLayout}
|
||||
.projectsCollapsed=${this.navigationSections.isCollapsed("projects")}
|
||||
.workspacesCollapsed=${this.navigationSections.isCollapsed("workspaces")}
|
||||
.sessionsCollapsed=${this.navigationSections.isCollapsed("sessions")}
|
||||
.workspaceLabelItems=${(workspace: Workspace) => this.workspaceLabelItems(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);
|
||||
})}
|
||||
.onToggleProjects=${() => { this.navigationSections.toggle("projects"); }}
|
||||
.onToggleWorkspaces=${() => { this.navigationSections.toggle("workspaces"); }}
|
||||
.onToggleSessions=${() => { this.navigationSections.toggle("sessions"); }}
|
||||
.onSelectProject=${(project: Project) => this.selectNavigationItem("projects", "workspaces", () => this.workspaces.selectProject(project))}
|
||||
.onCloseProject=${(project: Project) => this.projects.closeProject(project.id)}
|
||||
.onSelectWorkspace=${(workspace: Workspace) => this.withChatScrollTransition(async () => {
|
||||
this.mobileNavigation.expand("sessions");
|
||||
await this.workspaces.selectWorkspace(workspace);
|
||||
})}
|
||||
.onSelectWorkspace=${(workspace: Workspace) => this.selectNavigationItem("workspaces", "sessions", () => this.workspaces.selectWorkspace(workspace))}
|
||||
.onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
|
||||
.onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }}
|
||||
.onStartSession=${() => openChatAfter(() => this.sessions.startSession())}
|
||||
.onSelectSession=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))}
|
||||
.onStartSession=${() => this.selectNavigationItem("sessions", "chat", () => this.sessions.startSession())}
|
||||
.onSelectSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => 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))}
|
||||
.onArchiveSessions=${(sessions: SessionInfo[]) => this.sessions.archiveSessions(sessions)}
|
||||
.onRestoreSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.restoreSession(session))}
|
||||
.onDeleteCachedNewSession=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)}
|
||||
.onDeleteArchivedSession=${(session: SessionInfo) => this.sessions.deleteArchivedSessions([session])}
|
||||
.onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)}
|
||||
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
|
||||
.onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }}
|
||||
.onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }}
|
||||
></app-navigation-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private openNavigationSection(section: NavigationSection): void {
|
||||
this.mobileNavigation.open(section, () => { this.selectMainView("navigation"); });
|
||||
this.navigationSections.open(section, () => { this.selectMainView("navigation"); });
|
||||
}
|
||||
|
||||
private async selectNavigationItem(section: NavigationSection, nextTarget: NavigationFocusTarget, action: () => Promise<void>): Promise<void> {
|
||||
await this.withChatScrollTransition(async () => {
|
||||
this.navigationSections.advanceAfterSelection(section);
|
||||
await action();
|
||||
});
|
||||
await this.focusNavigationTarget(nextTarget);
|
||||
}
|
||||
|
||||
private async focusNavigationTarget(target: NavigationFocusTarget): Promise<void> {
|
||||
if (target === "chat") {
|
||||
await this.focusChatComposer();
|
||||
return;
|
||||
}
|
||||
await this.focusNavigationSection(target);
|
||||
}
|
||||
|
||||
private async focusNavigationSection(section: NavigationSection): Promise<void> {
|
||||
if (section === "machines" && !shouldShowMachinesSection(this.state.machines)) {
|
||||
await this.focusNavigationSection("projects");
|
||||
return;
|
||||
}
|
||||
this.panelCollapse.expandNavigationPanel();
|
||||
if (this.appShell.isMobileNavigationLayout) this.selectMainView("navigation");
|
||||
this.navigationSections.expand(section);
|
||||
await this.updateComplete;
|
||||
await nextFrame();
|
||||
await this.navigationPanel?.focusSection(section);
|
||||
}
|
||||
|
||||
private async focusChatComposer(): Promise<void> {
|
||||
if (this.state.mainView !== "chat") this.selectMainView("chat");
|
||||
await this.updateComplete;
|
||||
await nextFrame();
|
||||
this.promptEditor?.focusInput();
|
||||
}
|
||||
|
||||
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
|
||||
@@ -943,6 +1089,7 @@ export class PiWebApp extends LitElement {
|
||||
open: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); },
|
||||
runCommand: (input) => terminalCommandRuns.runCommand({ ...input, workspace }),
|
||||
},
|
||||
openTerminal: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); },
|
||||
host: this.createWorkspaceHost(),
|
||||
piWebUnstable: { terminalCommandRuns },
|
||||
fileTree: this.state.fileTree,
|
||||
@@ -970,7 +1117,74 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private getActions(): AppAction[] {
|
||||
return applyShortcutPreferences(this.plugins.getActions(this.createPluginRuntimeContext()), this.shortcutConfig);
|
||||
return applyActiveShortcutPreferences(this.getDefaultActions(), this.shortcutConfig);
|
||||
}
|
||||
|
||||
private getDefaultActions(): AppAction[] {
|
||||
return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions(), ...this.panelLayoutActions()];
|
||||
}
|
||||
|
||||
private panelLayoutActions(): AppAction[] {
|
||||
return [
|
||||
{
|
||||
id: "app.layout.reset-navigation-panel-size",
|
||||
title: "Reset Navigation Panel Size",
|
||||
description: "Restore the navigation panel to its default width",
|
||||
group: "View",
|
||||
run: () => { this.resetResizablePanel("navigation"); },
|
||||
},
|
||||
{
|
||||
id: "app.layout.reset-workspace-panel-size",
|
||||
title: "Reset Workspace Panel Size",
|
||||
description: "Restore the workspace panel to its default width",
|
||||
group: "View",
|
||||
run: () => { this.resetResizablePanel("workspace"); },
|
||||
},
|
||||
{
|
||||
id: "app.layout.reset-panel-sizes",
|
||||
title: "Reset Panel Sizes",
|
||||
description: "Restore all side panels to their default widths",
|
||||
group: "View",
|
||||
run: () => { this.resetResizablePanels(); },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private navigationFocusActions(): AppAction[] {
|
||||
return [
|
||||
{
|
||||
id: "app.navigation.focus-machines",
|
||||
title: "Focus Machines",
|
||||
description: "Move keyboard focus to the machine selector",
|
||||
shortcut: "mod+g m",
|
||||
group: "Navigation",
|
||||
run: () => this.focusNavigationSection("machines"),
|
||||
},
|
||||
{
|
||||
id: "app.navigation.focus-projects",
|
||||
title: "Focus Projects",
|
||||
description: "Move keyboard focus to the projects list",
|
||||
shortcut: "mod+g p",
|
||||
group: "Navigation",
|
||||
run: () => this.focusNavigationSection("projects"),
|
||||
},
|
||||
{
|
||||
id: "app.navigation.focus-workspaces",
|
||||
title: "Focus Workspaces",
|
||||
description: "Move keyboard focus to the workspaces list",
|
||||
shortcut: "mod+g w",
|
||||
group: "Navigation",
|
||||
run: () => this.focusNavigationSection("workspaces"),
|
||||
},
|
||||
{
|
||||
id: "app.navigation.focus-sessions",
|
||||
title: "Focus Sessions",
|
||||
description: "Move keyboard focus to the sessions list",
|
||||
shortcut: "mod+g s",
|
||||
group: "Navigation",
|
||||
run: () => this.focusNavigationSection("sessions"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private ensureGatewayPluginsLoaded(): Promise<void> {
|
||||
@@ -994,7 +1208,10 @@ export class PiWebApp extends LitElement {
|
||||
const existing = this.machinePluginLoadPromises.get(machine.id);
|
||||
if (existing !== undefined) return existing;
|
||||
|
||||
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, { machineId: machine.id }))
|
||||
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, {
|
||||
machineId: machine.id,
|
||||
shouldLoadPlugin: (entry) => this.plugins.shouldLoadRemotePlugin(entry.id, entry.machineSpecific),
|
||||
}))
|
||||
.then((loaded) => { if (loaded) this.loadedMachinePluginIds.add(machine.id); })
|
||||
.finally(() => { this.machinePluginLoadPromises.delete(machine.id); });
|
||||
this.machinePluginLoadPromises.set(machine.id, load);
|
||||
@@ -1028,10 +1245,12 @@ export class PiWebApp extends LitElement {
|
||||
openSettings: (section) => { this.openSettings(section); },
|
||||
},
|
||||
openActionPalette: () => { this.setState({ actionPaletteOpen: true }); },
|
||||
focusPrompt: () => { this.promptEditor?.focusInput(); },
|
||||
focusPrompt: () => { void this.focusChatComposer(); },
|
||||
addProject: () => { this.setState({ projectDialogOpen: true }); },
|
||||
addMachine: () => { this.openMachineDialog(); },
|
||||
refreshSelectedMachine: () => this.machines.refreshMachineHealth(),
|
||||
refreshSelectedMachine: async () => {
|
||||
await Promise.all([this.machines.refreshMachineHealth(), this.machines.refreshMachineRuntime()]);
|
||||
},
|
||||
removeSelectedMachine: () => this.removeMachine(),
|
||||
openSelectedMachine: () => { this.openSelectedMachine(); },
|
||||
configureAuth: () => this.auth.openLogin(),
|
||||
@@ -1158,7 +1377,10 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private async submitMachineDialog(input: MachineDialogSubmit): Promise<void> {
|
||||
const machine = await this.machines.addMachine(input);
|
||||
if (machine !== undefined) this.setState({ machineDialogOpen: false });
|
||||
if (machine !== undefined) {
|
||||
this.setState({ machineDialogOpen: false });
|
||||
this.schedulePiWebStatusRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
private async removeMachine(machine: Machine | undefined = this.state.selectedMachine): Promise<void> {
|
||||
@@ -1327,8 +1549,8 @@ export class PiWebApp extends LitElement {
|
||||
if (!this.appShell.isMobileNavigationLayout) return null;
|
||||
return html`
|
||||
<app-context-bar
|
||||
.machines=${this.state.machines}
|
||||
.machine=${this.state.selectedMachine}
|
||||
.machineActivityKind=${selectedMachineActivityIndicator(this.state)}
|
||||
.project=${this.state.selectedProject}
|
||||
.workspace=${this.state.selectedWorkspace}
|
||||
.session=${this.state.selectedSession}
|
||||
@@ -1366,24 +1588,24 @@ export class PiWebApp extends LitElement {
|
||||
}
|
||||
|
||||
private renderAppRefresh() {
|
||||
return html`<app-refresh-control .isRefreshing=${this.isRefreshingApp} .onRefresh=${() => this.refreshAppData()} .onReload=${() => { this.hardReloadApp(); }}></app-refresh-control>`;
|
||||
return html`<app-refresh-control .onReload=${() => { this.hardReloadApp(); }}></app-refresh-control>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const state = this.state;
|
||||
return html`
|
||||
<div class=${this.panelCollapse.shellClass(state.mainView)}>
|
||||
<aside id="navigation-panel">${this.appShell.isMobileNavigationLayout ? null : this.renderNavigationPanel(false)}</aside>
|
||||
<div class=${this.panelCollapse.shellClass(state.mainView)} style=${this.panelResize.shellStyle({ navigation: this.resizablePanelConstraints("navigation"), workspace: this.resizablePanelConstraints("workspace") })}>
|
||||
<aside id="navigation-panel">${this.appShell.isMobileNavigationLayout ? null : this.renderNavigationPanel()}</aside>
|
||||
${this.renderNavigationPanelEdgeControl()}
|
||||
<main class=${mainViewClass(state.mainView)}>
|
||||
${this.renderContextBar()}
|
||||
${this.renderMobileMainTabs()}
|
||||
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}</div>
|
||||
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
|
||||
${state.selectedSession ? html`
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
<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.workspaceLabelItems(state.selectedWorkspace)}></status-bar>
|
||||
<status-bar .status=${state.status}></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}
|
||||
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
|
||||
${state.thinkingDialog !== undefined ? html`<command-picker title=${state.thinkingDialog.title} .options=${state.thinkingDialog.options} .selectedValue=${state.thinkingDialog.selectedValue} .onPick=${(value: string) => { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}></command-picker>` : null}
|
||||
@@ -1396,7 +1618,7 @@ export class PiWebApp extends LitElement {
|
||||
${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.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-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.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
||||
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1433,14 +1655,6 @@ function shouldRefreshMachineActivity(machine: Machine, health: MachineHealth |
|
||||
return status === undefined || status === "unknown" || status === "online";
|
||||
}
|
||||
|
||||
function selectedMachineActivityIndicator(state: AppState) {
|
||||
const machineId = selectedMachineId(state);
|
||||
const machine = state.selectedMachine;
|
||||
const status = state.machineStatuses[machineId]?.status ?? machine?.status;
|
||||
if (status === "offline" || status === "error") return undefined;
|
||||
return machineActivityIndicator(state.machineActivities[machineId]);
|
||||
}
|
||||
|
||||
function patchChangesState(state: AppState, patch: Partial<AppState>): boolean {
|
||||
return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { Project, Workspace, WorkspaceActivity } from "../api";
|
||||
import { projectActivityIndicator } from "../workspaceActivity";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { renderActivityIndicator } from "./activityBadge";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||
import { renderActionActivityIndicator } from "./activityBadge";
|
||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
||||
import { listStyles } from "./shared";
|
||||
|
||||
@customElement("project-list")
|
||||
export class ProjectList extends LitElement {
|
||||
export class ProjectList extends LitElement implements KeyboardNavigableSection {
|
||||
@property({ attribute: false }) projects: Project[] = [];
|
||||
@property({ attribute: false }) selected?: Project;
|
||||
@property({ attribute: false }) activities: Record<string, WorkspaceActivity> = {};
|
||||
@@ -18,6 +19,9 @@ export class ProjectList extends LitElement {
|
||||
@property({ attribute: false }) onSelect?: (project: Project) => void;
|
||||
@property({ attribute: false }) onClose?: (project: Project) => void;
|
||||
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
||||
@property({ attribute: false }) onFocusPreviousSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
|
||||
@state() private openMenuProjectId: string | undefined;
|
||||
@state() private menuStyle = "";
|
||||
private readonly onDocumentClick = (event: MouseEvent) => {
|
||||
@@ -40,6 +44,11 @@ export class ProjectList extends LitElement {
|
||||
if (changed.has("collapsed") && this.collapsed) this.openMenuProjectId = undefined;
|
||||
}
|
||||
|
||||
async focusSelectedOrFirst(): Promise<boolean> {
|
||||
await this.updateComplete;
|
||||
return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle" });
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<section>
|
||||
@@ -52,10 +61,11 @@ export class ProjectList extends LitElement {
|
||||
tabindex="0"
|
||||
title=${project.path}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(project)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleProjectKeydown(event, project); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
<span class="action-name">${project.name}</span><small>${this.renderActivity(project)}${project.path}</small>
|
||||
<span class="action-name">${project.name}</span><small>${project.path}</small>
|
||||
${this.renderActivity(project)}
|
||||
</div>
|
||||
<div class="action-menu">
|
||||
<button class="action-menu-toggle" title="Project actions" aria-label=${`Actions for ${project.name}`} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(project.id, event.currentTarget); }}>⋯</button>
|
||||
@@ -73,16 +83,25 @@ export class ProjectList extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private handleProjectKeydown(event: KeyboardEvent, project: Project): void {
|
||||
handleSelectableRowKeyboard(event, {
|
||||
activate: () => this.onSelect?.(project),
|
||||
previousSection: this.onFocusPreviousSection === undefined ? undefined : () => { void this.onFocusPreviousSection?.(); },
|
||||
nextSection: this.onFocusNextSection === undefined ? undefined : () => { void this.onFocusNextSection?.(); },
|
||||
cancel: this.onCancelKeyboardNavigation === undefined ? undefined : () => { void this.onCancelKeyboardNavigation?.(); },
|
||||
});
|
||||
}
|
||||
|
||||
private renderHeading() {
|
||||
if (!this.collapsible) return "Projects";
|
||||
const selectedSummary = this.selected?.name ?? "No project selected";
|
||||
const selectedTitle = this.selected?.path ?? selectedSummary;
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Projects</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.projects.length}</small></button>`;
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Projects</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span><small class="section-count">${this.projects.length}</small></button>`;
|
||||
}
|
||||
|
||||
private renderActivity(project: Project) {
|
||||
const kind = projectActivityIndicator(project, this.workspacesByProjectId[project.id] ?? [], this.activities);
|
||||
return renderActivityIndicator(kind, kind === "terminal" ? "Project terminal active" : "Project active") ?? "";
|
||||
return renderActionActivityIndicator(kind, kind === "terminal" ? "Project terminal active" : "Project active");
|
||||
}
|
||||
|
||||
private toggleMenu(projectId: string, target: EventTarget | null) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionInfo } from "../api";
|
||||
import { sessionRowsForCurrentTree } from "./SessionList";
|
||||
|
||||
describe("sessionRowsForCurrentTree", () => {
|
||||
it("keeps archived ancestors visible while they have unarchived descendants", () => {
|
||||
const parent = { ...session("parent"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
|
||||
const child = session("child", { parentSessionPath: parent.path });
|
||||
|
||||
expect(rowSummaries(sessionRowsForCurrentTree([parent, child]))).toEqual([
|
||||
{ id: "parent", depth: 0, hasMissingParent: false },
|
||||
{ id: "child", depth: 1, hasMissingParent: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides archived parents from the current tree once children are detached", () => {
|
||||
const parent = { ...session("parent"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
|
||||
const detachedChild = session("child");
|
||||
|
||||
expect(rowSummaries(sessionRowsForCurrentTree([parent, detachedChild]))).toEqual([
|
||||
{ id: "child", depth: 0, hasMissingParent: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("still marks unavailable parents when the parent record is missing", () => {
|
||||
const child = session("child", { parentSessionPath: "/sessions/missing.jsonl" });
|
||||
|
||||
expect(rowSummaries(sessionRowsForCurrentTree([child]))).toEqual([
|
||||
{ id: "child", depth: 0, hasMissingParent: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function rowSummaries(rows: ReturnType<typeof sessionRowsForCurrentTree>) {
|
||||
return rows.map((row) => ({ id: row.session.id, depth: row.depth, hasMissingParent: row.hasMissingParent }));
|
||||
}
|
||||
|
||||
function session(id: string, overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
path: `/sessions/${id}.jsonl`,
|
||||
cwd: "/workspace",
|
||||
created: "2026-06-09T00:00:00.000Z",
|
||||
modified: "2026-06-09T00:00:00.000Z",
|
||||
messageCount: 1,
|
||||
firstMessage: id,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { LitElement, html, type PropertyValues } from "lit";
|
||||
import { LitElement, css, html, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
||||
import { isCachedNewSessionInfo } from "../cachedNewSessions";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { renderActivityIndicator } from "./activityBadge";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||
import { renderActionActivityIndicator } from "./activityBadge";
|
||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
||||
import { listStyles } from "./shared";
|
||||
|
||||
function sessionLabel(session: SessionInfo): string {
|
||||
@@ -13,37 +14,51 @@ function sessionLabel(session: SessionInfo): string {
|
||||
return session.firstMessage !== "" ? session.firstMessage : session.id.slice(0, 8);
|
||||
}
|
||||
|
||||
interface SessionRow {
|
||||
export interface SessionRow {
|
||||
session: SessionInfo;
|
||||
depth: number;
|
||||
hasMissingParent: boolean;
|
||||
}
|
||||
|
||||
type SessionSelectionScope = "current" | "archived";
|
||||
|
||||
@customElement("session-list")
|
||||
export class SessionList extends LitElement {
|
||||
export class SessionList extends LitElement implements KeyboardNavigableSection {
|
||||
@property({ attribute: false }) sessions: SessionInfo[] = [];
|
||||
@property({ attribute: false }) statuses: Record<string, SessionStatus> = {};
|
||||
@property({ attribute: false }) activities: Record<string, SessionActivity> = {};
|
||||
@property({ attribute: false }) selected?: SessionInfo;
|
||||
@property({ type: Boolean }) canStart = false;
|
||||
@property({ type: Boolean }) canDeleteArchived = false;
|
||||
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
|
||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||
@property({ attribute: false }) onSelect?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onStart?: () => void;
|
||||
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
||||
@property({ attribute: false }) onArchivedCollapsed?: () => void;
|
||||
@property({ attribute: false }) onFocusPreviousSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onArchive?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onArchiveWithDescendants?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onArchiveMany?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onRestore?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onDelete?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onDeleteArchived?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
|
||||
|
||||
@state() private openMenuSessionId: string | undefined;
|
||||
@state() private menuStyle = "";
|
||||
@state() private archivedExpanded = false;
|
||||
@state() private selectionScopes: ReadonlySet<SessionSelectionScope> = new Set();
|
||||
@state() private selectedSessionIds: ReadonlySet<string> = new Set();
|
||||
|
||||
private readonly onDocumentClick = (event: MouseEvent) => {
|
||||
if (event.composedPath().includes(this)) return;
|
||||
this.openMenuSessionId = undefined;
|
||||
};
|
||||
@property({ attribute: false }) onArchive?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onArchiveWithDescendants?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onRestore?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onDelete?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
@@ -56,9 +71,12 @@ export class SessionList extends LitElement {
|
||||
}
|
||||
|
||||
protected override updated(changed: PropertyValues<this>): void {
|
||||
if (changed.has("sessions") && this.openMenuSessionId !== undefined && !this.sessions.some((session) => session.id === this.openMenuSessionId)) this.openMenuSessionId = undefined;
|
||||
if (changed.has("sessions")) {
|
||||
if (this.openMenuSessionId !== undefined && !this.sessions.some((session) => session.id === this.openMenuSessionId)) this.openMenuSessionId = undefined;
|
||||
if (!this.sessions.some((session) => session.archived === true)) this.archivedExpanded = false;
|
||||
this.pruneSelectedSessionIds();
|
||||
}
|
||||
if (changed.has("collapsed") && this.collapsed) this.openMenuSessionId = undefined;
|
||||
if (changed.has("sessions") && !this.sessions.some((session) => session.archived === true)) this.archivedExpanded = false;
|
||||
const previousSelected = changed.get("selected");
|
||||
if (changed.has("selected") && this.selected?.archived === true && (previousSelected?.id !== this.selected.id || previousSelected.archived !== true) && !this.archivedExpanded) {
|
||||
this.archivedExpanded = true;
|
||||
@@ -68,20 +86,30 @@ export class SessionList extends LitElement {
|
||||
if ((changed.has("selected") || changed.has("sessions") || changed.has("collapsed")) && !this.collapsed) this.scrollSelectedIntoView();
|
||||
}
|
||||
|
||||
async focusSelectedOrFirst(): Promise<boolean> {
|
||||
await this.updateComplete;
|
||||
return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle, h2 button:not([disabled])" });
|
||||
}
|
||||
|
||||
override render() {
|
||||
const activeRows = sessionRowsForActiveTree(this.sessions);
|
||||
const activeIds = new Set(activeRows.map((row) => row.session.id));
|
||||
const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !activeIds.has(session.id)));
|
||||
const currentRows = sessionRowsForCurrentTree(this.sessions);
|
||||
const currentRowIds = new Set(currentRows.map((row) => row.session.id));
|
||||
const currentSelectableSessions = currentRows.map((row) => row.session).filter((session) => sessionSelectionScope(session) === "current");
|
||||
const archivedRows = sessionRows(this.sessions.filter((session) => session.archived === true && !currentRowIds.has(session.id)));
|
||||
const descendantCounts = unarchivedDescendantCounts(this.sessions);
|
||||
return html`
|
||||
<section>
|
||||
${this.renderHeading(activeRows.length + archivedRows.length)}
|
||||
${this.renderHeading(currentRows.length + archivedRows.length, currentSelectableSessions)}
|
||||
${this.collapsed ? null : html`
|
||||
<div class="list-body">
|
||||
${activeRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0))}
|
||||
${this.renderCurrentSelectionToolbar(currentSelectableSessions)}
|
||||
${currentRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0, "current"))}
|
||||
${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}
|
||||
${this.renderArchivedHeading(archivedRows.map((row) => row.session))}
|
||||
${this.archivedExpanded ? html`
|
||||
${this.renderArchivedSelectionToolbar(archivedRows.map((row) => row.session))}
|
||||
${archivedRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0, "archived"))}
|
||||
` : null}
|
||||
` : null}
|
||||
</div>
|
||||
`}
|
||||
@@ -89,43 +117,115 @@ export class SessionList extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderHeading(sessionCount: number) {
|
||||
if (!this.collapsible) return html`<h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2>`;
|
||||
private renderHeading(sessionCount: number, currentSessions: SessionInfo[]) {
|
||||
if (!this.collapsible) {
|
||||
return html`
|
||||
<h2>
|
||||
Sessions
|
||||
${this.renderCurrentSelectionButton(currentSessions)}
|
||||
<button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button>
|
||||
</h2>
|
||||
`;
|
||||
}
|
||||
const selectedSummary = this.selected === undefined ? "No session selected" : sessionLabel(this.selected);
|
||||
const selectedTitle = this.selected?.path ?? selectedSummary;
|
||||
return html`
|
||||
<h2>
|
||||
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${sessionCount}</small></button>
|
||||
<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Sessions</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button>
|
||||
${this.renderCurrentSelectionButton(currentSessions)}
|
||||
<small class="section-count">${sessionCount}</small>
|
||||
<button ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>
|
||||
</h2>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSession(row: SessionRow, descendantCount: number) {
|
||||
private renderCurrentSelectionButton(currentSessions: SessionInfo[]) {
|
||||
if (this.collapsed || currentSessions.length === 0) return null;
|
||||
const active = this.selectionScopes.has("current");
|
||||
return html`<button class="bulk-select-entry ${active ? "selected" : ""}" title=${active ? "Close current session selection" : "Select current sessions"} aria-label=${active ? "Close current session selection" : "Select current sessions"} aria-expanded=${String(active)} aria-pressed=${String(active)} @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleSelection("current", currentSessions); }}>☑</button>`;
|
||||
}
|
||||
|
||||
private renderArchivedHeading(archivedSessions: SessionInfo[]) {
|
||||
const active = this.selectionScopes.has("archived");
|
||||
return html`
|
||||
<h2 class="subheading">
|
||||
<button class="section-toggle" aria-expanded=${String(this.archivedExpanded)} @click=${() => { this.toggleArchived(); }}><span>${this.archivedExpanded ? "▾" : "▸"} Archived</span></button>
|
||||
${this.archivedExpanded ? html`<button class="bulk-select-entry ${active ? "selected" : ""}" title=${active ? "Close archived session selection" : "Select archived sessions"} aria-label=${active ? "Close archived session selection" : "Select archived sessions"} aria-expanded=${String(active)} aria-pressed=${String(active)} @click=${() => { this.toggleSelection("archived", archivedSessions); }}>☑</button>` : null}
|
||||
<small class="section-count">${archivedSessions.length}</small>
|
||||
</h2>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCurrentSelectionToolbar(visibleSessions: SessionInfo[]) {
|
||||
if (visibleSessions.length === 0 || !this.selectionScopes.has("current")) return null;
|
||||
|
||||
const selectedSessions = this.selectedSessions("current");
|
||||
const archivableSessions = selectedSessions.filter((session) => !isCachedNewSessionInfo(session));
|
||||
const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id));
|
||||
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
|
||||
return html`
|
||||
<div class="bulk-row selecting">
|
||||
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
|
||||
<small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small>
|
||||
<button ?disabled=${archivableSessions.length === 0} @click=${() => { this.archiveSelectedCurrent(); }}>Archive selected</button>
|
||||
<button @click=${() => { this.clearSelection("current"); }}>Clear</button>
|
||||
<button @click=${() => { this.closeSelection("current"); }}>Done</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderArchivedSelectionToolbar(visibleSessions: SessionInfo[]) {
|
||||
if (visibleSessions.length === 0 || !this.selectionScopes.has("archived")) return null;
|
||||
|
||||
const selectedSessions = this.selectedSessions("archived");
|
||||
const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id));
|
||||
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
|
||||
return html`
|
||||
<div class="bulk-row selecting">
|
||||
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
|
||||
<small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small>
|
||||
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete selected archived sessions" : this.archivedDeleteUnavailableMessage} ?disabled=${selectedSessions.length === 0 || !this.canDeleteArchived} @click=${() => { this.confirmDeleteSelectedArchived(); }}>Delete selected</button>
|
||||
<button @click=${() => { this.clearSelection("archived"); }}>Clear</button>
|
||||
<button @click=${() => { this.closeSelection("archived"); }}>Done</button>
|
||||
${this.canDeleteArchived ? null : html`<small class="capability-hint">${this.archivedDeleteUnavailableMessage}</small>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSession(row: SessionRow, descendantCount: number, scope: SessionSelectionScope) {
|
||||
const { session } = row;
|
||||
const cappedDepth = Math.min(row.depth, 2);
|
||||
const canBulkSelect = sessionSelectionScope(session) === scope;
|
||||
const selectionActive = this.selectionScopes.has(scope);
|
||||
const showsCheckbox = selectionActive && canBulkSelect;
|
||||
const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id);
|
||||
return html`
|
||||
<div
|
||||
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${session.archived === true ? "archived" : ""}"
|
||||
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${selectionActive ? "selecting" : ""}"
|
||||
style=${`--depth:${String(cappedDepth)}`}
|
||||
tabindex="0"
|
||||
title=${session.path}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => this.onSelect?.(session)); }}
|
||||
@keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(session)); }}
|
||||
@click=${(event: MouseEvent) => { activateSelectableRow(event, () => { this.activateSessionRow(session, scope); }); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.handleSessionKeydown(event, session, scope); }}
|
||||
>
|
||||
<div class="action-main">
|
||||
<span class="action-name">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderStatus(session)}${String(session.messageCount)} messages</small>
|
||||
<div class="action-main ${selectionActive ? "selecting" : ""}">
|
||||
${showsCheckbox ? html`<input class="session-checkbox" type="checkbox" aria-label=${`Select ${sessionLabel(session)}`} .checked=${bulkSelected} @click=${(event: MouseEvent) => { event.stopPropagation(); }} @change=${() => { this.toggleSelected(session.id); }}>` : null}
|
||||
<span class="action-name">${row.depth > 0 ? html`<span class="tree-marker">↳</span>` : null}${sessionLabel(session)}${row.depth > 2 ? html` <span class="badge">depth ${row.depth}</span>` : null}${row.hasMissingParent ? html` <span class="badge">parent unavailable</span>` : null}</span><small>${this.renderSessionMetaPrefix(session)}${String(session.messageCount)} messages</small>
|
||||
${this.renderActivity(session)}
|
||||
</div>
|
||||
<div class="action-menu">
|
||||
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button>
|
||||
${this.openMenuSessionId === session.id ? html`
|
||||
<div class="action-menu-panel" style=${this.menuStyle}>
|
||||
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
|
||||
${isCachedNewSessionInfo(session)
|
||||
? html`<button title="Delete browser-cached new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
|
||||
: session.archived === true
|
||||
? html`<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>`
|
||||
? html`
|
||||
<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>
|
||||
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
|
||||
`
|
||||
: html`
|
||||
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
|
||||
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
|
||||
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
|
||||
`}
|
||||
@@ -136,11 +236,101 @@ export class SessionList extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private handleSessionKeydown(event: KeyboardEvent, session: SessionInfo, scope: SessionSelectionScope): void {
|
||||
handleSelectableRowKeyboard(event, {
|
||||
activate: () => { this.activateSessionRow(session, scope); },
|
||||
previousSection: this.onFocusPreviousSection === undefined ? undefined : () => { void this.onFocusPreviousSection?.(); },
|
||||
nextSection: this.onFocusNextSection === undefined ? undefined : () => { void this.onFocusNextSection?.(); },
|
||||
cancel: this.onCancelKeyboardNavigation === undefined ? undefined : () => { void this.onCancelKeyboardNavigation?.(); },
|
||||
});
|
||||
}
|
||||
|
||||
private activateSessionRow(session: SessionInfo, scope: SessionSelectionScope): void {
|
||||
if (this.selectionScopes.has(scope) && sessionSelectionScope(session) === scope) {
|
||||
this.toggleSelected(session.id);
|
||||
return;
|
||||
}
|
||||
this.onSelect?.(session);
|
||||
}
|
||||
|
||||
private confirmArchiveWithDescendants(session: SessionInfo, descendantCount: number): void {
|
||||
const noun = descendantCount === 1 ? "descendant session" : "descendant sessions";
|
||||
if (confirm(`Archive “${sessionLabel(session)}” and ${String(descendantCount)} ${noun}?`)) this.onArchiveWithDescendants?.(session);
|
||||
}
|
||||
|
||||
private confirmDeleteArchived(session: SessionInfo): void {
|
||||
if (!this.canDeleteArchived) return;
|
||||
if (confirm(`Permanently delete archived session “${sessionLabel(session)}”? This cannot be undone.`)) void this.onDeleteArchived?.(session);
|
||||
}
|
||||
|
||||
private confirmDeleteSelectedArchived(): void {
|
||||
if (!this.canDeleteArchived) return;
|
||||
const archived = this.selectedSessions("archived");
|
||||
if (archived.length === 0) return;
|
||||
const noun = archived.length === 1 ? "archived session" : "archived sessions";
|
||||
if (!confirm(`Permanently delete ${String(archived.length)} selected ${noun}? This cannot be undone.`)) return;
|
||||
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, archived.map((session) => session.id));
|
||||
void this.onDeleteArchivedMany?.(archived);
|
||||
}
|
||||
|
||||
private archiveSelectedCurrent(): void {
|
||||
const sessions = this.selectedSessions("current").filter((session) => !isCachedNewSessionInfo(session));
|
||||
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id));
|
||||
void this.onArchiveMany?.(sessions);
|
||||
}
|
||||
|
||||
private toggleSelection(scope: SessionSelectionScope, visibleSessions: SessionInfo[]): void {
|
||||
if (this.selectionScopes.has(scope)) {
|
||||
this.closeSelection(scope);
|
||||
return;
|
||||
}
|
||||
this.startSelection(scope, visibleSessions);
|
||||
}
|
||||
|
||||
private startSelection(scope: SessionSelectionScope, visibleSessions: SessionInfo[]): void {
|
||||
this.selectionScopes = new Set([...this.selectionScopes, scope]);
|
||||
const onlyVisibleSession = visibleSessions.length === 1 ? visibleSessions[0] : undefined;
|
||||
if (onlyVisibleSession !== undefined) this.selectedSessionIds = new Set([...this.selectedSessionIds, onlyVisibleSession.id]);
|
||||
}
|
||||
|
||||
private closeSelection(scope: SessionSelectionScope): void {
|
||||
this.selectionScopes = new Set([...this.selectionScopes].filter((candidate) => candidate !== scope));
|
||||
this.clearSelection(scope);
|
||||
}
|
||||
|
||||
private clearSelection(scope: SessionSelectionScope): void {
|
||||
const sessionIds = this.sessions.filter((session) => sessionSelectionScope(session) === scope).map((session) => session.id);
|
||||
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessionIds);
|
||||
}
|
||||
|
||||
private toggleSelected(sessionId: string): void {
|
||||
const next = new Set(this.selectedSessionIds);
|
||||
if (next.has(sessionId)) next.delete(sessionId);
|
||||
else next.add(sessionId);
|
||||
this.selectedSessionIds = next;
|
||||
}
|
||||
|
||||
private toggleVisibleSelection(sessions: SessionInfo[], selected: boolean): void {
|
||||
const next = new Set(this.selectedSessionIds);
|
||||
for (const session of sessions) {
|
||||
if (selected) next.add(session.id);
|
||||
else next.delete(session.id);
|
||||
}
|
||||
this.selectedSessionIds = next;
|
||||
}
|
||||
|
||||
private selectedSessions(scope: SessionSelectionScope): SessionInfo[] {
|
||||
return this.sessions.filter((session) => this.selectedSessionIds.has(session.id) && sessionSelectionScope(session) === scope);
|
||||
}
|
||||
|
||||
private pruneSelectedSessionIds(): void {
|
||||
const existing = new Set(this.sessions.map((session) => session.id));
|
||||
const next = new Set([...this.selectedSessionIds].filter((sessionId) => existing.has(sessionId)));
|
||||
if (next.size !== this.selectedSessionIds.size) this.selectedSessionIds = next;
|
||||
if (this.selectionScopes.has("archived") && !this.sessions.some((session) => session.archived === true)) this.closeSelection("archived");
|
||||
if (this.selectionScopes.has("current") && !this.sessions.some((session) => session.archived !== true)) this.closeSelection("current");
|
||||
}
|
||||
|
||||
private toggleMenu(sessionId: string, target: EventTarget | null) {
|
||||
if (this.openMenuSessionId === sessionId) {
|
||||
this.openMenuSessionId = undefined;
|
||||
@@ -154,6 +344,7 @@ export class SessionList extends LitElement {
|
||||
this.archivedExpanded = !this.archivedExpanded;
|
||||
if (!this.archivedExpanded) {
|
||||
this.openMenuSessionId = undefined;
|
||||
if (this.selectionScopes.has("archived")) this.closeSelection("archived");
|
||||
this.onArchivedCollapsed?.();
|
||||
}
|
||||
}
|
||||
@@ -162,13 +353,41 @@ export class SessionList extends LitElement {
|
||||
this.renderRoot.querySelector<HTMLElement>(".action-row.selected")?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
private renderStatus(session: SessionInfo) {
|
||||
private renderSessionMetaPrefix(session: SessionInfo) {
|
||||
if (isCachedNewSessionInfo(session)) return "new · ";
|
||||
if (session.archived === true) return "read-only · ";
|
||||
return renderActivityIndicator(isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "session" : undefined, "Session active") ?? "";
|
||||
return "";
|
||||
}
|
||||
|
||||
static override styles = listStyles;
|
||||
private renderActivity(session: SessionInfo) {
|
||||
if (isCachedNewSessionInfo(session) || session.archived === true) return undefined;
|
||||
return renderActionActivityIndicator(isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "session" : undefined, "Session active");
|
||||
}
|
||||
|
||||
static override styles = [listStyles, css`
|
||||
h2 { min-height: 30px; }
|
||||
h2 > .section-count { flex: 0 0 auto; display: inline; color: var(--pi-muted); font-size: inherit; }
|
||||
.bulk-select-entry { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; font-size: 13px; line-height: 1; text-transform: none; }
|
||||
.bulk-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; }
|
||||
.bulk-row button { padding: 5px 7px; font-size: 12px; }
|
||||
.bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); }
|
||||
.bulk-row .capability-hint { flex: 1 0 100%; color: var(--pi-warning); }
|
||||
.bulk-row.selecting { padding: 6px; border: 1px solid var(--pi-border-muted); border-radius: 8px; background: color-mix(in srgb, var(--pi-surface) 65%, transparent); }
|
||||
button.danger, .action-menu-panel button.danger { color: var(--pi-danger); }
|
||||
button.danger:hover, .action-menu-panel button.danger:hover { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
|
||||
.action-row.bulk-selected .action-main { border-color: var(--pi-accent); box-shadow: inset 3px 0 0 var(--pi-accent); }
|
||||
.action-main.selecting { padding-left: calc(32px + var(--depth, 0) * 16px); }
|
||||
.session-checkbox { position: absolute; top: 9px; left: calc(8px + var(--depth, 0) * 16px); z-index: 2; margin: 0; }
|
||||
`];
|
||||
}
|
||||
|
||||
function sessionSelectionScope(session: SessionInfo): SessionSelectionScope {
|
||||
return session.archived === true ? "archived" : "current";
|
||||
}
|
||||
|
||||
function removeSessionIds(sessionIds: ReadonlySet<string>, removedIds: readonly string[]): ReadonlySet<string> {
|
||||
const removed = new Set(removedIds);
|
||||
return new Set([...sessionIds].filter((sessionId) => !removed.has(sessionId)));
|
||||
}
|
||||
|
||||
function unarchivedDescendantCounts(sessions: SessionInfo[]): Map<string, number> {
|
||||
@@ -196,16 +415,16 @@ function unarchivedDescendantCounts(sessions: SessionInfo[]): Map<string, number
|
||||
return new Map(sessions.map((session) => [session.id, countFor(session, new Set())]));
|
||||
}
|
||||
|
||||
function sessionRowsForActiveTree(sessions: SessionInfo[]): SessionRow[] {
|
||||
export function sessionRowsForCurrentTree(sessions: SessionInfo[]): SessionRow[] {
|
||||
const byPath = new Map(sessions.map((session) => [session.path, session]));
|
||||
const visible = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
if (session.archived === true) continue;
|
||||
visible.add(session.id);
|
||||
let parentPath = session.parentSessionPath;
|
||||
const seen = new Set<string>([session.path]);
|
||||
while (parentPath !== undefined && !seen.has(parentPath)) {
|
||||
seen.add(parentPath);
|
||||
const seenPaths = new Set<string>([session.path]);
|
||||
while (parentPath !== undefined && !seenPaths.has(parentPath)) {
|
||||
seenPaths.add(parentPath);
|
||||
const parent = byPath.get(parentPath);
|
||||
if (parent === undefined) break;
|
||||
visible.add(parent.id);
|
||||
|
||||
@@ -61,7 +61,18 @@ export class SettingsDialog extends LitElement {
|
||||
|
||||
private renderActiveSection(): TemplateResult {
|
||||
if (this.section === "shortcuts") {
|
||||
return html`<settings-shortcuts-panel .actions=${this.actions} .configResponse=${this.configResponse}></settings-shortcuts-panel>`;
|
||||
return html`
|
||||
<settings-shortcuts-panel
|
||||
.actions=${this.actions}
|
||||
.configResponse=${this.configResponse}
|
||||
.loading=${this.loading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
|
||||
></settings-shortcuts-panel>
|
||||
`;
|
||||
}
|
||||
if (this.section === "plugins") {
|
||||
return html`
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import type { Machine, SessionStatus, Workspace } from "../api";
|
||||
import type { WorkspaceLabelItem } from "../plugins/types";
|
||||
import type { SessionStatus } from "../api";
|
||||
import { formatCost, formatTokenCount } from "../utils/format";
|
||||
import { statusBarStyles } from "./shared";
|
||||
import { renderWorkspaceLabel } from "./workspaceLabel";
|
||||
|
||||
@customElement("status-bar")
|
||||
export class StatusBar extends LitElement {
|
||||
@property({ attribute: false }) status?: SessionStatus;
|
||||
@property({ attribute: false }) machine?: Machine;
|
||||
@property({ attribute: false }) workspace?: Workspace;
|
||||
@property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = [];
|
||||
|
||||
override render() {
|
||||
const status = this.status;
|
||||
@@ -25,11 +20,9 @@ export class StatusBar extends LitElement {
|
||||
const tokens = status.tokens;
|
||||
return html`
|
||||
<div class="bar">
|
||||
<span>${this.machine?.name ?? "Local"}</span>
|
||||
<span>${renderWorkspaceLabel(this.workspace?.label ?? "workspace", this.workspaceLabelItems, this.workspace?.path)}</span>
|
||||
<span>↑${formatTokenCount(tokens.input)}</span>
|
||||
<span>↓${formatTokenCount(tokens.output)}</span>
|
||||
<span>${contextText}</span>
|
||||
<span class="context">${contextText}</span>
|
||||
<span>${formatCost(status.cost)}</span>
|
||||
${status.pendingMessageCount > 0 ? html`<span>${String(status.pendingMessageCount)} queued</span>` : null}
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,14 @@ import type { Workspace, WorkspaceActivity } from "../api";
|
||||
import type { WorkspaceLabelItem } from "../plugins/types";
|
||||
import { workspaceActivityFor, workspaceActivityIndicator } from "../workspaceActivity";
|
||||
import { actionMenuPanelStyle } from "./actionMenu";
|
||||
import { renderActivityIndicator } from "./activityBadge";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||
import { renderActionActivityIndicator } from "./activityBadge";
|
||||
import type { KeyboardNavigableSection } from "./navigationFocus";
|
||||
import { activateSelectableRow, focusSelectedOrFirstSelectableRow, handleSelectableRowKeyboard } from "./selectableRow";
|
||||
import { listStyles } from "./shared";
|
||||
import { renderWorkspaceLabelInlineItems } from "./workspaceLabel";
|
||||
|
||||
@customElement("workspace-list")
|
||||
export class WorkspaceList extends LitElement {
|
||||
export class WorkspaceList extends LitElement implements KeyboardNavigableSection {
|
||||
@property({ attribute: false }) workspaces: Workspace[] = [];
|
||||
@property({ attribute: false }) selected?: Workspace;
|
||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||
@@ -21,6 +22,9 @@ export class WorkspaceList extends LitElement {
|
||||
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
|
||||
@property({ attribute: false }) onDelete?: (workspace: Workspace) => void;
|
||||
@property({ attribute: false }) onToggleCollapsed?: () => void;
|
||||
@property({ attribute: false }) onFocusPreviousSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onFocusNextSection?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
|
||||
@state() private openMenuWorkspaceId: string | undefined;
|
||||
@state() private menuStyle = "";
|
||||
|
||||
@@ -45,6 +49,11 @@ export class WorkspaceList extends LitElement {
|
||||
if ((changed.has("selected") || changed.has("workspaces") || changed.has("collapsed")) && !this.collapsed) this.scrollSelectedIntoView();
|
||||
}
|
||||
|
||||
async focusSelectedOrFirst(): Promise<boolean> {
|
||||
await this.updateComplete;
|
||||
return focusSelectedOrFirstSelectableRow(this.renderRoot, { fallbackSelector: ".section-toggle" });
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<section>
|
||||
@@ -79,18 +88,17 @@ export class WorkspaceList extends LitElement {
|
||||
if (!this.collapsible) return "Workspaces";
|
||||
const selectedSummary = this.selected === undefined ? "No workspace selected" : `${this.selected.label}${this.selected.isMain ? " · main" : ""} · ${this.selected.path}`;
|
||||
const selectedTitle = this.selected?.path ?? selectedSummary;
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Workspaces</span><small class="section-selected" title=${selectedTitle}>${selectedSummary}</small></span><small class="section-count">${this.workspaces.length}</small></button>`;
|
||||
return html`<button class="section-toggle" aria-expanded=${String(!this.collapsed)} @click=${() => { this.onToggleCollapsed?.(); }}><span class="section-title"><span class="section-name">${this.collapsed ? "▸" : "▾"} Workspaces</span>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span><small class="section-count">${this.workspaces.length}</small></button>`;
|
||||
}
|
||||
|
||||
private renderActivity(workspace: Workspace): TemplateResult | undefined {
|
||||
const kind = workspaceActivityIndicator(workspaceActivityFor(workspace, this.activities));
|
||||
return renderActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active");
|
||||
return renderActionActivityIndicator(kind, kind === "terminal" ? "Workspace terminal active" : "Workspace active");
|
||||
}
|
||||
|
||||
private renderWorkspaceMain(label: string, items: WorkspaceLabelItem[], workspace: Workspace): TemplateResult {
|
||||
return html`
|
||||
<span class="workspace-primary">
|
||||
${this.renderActivity(workspace)}
|
||||
<span class="workspace-primary-label">${label}</span>
|
||||
${this.isDeleting(workspace) ? html`<span class="workspace-status">Deleting…</span>` : null}
|
||||
</span>
|
||||
@@ -99,6 +107,7 @@ export class WorkspaceList extends LitElement {
|
||||
<span class="workspace-label">${renderWorkspaceLabelInlineItems(items)}</span>
|
||||
</small>
|
||||
`}
|
||||
${this.renderActivity(workspace)}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -182,7 +191,12 @@ export class WorkspaceList extends LitElement {
|
||||
this.openMenuWorkspaceId = undefined;
|
||||
return;
|
||||
}
|
||||
activateSelectableRowFromKeyboard(event, () => this.onSelect?.(workspace));
|
||||
handleSelectableRowKeyboard(event, {
|
||||
activate: () => this.onSelect?.(workspace),
|
||||
previousSection: this.onFocusPreviousSection === undefined ? undefined : () => { void this.onFocusPreviousSection?.(); },
|
||||
nextSection: this.onFocusNextSection === undefined ? undefined : () => { void this.onFocusNextSection?.(); },
|
||||
cancel: this.onCancelKeyboardNavigation === undefined ? undefined : () => { void this.onCancelKeyboardNavigation?.(); },
|
||||
});
|
||||
}
|
||||
|
||||
private scrollSelectedIntoView(): void {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { LitElement, html, type TemplateResult } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import type { Workspace } from "../api";
|
||||
import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
||||
import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspacePanelContext } from "../plugins/types";
|
||||
import { workspacePanelStyles } from "./shared";
|
||||
import { renderWorkspaceLabel } from "./workspaceLabel";
|
||||
|
||||
export interface WorkspacePanelEmptyState {
|
||||
title: string;
|
||||
@@ -19,7 +18,6 @@ export class WorkspacePanel extends LitElement {
|
||||
@property({ attribute: false }) emptyState: WorkspacePanelEmptyState | undefined;
|
||||
@property() tool: QualifiedContributionId = "core:workspace.files";
|
||||
@property({ attribute: false }) panels: QualifiedWorkspacePanelContribution[] = [];
|
||||
@property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = [];
|
||||
@property({ type: Boolean }) hideToolTabs = false;
|
||||
@property({ attribute: false }) onSelectTool: (tool: QualifiedContributionId) => void = () => undefined;
|
||||
@query(".workspace-header-strip") private workspaceHeaderStrip?: HTMLElement | null;
|
||||
@@ -63,10 +61,10 @@ export class WorkspacePanel extends LitElement {
|
||||
const visiblePanels = this.panels;
|
||||
const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0];
|
||||
return html`
|
||||
<header>
|
||||
<div class=${this.workspaceHeaderFrameClass()}>
|
||||
<div class="workspace-header-strip" @scroll=${this.onWorkspaceHeaderScroll}>
|
||||
${this.hideToolTabs ? null : html`
|
||||
${this.hideToolTabs ? null : html`
|
||||
<header>
|
||||
<div class=${this.workspaceHeaderFrameClass()}>
|
||||
<div class="workspace-header-strip" @scroll=${this.onWorkspaceHeaderScroll}>
|
||||
<div class="tabs">
|
||||
${visiblePanels.map((panel) => {
|
||||
const selected = selectedPanel?.id === panel.id;
|
||||
@@ -79,11 +77,10 @@ export class WorkspacePanel extends LitElement {
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`}
|
||||
<small>${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
`}
|
||||
${selectedPanel === undefined ? this.renderEmptyState({
|
||||
title: "No workspace tools available",
|
||||
body: "No tools are available for this workspace.",
|
||||
|
||||
@@ -6,3 +6,9 @@ export function renderActivityIndicator(kind: ActivityIndicatorKind | undefined,
|
||||
if (kind === undefined) return undefined;
|
||||
return html`<span class=${`activity-indicator ${kind}`} role="img" aria-label=${label} title=${label}></span>`;
|
||||
}
|
||||
|
||||
export function renderActionActivityIndicator(kind: ActivityIndicatorKind | undefined, label = "Active"): TemplateResult | undefined {
|
||||
const indicator = renderActivityIndicator(kind, label);
|
||||
if (indicator === undefined) return undefined;
|
||||
return html`<span class="action-activity">${indicator}</span>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Machine } from "../../api";
|
||||
import { shouldShowMachineContext } from "./AppContextBar";
|
||||
|
||||
describe("shouldShowMachineContext", () => {
|
||||
it("hides the machine crumb when there is no machine choice", () => {
|
||||
expect(shouldShowMachineContext([])).toBe(false);
|
||||
expect(shouldShowMachineContext([machine("local")])).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the machine crumb when multiple machines exist", () => {
|
||||
expect(shouldShowMachineContext([machine("local"), machine("remote-a")])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function machine(id: string): Machine {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
kind: id === "local" ? "local" : "remote",
|
||||
createdAt: "2026-06-04T00:00:00.000Z",
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
@@ -2,12 +2,11 @@ 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";
|
||||
import { renderActivityIndicator, type ActivityIndicatorKind } from "../activityBadge";
|
||||
|
||||
@customElement("app-context-bar")
|
||||
export class AppContextBar extends LitElement {
|
||||
@property({ attribute: false }) machines: Machine[] = [];
|
||||
@property({ attribute: false }) machine?: Machine;
|
||||
@property({ attribute: false }) machineActivityKind?: ActivityIndicatorKind;
|
||||
@property({ attribute: false }) project?: Project;
|
||||
@property({ attribute: false }) workspace?: Workspace;
|
||||
@property({ attribute: false }) session?: SessionInfo;
|
||||
@@ -38,6 +37,7 @@ export class AppContextBar extends LitElement {
|
||||
}
|
||||
|
||||
override render() {
|
||||
const showMachineContext = shouldShowMachineContext(this.machines);
|
||||
const machineLabel = machineContextLabel(this.machine);
|
||||
const projectLabel = projectContextLabel(this.project);
|
||||
const workspaceLabel = workspaceContextLabel(this.workspace);
|
||||
@@ -46,13 +46,14 @@ export class AppContextBar extends LitElement {
|
||||
<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>
|
||||
${this.renderMachineActivity()}
|
||||
<span class="context-value">${machineLabel}</span>
|
||||
</button>
|
||||
</li>
|
||||
${showMachineContext ? html`
|
||||
<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>
|
||||
` : null}
|
||||
<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>
|
||||
@@ -77,10 +78,6 @@ export class AppContextBar extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMachineActivity() {
|
||||
return renderActivityIndicator(this.machineActivityKind, this.machineActivityKind === "terminal" ? "Machine terminal active" : "Machine active");
|
||||
}
|
||||
|
||||
private renderActionsButton() {
|
||||
if (this.onShowActions === undefined) return null;
|
||||
return html`
|
||||
@@ -159,16 +156,16 @@ export class AppContextBar extends LitElement {
|
||||
.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); }
|
||||
.activity-indicator { flex: 0 0 auto; display: inline-block; width: 7px; height: 7px; margin-right: 0; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; vertical-align: 1px; }
|
||||
.activity-indicator.session { border-radius: 50%; background: var(--pi-success); }
|
||||
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
|
||||
.context-kind { display: none; }
|
||||
.context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; }
|
||||
button { cursor: pointer; }
|
||||
@keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } }
|
||||
`;
|
||||
}
|
||||
|
||||
export function shouldShowMachineContext(machines: readonly Machine[]): boolean {
|
||||
return machines.length > 1;
|
||||
}
|
||||
|
||||
function machineContextLabel(machine: Machine | undefined): string {
|
||||
return machine === undefined ? "No machine" : `${machine.name}${machine.kind === "remote" ? " · remote" : ""}`;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import type { Machine } from "../../api";
|
||||
import { shouldShowMachinesSection } from "./AppNavigationPanel";
|
||||
|
||||
describe("shouldShowMachinesSection", () => {
|
||||
it("hides the machines section when there is no machine choice", () => {
|
||||
it("hides machine navigation when there is no machine choice", () => {
|
||||
expect(shouldShowMachinesSection([])).toBe(false);
|
||||
expect(shouldShowMachinesSection([machine("local")])).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the machines section when there are multiple machines", () => {
|
||||
it("shows machine navigation when there are multiple machines", () => {
|
||||
expect(shouldShowMachinesSection([machine("local"), machine("remote-a")])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api";
|
||||
import type { WorkspaceLabelItem } from "../../plugins/types";
|
||||
import type { NavigationSection } from "../../appShell/navigationState";
|
||||
import { NAVIGATION_SECTION_ORDER } from "../../appShell/navigationState";
|
||||
import type { KeyboardNavigableSection } from "../navigationFocus";
|
||||
import "../MachineList";
|
||||
import "../MachineSwitcher";
|
||||
import "../ProjectList";
|
||||
import "../WorkspaceList";
|
||||
import "../SessionList";
|
||||
|
||||
export type NavigationFocusTarget = NavigationSection | "chat";
|
||||
|
||||
@customElement("app-navigation-panel")
|
||||
export class AppNavigationPanel extends LitElement {
|
||||
@property({ attribute: false }) machines: Machine[] = [];
|
||||
@@ -27,11 +33,14 @@ export class AppNavigationPanel extends LitElement {
|
||||
@property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
|
||||
@property({ attribute: false }) refreshControl: unknown;
|
||||
@property({ type: Boolean, reflect: true }) collapsible = false;
|
||||
@property({ type: Boolean, reflect: true }) compact = 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({ type: Boolean }) canDeleteArchivedSessions = false;
|
||||
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
|
||||
@property({ attribute: false }) onShowActions?: () => void;
|
||||
@property({ attribute: false }) onToggleMachines?: () => void;
|
||||
@property({ attribute: false }) onToggleProjects?: () => void;
|
||||
@@ -45,23 +54,56 @@ export class AppNavigationPanel extends LitElement {
|
||||
@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 }) onArchiveSessions?: (sessions: 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 }) onDeleteArchivedSession?: (session: SessionInfo) => void | Promise<void>;
|
||||
@property({ attribute: false }) onDeleteArchivedSessions?: (sessions: 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>;
|
||||
@property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>;
|
||||
@property({ attribute: false }) onFocusNavigationTarget?: (target: NavigationFocusTarget) => void | Promise<void>;
|
||||
@property({ attribute: false }) onCancelKeyboardNavigation?: () => void | Promise<void>;
|
||||
|
||||
@query("machine-list") private machineList?: KeyboardNavigableSection;
|
||||
@query("machine-switcher") private machineSwitcher?: KeyboardNavigableSection;
|
||||
@query("project-list") private projectList?: KeyboardNavigableSection;
|
||||
@query("workspace-list") private workspaceList?: KeyboardNavigableSection;
|
||||
@query("session-list") private sessionList?: KeyboardNavigableSection;
|
||||
|
||||
async focusSection(section: NavigationSection): Promise<boolean> {
|
||||
await this.updateComplete;
|
||||
switch (section) {
|
||||
case "machines": return await this.focusNavigableSection(this.compact ? this.machineList : this.machineSwitcher);
|
||||
case "projects": return await this.focusNavigableSection(this.projectList);
|
||||
case "workspaces": return await this.focusNavigableSection(this.workspaceList);
|
||||
case "sessions": return await this.focusNavigableSection(this.sessionList);
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<header>
|
||||
<strong>PI WEB</strong>
|
||||
${shouldShowMachinesSection(this.machines) ? html`
|
||||
<machine-switcher
|
||||
.machines=${this.machines}
|
||||
.selected=${this.selectedMachine}
|
||||
.statuses=${this.machineStatuses}
|
||||
.activities=${this.machineActivities}
|
||||
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
|
||||
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
|
||||
.onFocusNextSection=${() => { this.focusNextFrom("machines"); }}
|
||||
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
|
||||
></machine-switcher>
|
||||
` : null}
|
||||
<div class="header-actions">
|
||||
${this.refreshControl}
|
||||
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.onShowActions?.(); }}>Actions</button>
|
||||
</div>
|
||||
</header>
|
||||
${shouldShowMachinesSection(this.machines) ? html`
|
||||
${this.compact && shouldShowMachinesSection(this.machines) ? html`
|
||||
<machine-list
|
||||
.machines=${this.machines}
|
||||
.selected=${this.selectedMachine}
|
||||
@@ -72,6 +114,8 @@ export class AppNavigationPanel extends LitElement {
|
||||
.onToggleCollapsed=${() => { this.onToggleMachines?.(); }}
|
||||
.onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
|
||||
.onRemove=${(machine: Machine) => this.onRemoveMachine?.(machine)}
|
||||
.onFocusNextSection=${() => { this.focusNextFrom("machines"); }}
|
||||
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
|
||||
></machine-list>
|
||||
` : null}
|
||||
<project-list
|
||||
@@ -84,6 +128,9 @@ export class AppNavigationPanel extends LitElement {
|
||||
.onToggleCollapsed=${() => { this.onToggleProjects?.(); }}
|
||||
.onSelect=${(project: Project) => this.onSelectProject?.(project)}
|
||||
.onClose=${(project: Project) => this.onCloseProject?.(project)}
|
||||
.onFocusPreviousSection=${() => { this.focusPreviousFrom("projects"); }}
|
||||
.onFocusNextSection=${() => { this.focusNextFrom("projects"); }}
|
||||
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
|
||||
></project-list>
|
||||
<workspace-list
|
||||
.workspaces=${this.workspaces}
|
||||
@@ -96,6 +143,9 @@ export class AppNavigationPanel extends LitElement {
|
||||
.onToggleCollapsed=${() => { this.onToggleWorkspaces?.(); }}
|
||||
.onSelect=${(workspace: Workspace) => this.onSelectWorkspace?.(workspace)}
|
||||
.onDelete=${(workspace: Workspace) => this.onDeleteWorkspace?.(workspace)}
|
||||
.onFocusPreviousSection=${() => { this.focusPreviousFrom("workspaces"); }}
|
||||
.onFocusNextSection=${() => { this.focusNextFrom("workspaces"); }}
|
||||
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
|
||||
></workspace-list>
|
||||
<session-list
|
||||
.sessions=${this.sessions}
|
||||
@@ -103,6 +153,8 @@ export class AppNavigationPanel extends LitElement {
|
||||
.activities=${this.sessionActivities}
|
||||
.selected=${this.selectedSession}
|
||||
.canStart=${this.canStartSession}
|
||||
.canDeleteArchived=${this.canDeleteArchivedSessions}
|
||||
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage}
|
||||
.collapsible=${this.collapsible}
|
||||
.collapsed=${this.sessionsCollapsed}
|
||||
.onToggleCollapsed=${() => { this.onToggleSessions?.(); }}
|
||||
@@ -111,29 +163,59 @@ export class AppNavigationPanel extends LitElement {
|
||||
.onSelect=${(session: SessionInfo) => this.onSelectSession?.(session)}
|
||||
.onArchive=${(session: SessionInfo) => this.onArchiveSession?.(session)}
|
||||
.onArchiveWithDescendants=${(session: SessionInfo) => this.onArchiveSessionWithDescendants?.(session)}
|
||||
.onArchiveMany=${(sessions: SessionInfo[]) => this.onArchiveSessions?.(sessions)}
|
||||
.onRestore=${(session: SessionInfo) => this.onRestoreSession?.(session)}
|
||||
.onDelete=${(session: SessionInfo) => this.onDeleteCachedNewSession?.(session)}
|
||||
.onDeleteArchived=${(session: SessionInfo) => this.onDeleteArchivedSession?.(session)}
|
||||
.onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)}
|
||||
.onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)}
|
||||
.onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }}
|
||||
.onFocusNextSection=${() => { this.focusNextFrom("sessions"); }}
|
||||
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
|
||||
></session-list>
|
||||
`;
|
||||
}
|
||||
|
||||
private async focusNavigableSection(section: KeyboardNavigableSection | undefined): Promise<boolean> {
|
||||
if (section === undefined) return false;
|
||||
return await section.focusSelectedOrFirst();
|
||||
}
|
||||
|
||||
private focusPreviousFrom(section: NavigationSection): void {
|
||||
const target = previousVisibleNavigationTarget(section, this.machines);
|
||||
if (target !== undefined) void this.onFocusNavigationTarget?.(target);
|
||||
}
|
||||
|
||||
private focusNextFrom(section: NavigationSection): void {
|
||||
void this.onFocusNavigationTarget?.(nextVisibleNavigationTarget(section, this.machines));
|
||||
}
|
||||
|
||||
private cancelKeyboardNavigation(): void {
|
||||
void this.onCancelKeyboardNavigation?.();
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
:host([collapsible]) { flex: 1 1 auto; }
|
||||
:host([compact]) { 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); }
|
||||
:host([collapsible]) header { display: none; }
|
||||
.header-actions { display: flex; align-items: center; gap: 8px; }
|
||||
header strong { flex: 0 0 auto; }
|
||||
machine-switcher { flex: 1 1 auto; min-width: 0; }
|
||||
:host([compact]) header { display: none; }
|
||||
.header-actions { flex: 0 0 auto; 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; }
|
||||
machine-list[collapsed],
|
||||
project-list[collapsed],
|
||||
workspace-list[collapsed],
|
||||
session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; }
|
||||
:host([compact]) machine-list,
|
||||
:host([compact]) project-list,
|
||||
:host([compact]) workspace-list,
|
||||
:host([compact]) session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; }
|
||||
:host([compact]) machine-list[collapsed],
|
||||
:host([compact]) project-list[collapsed],
|
||||
:host([compact]) workspace-list[collapsed],
|
||||
:host([compact]) 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; }
|
||||
`;
|
||||
}
|
||||
@@ -141,3 +223,17 @@ export class AppNavigationPanel extends LitElement {
|
||||
export function shouldShowMachinesSection(machines: readonly Machine[]): boolean {
|
||||
return machines.length > 1;
|
||||
}
|
||||
|
||||
function previousVisibleNavigationTarget(section: NavigationSection, machines: readonly Machine[]): NavigationSection | undefined {
|
||||
const sections = visibleNavigationSections(machines);
|
||||
return sections[sections.indexOf(section) - 1];
|
||||
}
|
||||
|
||||
function nextVisibleNavigationTarget(section: NavigationSection, machines: readonly Machine[]): NavigationFocusTarget {
|
||||
const sections = visibleNavigationSections(machines);
|
||||
return sections[sections.indexOf(section) + 1] ?? "chat";
|
||||
}
|
||||
|
||||
function visibleNavigationSections(machines: readonly Machine[]): NavigationSection[] {
|
||||
return NAVIGATION_SECTION_ORDER.filter((section) => section !== "machines" || shouldShowMachinesSection(machines));
|
||||
}
|
||||
|
||||
@@ -1,20 +1,51 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { clampPanelWidth, panelResizeConstraints, panelWidthFromDrag, panelWidthFromKeyboard, type PanelResizeConstraints, type ResizablePanelSide } from "../../appShell/panelResizeController";
|
||||
|
||||
export type PanelEdgeSide = "navigation" | "workspace";
|
||||
export type PanelEdgeSide = ResizablePanelSide;
|
||||
|
||||
interface ActivePanelResize {
|
||||
pointerId: number;
|
||||
startClientX: number;
|
||||
startWidth: number;
|
||||
handle: HTMLElement;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
const RESIZE_KEYS = new Set(["ArrowLeft", "ArrowRight", "Home", "End"]);
|
||||
const DOUBLE_TAP_RESET_MS = 420;
|
||||
const TAP_MOVE_TOLERANCE_PX = 4;
|
||||
|
||||
@customElement("app-panel-edge-control")
|
||||
export class AppPanelEdgeControl extends LitElement {
|
||||
@property({ reflect: true }) side: PanelEdgeSide = "navigation";
|
||||
@property({ type: Boolean, reflect: true }) collapsed = false;
|
||||
@property({ type: Boolean }) resizable = false;
|
||||
@property({ type: Number }) panelWidth?: number;
|
||||
@property({ type: Number }) minWidth?: number;
|
||||
@property({ type: Number }) maxWidth?: number;
|
||||
@property() controls = "";
|
||||
@property() resizeLabel = "Resize panel";
|
||||
@property() expandLabel = "Expand panel";
|
||||
@property() collapseLabel = "Collapse panel";
|
||||
@property({ attribute: false }) onToggle?: () => void;
|
||||
@property({ attribute: false }) onResizeStart?: () => number | undefined;
|
||||
@property({ attribute: false }) onResize?: (width: number) => void;
|
||||
@property({ attribute: false }) onResizeEnd?: () => void;
|
||||
@property({ attribute: false }) onReset?: () => void;
|
||||
|
||||
private activeResize: ActivePanelResize | undefined;
|
||||
private lastTapAt = 0;
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.finishActiveResize();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const label = this.collapsed ? this.expandLabel : this.collapseLabel;
|
||||
return html`
|
||||
${this.renderResizeHandle()}
|
||||
<button
|
||||
type="button"
|
||||
class="edge-button"
|
||||
@@ -27,6 +58,35 @@ export class AppPanelEdgeControl extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderResizeHandle() {
|
||||
if (!this.resizable) return nothing;
|
||||
const constraints = this.resizeConstraints();
|
||||
return html`
|
||||
<div
|
||||
class="resize-handle"
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
aria-label=${this.resizeLabel}
|
||||
title=${`${this.resizeLabel}. Double-click or double-tap to reset.`}
|
||||
aria-controls=${this.controls}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin=${String(constraints.minWidth)}
|
||||
aria-valuemax=${String(constraints.maxWidth)}
|
||||
aria-valuenow=${this.resizeAriaValueNow()}
|
||||
@pointerdown=${(event: PointerEvent) => { this.onResizePointerDown(event); }}
|
||||
@pointermove=${(event: PointerEvent) => { this.onResizePointerMove(event); }}
|
||||
@pointerup=${(event: PointerEvent) => { this.onResizePointerUp(event); }}
|
||||
@pointercancel=${(event: PointerEvent) => { this.onResizePointerCancel(event); }}
|
||||
@dblclick=${(event: MouseEvent) => { this.onResizeDoubleClick(event); }}
|
||||
@keydown=${(event: KeyboardEvent) => { this.onResizeKeyDown(event); }}
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
private resizeAriaValueNow() {
|
||||
return this.panelWidth === undefined ? nothing : String(Math.round(this.panelWidth));
|
||||
}
|
||||
|
||||
private renderIcon() {
|
||||
const direction = this.iconDirection();
|
||||
const path = direction === "left" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6";
|
||||
@@ -38,10 +98,115 @@ export class AppPanelEdgeControl extends LitElement {
|
||||
return this.collapsed ? "left" : "right";
|
||||
}
|
||||
|
||||
private onResizePointerDown(event: PointerEvent): void {
|
||||
if (!this.resizable || event.button !== 0) return;
|
||||
const handle = event.currentTarget;
|
||||
if (!(handle instanceof HTMLElement)) return;
|
||||
const startWidth = this.resizeStartWidth();
|
||||
if (startWidth === undefined) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
this.activeResize = { pointerId: event.pointerId, startClientX: event.clientX, startWidth, handle, moved: false };
|
||||
this.toggleAttribute("resizing", true);
|
||||
}
|
||||
|
||||
private onResizePointerMove(event: PointerEvent): void {
|
||||
const activeResize = this.activeResize;
|
||||
if (activeResize?.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
if (Math.abs(event.clientX - activeResize.startClientX) <= TAP_MOVE_TOLERANCE_PX) return;
|
||||
activeResize.moved = true;
|
||||
this.commitPanelWidth(panelWidthFromDrag(this.side, activeResize.startWidth, activeResize.startClientX, event.clientX, this.resizeConstraints()));
|
||||
}
|
||||
|
||||
private onResizePointerUp(event: PointerEvent): void {
|
||||
const activeResize = this.activeResize;
|
||||
if (activeResize?.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
this.finishActiveResize();
|
||||
if (!activeResize.moved) this.registerTapForReset();
|
||||
}
|
||||
|
||||
private onResizePointerCancel(event: PointerEvent): void {
|
||||
if (this.activeResize?.pointerId !== event.pointerId) return;
|
||||
this.finishActiveResize();
|
||||
}
|
||||
|
||||
private onResizeDoubleClick(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.resetPanelSize();
|
||||
}
|
||||
|
||||
private onResizeKeyDown(event: KeyboardEvent): void {
|
||||
if (!this.resizable || !RESIZE_KEYS.has(event.key)) return;
|
||||
const currentWidth = this.resizeStartWidth();
|
||||
if (currentWidth === undefined) return;
|
||||
const nextWidth = panelWidthFromKeyboard(this.side, currentWidth, event.key, { largeStep: event.shiftKey, constraints: this.resizeConstraints() });
|
||||
if (nextWidth === undefined) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.commitPanelWidth(nextWidth);
|
||||
this.onResizeEnd?.();
|
||||
}
|
||||
|
||||
private registerTapForReset(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastTapAt <= DOUBLE_TAP_RESET_MS) {
|
||||
this.lastTapAt = 0;
|
||||
this.resetPanelSize();
|
||||
return;
|
||||
}
|
||||
this.lastTapAt = now;
|
||||
}
|
||||
|
||||
private resetPanelSize(): void {
|
||||
this.finishActiveResize();
|
||||
this.onReset?.();
|
||||
}
|
||||
|
||||
private resizeStartWidth(): number | undefined {
|
||||
const width = this.onResizeStart?.() ?? this.panelWidth;
|
||||
if (width === undefined) return undefined;
|
||||
return clampPanelWidth(this.side, width, this.resizeConstraints());
|
||||
}
|
||||
|
||||
private commitPanelWidth(width: number): void {
|
||||
this.onResize?.(clampPanelWidth(this.side, width, this.resizeConstraints()));
|
||||
}
|
||||
|
||||
private finishActiveResize(): void {
|
||||
const activeResize = this.activeResize;
|
||||
if (activeResize === undefined) return;
|
||||
try {
|
||||
activeResize.handle.releasePointerCapture(activeResize.pointerId);
|
||||
} catch {
|
||||
// Pointer capture may already be gone if the browser canceled the drag.
|
||||
}
|
||||
this.activeResize = undefined;
|
||||
this.toggleAttribute("resizing", false);
|
||||
this.onResizeEnd?.();
|
||||
}
|
||||
|
||||
private resizeConstraints(): PanelResizeConstraints {
|
||||
const defaults = panelResizeConstraints(this.side);
|
||||
return {
|
||||
...defaults,
|
||||
minWidth: this.minWidth ?? defaults.minWidth,
|
||||
maxWidth: this.maxWidth ?? defaults.maxWidth,
|
||||
};
|
||||
}
|
||||
|
||||
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 { position: relative; 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; }
|
||||
.resize-handle { position: absolute; inset: 0 -6px; z-index: 0; cursor: col-resize; touch-action: none; outline: none; }
|
||||
.resize-handle::after { content: ""; position: absolute; top: 0; bottom: 0; left: 50%; width: 1px; transform: translateX(-50%); background: transparent; transition: width .12s ease, background .12s ease, opacity .12s ease; }
|
||||
.resize-handle:hover::after, .resize-handle:focus-visible::after, :host([resizing]) .resize-handle::after { width: 3px; background: var(--pi-accent); opacity: .72; }
|
||||
.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)); }
|
||||
|
||||
@@ -1,51 +1,18 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { actionMenuPanelStyle } from "../actionMenu";
|
||||
|
||||
const REFRESH_LONG_PRESS_MS = 550;
|
||||
const REFRESH_MENU_PORTAL_STYLE_ID = "pi-web-app-refresh-menu-portal-style";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@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;
|
||||
private menuStyle = "";
|
||||
private menuPortal: HTMLDivElement | undefined;
|
||||
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();
|
||||
this.removePortalMenu();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const label = this.isRefreshing ? "Refreshing app data. Long-press for reload options." : "Refresh app data. Long-press for reload options.";
|
||||
const label = "Full page reload";
|
||||
return html`
|
||||
<button
|
||||
class=${`app-refresh-button${this.isRefreshing ? " refreshing" : ""}`}
|
||||
class="app-refresh-button"
|
||||
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(); }}
|
||||
@click=${this.onReloadClick}
|
||||
>${this.renderRefreshIcon()}</button>
|
||||
`;
|
||||
}
|
||||
@@ -61,114 +28,9 @@ export class AppRefreshControl extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private readonly onRefreshClick = (event: MouseEvent): void => {
|
||||
private readonly onReloadClick = (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 => {
|
||||
const path = event.composedPath();
|
||||
if (path.includes(this) || (this.menuPortal !== undefined && path.includes(this.menuPortal))) 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;
|
||||
this.renderPortalMenu();
|
||||
}
|
||||
|
||||
private closeMenu(): void {
|
||||
this.menuOpen = false;
|
||||
this.suppressNextClick = false;
|
||||
this.removePortalMenu();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private renderPortalMenu(): void {
|
||||
const ownerDocument = this.ownerDocument;
|
||||
ensurePortalMenuStyles(ownerDocument);
|
||||
|
||||
const menu = this.menuPortal ?? ownerDocument.createElement("div");
|
||||
this.menuPortal = menu;
|
||||
menu.className = "pi-web-app-refresh-menu-portal";
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.setAttribute("style", this.menuStyle);
|
||||
menu.replaceChildren(
|
||||
this.createPortalMenuButton("Refresh app data", () => { this.refresh(); }),
|
||||
this.createPortalMenuButton("Full page reload", () => { this.reload(); }),
|
||||
);
|
||||
menu.addEventListener("click", this.onPortalMenuClick);
|
||||
if (!menu.isConnected) ownerDocument.body.append(menu);
|
||||
}
|
||||
|
||||
private createPortalMenuButton(label: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = this.ownerDocument.createElement("button");
|
||||
button.type = "button";
|
||||
button.setAttribute("role", "menuitem");
|
||||
button.textContent = label;
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
private removePortalMenu(): void {
|
||||
this.menuPortal?.removeEventListener("click", this.onPortalMenuClick);
|
||||
this.menuPortal?.remove();
|
||||
this.menuPortal = undefined;
|
||||
}
|
||||
|
||||
private readonly onPortalMenuClick = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
static override styles = css`
|
||||
@@ -176,52 +38,5 @@ export class AppRefreshControl extends LitElement {
|
||||
: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; }
|
||||
@keyframes app-refresh-spin { to { transform: rotate(360deg); } }
|
||||
`;
|
||||
}
|
||||
|
||||
function ensurePortalMenuStyles(ownerDocument: Document): void {
|
||||
if (ownerDocument.getElementById(REFRESH_MENU_PORTAL_STYLE_ID) !== null) return;
|
||||
const style = ownerDocument.createElement("style");
|
||||
style.id = REFRESH_MENU_PORTAL_STYLE_ID;
|
||||
style.textContent = `
|
||||
.pi-web-app-refresh-menu-portal {
|
||||
position: fixed;
|
||||
z-index: 2147483647;
|
||||
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);
|
||||
color: var(--pi-text);
|
||||
box-shadow: 0 8px 24px var(--pi-shadow);
|
||||
overflow-wrap: anywhere;
|
||||
font: 14px system-ui, sans-serif;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.pi-web-app-refresh-menu-portal 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;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pi-web-app-refresh-menu-portal button:hover,
|
||||
.pi-web-app-refresh-menu-portal button:focus {
|
||||
background: var(--pi-selection-bg);
|
||||
}
|
||||
`;
|
||||
ownerDocument.head.append(style);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface KeyboardNavigableSection {
|
||||
focusSelectedOrFirst(): boolean | Promise<boolean>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
|
||||
import { activateSelectableRow, activateSelectableRowFromKeyboard, handleSelectableRowKeyboard } from "./selectableRow";
|
||||
|
||||
describe("selectable row activation", () => {
|
||||
it("activates rows from non-interactive click targets", () => {
|
||||
@@ -38,10 +38,30 @@ describe("selectable row activation", () => {
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes row keyboard navigation to adjacent section callbacks", () => {
|
||||
const nextSection = vi.fn();
|
||||
const event = keyboardEventWithPath("ArrowRight", matchTarget(() => false));
|
||||
|
||||
expect(handleSelectableRowKeyboard(event, { activate: vi.fn(), nextSection })).toBe(true);
|
||||
|
||||
expect(nextSection).toHaveBeenCalledOnce();
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(event.stopPropagation).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("routes Escape row keyboard navigation to cancel", () => {
|
||||
const cancel = vi.fn();
|
||||
const event = keyboardEventWithPath("Escape", matchTarget(() => false));
|
||||
|
||||
expect(handleSelectableRowKeyboard(event, { activate: vi.fn(), cancel })).toBe(true);
|
||||
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
type EventWithPath = Pick<Event, "composedPath">;
|
||||
type KeyboardEventWithPath = EventWithPath & Pick<KeyboardEvent, "key" | "preventDefault">;
|
||||
type KeyboardEventWithPath = EventWithPath & Pick<KeyboardEvent, "key" | "preventDefault" | "stopPropagation">;
|
||||
type MatchTarget = EventTarget & Pick<Element, "matches">;
|
||||
|
||||
function matchTarget(matches: Element["matches"]): MatchTarget {
|
||||
@@ -53,5 +73,5 @@ function eventWithPath(target: MatchTarget): EventWithPath {
|
||||
}
|
||||
|
||||
function keyboardEventWithPath(key: string, target: MatchTarget): KeyboardEventWithPath {
|
||||
return { key, preventDefault: vi.fn<() => void>(), composedPath: () => [target] };
|
||||
return { key, preventDefault: vi.fn<() => void>(), stopPropagation: vi.fn<() => void>(), composedPath: () => [target] };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@ const interactiveSelector = [
|
||||
|
||||
type ComposedPathEvent = Pick<Event, "composedPath">;
|
||||
type SelectableKeyboardEvent = ComposedPathEvent & Pick<KeyboardEvent, "key" | "preventDefault">;
|
||||
type SelectableNavigationKeyboardEvent = SelectableKeyboardEvent & Partial<Pick<KeyboardEvent, "currentTarget" | "stopPropagation">>;
|
||||
|
||||
export interface SelectableRowKeyboardOptions {
|
||||
activate: () => void;
|
||||
previousSection?: (() => void) | undefined;
|
||||
nextSection?: (() => void) | undefined;
|
||||
cancel?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
export function isFromInteractiveElement(event: ComposedPathEvent): boolean {
|
||||
return event.composedPath().some((target) => targetMatches(target, interactiveSelector));
|
||||
@@ -35,3 +43,74 @@ export function activateSelectableRowFromKeyboard(event: SelectableKeyboardEvent
|
||||
event.preventDefault();
|
||||
action();
|
||||
}
|
||||
|
||||
export function handleSelectableRowKeyboard(event: SelectableNavigationKeyboardEvent, options: SelectableRowKeyboardOptions): boolean {
|
||||
if (isFromInteractiveElement(event)) return false;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
options.activate();
|
||||
return true;
|
||||
}
|
||||
if (event.key === "ArrowUp") return handleRowFocusKey(event, () => { focusRelativeSelectableRow(event.currentTarget, -1); });
|
||||
if (event.key === "ArrowDown") return handleRowFocusKey(event, () => { focusRelativeSelectableRow(event.currentTarget, 1); });
|
||||
if (event.key === "Home") return handleRowFocusKey(event, () => { focusIndexedSelectableRow(event.currentTarget, 0); });
|
||||
if (event.key === "End") return handleRowFocusKey(event, () => { focusIndexedSelectableRow(event.currentTarget, -1); });
|
||||
if (event.key === "ArrowLeft" && options.previousSection !== undefined) return handleRowFocusKey(event, options.previousSection);
|
||||
if (event.key === "ArrowRight" && options.nextSection !== undefined) return handleRowFocusKey(event, options.nextSection);
|
||||
if (event.key === "Escape" && options.cancel !== undefined) return handleRowFocusKey(event, options.cancel);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function focusSelectedOrFirstSelectableRow(root: ParentNode, options: { fallbackSelector?: string | undefined } = {}): boolean {
|
||||
const target = root.querySelector<HTMLElement>(".action-row.selected")
|
||||
?? root.querySelector<HTMLElement>(".action-row")
|
||||
?? (options.fallbackSelector === undefined ? undefined : root.querySelector<HTMLElement>(options.fallbackSelector));
|
||||
if (target === undefined || target === null) return false;
|
||||
target.focus();
|
||||
target.scrollIntoView({ block: "nearest" });
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleRowFocusKey(event: SelectableNavigationKeyboardEvent, action: () => void): true {
|
||||
event.preventDefault();
|
||||
event.stopPropagation?.();
|
||||
action();
|
||||
return true;
|
||||
}
|
||||
|
||||
function focusRelativeSelectableRow(target: EventTarget | null | undefined, delta: number): void {
|
||||
const rows = selectableRowsForTarget(target);
|
||||
const current = currentSelectableRow(target);
|
||||
if (current === undefined || rows.length === 0) return;
|
||||
const index = rows.indexOf(current);
|
||||
if (index < 0) return;
|
||||
focusSelectableRowAt(rows, index + delta);
|
||||
}
|
||||
|
||||
function focusIndexedSelectableRow(target: EventTarget | null | undefined, index: number): void {
|
||||
const rows = selectableRowsForTarget(target);
|
||||
if (rows.length === 0) return;
|
||||
focusSelectableRowAt(rows, index < 0 ? rows.length - 1 : index);
|
||||
}
|
||||
|
||||
function focusSelectableRowAt(rows: HTMLElement[], index: number): void {
|
||||
const target = rows[Math.min(Math.max(index, 0), rows.length - 1)];
|
||||
target?.focus();
|
||||
target?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function selectableRowsForTarget(target: EventTarget | null | undefined): HTMLElement[] {
|
||||
const root = currentSelectableRow(target)?.getRootNode();
|
||||
if (root === undefined || !isSelectableRowRoot(root)) return [];
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(".action-row"));
|
||||
}
|
||||
|
||||
function isSelectableRowRoot(root: Node): root is Document | DocumentFragment {
|
||||
return (typeof Document !== "undefined" && root instanceof Document)
|
||||
|| (typeof DocumentFragment !== "undefined" && root instanceof DocumentFragment);
|
||||
}
|
||||
|
||||
function currentSelectableRow(target: EventTarget | null | undefined): HTMLElement | undefined {
|
||||
if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) return undefined;
|
||||
return target.closest<HTMLElement>(".action-row") ?? undefined;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export class SettingsPluginsPanel extends LitElement {
|
||||
<article class=${`plugin-card${plugin.enabled ? "" : " disabled"}`}>
|
||||
<div class="plugin-main">
|
||||
<strong>${plugin.id}</strong>
|
||||
<small>${plugin.source} · ${plugin.scope}</small>
|
||||
<small>${plugin.source} · ${plugin.scope}${plugin.machineSpecific ? " · machine-specific" : ""}</small>
|
||||
<small>${configuredState}</small>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
|
||||
@@ -1,55 +1,283 @@
|
||||
import { css, html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { AppAction } from "../../actions";
|
||||
import type { PiWebConfigResponse, PiWebShortcutConfig } from "../../api";
|
||||
import { formatShortcut } from "../../keyboardShortcuts";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues, PiWebShortcutConfig } from "../../api";
|
||||
import { formatShortcut, isShortcutSequenceStarter, parseShortcutInput, resolveShortcutBindings, shortcutSequenceTimeoutMs, shortcutTokenFromEvent, type ShortcutBindingResolution } from "../../keyboardShortcuts";
|
||||
|
||||
const RECORD_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
|
||||
|
||||
@customElement("settings-shortcuts-panel")
|
||||
export class SettingsShortcutsPanel extends LitElement {
|
||||
@property({ attribute: false }) actions: AppAction[] = [];
|
||||
@property({ attribute: false }) configResponse: PiWebConfigResponse | undefined;
|
||||
@property({ type: Boolean }) loading = false;
|
||||
@property({ type: Boolean }) saving = false;
|
||||
@property() error = "";
|
||||
@property() savedMessage = "";
|
||||
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
|
||||
@state() private drafts: Record<string, string> = {};
|
||||
@state() private localError = "";
|
||||
@state() private recording: RecordingState | undefined;
|
||||
private recordingTimer: number | undefined;
|
||||
private recordingListenerActive = false;
|
||||
|
||||
private readonly onRecordKeyDown = (event: KeyboardEvent): void => {
|
||||
const recording = this.recording;
|
||||
if (recording === undefined) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.key === "Escape") {
|
||||
this.stopRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = shortcutTokenFromEvent(event);
|
||||
if (token === undefined) {
|
||||
this.localError = "Press a letter, number, punctuation, function, or navigation key. Press Esc to cancel recording.";
|
||||
return;
|
||||
}
|
||||
if (recording.tokens.length === 0 && !isShortcutSequenceStarter(token)) {
|
||||
this.localError = "Start shortcuts with Ctrl/⌘ or Alt so normal typing is not captured.";
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = [...recording.tokens, token];
|
||||
this.localError = "";
|
||||
this.drafts = { [recording.actionId]: tokens.join(" ") };
|
||||
this.recording = { actionId: recording.actionId, tokens };
|
||||
this.armRecordingTimer();
|
||||
};
|
||||
|
||||
protected override willUpdate(changed: PropertyValues<this>): void {
|
||||
if (changed.has("configResponse") && this.configResponse !== undefined) {
|
||||
this.drafts = {};
|
||||
this.localError = "";
|
||||
this.stopRecording();
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.stopRecording();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
const groups = shortcutGroups(this.actions);
|
||||
const shortcutResolutions = this.shortcutResolutions();
|
||||
return html`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Keyboard shortcuts</h2>
|
||||
<p>Review registered app actions and the shortcut config that will become editable here. Manual config entries use action ids and can override a default shortcut or set it to <code>null</code> to disable it.</p>
|
||||
<p>Edit app shortcuts by action. Type a shortcut such as <code>mod+k</code> or <code>mod+g p</code>, record one from the keyboard, disable it with None, or reset it to the default. When shortcuts conflict, custom shortcuts win before defaults; ties are resolved by action id, and shorter shortcuts shadow longer sequences with the same prefix.</p>
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
|
||||
</div>
|
||||
<div class="shortcut-note">Config key: <code>shortcuts</code>. Example: <code>{ "core:view.chat": "mod+1", "core:session.stop": null }</code></div>
|
||||
${groups.length === 0 ? html`<div class="loading-card">No actions registered.</div>` : groups.map((group) => html`
|
||||
<section class="shortcut-group">
|
||||
<h3>${group.name}</h3>
|
||||
<div class="shortcut-list">
|
||||
${group.actions.map((action) => this.renderShortcutRow(action))}
|
||||
</div>
|
||||
</section>
|
||||
`)}
|
||||
${this.renderMessages()}
|
||||
${this.configResponse === undefined && this.loading ? html`<div class="loading-card">Loading shortcuts…</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Config file</span>
|
||||
<code>${this.configResponse?.path ?? "Unknown"}</code>
|
||||
<small>Shortcut overrides are saved under <code>shortcuts</code>. A value of <code>null</code> disables the action shortcut.</small>
|
||||
</div>
|
||||
${groups.length === 0 ? html`<div class="loading-card">No actions registered.</div>` : groups.map((group) => html`
|
||||
<section class="shortcut-group">
|
||||
<h3>${group.name}</h3>
|
||||
<div class="shortcut-list">
|
||||
${group.actions.map((action) => this.renderShortcutRow(action, shortcutResolutions.get(action.id)))}
|
||||
</div>
|
||||
</section>
|
||||
`)}
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderShortcutRow(action: AppAction): TemplateResult {
|
||||
private renderMessages(): TemplateResult | null {
|
||||
const error = this.localError || this.error;
|
||||
if (error !== "") return html`<div class="message error-message">${error}</div>`;
|
||||
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
|
||||
return null;
|
||||
}
|
||||
|
||||
private renderShortcutRow(action: AppAction, resolution: ShortcutBindingResolution | undefined): TemplateResult {
|
||||
const shortcuts = this.configResponse?.config.shortcuts;
|
||||
const configured = shortcutPreference(action.id, shortcuts);
|
||||
const shortcut = configured === null ? undefined : configured ?? action.shortcut;
|
||||
const state = shortcutState(action, shortcuts);
|
||||
const inputText = this.shortcutInputText(action);
|
||||
const parsedInput = inputText.trim() === "" ? undefined : parseShortcutInput(inputText);
|
||||
const previewShortcut = parsedInput?.ok === true ? parsedInput.shortcut : effectiveShortcut(action, shortcuts);
|
||||
const hasConfiguredShortcut = configured !== undefined;
|
||||
const hasDraft = this.drafts[action.id] !== undefined;
|
||||
const displayState = hasDraft && inputText.trim() !== "" ? "custom" : state;
|
||||
const recordingHint = this.recordingHint(action.id);
|
||||
const conflictLabel = shortcutConflictLabel(resolution);
|
||||
return html`
|
||||
<div class="shortcut-row">
|
||||
<article class=${shortcutRowClass(resolution)}>
|
||||
<div class="shortcut-main">
|
||||
<strong>${action.title}</strong>
|
||||
${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null}
|
||||
<small class="shortcut-id">${action.id}</small>
|
||||
<small>${action.shortcut !== undefined && action.shortcut !== "" ? html`Default: <kbd>${formatShortcut(action.shortcut)}</kbd>` : "No default shortcut"}</small>
|
||||
</div>
|
||||
<div class="shortcut-value">
|
||||
${shortcut !== undefined && shortcut !== "" ? html`<kbd>${formatShortcut(shortcut)}</kbd>` : html`<span class="unassigned">${state === "disabled" ? "Disabled" : "Unassigned"}</span>`}
|
||||
<small class=${state}>${shortcutStateLabel(state)}</small>
|
||||
<div class="shortcut-editor">
|
||||
<div class="shortcut-status">
|
||||
${previewShortcut !== undefined && previewShortcut !== "" ? html`<kbd>${formatShortcut(previewShortcut)}</kbd>` : html`<span class="unassigned">${state === "disabled" ? "Disabled" : "Unassigned"}</span>`}
|
||||
<small class=${displayState}>${shortcutStateLabel(displayState)}${hasDraft ? " · Unsaved" : ""}</small>
|
||||
${conflictLabel === undefined ? null : html`<small class=${shortcutConflictClass(resolution)}>${conflictLabel}</small>`}
|
||||
</div>
|
||||
<label class="shortcut-input-label">
|
||||
<span>Shortcut</span>
|
||||
<input
|
||||
class="shortcut-input"
|
||||
data-action-id=${action.id}
|
||||
.value=${inputText}
|
||||
placeholder=${action.shortcut ?? "mod+k"}
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
?disabled=${this.saving}
|
||||
@input=${(event: Event) => { this.updateDraft(action.id, inputValue(event)); }}
|
||||
>
|
||||
</label>
|
||||
${recordingHint !== "" ? html`<small class="recording-hint">${recordingHint}</small>` : null}
|
||||
<div class="shortcut-actions">
|
||||
<button class="primary" ?disabled=${this.loading || this.saving || !hasDraft || inputText.trim() === ""} @click=${() => { void this.saveShortcut(action); }}>Save</button>
|
||||
<button ?disabled=${this.loading || this.saving} @click=${() => { void this.toggleRecording(action.id); }}>${this.recording?.actionId === action.id ? "Cancel recording" : "Record"}</button>
|
||||
<button ?disabled=${this.loading || this.saving || configured === null} @click=${() => { void this.setShortcutNone(action.id); }}>None</button>
|
||||
<button ?disabled=${this.loading || this.saving || !hasConfiguredShortcut} @click=${() => { void this.resetShortcut(action.id); }}>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
private shortcutInputText(action: AppAction): string {
|
||||
const draft = this.drafts[action.id];
|
||||
if (draft !== undefined) return draft;
|
||||
const configured = shortcutPreference(action.id, this.configResponse?.config.shortcuts);
|
||||
if (configured === null) return "";
|
||||
return configured ?? action.shortcut ?? "";
|
||||
}
|
||||
|
||||
private recordingHint(actionId: string): string {
|
||||
const recording = this.recording;
|
||||
if (recording?.actionId !== actionId) return "";
|
||||
if (recording.tokens.length === 0) return "Recording: press Ctrl/⌘ or Alt with a key. Press Esc to cancel.";
|
||||
return `Recording: ${formatShortcut(recording.tokens.join(" "))}. Press another key to add a sequence, or wait to finish.`;
|
||||
}
|
||||
|
||||
private updateDraft(actionId: string, value: string): void {
|
||||
this.drafts = { [actionId]: value };
|
||||
this.localError = "";
|
||||
}
|
||||
|
||||
private async saveShortcut(action: AppAction): Promise<void> {
|
||||
this.stopRecording();
|
||||
const input = this.shortcutInputText(action).trim();
|
||||
const parsed = parseShortcutInput(input);
|
||||
if (!parsed.ok) {
|
||||
this.localError = parsed.message;
|
||||
return;
|
||||
}
|
||||
this.localError = "";
|
||||
await this.saveShortcutPreference(action.id, parsed.shortcut);
|
||||
}
|
||||
|
||||
private async setShortcutNone(actionId: string): Promise<void> {
|
||||
this.stopRecording();
|
||||
this.localError = "";
|
||||
await this.saveShortcutPreference(actionId, null);
|
||||
}
|
||||
|
||||
private async resetShortcut(actionId: string): Promise<void> {
|
||||
this.stopRecording();
|
||||
this.localError = "";
|
||||
await this.saveShortcutPreference(actionId, undefined);
|
||||
}
|
||||
|
||||
private async saveShortcutPreference(actionId: string, shortcut: string | null | undefined): Promise<void> {
|
||||
const config: PiWebConfigValues = { ...(this.configResponse?.config ?? {}) };
|
||||
const currentShortcuts = config.shortcuts ?? {};
|
||||
const shortcuts = shortcut === undefined ? withoutShortcutPreference(currentShortcuts, actionId) : { ...currentShortcuts, [actionId]: shortcut };
|
||||
if (Object.keys(shortcuts).length === 0) {
|
||||
delete config.shortcuts;
|
||||
} else {
|
||||
config.shortcuts = shortcuts;
|
||||
}
|
||||
await this.onSave?.(config);
|
||||
}
|
||||
|
||||
private shortcutResolutions(): Map<string, ShortcutBindingResolution> {
|
||||
return new Map(resolveShortcutBindings(this.actions, this.previewShortcutConfig(), { enabledOnly: true }).map((resolution) => [resolution.action.id, resolution]));
|
||||
}
|
||||
|
||||
private previewShortcutConfig(): PiWebShortcutConfig | undefined {
|
||||
const shortcuts = { ...(this.configResponse?.config.shortcuts ?? {}) };
|
||||
for (const [actionId, draft] of Object.entries(this.drafts)) {
|
||||
const trimmedDraft = draft.trim();
|
||||
if (trimmedDraft === "") continue;
|
||||
const parsed = parseShortcutInput(trimmedDraft);
|
||||
if (parsed.ok) shortcuts[actionId] = parsed.shortcut;
|
||||
}
|
||||
return Object.keys(shortcuts).length === 0 ? undefined : shortcuts;
|
||||
}
|
||||
|
||||
private async toggleRecording(actionId: string): Promise<void> {
|
||||
if (this.recording?.actionId === actionId) {
|
||||
this.stopRecording();
|
||||
return;
|
||||
}
|
||||
this.stopRecording();
|
||||
this.localError = "";
|
||||
this.recording = { actionId, tokens: [] };
|
||||
this.ensureRecordingListener();
|
||||
await this.updateComplete;
|
||||
this.focusShortcutInput(actionId);
|
||||
}
|
||||
|
||||
private focusShortcutInput(actionId: string): void {
|
||||
for (const input of this.renderRoot.querySelectorAll<HTMLInputElement>(".shortcut-input")) {
|
||||
if (input.dataset["actionId"] === actionId) {
|
||||
input.focus();
|
||||
input.select();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private armRecordingTimer(): void {
|
||||
this.clearRecordingTimer();
|
||||
this.recordingTimer = window.setTimeout(() => {
|
||||
this.recordingTimer = undefined;
|
||||
this.stopRecording();
|
||||
}, shortcutSequenceTimeoutMs);
|
||||
}
|
||||
|
||||
private stopRecording(): void {
|
||||
this.clearRecordingTimer();
|
||||
this.removeRecordingListener();
|
||||
this.recording = undefined;
|
||||
}
|
||||
|
||||
private clearRecordingTimer(): void {
|
||||
if (this.recordingTimer === undefined) return;
|
||||
window.clearTimeout(this.recordingTimer);
|
||||
this.recordingTimer = undefined;
|
||||
}
|
||||
|
||||
private ensureRecordingListener(): void {
|
||||
if (this.recordingListenerActive) return;
|
||||
window.addEventListener("keydown", this.onRecordKeyDown, RECORD_SHORTCUT_LISTENER_OPTIONS);
|
||||
this.recordingListenerActive = true;
|
||||
}
|
||||
|
||||
private removeRecordingListener(): void {
|
||||
if (!this.recordingListenerActive) return;
|
||||
window.removeEventListener("keydown", this.onRecordKeyDown, RECORD_SHORTCUT_LISTENER_OPTIONS);
|
||||
this.recordingListenerActive = false;
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { display: block; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
||||
@@ -58,37 +286,83 @@ export class SettingsShortcutsPanel extends LitElement {
|
||||
h2 { font-size: 17px; line-height: 1.25; }
|
||||
h3 { font-size: 13px; line-height: 1.3; }
|
||||
p { color: var(--pi-muted); line-height: 1.45; }
|
||||
button, input { font: inherit; }
|
||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||
button:disabled, input:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); }
|
||||
.secondary { flex: 0 0 auto; }
|
||||
.message, .loading-card, .config-path-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.message { margin-bottom: 12px; }
|
||||
.error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
|
||||
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
|
||||
.loading-card, .config-path-card { color: var(--pi-muted); }
|
||||
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
|
||||
.config-path-card span { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
|
||||
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
||||
.loading-card, .shortcut-note { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.loading-card, .shortcut-note { color: var(--pi-muted); }
|
||||
.shortcut-note { margin-bottom: 14px; }
|
||||
.shortcut-group { margin: 0 0 16px; }
|
||||
.shortcut-group h3 { margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
|
||||
.shortcut-list { border: 1px solid var(--pi-border); border-radius: 10px; overflow: hidden; }
|
||||
.shortcut-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); }
|
||||
.shortcut-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 48%); gap: 14px; align-items: start; padding: 12px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-surface); }
|
||||
.shortcut-row.shadowed { background: color-mix(in srgb, var(--pi-warning) 5%, var(--pi-surface)); }
|
||||
.shortcut-row.shadowing { background: color-mix(in srgb, var(--pi-accent) 5%, var(--pi-surface)); }
|
||||
.shortcut-row:last-child { border-bottom: 0; }
|
||||
.shortcut-main { min-width: 0; display: grid; gap: 3px; }
|
||||
.shortcut-main { min-width: 0; display: grid; gap: 4px; }
|
||||
.shortcut-main strong, .shortcut-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.shortcut-main small { color: var(--pi-muted); }
|
||||
.shortcut-id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.shortcut-value { justify-self: end; display: grid; justify-items: end; gap: 3px; }
|
||||
kbd { justify-self: end; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); padding: 3px 7px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
|
||||
.unassigned { justify-self: end; color: var(--pi-muted); font-size: 12px; }
|
||||
.shortcut-value small { color: var(--pi-muted); font-size: 11px; }
|
||||
.shortcut-value small.custom { color: var(--pi-accent); }
|
||||
.shortcut-value small.disabled { color: var(--pi-warning); }
|
||||
.shortcut-editor { min-width: 0; display: grid; gap: 8px; }
|
||||
.shortcut-status { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
.shortcut-status small { color: var(--pi-muted); font-size: 11px; }
|
||||
.shortcut-status small.custom { color: var(--pi-accent); }
|
||||
.shortcut-status small.disabled { color: var(--pi-warning); }
|
||||
.shortcut-status small.conflict { border: 1px solid currentColor; border-radius: 999px; padding: 2px 7px; }
|
||||
.shortcut-status small.conflict.shadowing { color: var(--pi-accent); }
|
||||
.shortcut-status small.conflict.shadowed { color: var(--pi-warning); }
|
||||
.shortcut-input-label { min-width: 0; display: grid; gap: 5px; }
|
||||
.shortcut-input-label span { color: var(--pi-muted); font-size: 11px; font-weight: 700; text-transform: uppercase; }
|
||||
input { box-sizing: border-box; width: 100%; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; outline: none; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
input:focus { border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); }
|
||||
.shortcut-actions { display: flex; justify-content: flex-end; gap: 7px; flex-wrap: wrap; }
|
||||
kbd { border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-bg); color: var(--pi-text-secondary); padding: 3px 7px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
|
||||
.unassigned { color: var(--pi-muted); font-size: 12px; }
|
||||
.recording-hint { color: var(--pi-accent); font-size: 12px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.section-heading { display: grid; gap: 12px; }
|
||||
.section-heading .secondary { justify-self: start; }
|
||||
.shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; }
|
||||
.shortcut-value { justify-self: start; justify-items: start; }
|
||||
kbd, .unassigned { justify-self: start; }
|
||||
.shortcut-status, .shortcut-actions { justify-content: flex-start; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
interface RecordingState {
|
||||
actionId: string;
|
||||
tokens: string[];
|
||||
}
|
||||
|
||||
type ShortcutState = "default" | "custom" | "disabled" | "unassigned";
|
||||
|
||||
function shortcutRowClass(resolution: ShortcutBindingResolution | undefined): string {
|
||||
if (resolution?.active === false) return "shortcut-row shadowed";
|
||||
if (resolution?.active === true && resolution.shadows.length > 0) return "shortcut-row shadowing";
|
||||
return "shortcut-row";
|
||||
}
|
||||
|
||||
function shortcutConflictClass(resolution: ShortcutBindingResolution | undefined): string {
|
||||
return resolution?.active === false ? "conflict shadowed" : "conflict shadowing";
|
||||
}
|
||||
|
||||
function shortcutConflictLabel(resolution: ShortcutBindingResolution | undefined): string | undefined {
|
||||
if (resolution === undefined) return undefined;
|
||||
if (!resolution.active) return `Shadowed by ${resolution.shadowedBy?.action.title ?? "another action"}`;
|
||||
const shadowedCount = resolution.shadows.length;
|
||||
if (shadowedCount === 0) return undefined;
|
||||
const shadowedNames = resolution.shadows.slice(0, 2).map((binding) => binding.action.title).join(", ");
|
||||
const suffix = shadowedCount > 2 ? `, +${String(shadowedCount - 2)} more` : "";
|
||||
return `Shadows ${String(shadowedCount)} ${shadowedCount === 1 ? "action" : "actions"}: ${shadowedNames}${suffix}`;
|
||||
}
|
||||
|
||||
function shortcutGroups(actions: AppAction[]): { name: string; actions: AppAction[] }[] {
|
||||
const grouped = new Map<string, AppAction[]>();
|
||||
for (const action of [...actions].sort(compareActions)) {
|
||||
@@ -107,6 +381,16 @@ function shortcutPreference(actionId: string, shortcuts: PiWebShortcutConfig | u
|
||||
return shortcuts[actionId];
|
||||
}
|
||||
|
||||
function withoutShortcutPreference(shortcuts: PiWebShortcutConfig, actionId: string): PiWebShortcutConfig {
|
||||
return Object.fromEntries(Object.entries(shortcuts).filter(([shortcutActionId]) => shortcutActionId !== actionId));
|
||||
}
|
||||
|
||||
function effectiveShortcut(action: AppAction, shortcuts: PiWebShortcutConfig | undefined): string | undefined {
|
||||
const configured = shortcutPreference(action.id, shortcuts);
|
||||
if (configured === null) return undefined;
|
||||
return configured ?? action.shortcut;
|
||||
}
|
||||
|
||||
function shortcutState(action: AppAction, shortcuts: PiWebShortcutConfig | undefined): ShortcutState {
|
||||
const configured = shortcutPreference(action.id, shortcuts);
|
||||
if (configured === null) return "disabled";
|
||||
@@ -117,8 +401,12 @@ function shortcutState(action: AppAction, shortcuts: PiWebShortcutConfig | undef
|
||||
function shortcutStateLabel(state: ShortcutState): string {
|
||||
switch (state) {
|
||||
case "default": return "Default";
|
||||
case "custom": return "Config override";
|
||||
case "disabled": return "Config disabled";
|
||||
case "custom": return "Custom";
|
||||
case "disabled": return "Disabled";
|
||||
case "unassigned": return "No default";
|
||||
}
|
||||
}
|
||||
|
||||
function inputValue(event: Event): string {
|
||||
return event.target instanceof HTMLInputElement ? event.target.value : "";
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export const appStyles = css`
|
||||
@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; }
|
||||
.shell { --navigation-panel-size: 340px; --workspace-panel-size: minmax(360px, 42vw); --navigation-panel-width: var(--navigation-panel-size); --workspace-panel-width: var(--workspace-panel-size); display: grid; grid-template-columns: var(--navigation-panel-width) 1px minmax(320px, 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); }
|
||||
@@ -75,14 +75,6 @@ export const appStyles = css`
|
||||
.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 { position: relative; z-index: 1; display: flex; align-items: center; pointer-events: auto; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
|
||||
.app-refresh, .app-refresh * { -webkit-user-select: none; user-select: none; }
|
||||
.app-refresh-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border-radius: 999px; padding: 0; line-height: 1; 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; background: transparent; color: var(--pi-text); text-align: left; white-space: normal; overflow-wrap: anywhere; }
|
||||
.app-refresh-menu button:hover, .app-refresh-menu button:focus { background: var(--pi-selection-bg); }
|
||||
.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; }
|
||||
@@ -155,7 +147,6 @@ export const appStyles = css`
|
||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||
.empty { margin: auto; color: var(--pi-muted); }
|
||||
.error { padding: 10px 16px; border-bottom: 1px solid var(--pi-border); color: var(--pi-danger); }
|
||||
@keyframes app-refresh-spin { to { transform: rotate(360deg); } }
|
||||
`;
|
||||
|
||||
export const workspacePanelStyles = css`
|
||||
@@ -186,10 +177,7 @@ export const workspacePanelStyles = css`
|
||||
.empty-state h2 { margin: 0; color: var(--pi-text); font-size: 15px; line-height: 1.3; }
|
||||
.empty-state p { margin: 0; line-height: 1.45; }
|
||||
small, .muted { color: var(--pi-muted); }
|
||||
header small { flex: 0 0 auto; min-width: max-content; overflow: visible; text-overflow: clip; white-space: nowrap; }
|
||||
header .workspace-label { width: max-content; max-width: none; overflow: visible; }
|
||||
header .workspace-label-base, header .workspace-label-item, header .workspace-label-render { overflow: visible; text-overflow: clip; }
|
||||
@media (max-width: 1180px) { .tabs { display: none; } }
|
||||
@media (max-width: 1180px) { header { display: none; } }
|
||||
.workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; }
|
||||
.workspace-label-base, .workspace-label-item, .workspace-label-render { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.workspace-label-item, .workspace-label-render, .workspace-label-separator { color: var(--pi-muted); }
|
||||
@@ -237,12 +225,11 @@ export const listStyles = css`
|
||||
.action-row:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; border-radius: 8px; }
|
||||
.action-row.selected .action-main, .action-row.selected .action-menu-toggle { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
.action-row.archived .action-main { color: var(--pi-muted); }
|
||||
.action-main { box-sizing: border-box; min-width: 0; width: 100%; border: 1px solid var(--pi-border); border-top-right-radius: 0; border-bottom-right-radius: 0; border-top-left-radius: 8px; border-bottom-left-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px 7px calc(9px + var(--depth, 0) * 16px); text-align: left; }
|
||||
.action-main { position: relative; box-sizing: border-box; min-width: 0; width: 100%; border: 1px solid var(--pi-border); border-top-right-radius: 0; border-bottom-right-radius: 0; border-top-left-radius: 8px; border-bottom-left-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 22px 7px calc(9px + var(--depth, 0) * 16px); text-align: left; }
|
||||
.action-name { display: -webkit-box; max-height: 2.5em; overflow: hidden; overflow-wrap: anywhere; line-height: 1.25; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.action-row:not(.selected):hover .action-main { background: var(--pi-surface-hover); }
|
||||
.workspace-row .action-main { border-radius: 8px 0 0 8px; }
|
||||
.workspace-primary { min-width: 0; display: flex; align-items: baseline; gap: 6px; }
|
||||
.workspace-primary .activity-indicator { flex: 0 0 auto; margin-right: 0; }
|
||||
.workspace-primary-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-status { flex: 0 0 auto; color: var(--pi-warning); font-size: 12px; }
|
||||
.workspace-secondary { margin-top: 3px; }
|
||||
@@ -256,6 +243,8 @@ export const listStyles = css`
|
||||
.workspace-detail-row dd { min-width: 0; margin: 0; overflow-wrap: anywhere; white-space: normal; }
|
||||
.tree-marker { color: var(--pi-dim); margin-right: 5px; }
|
||||
.badge { display: inline-block; margin-left: 5px; border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); padding: 0 5px; font-size: 11px; font-weight: 400; }
|
||||
.action-activity { position: absolute; top: 5px; right: 6px; z-index: 1; display: grid; place-items: center; width: 10px; height: 10px; }
|
||||
.action-activity .activity-indicator { margin: 0; vertical-align: 0; }
|
||||
.activity-indicator { display: inline-block; width: 7px; height: 7px; margin-right: 6px; background: var(--pi-success); animation: pulse 1s ease-in-out infinite; vertical-align: 1px; }
|
||||
.activity-indicator.session { border-radius: 50%; background: var(--pi-success); }
|
||||
.activity-indicator.terminal { border-radius: 2px; background: var(--pi-accent); }
|
||||
@@ -393,14 +382,8 @@ export const formattedTextStyles = css`
|
||||
|
||||
export const statusBarStyles = css`
|
||||
:host { display: block; color: var(--pi-muted); font: 12px system-ui, sans-serif; }
|
||||
.bar { display: flex; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-top: 1px solid var(--pi-border); background: var(--pi-bg); white-space: nowrap; overflow: hidden; }
|
||||
span { overflow: hidden; text-overflow: ellipsis; }
|
||||
.workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; }
|
||||
.workspace-label-base, .workspace-label-item, .workspace-label-render { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.workspace-label-item, .workspace-label-render, .workspace-label-separator { color: var(--pi-muted); }
|
||||
.workspace-label-link { color: var(--pi-accent); text-decoration: none; }
|
||||
.workspace-label-link:hover, .workspace-label-link:focus { text-decoration: underline; }
|
||||
.bar > span:first-child { flex: 1 1 auto; min-width: 80px; }
|
||||
.bar { display: flex; justify-content: flex-end; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-top: 1px solid var(--pi-border); background: var(--pi-bg); white-space: nowrap; overflow: hidden; }
|
||||
span { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.activity { display: inline-flex; align-items: center; gap: 6px; color: var(--pi-muted); }
|
||||
.activity.active { color: var(--pi-success); }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; }
|
||||
|
||||
@@ -12,8 +12,9 @@ export class MachineController {
|
||||
const machines = await api.machines();
|
||||
const selectedMachine = await this.selectInitialMachine(machines, routeMachineId);
|
||||
const machineIds = new Set(machines.map((machine) => machine.id));
|
||||
this.setState({ machines, selectedMachine, machineActivities: filterKeys(this.getState().machineActivities, machineIds) });
|
||||
this.setState({ machines, selectedMachine, machineActivities: filterKeys(this.getState().machineActivities, machineIds), machineRuntimes: filterKeys(this.getState().machineRuntimes, machineIds) });
|
||||
void this.refreshMachineHealthFor(machines);
|
||||
void this.refreshMachineRuntimeFor(machines);
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
} finally {
|
||||
@@ -47,6 +48,7 @@ export class MachineController {
|
||||
if (options.updateUrl !== false) this.updateUrl();
|
||||
await this.projects.loadProjects();
|
||||
void this.refreshMachineHealth(machine.id);
|
||||
void this.refreshMachineRuntime(machine.id);
|
||||
}
|
||||
|
||||
async addMachine(input: { name: string; baseUrl: string; token?: string }): Promise<Machine | undefined> {
|
||||
@@ -73,7 +75,7 @@ export class MachineController {
|
||||
await api.deleteMachine(machine.id);
|
||||
const machines = this.getState().machines.filter((candidate) => candidate.id !== machine.id);
|
||||
const local = machines.find((candidate) => candidate.id === "local") ?? machines[0];
|
||||
this.setState({ machines, machineStatuses: omitKey(this.getState().machineStatuses, machine.id), machineActivities: omitKey(this.getState().machineActivities, machine.id) });
|
||||
this.setState({ machines, machineStatuses: omitKey(this.getState().machineStatuses, machine.id), machineRuntimes: omitKey(this.getState().machineRuntimes, machine.id), machineActivities: omitKey(this.getState().machineActivities, machine.id) });
|
||||
if (wasSelected && local !== undefined) {
|
||||
if (options.selectFallback === false) return local;
|
||||
await this.selectMachine(local);
|
||||
@@ -95,6 +97,15 @@ export class MachineController {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<void> {
|
||||
try {
|
||||
const runtime = await api.runtime(machineId);
|
||||
this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, [runtime.machineId]: runtime } });
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
private async selectInitialMachine(machines: Machine[], routeMachineId?: string): Promise<Machine | undefined> {
|
||||
const requestedMachine = machines.find((machine) => machine.id === (routeMachineId ?? "local"));
|
||||
if (requestedMachine?.kind !== "remote") return requestedMachine ?? this.localMachine(machines);
|
||||
@@ -133,6 +144,12 @@ export class MachineController {
|
||||
const health = Object.fromEntries(results.flatMap((result) => result.status === "fulfilled" ? [[result.value.machineId, result.value] as const] : []));
|
||||
if (Object.keys(health).length > 0) this.setState({ machineStatuses: { ...this.getState().machineStatuses, ...health } });
|
||||
}
|
||||
|
||||
private async refreshMachineRuntimeFor(machines: Machine[]): Promise<void> {
|
||||
const results = await Promise.allSettled(machines.map((machine) => api.runtime(machine.id)));
|
||||
const runtimes = Object.fromEntries(results.flatMap((result) => result.status === "fulfilled" ? [[result.value.machineId, result.value] as const] : []));
|
||||
if (Object.keys(runtimes).length > 0) this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, ...runtimes } });
|
||||
}
|
||||
}
|
||||
|
||||
function omitKey<T>(record: Record<string, T>, keyToOmit: string): Record<string, T> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api as defaultApi, type MessagePage, type SessionActivity, type Session
|
||||
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
import { SessionController, type SessionEventSocket } from "./sessionController";
|
||||
import { InMemorySessionSelectionMemory } from "./sessionSelection";
|
||||
@@ -290,6 +291,98 @@ describe("SessionController", () => {
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
});
|
||||
|
||||
it("archives selected sessions in bulk", async () => {
|
||||
const secondSession = { ...oldSession, id: "second-session", path: "/tmp/second-session.jsonl" };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
|
||||
const archivedIds: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession, secondSession, nextSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
archive: (session) => {
|
||||
archivedIds.push(session.id);
|
||||
return Promise.resolve({ archived: true });
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(session.id)),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.selectSession(oldSession, { updateUrl: false });
|
||||
await controller.archiveSessions([oldSession, secondSession]);
|
||||
|
||||
expect(archivedIds).toEqual([oldSession.id, secondSession.id]);
|
||||
expect(state.sessions.find((session) => session.id === oldSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.sessions.find((session) => session.id === secondSession.id)).toMatchObject({ archived: true });
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
});
|
||||
|
||||
it("deletes selected archived sessions in bulk and selects the next current session", async () => {
|
||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
const nextSession = { ...oldSession, id: "next-session", path: "/tmp/next-session.jsonl" };
|
||||
const deletedIds: string[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: archivedSession,
|
||||
sessions: [archivedSession, nextSession],
|
||||
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] } },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
deleteArchived: (session) => {
|
||||
deletedIds.push(session.id);
|
||||
return Promise.resolve({ deleted: true });
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(session.id)),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.deleteArchivedSessions([archivedSession]);
|
||||
|
||||
expect(deletedIds).toEqual([archivedSession.id]);
|
||||
expect(state.sessions.map((session) => session.id)).toEqual([nextSession.id]);
|
||||
expect(state.selectedSession?.id).toBe(nextSession.id);
|
||||
});
|
||||
|
||||
it("does not delete archived sessions when the selected machine runtime does not support it", async () => {
|
||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
const deletedIds: string[] = [];
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
deleteArchived: (session) => {
|
||||
deletedIds.push(session.id);
|
||||
return Promise.resolve({ deleted: true });
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
new InMemorySessionSelectionMemory(),
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.deleteArchivedSessions([archivedSession]);
|
||||
|
||||
expect(deletedIds).toEqual([]);
|
||||
expect(state.sessions).toEqual([archivedSession]);
|
||||
expect(state.error).toContain("requires an updated Pi-Web runtime");
|
||||
});
|
||||
|
||||
it("forgets archived selections when the archived section collapse clears selection", async () => {
|
||||
const archivedSession = { ...oldSession, archived: true, archivedAt: "later" };
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [archivedSession] };
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ChatTranscriptStore } from "../chatTranscriptStore";
|
||||
import { isShellInput } from "../inputModes";
|
||||
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
|
||||
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
|
||||
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
|
||||
|
||||
@@ -261,6 +262,57 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
async archiveSessions(sessions: readonly SessionInfo[]): Promise<void> {
|
||||
const candidates = uniqueSessionsById(sessions).filter((session) => session.archived !== true && !isCachedNewSessionInfo(session));
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const results = await Promise.allSettled(candidates.map(async (session) => {
|
||||
await this.api.archive(session, machineId);
|
||||
return session.id;
|
||||
}));
|
||||
const archivedIds = fulfilledValues(results);
|
||||
if (archivedIds.length > 0) {
|
||||
const state = this.getState();
|
||||
const nextSessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString());
|
||||
const selectionChange = selectionAfterArchivingSessions(nextSessions, state.selectedSession?.id, archivedIds);
|
||||
this.setState({ sessions: nextSessions });
|
||||
|
||||
if (selectionChange.type === "select") await this.selectSession(selectionChange.session);
|
||||
else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true });
|
||||
}
|
||||
this.applyBulkSessionError("Archive", results);
|
||||
}
|
||||
|
||||
async deleteArchivedSessions(sessions: readonly SessionInfo[]): Promise<void> {
|
||||
const candidates = uniqueSessionsById(sessions).filter((session) => session.archived === true);
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const runtime = this.getState().machineRuntimes[machineId];
|
||||
if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsDeleteArchived)) {
|
||||
this.setState({ error: "Deleting archived sessions requires an updated Pi-Web runtime on this machine." });
|
||||
return;
|
||||
}
|
||||
const results = await Promise.allSettled(candidates.map(async (session) => {
|
||||
await this.api.deleteArchived(session, machineId);
|
||||
return session.id;
|
||||
}));
|
||||
const deletedIds = fulfilledValues(results);
|
||||
if (deletedIds.length > 0) {
|
||||
const deletedIdSet = new Set(deletedIds);
|
||||
const state = this.getState();
|
||||
const nextSessions = state.sessions.filter((session) => !deletedIdSet.has(session.id));
|
||||
this.setState({ sessions: nextSessions });
|
||||
if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) {
|
||||
const next = nextSessions.find((session) => session.archived !== true) ?? nextSessions[0];
|
||||
if (next !== undefined) await this.selectSession(next);
|
||||
else this.deselectSession({ forgetRememberedSelection: true });
|
||||
}
|
||||
}
|
||||
this.applyBulkSessionError("Delete", results);
|
||||
}
|
||||
|
||||
async deleteCachedNewSession(session = this.getState().selectedSession) {
|
||||
if (!isCachedNewSessionInfo(session)) return;
|
||||
void this.api.stop(session, selectedMachineId(this.getState())).catch(() => {
|
||||
@@ -397,6 +449,12 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
private applyBulkSessionError(action: string, results: readonly PromiseSettledResult<string>[]): void {
|
||||
const failures = rejectedReasons(results);
|
||||
if (failures.length === 0) return;
|
||||
this.setState({ error: `${action} failed for ${String(failures.length)} session${failures.length === 1 ? "" : "s"}: ${failures.join("; ")}` });
|
||||
}
|
||||
|
||||
private sessionCacheKey(sessionId: string): string {
|
||||
return machineSessionKey(selectedMachineId(this.getState()), sessionId);
|
||||
}
|
||||
@@ -563,6 +621,37 @@ function omitSessionActivity(activities: Record<string, SessionActivity>, sessio
|
||||
return Object.fromEntries(Object.entries(activities).filter(([id]) => id !== sessionId));
|
||||
}
|
||||
|
||||
function uniqueSessionsById(sessions: readonly SessionInfo[]): SessionInfo[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: SessionInfo[] = [];
|
||||
for (const session of sessions) {
|
||||
if (seen.has(session.id)) continue;
|
||||
seen.add(session.id);
|
||||
unique.push(session);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function fulfilledValues<T>(results: readonly PromiseSettledResult<T>[]): T[] {
|
||||
return results.filter(isFulfilled).map((result) => result.value);
|
||||
}
|
||||
|
||||
function rejectedReasons(results: readonly PromiseSettledResult<unknown>[]): string[] {
|
||||
return results.filter(isRejected).map((result) => errorMessage(result.reason));
|
||||
}
|
||||
|
||||
function isFulfilled<T>(result: PromiseSettledResult<T>): result is PromiseFulfilledResult<T> {
|
||||
return result.status === "fulfilled";
|
||||
}
|
||||
|
||||
function isRejected<T>(result: PromiseSettledResult<T>): result is PromiseRejectedResult {
|
||||
return result.status === "rejected";
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function sessionMessageCountPatch(state: AppState, sessionId: string, messageCount: number | undefined): Pick<Partial<AppState>, "sessions" | "selectedSession"> {
|
||||
if (messageCount === undefined) return {};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AppAction } from "./actions";
|
||||
import { KeyboardShortcutDispatcher, type ShortcutKeyEvent } from "./keyboardShortcuts";
|
||||
import { KeyboardShortcutDispatcher, parseShortcutInput, resolveShortcutBindings, shortcutTokenFromEvent, type ShortcutKeyEvent } from "./keyboardShortcuts";
|
||||
|
||||
function keyEvent(key: string, modifiers: Partial<ShortcutKeyEvent> = {}): ShortcutKeyEvent {
|
||||
return {
|
||||
@@ -16,10 +16,14 @@ function keyEvent(key: string, modifiers: Partial<ShortcutKeyEvent> = {}): Short
|
||||
}
|
||||
|
||||
function action(shortcut: string, enabled = true) {
|
||||
return actionWithId(shortcut, shortcut, enabled);
|
||||
}
|
||||
|
||||
function actionWithId(id: string, shortcut: string, enabled = true) {
|
||||
const run = vi.fn();
|
||||
const value: AppAction = {
|
||||
id: shortcut,
|
||||
title: shortcut,
|
||||
id,
|
||||
title: id,
|
||||
shortcut,
|
||||
enabled,
|
||||
run,
|
||||
@@ -48,6 +52,26 @@ describe("KeyboardShortcutDispatcher", () => {
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches manually typed Ctrl shortcuts as the cross-platform Mod modifier", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const { value, run } = action("ctrl+k");
|
||||
|
||||
const handled = dispatcher.handle(keyEvent("k", { ctrlKey: true }), [value]);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores shift-only shortcuts so capitalized typing is never captured", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const { value, run } = action("shift+r");
|
||||
|
||||
const handled = dispatcher.handle(keyEvent("r", { shiftKey: true }), [value]);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores disabled matching shortcuts", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const { value, run } = action("mod+enter", false);
|
||||
@@ -66,4 +90,108 @@ describe("KeyboardShortcutDispatcher", () => {
|
||||
expect(dispatcher.handle(keyEvent("r", { ctrlKey: true, shiftKey: true }), [value])).toBe(true);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runs a shortcut sequence that starts with a modified key", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const { value, run } = action("mod+g p");
|
||||
|
||||
expect(dispatcher.handle(keyEvent("g", { ctrlKey: true }), [value])).toBe(true);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(dispatcher.handle(keyEvent("p"), [value])).toBe(true);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deterministically runs the lowest action id when default shortcuts conflict", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const later = actionWithId("plugin:z", "mod+k");
|
||||
const earlier = actionWithId("plugin:a", "mod+k");
|
||||
|
||||
const handled = dispatcher.handle(keyEvent("k", { ctrlKey: true }), [later.value, earlier.value]);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(earlier.run).toHaveBeenCalledTimes(1);
|
||||
expect(later.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs custom shortcut winners before default shortcut conflicts", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const defaultAction = actionWithId("plugin:a", "mod+j");
|
||||
const customAction = actionWithId("plugin:z", "mod+k");
|
||||
|
||||
const handled = dispatcher.handle(keyEvent("j", { ctrlKey: true }), [defaultAction.value, customAction.value], { shortcuts: { "plugin:z": "mod+j" } });
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(customAction.run).toHaveBeenCalledTimes(1);
|
||||
expect(defaultAction.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the same shadowing rules when a standalone shortcut is a sequence prefix", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const standalone = actionWithId("plugin:standalone", "mod+g");
|
||||
const sequence = actionWithId("plugin:sequence", "mod+g p");
|
||||
|
||||
const handled = dispatcher.handle(keyEvent("g", { ctrlKey: true }), [standalone.value, sequence.value]);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(standalone.run).toHaveBeenCalledTimes(1);
|
||||
expect(sequence.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to a standalone modified shortcut when a pending sequence misses", () => {
|
||||
const dispatcher = new KeyboardShortcutDispatcher();
|
||||
const sequence = action("mod+g p");
|
||||
const standalone = action("mod+k");
|
||||
|
||||
expect(dispatcher.handle(keyEvent("g", { ctrlKey: true }), [sequence.value, standalone.value])).toBe(true);
|
||||
expect(dispatcher.handle(keyEvent("k", { ctrlKey: true }), [sequence.value, standalone.value])).toBe(true);
|
||||
expect(sequence.run).not.toHaveBeenCalled();
|
||||
expect(standalone.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortcut conflict resolution", () => {
|
||||
it("reports which duplicate bindings shadow and which are shadowed", () => {
|
||||
const defaultAction = actionWithId("plugin:a", "mod+k");
|
||||
const customAction = actionWithId("plugin:z", "mod+j");
|
||||
|
||||
const resolutions = resolveShortcutBindings([defaultAction.value, customAction.value], { "plugin:z": "mod+k" });
|
||||
const defaultResolution = resolutions.find((resolution) => resolution.action.id === "plugin:a");
|
||||
const customResolution = resolutions.find((resolution) => resolution.action.id === "plugin:z");
|
||||
|
||||
expect(customResolution?.active).toBe(true);
|
||||
expect(customResolution?.shadows.map((binding) => binding.action.id)).toEqual(["plugin:a"]);
|
||||
expect(defaultResolution?.active).toBe(false);
|
||||
expect(defaultResolution?.shadowedBy?.action.id).toBe("plugin:z");
|
||||
});
|
||||
|
||||
it("reports sequence bindings shadowed by shorter shortcut prefixes", () => {
|
||||
const standalone = actionWithId("plugin:standalone", "mod+g");
|
||||
const sequence = actionWithId("plugin:sequence", "mod+g p");
|
||||
|
||||
const resolutions = resolveShortcutBindings([sequence.value, standalone.value]);
|
||||
const standaloneResolution = resolutions.find((resolution) => resolution.action.id === "plugin:standalone");
|
||||
const sequenceResolution = resolutions.find((resolution) => resolution.action.id === "plugin:sequence");
|
||||
|
||||
expect(standaloneResolution?.active).toBe(true);
|
||||
expect(standaloneResolution?.shadows.map((binding) => binding.action.id)).toEqual(["plugin:sequence"]);
|
||||
expect(sequenceResolution?.active).toBe(false);
|
||||
expect(sequenceResolution?.shadowedBy?.action.id).toBe("plugin:standalone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortcut input parsing", () => {
|
||||
it("normalizes manually typed shortcuts", () => {
|
||||
expect(parseShortcutInput("Ctrl + Shift + K")).toEqual({ ok: true, shortcut: "mod+shift+k", tokens: ["mod+shift+k"] });
|
||||
expect(parseShortcutInput("cmd+g p")).toEqual({ ok: true, shortcut: "mod+g p", tokens: ["mod+g", "p"] });
|
||||
});
|
||||
|
||||
it("rejects shortcuts that would capture normal typing", () => {
|
||||
expect(parseShortcutInput("r")).toEqual({ ok: false, message: "Shortcuts must start with Ctrl/⌘ or Alt so normal typing is not captured." });
|
||||
expect(parseShortcutInput("shift+r")).toEqual({ ok: false, message: "Shortcuts must start with Ctrl/⌘ or Alt so normal typing is not captured." });
|
||||
});
|
||||
|
||||
it("builds canonical tokens from recorded key events", () => {
|
||||
expect(shortcutTokenFromEvent(keyEvent("K", { metaKey: true, shiftKey: true }))).toBe("mod+shift+k");
|
||||
expect(shortcutTokenFromEvent(keyEvent("ArrowDown", { altKey: true }))).toBe("alt+arrowdown");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { AppAction } from "./actions";
|
||||
|
||||
const sequenceTimeoutMs = 1200;
|
||||
export const shortcutSequenceTimeoutMs = 1200;
|
||||
|
||||
const modifierOrder = ["mod", "alt", "shift"] as const;
|
||||
type ShortcutModifier = typeof modifierOrder[number];
|
||||
|
||||
export type ShortcutPreferenceConfig = Record<string, string | null>;
|
||||
export type ShortcutBindingSource = "default" | "custom";
|
||||
|
||||
export interface ShortcutKeyEvent {
|
||||
key: string;
|
||||
@@ -12,22 +18,58 @@ export interface ShortcutKeyEvent {
|
||||
target: EventTarget | null;
|
||||
}
|
||||
|
||||
export type ShortcutParseResult =
|
||||
| { ok: true; shortcut: string; tokens: string[] }
|
||||
| { ok: false; message: string };
|
||||
|
||||
export interface ShortcutBindingSummary {
|
||||
action: AppAction;
|
||||
shortcut: string;
|
||||
source: ShortcutBindingSource;
|
||||
}
|
||||
|
||||
export interface ShortcutBindingResolution extends ShortcutBindingSummary {
|
||||
tokens: string[];
|
||||
key: string;
|
||||
order: number;
|
||||
active: boolean;
|
||||
shadows: ShortcutBindingSummary[];
|
||||
shadowedBy?: ShortcutBindingSummary;
|
||||
}
|
||||
|
||||
interface ShortcutBinding extends ShortcutBindingSummary {
|
||||
tokens: string[];
|
||||
key: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export class KeyboardShortcutDispatcher {
|
||||
private pendingTokens: string[] = [];
|
||||
private pendingTimer: number | undefined;
|
||||
private pendingTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
handle(event: ShortcutKeyEvent, actions: AppAction[]): boolean {
|
||||
const token = eventToken(event);
|
||||
if (token === undefined || !isModifiedShortcut(token)) return false;
|
||||
handle(event: ShortcutKeyEvent, actions: AppAction[], options: { shortcuts?: ShortcutPreferenceConfig } = {}): boolean {
|
||||
const token = shortcutTokenFromEvent(event);
|
||||
if (token === undefined) return false;
|
||||
|
||||
const shortcuts = actions
|
||||
.filter((action) => action.shortcut !== undefined && action.enabled !== false)
|
||||
.map((action) => ({ action, tokens: normalizeShortcut(action.shortcut ?? "") }))
|
||||
.filter((entry) => entry.tokens.length > 0);
|
||||
const shortcuts = resolveShortcutBindings(actions, options.shortcuts, { enabledOnly: true })
|
||||
.filter((binding) => binding.active)
|
||||
.map((binding) => ({ action: binding.action, tokens: binding.tokens }));
|
||||
|
||||
const sequence = this.pendingTokens.length > 0 && !isModifiedShortcut(token)
|
||||
? [...this.pendingTokens, token]
|
||||
: [token];
|
||||
if (this.pendingTokens.length > 0) {
|
||||
const handledPending = this.handleSequence([...this.pendingTokens, token], shortcuts);
|
||||
if (handledPending) return true;
|
||||
this.clearPending();
|
||||
if (!isShortcutSequenceStarter(token)) return false;
|
||||
} else if (!isShortcutSequenceStarter(token)) return false;
|
||||
|
||||
return this.handleSequence([token], shortcuts);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.clearPending();
|
||||
}
|
||||
|
||||
private handleSequence(sequence: string[], shortcuts: { action: AppAction; tokens: string[] }[]): boolean {
|
||||
const exact = shortcuts.find((entry) => sameTokens(entry.tokens, sequence));
|
||||
if (exact !== undefined) {
|
||||
this.clearPending();
|
||||
@@ -41,51 +83,88 @@ export class KeyboardShortcutDispatcher {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.clearPending();
|
||||
return false;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.clearPending();
|
||||
}
|
||||
|
||||
private setPending(tokens: string[]): void {
|
||||
this.clearPending();
|
||||
this.pendingTokens = tokens;
|
||||
this.pendingTimer = window.setTimeout(() => {
|
||||
this.pendingTimer = globalThis.setTimeout(() => {
|
||||
this.pendingTokens = [];
|
||||
this.pendingTimer = undefined;
|
||||
}, sequenceTimeoutMs);
|
||||
}, shortcutSequenceTimeoutMs);
|
||||
}
|
||||
|
||||
private clearPending(): void {
|
||||
this.pendingTokens = [];
|
||||
if (this.pendingTimer !== undefined) {
|
||||
window.clearTimeout(this.pendingTimer);
|
||||
globalThis.clearTimeout(this.pendingTimer);
|
||||
this.pendingTimer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveShortcutBindings(actions: AppAction[], shortcuts?: ShortcutPreferenceConfig, options: { enabledOnly?: boolean } = {}): ShortcutBindingResolution[] {
|
||||
const bindings = actions.flatMap((action, order) => {
|
||||
if (options.enabledOnly === true && action.enabled === false) return [];
|
||||
const binding = shortcutBindingForAction(action, shortcuts, order);
|
||||
return binding === undefined ? [] : [binding];
|
||||
});
|
||||
const bindingsByKey = new Map<string, ShortcutBinding[]>();
|
||||
for (const binding of bindings) {
|
||||
bindingsByKey.set(binding.key, [...(bindingsByKey.get(binding.key) ?? []), binding]);
|
||||
}
|
||||
|
||||
const exactWinnersByKey = new Map<string, ShortcutBinding>();
|
||||
for (const conflictSet of bindingsByKey.values()) {
|
||||
const winner = [...conflictSet].sort(compareShortcutBindings)[0];
|
||||
if (winner !== undefined) exactWinnersByKey.set(winner.key, winner);
|
||||
}
|
||||
|
||||
const exactWinners = [...exactWinnersByKey.values()].sort(compareShortcutPrefixCandidates);
|
||||
const shadowsByWinner = new Map<ShortcutBinding, ShortcutBinding[]>();
|
||||
const winnerByBinding = new Map<ShortcutBinding, ShortcutBinding>();
|
||||
for (const binding of bindings) {
|
||||
const exactWinner = exactWinnersByKey.get(binding.key);
|
||||
if (exactWinner === undefined) continue;
|
||||
const winner = prefixWinnerFor(exactWinner, exactWinners) ?? exactWinner;
|
||||
winnerByBinding.set(binding, winner);
|
||||
if (binding !== winner) shadowsByWinner.set(winner, [...(shadowsByWinner.get(winner) ?? []), binding]);
|
||||
}
|
||||
|
||||
return bindings.map((binding) => {
|
||||
const winner = winnerByBinding.get(binding);
|
||||
const active = winner === binding;
|
||||
const shadowedBy = winner === undefined || active ? undefined : shortcutBindingSummary(winner);
|
||||
const shadows = active ? [...(shadowsByWinner.get(binding) ?? [])].sort(compareShortcutBindings).map(shortcutBindingSummary) : [];
|
||||
return {
|
||||
...binding,
|
||||
active,
|
||||
shadows,
|
||||
...(shadowedBy === undefined ? {} : { shadowedBy }),
|
||||
};
|
||||
}).sort((left, right) => left.order - right.order);
|
||||
}
|
||||
|
||||
export function parseShortcutInput(shortcut: string): ShortcutParseResult {
|
||||
return parseShortcut(shortcut, { requireFirstChordActivator: true });
|
||||
}
|
||||
|
||||
export function normalizeShortcut(shortcut: string): string[] {
|
||||
const parsed = parseShortcut(shortcut, { requireFirstChordActivator: false });
|
||||
return parsed.ok ? parsed.tokens : [];
|
||||
}
|
||||
|
||||
export function formatShortcut(shortcut: string): string {
|
||||
return normalizeShortcut(shortcut)
|
||||
.map((token) => token
|
||||
.split("+")
|
||||
.map((part) => {
|
||||
if (part === "mod") return isMac() ? "⌘" : "Ctrl";
|
||||
if (part === "shift") return "Shift";
|
||||
if (part === "alt") return isMac() ? "⌥" : "Alt";
|
||||
if (part === "ctrl") return "Ctrl";
|
||||
if (part === "enter") return "Enter";
|
||||
if (part === "escape") return "Esc";
|
||||
if (part === ".") return ".";
|
||||
return part.length === 1 ? part.toUpperCase() : `${part.charAt(0).toUpperCase()}${part.slice(1)}`;
|
||||
})
|
||||
.map(formatShortcutPart)
|
||||
.join("+"))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function eventToken(event: ShortcutKeyEvent): string | undefined {
|
||||
export function shortcutTokenFromEvent(event: ShortcutKeyEvent): string | undefined {
|
||||
if (event.isComposing) return undefined;
|
||||
const key = normalizeKey(event.key);
|
||||
if (key === undefined) return undefined;
|
||||
@@ -97,21 +176,228 @@ function eventToken(event: ShortcutKeyEvent): string | undefined {
|
||||
return modifiers.join("+");
|
||||
}
|
||||
|
||||
function normalizeShortcut(shortcut: string): string[] {
|
||||
return shortcut
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/\s+/u)
|
||||
.filter((token) => token !== "")
|
||||
.map((token) => token.split("+").filter((part) => part !== "").join("+"));
|
||||
export function isShortcutSequenceStarter(token: string): boolean {
|
||||
return token.split("+").includes("mod") || token.split("+").includes("alt");
|
||||
}
|
||||
|
||||
function shortcutBindingForAction(action: AppAction, shortcuts: ShortcutPreferenceConfig | undefined, order: number): ShortcutBinding | undefined {
|
||||
const configured = shortcutPreference(action.id, shortcuts);
|
||||
if (configured === null) return undefined;
|
||||
const shortcut = configured ?? action.shortcut;
|
||||
if (shortcut === undefined || shortcut === "") return undefined;
|
||||
const tokens = normalizeShortcut(shortcut);
|
||||
const firstToken = tokens[0];
|
||||
if (firstToken === undefined || !isShortcutSequenceStarter(firstToken)) return undefined;
|
||||
return {
|
||||
action,
|
||||
shortcut: tokens.join(" "),
|
||||
source: configured === undefined ? "default" : "custom",
|
||||
tokens,
|
||||
key: shortcutBindingKey(tokens),
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
function shortcutPreference(actionId: string, shortcuts: ShortcutPreferenceConfig | undefined): string | null | undefined {
|
||||
if (shortcuts === undefined || !Object.hasOwn(shortcuts, actionId)) return undefined;
|
||||
return shortcuts[actionId];
|
||||
}
|
||||
|
||||
function shortcutBindingKey(tokens: string[]): string {
|
||||
return tokens.join("\u0000");
|
||||
}
|
||||
|
||||
function shortcutBindingSummary(binding: ShortcutBinding): ShortcutBindingSummary {
|
||||
return { action: binding.action, shortcut: binding.shortcut, source: binding.source };
|
||||
}
|
||||
|
||||
function compareShortcutBindings(left: ShortcutBinding, right: ShortcutBinding): number {
|
||||
return shortcutSourceRank(left.source) - shortcutSourceRank(right.source)
|
||||
|| compareStrings(left.action.id, right.action.id)
|
||||
|| compareStrings(left.action.title, right.action.title)
|
||||
|| left.order - right.order;
|
||||
}
|
||||
|
||||
function compareShortcutPrefixCandidates(left: ShortcutBinding, right: ShortcutBinding): number {
|
||||
return left.tokens.length - right.tokens.length || compareShortcutBindings(left, right);
|
||||
}
|
||||
|
||||
function prefixWinnerFor(binding: ShortcutBinding, exactWinners: ShortcutBinding[]): ShortcutBinding | undefined {
|
||||
return exactWinners.find((candidate) => candidate !== binding && candidate.tokens.length < binding.tokens.length && startsWithTokens(binding.tokens, candidate.tokens));
|
||||
}
|
||||
|
||||
function shortcutSourceRank(source: ShortcutBindingSource): number {
|
||||
switch (source) {
|
||||
case "custom": return 0;
|
||||
case "default": return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function compareStrings(left: string, right: string): number {
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseShortcut(shortcut: string, options: { requireFirstChordActivator: boolean }): ShortcutParseResult {
|
||||
const cleaned = shortcut.trim().toLowerCase().replace(/\s*\+\s*/gu, "+");
|
||||
if (cleaned === "") return { ok: false, message: "Enter a shortcut, choose None, or reset to the default." };
|
||||
|
||||
const tokens: string[] = [];
|
||||
const chordInputs = cleaned.split(/\s+/u).filter((token) => token !== "");
|
||||
for (const [index, chordInput] of chordInputs.entries()) {
|
||||
const parsed = parseShortcutChord(chordInput);
|
||||
if (!parsed.ok) return parsed;
|
||||
if (index === 0 && options.requireFirstChordActivator && !isShortcutSequenceStarter(parsed.token)) {
|
||||
return { ok: false, message: "Shortcuts must start with Ctrl/⌘ or Alt so normal typing is not captured." };
|
||||
}
|
||||
tokens.push(parsed.token);
|
||||
}
|
||||
|
||||
return { ok: true, shortcut: tokens.join(" "), tokens };
|
||||
}
|
||||
|
||||
type ShortcutChordParseResult =
|
||||
| { ok: true; token: string }
|
||||
| { ok: false; message: string };
|
||||
|
||||
function parseShortcutChord(chord: string): ShortcutChordParseResult {
|
||||
const parts = chord.split("+").filter((part) => part !== "");
|
||||
if (parts.length === 0) return { ok: false, message: "Shortcut chords must include a key." };
|
||||
|
||||
const modifiers = new Set<ShortcutModifier>();
|
||||
let key: string | undefined;
|
||||
for (const part of parts) {
|
||||
const modifier = modifierAlias(part);
|
||||
if (modifier !== undefined) {
|
||||
if (modifiers.has(modifier)) return { ok: false, message: `Shortcut has duplicate ${formatShortcutPart(modifier)} modifiers.` };
|
||||
modifiers.add(modifier);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedKey = normalizeShortcutKeyName(part);
|
||||
if (normalizedKey === undefined) return { ok: false, message: `Unsupported shortcut key: ${part}` };
|
||||
if (key !== undefined) return { ok: false, message: "Each shortcut chord can include only one non-modifier key." };
|
||||
key = normalizedKey;
|
||||
}
|
||||
|
||||
if (key === undefined) return { ok: false, message: "Shortcut chords must include a key." };
|
||||
|
||||
const orderedModifiers = modifierOrder.filter((modifier) => modifiers.has(modifier));
|
||||
return { ok: true, token: [...orderedModifiers, key].join("+") };
|
||||
}
|
||||
|
||||
function modifierAlias(part: string): ShortcutModifier | undefined {
|
||||
switch (part) {
|
||||
case "mod":
|
||||
case "meta":
|
||||
case "cmd":
|
||||
case "command":
|
||||
case "ctrl":
|
||||
case "control":
|
||||
case "primary":
|
||||
return "mod";
|
||||
case "alt":
|
||||
case "option":
|
||||
case "opt":
|
||||
return "alt";
|
||||
case "shift":
|
||||
return "shift";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeShortcutKeyName(key: string): string | undefined {
|
||||
const alias = keyAlias(key);
|
||||
if (alias !== undefined) return alias;
|
||||
if (/^f(?:[1-9]|1[0-9]|2[0-4])$/u.test(key)) return key;
|
||||
if (key.length === 1) return key;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeKey(key: string): string | undefined {
|
||||
if (key === " ") return "space";
|
||||
if (key.length === 1) return key.toLowerCase();
|
||||
const normalized = key.toLowerCase();
|
||||
if (["enter", "escape", "tab", "arrowup", "arrowdown", "arrowleft", "arrowright", "backspace", "delete"].includes(normalized)) return normalized;
|
||||
return undefined;
|
||||
return normalizeShortcutKeyName(normalized);
|
||||
}
|
||||
|
||||
function keyAlias(key: string): string | undefined {
|
||||
switch (key) {
|
||||
case " ":
|
||||
case "spacebar":
|
||||
case "space":
|
||||
return "space";
|
||||
case "esc":
|
||||
case "escape":
|
||||
return "escape";
|
||||
case "return":
|
||||
case "enter":
|
||||
return "enter";
|
||||
case "del":
|
||||
case "delete":
|
||||
return "delete";
|
||||
case "backspace":
|
||||
return "backspace";
|
||||
case "tab":
|
||||
return "tab";
|
||||
case "up":
|
||||
case "arrowup":
|
||||
return "arrowup";
|
||||
case "down":
|
||||
case "arrowdown":
|
||||
return "arrowdown";
|
||||
case "left":
|
||||
case "arrowleft":
|
||||
return "arrowleft";
|
||||
case "right":
|
||||
case "arrowright":
|
||||
return "arrowright";
|
||||
case "pageup":
|
||||
case "pagedown":
|
||||
case "home":
|
||||
case "end":
|
||||
return key;
|
||||
case "+":
|
||||
case "plus":
|
||||
return "plus";
|
||||
case "period":
|
||||
case "dot":
|
||||
return ".";
|
||||
case "comma":
|
||||
return ",";
|
||||
case "slash":
|
||||
return "/";
|
||||
case "backslash":
|
||||
return "\\";
|
||||
case "minus":
|
||||
return "-";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatShortcutPart(part: string): string {
|
||||
if (part === "mod") return isMac() ? "⌘" : "Ctrl";
|
||||
if (part === "shift") return "Shift";
|
||||
if (part === "alt") return isMac() ? "⌥" : "Alt";
|
||||
if (part === "enter") return "Enter";
|
||||
if (part === "escape") return "Esc";
|
||||
if (part === "space") return "Space";
|
||||
if (part === "tab") return "Tab";
|
||||
if (part === "backspace") return "Backspace";
|
||||
if (part === "delete") return "Delete";
|
||||
if (part === "arrowup") return "↑";
|
||||
if (part === "arrowdown") return "↓";
|
||||
if (part === "arrowleft") return "←";
|
||||
if (part === "arrowright") return "→";
|
||||
if (part === "pageup") return "PageUp";
|
||||
if (part === "pagedown") return "PageDown";
|
||||
if (part === "home") return "Home";
|
||||
if (part === "end") return "End";
|
||||
if (part === "plus") return "+";
|
||||
if (/^f(?:[1-9]|1[0-9]|2[0-4])$/u.test(part)) return part.toUpperCase();
|
||||
return part.length === 1 ? part.toUpperCase() : `${part.charAt(0).toUpperCase()}${part.slice(1)}`;
|
||||
}
|
||||
|
||||
function sameTokens(left: string[], right: string[]): boolean {
|
||||
@@ -122,10 +408,6 @@ function startsWithTokens(tokens: string[], prefix: string[]): boolean {
|
||||
return prefix.every((token, index) => tokens[index] === token);
|
||||
}
|
||||
|
||||
function isModifiedShortcut(token: string): boolean {
|
||||
return token.includes("+");
|
||||
}
|
||||
|
||||
function isMac(): boolean {
|
||||
return navigator.userAgent.toLowerCase().includes("mac");
|
||||
return typeof navigator !== "undefined" && navigator.userAgent.toLowerCase().includes("mac");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user