Merge remote-tracking branch 'origin/main' into feat/docker-runtime-host-admin

# Conflicts:
#	src/client/src/api.ts
#	src/client/src/api/parsers.test.ts
#	src/shared/piWebStatusParsing.test.ts
This commit is contained in:
Pi Web Agent
2026-07-02 20:52:50 +00:00
155 changed files with 11092 additions and 1306 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Improve bulk session archive and delete reliability by adding true bulk mutation support for large session selections.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Manage Pi packages from Settings on the selected PI WEB machine, including federated remote machines, while keeping gateway-local Settings scopes clear.
@@ -1,9 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Fix `pi-web doctor` "can find npm/pi" checks on fish. The `--version` check
wrapped the version command in a POSIX subshell `(cmd --version 2>&1 || true)`,
which fish parses as a command substitution in command position and rejects
(`command substitutions not allowed in command position`), producing a false
negative. Emit fish's `begin; ...; end` grouping when the service shell is fish.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Improve session-start feedback so concurrent new sessions stay visible without disrupting session-list navigation.
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Highlight within-line changes in the Git diff viewer.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Show full chat tool file paths and commands in horizontally scrollable headers, repeat them in expanded tool details, and keep result output horizontally scrollable.
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Make spawned and tracked subsessions inherit the dispatching session's current model instead of falling back to the last globally selected model.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add PI WEB settings for managing Pi packages separately from PI WEB plugin enablement, including install/remove/update flows and browser/session reload guidance.
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Add manual Files panel uploads with direct drag/drop, an options flow from the Upload button, safe non-overwrite defaults, visible per-file progress/error reporting with clear failed/cancelled terminal states, and project-local default destinations.
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Persist tracked subsession links in session history so parents can list, check, and read child sessions after the session daemon restarts, and reopened children can resume parent notifications.
-5
View File
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Add workspace file mutation (`files.writeFile`, `files.deleteFile`, `files.moveFile`) and prompt editor (`prompt.insertText`, `prompt.getText`, `prompt.getSelection`) APIs to the plugin system. File mutations work for local and federated machines, enforce workspace path safety, and auto-refresh the File Explorer.
@@ -1,5 +0,0 @@
---
"@jmfederico/pi-web": patch
---
Expose the plugin prompt editor helper in workspace panel contexts so panel interactions can insert text into the current prompt.
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Keep the chat prompt input stable during streaming so mobile touch gestures (such as the iOS long-press paste/edit callout) are no longer interrupted. Session status and activity updates are now coalesced into a single render per animation frame instead of one per token, the prompt editor ignores status changes that do not affect what it displays, and per-keystroke draft state no longer triggers surrounding re-renders.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Prevent iOS Safari from zooming into small text inputs across the web UI.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Raise the minimum supported Pi version to 0.80, removing reliance on Pi's deprecated `pi-ai` compat API for session-name generation in favor of the stable `pi-agent-core` streaming interface.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Promote the Updates tab to stable by removing its beta label while keeping update message counts visible.
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Keep gateway Settings panels responsive while selected-machine Pi packages load or fail separately, report Pi package-management support through runtime capabilities, guide remote Pi package-management UI when support is known unavailable, and serialize Pi package mutations to avoid concurrent settings/install-root races within a PI WEB server process.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Make Settings edit machine-scoped PI WEB config on the selected machine for session daemon tools, plugin enablement, external path access, and upload defaults while keeping gateway/browser-only settings local, and show those forms as unavailable when a remote machine does not advertise support.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Show the random-looking suffix for unnamed sessions so newly created empty sessions are easier to distinguish.
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add `/reload` support for PI WEB sessions so installed Pi package resources can be refreshed in existing sessions without restarting the session daemon, while keeping browser plugin reload guidance separate.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Create editable chats immediately when starting sessions, open the chat right away on mobile, queue sends until the backend session is ready, reconcile concurrent session-created broadcasts, and use server-backed persistence signals for session archive/delete/reload actions.
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Standardize Settings tabs so descriptions, notices, and controls render in a consistent order, with unavailable remote settings hiding blocked controls.
+19
View File
@@ -1,5 +1,24 @@
# @jmfederico/pi-web # @jmfederico/pi-web
## 1.202606.7
### Patch Changes
- b17faeb: Improve chat, prompt, and session text rendering for RTL and mixed-direction content.
- 7e812aa: Allow chat composer attachments to save and mention general files while preserving native inline image delivery for supported image-only batches.
- 47c9b66: Fix `pi-web doctor` "can find npm/pi" checks on fish. The `--version` check
wrapped the version command in a POSIX subshell `(cmd --version 2>&1 || true)`,
which fish parses as a command substitution in command position and rejects
(`command substitutions not allowed in command position`), producing a false
negative. Emit fish's `begin; ...; end` grouping when the service shell is fish.
- b14205e: Highlight within-line changes in the Git diff viewer.
- cb13af4: Add a manual sessions cleanup flow that previews and confirms archiving idle sessions and deleting old archived sessions, with per-project selection and capability guidance for unsupported machines. Actions can now expose disabled reasons so unavailable remote-machine actions stay visible with an explanation.
- e46d9ec: Add manual Files panel uploads with direct drag/drop, an options flow from the Upload button, safe non-overwrite defaults, visible per-file progress/error reporting with clear failed/cancelled terminal states, and project-local default destinations.
- 32ea809: Add a Keyboard shortcuts setting for choosing whether Enter sends chat messages or inserts new lines in this browser, with Shift+Enter performing the opposite action when supported, while preserving the desktop-vs-mobile default (desktop Enter sends; mobile/coarse/narrow Enter inserts a new line).
- a99696b: Persist tracked subsession links in session history so parents can list, check, and read child sessions after the session daemon restarts, and reopened children can resume parent notifications.
- 27a3b2b: Add workspace file mutation (`files.writeFile`, `files.deleteFile`, `files.moveFile`) and prompt editor (`prompt.insertText`, `prompt.getText`, `prompt.getSelection`) APIs to the plugin system. File mutations work for local and federated machines, enforce workspace path safety, and auto-refresh the File Explorer.
- 9980027: Expose the plugin prompt editor helper in workspace panel contexts so panel interactions can insert text into the current prompt.
## 1.202606.6 ## 1.202606.6
### Patch Changes ### Patch Changes
+9 -3
View File
@@ -111,13 +111,19 @@ Read more: [Remote-first development](https://pi-web.dev/remote-first)
## Machines and fleets ## Machines and fleets
PI WEB can register other PI WEB runtimes as remote machines. One browser-facing PI WEB instance can proxy projects, files, git state, sessions, terminals, and activity from trusted remote machines. PI WEB can register other PI WEB runtimes as remote machines. One browser-facing PI WEB instance can proxy projects, files, git state, sessions, terminals, activity, Pi package management, and selected-machine settings from trusted remote machines.
When a remote machine is selected, Settings tabs label their target. Pi packages, PI WEB plugin enablement, session daemon toggles, external file access, and upload defaults target the selected machine. Gateway/server settings such as host, port, allowed hosts, registered machines/tokens, and keyboard shortcuts stay local to the gateway/browser.
Read more: [Fleet and machines guide](https://pi-web.dev/machines) Read more: [Fleet and machines guide](https://pi-web.dev/machines)
## Plugins ## Plugins
PI WEB supports trusted local browser-side plugins that can add actions, workspace panels, and workspace metadata. PI WEB supports trusted browser-side PI WEB plugins that can add actions, workspace panels, and workspace metadata.
Pi packages are managed separately through Pi's package manager or **Settings → Pi packages**. In a federated setup, the Pi packages panel targets the selected machine and labels where installs, updates, or removals will run. Use **Settings → PI WEB plugins** to enable or disable discovered browser plugins on the selected machine.
After installing, updating, or removing a Pi package, type `/reload` in each idle PI WEB session on that machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for newly discovered or changed PI WEB plugins.
Read more: [Plugin API](https://pi-web.dev/plugins) Read more: [Plugin API](https://pi-web.dev/plugins)
@@ -136,7 +142,7 @@ Project-local PI WEB config lives at:
<project>/.pi-web/config.json <project>/.pi-web/config.json
``` ```
Common configuration includes host/port, path access, uploads, plugins, shortcuts, and session daemon options. Common configuration includes host/port, path access, uploads, PI WEB plugin enablement, shortcuts, and session daemon options. In Settings, machine-affecting config targets the selected machine; gateway host/port/allowed-hosts, remote machine registration, tokens, and keyboard shortcuts stay local.
Read more: [Configuration reference](https://pi-web.dev/config) Read more: [Configuration reference](https://pi-web.dev/config)
+107 -27
View File
@@ -3,10 +3,10 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Configure PI WEB — config files, paths, and session tools</title> <title>Configure PI WEB — config files, uploads, paths, and session tools</title>
<meta <meta
name="description" name="description"
content="Configure PI WEB config files, external path access, session daemon tools, plugins, shortcuts, uploads, and runtime environment variables." content="Configure PI WEB config files, external path access, manual upload defaults, session daemon tools, plugins, shortcuts, and runtime environment variables."
/> />
<link rel="canonical" href="https://pi-web.dev/config" /> <link rel="canonical" href="https://pi-web.dev/config" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
@@ -14,7 +14,7 @@
<meta property="og:title" content="Configure PI WEB" /> <meta property="og:title" content="Configure PI WEB" />
<meta <meta
property="og:description" property="og:description"
content="Reference for PI WEB config files, path access allowlists, session daemon options, plugins, shortcuts, uploads, and environment variables." content="Reference for PI WEB config files, path access allowlists, manual upload defaults, session daemon options, plugins, shortcuts, and environment variables."
/> />
<meta property="og:url" content="https://pi-web.dev/config" /> <meta property="og:url" content="https://pi-web.dev/config" />
<meta property="og:image" content="https://pi-web.dev/assets/pi-web-banner.png" /> <meta property="og:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
@@ -23,7 +23,7 @@
<meta name="twitter:title" content="Configure PI WEB" /> <meta name="twitter:title" content="Configure PI WEB" />
<meta <meta
name="twitter:description" name="twitter:description"
content="Reference for PI WEB config files, path access allowlists, session daemon options, plugins, shortcuts, uploads, and environment variables." content="Reference for PI WEB config files, path access allowlists, manual upload defaults, session daemon options, plugins, shortcuts, and environment variables."
/> />
<meta name="twitter:image" content="https://pi-web.dev/assets/pi-web-banner.png" /> <meta name="twitter:image" content="https://pi-web.dev/assets/pi-web-banner.png" />
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
@@ -80,8 +80,8 @@
<h1>Configure PI WEB where your agents work.</h1> <h1>Configure PI WEB where your agents work.</h1>
<p> <p>
PI WEB configuration covers the machine-local and project-local settings you usually need: bind address, PI WEB configuration covers the machine-local and project-local settings you usually need: bind address,
trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, upload trusted development-host settings, UI preferences, PI WEB plugin enablement, file-explorer path access,
limits, and session-daemon tools. manual upload defaults, upload limits, and session-daemon tools.
</p> </p>
</div> </div>
</section> </section>
@@ -96,6 +96,7 @@
<a href="#project-config">Project config</a> <a href="#project-config">Project config</a>
<a href="#keys">Config matrix</a> <a href="#keys">Config matrix</a>
<a href="#path-access">External path access</a> <a href="#path-access">External path access</a>
<a href="#manual-uploads">Manual uploads</a>
<a href="#session-tools">Session tools</a> <a href="#session-tools">Session tools</a>
<a href="#completion-tools">Completion tools</a> <a href="#completion-tools">Completion tools</a>
</aside> </aside>
@@ -109,8 +110,20 @@
<li><strong>Project config:</strong> <code>&lt;project&gt;/.pi-web/config.json</code> for commit-able project settings.</li> <li><strong>Project config:</strong> <code>&lt;project&gt;/.pi-web/config.json</code> for commit-able project settings.</li>
</ul> </ul>
<p> <p>
Each PI WEB machine has its own config. When using Fleet/machine federation, edit a remote machine's Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected
config by opening that machine directly or changing files on that machine. machine for config that affects work running there: session daemon tools, PI WEB plugin enablement,
external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway:
keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers
that do not advertise selected-machine settings support report those settings as unavailable instead of
silently falling back to the gateway.
</p>
<p>
Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings on the
target machine and are managed by Pi (<code>pi install</code>, <code>pi remove</code>, <code>pi update</code>) or
<strong>Settings → Pi packages</strong>. In a federated setup, <strong>Settings → Pi packages</strong>
targets the currently selected machine. The PI WEB <code>plugins</code> config key only enables or
disables discovered PI WEB browser plugins on the machine whose config you are editing; it does not
install, remove, or update Pi packages.
</p> </p>
<p> <p>
If you installed services with a custom config path, rerun If you installed services with a custom config path, rerun
@@ -122,21 +135,27 @@
<section id="precedence"> <section id="precedence">
<h2>Precedence and reloads</h2> <h2>Precedence and reloads</h2>
<p>Runtime values are resolved in this order:</p> <p>Machine-global runtime values are resolved in this order:</p>
<div class="code-card"> <div class="code-card">
<pre><code>defaults → config file → environment overrides</code></pre> <pre><code>defaults → global config file → environment overrides</code></pre>
</div> </div>
<p>
Supported project-local settings are then applied for that project's workspaces. For upload defaults,
<code>&lt;project&gt;/.pi-web/config.json</code> overrides the global value.
</p>
<p> <p>
Environment overrides include <code>PI_WEB_HOST</code>, <code>PI_WEB_PORT</code> / <code>PORT</code>, Environment overrides include <code>PI_WEB_HOST</code>, <code>PI_WEB_PORT</code> / <code>PORT</code>,
<code>PI_WEB_ALLOWED_HOSTS</code>, <code>PI_WEB_MAX_UPLOAD_BYTES</code>, <code>PI_WEB_SPAWN_SESSIONS</code>, <code>PI_WEB_ALLOWED_HOSTS</code>, <code>PI_WEB_MAX_UPLOAD_BYTES</code>, <code>PI_WEB_SPAWN_SESSIONS</code>,
and <code>PI_WEB_SUBSESSIONS</code>. and <code>PI_WEB_SUBSESSIONS</code>.
</p> </p>
<ul> <ul>
<li><code>host</code> / <code>port</code>: restart the web/API service or process.</li> <li><code>host</code> / <code>port</code>: restart the gateway web/API service or process.</li>
<li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon.</li> <li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon on that machine.</li>
<li><code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon.</li> <li><code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon on that machine.</li>
<li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li> <li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li>
<li><code>plugins</code>: reload the browser tab after changing plugin enablement.</li> <li><code>uploads.defaultFolder</code>: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.</li>
<li><code>plugins</code>: reload the browser tab after changing PI WEB plugin enablement.</li>
<li>Pi package install/remove/update: not a PI WEB config key; after a mutation, type <code>/reload</code> in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required.</li>
<li><code>shortcuts</code>: saved settings apply in the browser after config refresh/save.</li> <li><code>shortcuts</code>: saved settings apply in the browser after config refresh/save.</li>
</ul> </ul>
</section> </section>
@@ -144,9 +163,11 @@
<section id="global-config"> <section id="global-config">
<h2>Global config example</h2> <h2>Global config example</h2>
<p> <p>
<code>pi-web install</code> creates the initial file. You can also save settings from <code>pi-web install</code> creates the initial file. You can also save PI WEB config settings from
<strong>Settings → General</strong>, <strong>Settings → Plugins</strong>, <strong>Settings → Keyboard</strong>, <strong>Settings → General</strong>, <strong>Settings → PI WEB plugins</strong>,
and <strong>Settings → Session daemon</strong>. <strong>Settings → Keyboard</strong>, and <strong>Settings → Session daemon</strong>. Machine-affecting
Settings fields target the selected machine; gateway host/port/allowed-hosts and keyboard shortcuts stay
local. Pi package operations live separately under <strong>Settings → Pi packages</strong>.
</p> </p>
<div class="code-card"> <div class="code-card">
<div class="copy-row"> <div class="copy-row">
@@ -159,6 +180,9 @@
"pathAccess": { "pathAccess": {
"allowedPaths": ["~/SDKs", "/opt/reference"] "allowedPaths": ["~/SDKs", "/opt/reference"]
}, },
"uploads": {
"defaultFolder": ".pi-web/uploads"
},
"maxUploadBytes": 67108864, "maxUploadBytes": 67108864,
"spawnSessions": true, "spawnSessions": true,
"subsessions": false, "subsessions": false,
@@ -179,8 +203,7 @@
<h2>Project-local config</h2> <h2>Project-local config</h2>
<p> <p>
Project-local config lives at <code>&lt;project&gt;/.pi-web/config.json</code>. Use it for settings that should Project-local config lives at <code>&lt;project&gt;/.pi-web/config.json</code>. Use it for settings that should
follow a repository. When a project config defines <code>pathAccess</code>, PI WEB merges it after the follow a repository.
global path list.
</p> </p>
<div class="code-card"> <div class="code-card">
<div class="copy-row"> <div class="copy-row">
@@ -191,13 +214,25 @@
"version": 1, "version": 1,
"pathAccess": { "pathAccess": {
"allowedPaths": ["~/SDKs", "/opt/reference"] "allowedPaths": ["~/SDKs", "/opt/reference"]
},
"uploads": {
"defaultFolder": "manual/uploads"
} }
}</code></pre> }</code></pre>
</div> </div>
<p> <p>
Project-local <code>pathAccess.allowedPaths</code> entries must still be host-absolute or Project-local <code>pathAccess.allowedPaths</code> entries are merged after the global list and deduplicated.
<code>~</code>-prefixed; relative roots are not supported. Plugins may own separate project files, such as Paths must still be host-absolute or <code>~</code>-prefixed; relative roots are not supported.
<code>.pi-web/tasks.json</code> for the built-in Workspace Tasks plugin. </p>
<p>
Project-local <code>uploads.defaultFolder</code> overrides the global upload destination for workspaces in
that project. Current PI WEB servers include this workspace-effective value on local and federated
workspace responses; older remote servers may omit it and the browser falls back to the global/default
upload folder.
</p>
<p>
Plugins may own separate project files, such as <code>.pi-web/tasks.json</code> for the built-in Workspace
Tasks plugin.
</p> </p>
</section> </section>
@@ -206,7 +241,11 @@
<p> <p>
Use this table as the quick reference for where a setting can live, which environment variable overrides Use this table as the quick reference for where a setting can live, which environment variable overrides
it, and whether project-local config overrides or merges with global config. Rows with JSON key it, and whether project-local config overrides or merges with global config. Rows with JSON key
<code></code> are runtime-only environment variables, not config-file keys. <code></code> are runtime-only environment variables, not config-file keys. <code>Global</code> means
machine-global. In Settings, selected-machine-safe global keys (<code>pathAccess</code>, <code>uploads</code>,
<code>maxUploadBytes</code>, <code>spawnSessions</code>, <code>subsessions</code>, and <code>plugins</code>)
are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine
registry/tokens stay local.
</p> </p>
<div class="table-scroll" role="region" aria-label="PI WEB configuration matrix" tabindex="0"> <div class="table-scroll" role="region" aria-label="PI WEB configuration matrix" tabindex="0">
<table class="config-matrix"> <table class="config-matrix">
@@ -254,13 +293,21 @@
<td><strong>Merges:</strong> global roots first, then project roots; duplicates removed</td> <td><strong>Merges:</strong> global roots first, then project roots; duplicates removed</td>
<td>Next file request; refresh existing views if needed</td> <td>Next file request; refresh existing views if needed</td>
</tr> </tr>
<tr>
<td>Manual file upload default folder</td>
<td><code>uploads.defaultFolder</code></td>
<td></td>
<td>Global + project</td>
<td><strong>Overrides:</strong> project value wins for workspaces in that project; otherwise global/default applies</td>
<td>New Upload dialogs and direct drag/drop batches after config/workspace refresh</td>
</tr>
<tr> <tr>
<td>Upload/body limit</td> <td>Upload/body limit</td>
<td><code>maxUploadBytes</code></td> <td><code>maxUploadBytes</code></td>
<td><code>PI_WEB_MAX_UPLOAD_BYTES</code></td> <td><code>PI_WEB_MAX_UPLOAD_BYTES</code></td>
<td>Global</td> <td>Global</td>
<td>Not supported locally</td> <td>Not supported locally</td>
<td>Restart web/API and session daemon</td> <td>Restart web/API and session daemon on that machine</td>
</tr> </tr>
<tr> <tr>
<td>Agent can spawn sessions</td> <td>Agent can spawn sessions</td>
@@ -268,7 +315,7 @@
<td><code>PI_WEB_SPAWN_SESSIONS</code></td> <td><code>PI_WEB_SPAWN_SESSIONS</code></td>
<td>Global/session daemon</td> <td>Global/session daemon</td>
<td>Not supported locally</td> <td>Not supported locally</td>
<td>Restart session daemon</td> <td>Restart session daemon on that machine</td>
</tr> </tr>
<tr> <tr>
<td>Tracked subsessions (beta)</td> <td>Tracked subsessions (beta)</td>
@@ -276,10 +323,10 @@
<td><code>PI_WEB_SUBSESSIONS</code></td> <td><code>PI_WEB_SUBSESSIONS</code></td>
<td>Global/session daemon</td> <td>Global/session daemon</td>
<td>Not supported locally; also requires <code>spawnSessions</code></td> <td>Not supported locally; also requires <code>spawnSessions</code></td>
<td>Restart session daemon</td> <td>Restart session daemon on that machine</td>
</tr> </tr>
<tr> <tr>
<td>Plugin enablement/settings</td> <td>PI WEB plugin enablement/settings</td>
<td><code>plugins.&lt;id&gt;.enabled</code>, <code>plugins.&lt;id&gt;.settings</code></td> <td><code>plugins.&lt;id&gt;.enabled</code>, <code>plugins.&lt;id&gt;.settings</code></td>
<td></td> <td></td>
<td>Global</td> <td>Global</td>
@@ -414,12 +461,41 @@
<code>realpath</code>, requires roots to be existing directories, and rejects symlink escapes outside the <code>realpath</code>, requires roots to be existing directories, and rejects symlink escapes outside the
allowed roots. allowed roots.
</p> </p>
<p>
In <strong>Settings → General</strong>, external filesystem roots are saved on the selected machine.
Gateway host, port, and allowed-hosts fields stay on the gateway config.
</p>
<div class="callout warning"> <div class="callout warning">
This is not a sandbox for the underlying Pi Coding Agent or your OS user. It only controls PI WEB UI/API This is not a sandbox for the underlying Pi Coding Agent or your OS user. It only controls PI WEB UI/API
file exposure outside a workspace. Add only roots you trust PI WEB to list and read through the browser UI. file exposure outside a workspace. Add only roots you trust PI WEB to list and read through the browser UI.
</div> </div>
</section> </section>
<section id="manual-uploads">
<h2>Manual upload defaults</h2>
<p>
The Files panel can upload files by dropping them onto the panel or by using the toolbar
<strong>Upload</strong> button. <code>uploads.defaultFolder</code> sets the workspace-effective default
destination. The built-in default is <code>.pi-web/uploads</code>; a project-local value overrides the
global value for workspaces in that project.
</p>
<p>
The value must be a non-empty workspace-relative folder. PI WEB normalizes repeated separators and
backslashes to <code>/</code>, and rejects absolute paths or <code>..</code> traversal. In the upload
dialog only, clearing the destination field uploads that batch to the workspace root.
</p>
<p>
Manual uploads use the workspace file-write path: paths stay workspace-relative, parent folder creation is
enabled by default, and overwrite is disabled by default. Browser-owned XHR progress is shown per
batch/file, and conflicts or errors stay visible in the upload progress UI.
</p>
<p>
For machine federation, Settings saves the global upload default on the selected machine. Current remote
PI WEB servers also return workspace-effective upload defaults on workspace responses; older remote
servers may omit them and the browser falls back to the global/default upload folder.
</p>
</section>
<section id="session-tools"> <section id="session-tools">
<h2>Session daemon tools</h2> <h2>Session daemon tools</h2>
<h3><code>spawnSessions</code></h3> <h3><code>spawnSessions</code></h3>
@@ -440,6 +516,10 @@
Tracked subsessions let an agent delegate work to child sessions, get notified when children stop Tracked subsessions let an agent delegate work to child sessions, get notified when children stop
working, and inspect their transcripts. Restart the session daemon after changing this setting. working, and inspect their transcripts. Restart the session daemon after changing this setting.
</p> </p>
<p>
In <strong>Settings → Session daemon</strong>, these keys are saved on the selected machine. Restart the
session daemon on that machine after changing them.
</p>
<p>Environment override: <code>PI_WEB_SUBSESSIONS=0|1|true|false</code>.</p> <p>Environment override: <code>PI_WEB_SUBSESSIONS=0|1|true|false</code>.</p>
</section> </section>
+21 -12
View File
@@ -11,7 +11,9 @@ PI WEB uses two config files:
- **Global PI WEB config:** `$PI_WEB_CONFIG`, or `$XDG_CONFIG_HOME/pi-web/config.json`, or `~/.config/pi-web/config.json`. - **Global PI WEB config:** `$PI_WEB_CONFIG`, or `$XDG_CONFIG_HOME/pi-web/config.json`, or `~/.config/pi-web/config.json`.
- **Project-local PI WEB config:** `<project>/.pi-web/config.json` for commit-able project settings. - **Project-local PI WEB config:** `<project>/.pi-web/config.json` for commit-able project settings.
Each PI WEB machine has its own config. When using Fleet/machine federation, edit a remote machine's config by opening that machine directly or changing files on that machine. Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected machine for config that affects work running there: session daemon tools, PI WEB plugin enablement, external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway: keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers that do not advertise selected-machine settings support report those settings as unavailable instead of silently falling back to the gateway.
Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings on the target machine and are managed by Pi (`pi install`, `pi remove`, `pi update`) or **Settings → Pi packages**. In a federated setup, **Settings → Pi packages** targets the currently selected machine. The PI WEB `plugins` config key only enables or disables discovered PI WEB browser plugins on the machine whose config you are editing; it does not install, remove, or update Pi packages.
If you installed services with a custom config path, rerun `pi-web install --config /path/to/config.json` after changing that path or after upgrading from a version that only applied the custom path to the web service. This regenerates service files so the web/API and session daemon use the same `PI_WEB_CONFIG`. If you installed services with a custom config path, rerun `pi-web install --config /path/to/config.json` after changing that path or after upgrading from a version that only applied the custom path to the web service. This regenerates service files so the web/API and session daemon use the same `PI_WEB_CONFIG`.
@@ -29,12 +31,13 @@ Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALL
Process restarts depend on the key: Process restarts depend on the key:
- `host` / `port`: restart the web/API service or process. - `host` / `port`: restart the gateway web/API service or process.
- `maxUploadBytes`: restart both the web/API process and the session daemon. - `maxUploadBytes`: restart both the web/API process and the session daemon on that machine.
- `spawnSessions` / `subsessions`: restart the session daemon. - `spawnSessions` / `subsessions`: restart the session daemon on that machine.
- `pathAccess`: applies on the next request; existing file views may need a browser refresh. - `pathAccess`: applies on the next request; existing file views may need a browser refresh.
- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh. - `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
- `plugins`: reload the browser tab after changing plugin enablement. - `plugins`: reload the browser tab after changing PI WEB plugin enablement.
- Pi package install/remove/update: not a PI WEB config key; after a mutation, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for PI WEB browser plugin changes. A routine session daemon restart is not required.
- `shortcuts`: saved settings apply in the browser after config refresh/save. - `shortcuts`: saved settings apply in the browser after config refresh/save.
## Global config example ## Global config example
@@ -88,7 +91,7 @@ Plugins may own separate project files, such as `.pi-web/tasks.json` for the bui
## Configuration matrix ## Configuration matrix
Rows with JSON key `—` are runtime-only environment variables, not config-file keys. Rows with JSON key `—` are runtime-only environment variables, not config-file keys. `Global` means machine-global. In Settings, selected-machine-safe global keys (`pathAccess`, `uploads`, `maxUploadBytes`, `spawnSessions`, `subsessions`, and `plugins`) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local.
| Config | JSON key | Env var | Scope | Project-local behavior | Applies / restart | | Config | JSON key | Env var | Scope | Project-local behavior | Applies / restart |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
@@ -98,9 +101,9 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
| Dev-server allowed hosts | `allowedHosts` | `PI_WEB_ALLOWED_HOSTS` | Global | Not supported locally | Restart dev web/UI | | Dev-server allowed hosts | `allowedHosts` | `PI_WEB_ALLOWED_HOSTS` | Global | Not supported locally | Restart dev web/UI |
| External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed | | External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed |
| Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh | | Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh |
| Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon | | Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon on that machine |
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon | | Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine |
| Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon | | Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine |
| Plugin enablement/settings | `plugins.<id>.enabled`, `plugins.<id>.settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab | | Plugin enablement/settings | `plugins.<id>.enabled`, `plugins.<id>.settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab |
| Keyboard shortcuts | `shortcuts.<actionId>` | — | Global | Not supported locally | Applies after settings save/config refresh | | Keyboard shortcuts | `shortcuts.<actionId>` | — | Global | Not supported locally | Applies after settings save/config refresh |
| Project config version | `version` | — | Project | Project-local only; must be `1` when present | Next project-config read | | Project config version | `version` | — | Project | Project-local only; must be `1` when present | Next project-config read |
@@ -133,6 +136,8 @@ Accepted root forms:
When an absolute request is served, PI WEB expands `~`, canonicalizes the configured roots with `realpath`, requires roots to be existing directories, and rejects symlink escapes outside the allowed roots. When an absolute request is served, PI WEB expands `~`, canonicalizes the configured roots with `realpath`, requires roots to be existing directories, and rejects symlink escapes outside the allowed roots.
In **Settings → General**, external filesystem roots are saved on the selected machine. Gateway host, port, and allowed-hosts fields stay on the gateway config.
This is not a sandbox for the underlying Pi Coding Agent or your OS user. It only controls PI WEB UI/API file exposure outside a workspace. This is not a sandbox for the underlying Pi Coding Agent or your OS user. It only controls PI WEB UI/API file exposure outside a workspace.
### Manual upload defaults ### Manual upload defaults
@@ -156,9 +161,9 @@ The value must be a non-empty workspace-relative folder. PI WEB normalizes repea
Manual uploads use the workspace file-write path: paths stay workspace-relative, parent folder creation is enabled by default, and overwrite is disabled by default. Direct drag/drop always keeps `overwrite` off; the review dialog lets you explicitly enable overwrite when needed. Browser-owned XHR progress is shown per batch/file, conflicts and errors stay visible in the upload progress UI, and the final file-write response is the source of truth. Manual uploads use the workspace file-write path: paths stay workspace-relative, parent folder creation is enabled by default, and overwrite is disabled by default. Direct drag/drop always keeps `overwrite` off; the review dialog lets you explicitly enable overwrite when needed. Browser-owned XHR progress is shown per batch/file, conflicts and errors stay visible in the upload progress UI, and the final file-write response is the source of truth.
For machine federation, current remote PI WEB servers return `workspace.effectiveConfig.uploads.defaultFolder` on the existing workspace-list response. Older remote servers can omit that optional field without breaking clients; the Files panel falls back to the global/default upload folder. For machine federation, Settings saves the global upload default on the selected machine. Current remote PI WEB servers also return `workspace.effectiveConfig.uploads.defaultFolder` on the existing workspace-list response. Older remote servers can omit that optional field without breaking clients; the Files panel falls back to the global/default upload folder.
The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES`. The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES` on the machine serving the upload.
### Session daemon tools ### Session daemon tools
@@ -168,9 +173,13 @@ The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX
Tracked subsessions let an agent delegate work to child sessions, get notified when children stop working, and inspect their transcripts. Tracked subsessions let an agent delegate work to child sessions, get notified when children stop working, and inspect their transcripts.
In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them.
### Plugin config ### Plugin config
Plugins are enabled by default. Set `plugins.<id>.enabled` to `false` to remove a plugin from `/pi-web-plugins/manifest.json` before the browser imports it. The `plugins` key is only for PI WEB browser plugin enablement/settings on the machine whose config you are editing. It does not install, remove, or update Pi packages; use **Settings → Pi packages** or Pi's package manager for package operations. In a federated setup, **Settings → PI WEB plugins** and **Settings → Pi packages** both target the currently selected machine, and each panel labels where changes will be saved or run.
Plugins are enabled by default. Set `plugins.<id>.enabled` to `false` to remove a plugin from that machine's `/pi-web-plugins/manifest.json` before the browser imports it. Settings lists discovered plugins from the selected machine, including disabled entries exposed by that machine.
```json ```json
{ {
+1 -1
View File
@@ -251,7 +251,7 @@
<p> <p>
Your device is replaceable. The sessions are not. Move between laptop, phone, tablet, and desktop Your device is replaceable. The sessions are not. Move between laptop, phone, tablet, and desktop
without moving the development environment. Under the hood, PI WEB coordinates the running sessions, without moving the development environment. Under the hood, PI WEB coordinates the running sessions,
files, terminals, and remote machines like a browser-based control plane. files, terminals, Pi package management, and remote machines like a browser-based control plane.
</p> </p>
</article> </article>
<article class="card"> <article class="card">
+2 -1
View File
@@ -281,7 +281,8 @@
</div> </div>
<p> <p>
Use <strong>Settings → General</strong> for host, port, and external filesystem roots; <strong>Settings → Session daemon</strong> Use <strong>Settings → General</strong> for host, port, and external filesystem roots; <strong>Settings → Session daemon</strong>
for agent-spawn tools; <strong>Settings → Plugins</strong> for plugin enablement; and <strong>Settings → Keyboard</strong> for agent-spawn tools; <strong>Settings → Pi packages</strong> for Pi package install/remove/update;
<strong>Settings → PI WEB plugins</strong> for browser plugin enablement; and <strong>Settings → Keyboard</strong>
for shortcut overrides. for shortcut overrides.
</p> </p>
<div class="callout"> <div class="callout">
+9 -7
View File
@@ -6,7 +6,7 @@
<title>PI WEB fleet — remote Pi web UI machines</title> <title>PI WEB fleet — remote Pi web UI machines</title>
<meta <meta
name="description" name="description"
content="Connect trusted PI WEB runtimes through machine federation so one web UI can supervise local and remote projects, sessions, files, git state, terminals, and plugins." content="Connect trusted PI WEB runtimes through machine federation so one web UI can supervise local and remote projects, sessions, files, git state, terminals, Pi packages, and plugins."
/> />
<link rel="canonical" href="https://pi-web.dev/machines" /> <link rel="canonical" href="https://pi-web.dev/machines" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
@@ -81,7 +81,7 @@
<p> <p>
Most PI WEB setups only need one runtime. When you do have more than one, machine federation lets the PI WEB 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, instance you opened act as a gateway to other trusted runtimes while each machine keeps its own repositories,
credentials, sessions, and plugins. credentials, sessions, Pi package settings, and plugins.
</p> </p>
</div> </div>
</section> </section>
@@ -111,7 +111,7 @@
<p> <p>
After registration, the browser keeps talking to the current PI WEB origin. The gateway contacts the 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, selected remote PI WEB server and routes that machine's projects, workspaces, sessions, files, git state,
activity, and terminals to the browser UI. activity, terminals, and Pi package-management operations to the browser UI.
</p> </p>
<div class="code-card"> <div class="code-card">
<div class="copy-row"> <div class="copy-row">
@@ -125,7 +125,7 @@ PI WEB gateway you opened
├─ [ Remote PI WEB runtime A ] ├─ [ Remote PI WEB runtime A ]
│ ↓ selected machine │ ↓ selected machine
│ projects, workspaces, sessions, terminals, plugins │ projects, workspaces, sessions, terminals, Pi packages, plugins
└─ [ Remote PI WEB runtime B ]</code></pre> └─ [ Remote PI WEB runtime B ]</code></pre>
</div> </div>
@@ -151,7 +151,7 @@ PI WEB gateway you opened
<article class="card"> <article class="card">
<div class="card-icon"></div> <div class="card-icon"></div>
<h3>Local ownership</h3> <h3>Local ownership</h3>
<p>Each target machine keeps its own Pi auth, sessions, worktrees, terminal state, and plugins.</p> <p>Each target machine keeps its own Pi auth, sessions, worktrees, terminal state, Pi package settings, and plugins.</p>
</article> </article>
</div> </div>
</section> </section>
@@ -213,6 +213,7 @@ PI WEB gateway you opened
<li>Pi sessions, transcripts, prompts, model controls, and commands.</li> <li>Pi sessions, transcripts, prompts, model controls, and commands.</li>
<li>Activity indicators and realtime updates.</li> <li>Activity indicators and realtime updates.</li>
<li>Terminals and terminal command runs.</li> <li>Terminals and terminal command runs.</li>
<li>Pi package listing, install, remove, and update from <strong>Settings → Pi packages</strong> when supported by the target PI WEB runtime.</li>
<li>Remote plugins from the selected machine.</li> <li>Remote plugins from the selected machine.</li>
</ul> </ul>
</section> </section>
@@ -220,8 +221,9 @@ PI WEB gateway you opened
<section id="auth-credentials"> <section id="auth-credentials">
<h2>Credentials stay on the target machine</h2> <h2>Credentials stay on the target machine</h2>
<p> <p>
Model-provider credentials, Pi configuration, OAuth state, repositories, and active session runtimes stay Model-provider credentials, Pi configuration, Pi package-manager settings, OAuth state, repositories, and
on the selected target machine. The gateway does not copy them into its own Pi configuration. active session runtimes stay on the selected target machine. The gateway does not copy them into its own
Pi configuration.
</p> </p>
<ul> <ul>
<li>API-key provider configuration can be proxied through the gateway.</li> <li>API-key provider configuration can be proxied through the gateway.</li>
+56 -8
View File
@@ -90,10 +90,11 @@
<aside class="toc" aria-label="Plugin page contents"> <aside class="toc" aria-label="Plugin page contents">
<strong>On this page</strong> <strong>On this page</strong>
<a href="#extend">What can be extended</a> <a href="#extend">What can be extended</a>
<a href="#packages-vs-plugins">Pi packages vs PI WEB plugins</a>
<a href="#ask-ai">What to ask AI to build</a> <a href="#ask-ai">What to ask AI to build</a>
<a href="#example">Canonical example</a> <a href="#example">Canonical example</a>
<a href="#built-in-plugins">Built-in plugins</a> <a href="#built-in-plugins">Built-in plugins</a>
<a href="#manage-plugins">Manage plugins</a> <a href="#manage-plugins">Manage PI WEB plugins</a>
<a href="#remote-machine-plugins">Remote machine plugins</a> <a href="#remote-machine-plugins">Remote machine plugins</a>
<a href="#production">Production usage</a> <a href="#production">Production usage</a>
<a href="#agent-docs">AI-friendly docs</a> <a href="#agent-docs">AI-friendly docs</a>
@@ -125,6 +126,44 @@
</div> </div>
</section> </section>
<section id="packages-vs-plugins">
<h2>Pi packages vs PI WEB plugins</h2>
<p>
<strong>Pi packages</strong> are packages managed by Pi (<code>pi install</code>, <code>pi remove</code>,
<code>pi update</code>). A Pi package can provide extensions, skills, prompt templates, themes,
context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin.
</p>
<p>
<strong>PI WEB plugins</strong> are browser-side UI modules discovered from bundled, local, dev, and
installed Pi-package sources. Enabling or disabling a PI WEB plugin is a PI WEB config task; installing,
removing, or updating a Pi package is a Pi package-manager task.
</p>
<p>
Use <strong>Settings → Pi packages</strong> to view configured Pi packages or install/remove/update a
package. Enter only the package source, such as <code>npm:@scope/package</code>, a git/URL source, or a
local path. PI WEB uses Pi's default package location, equivalent to <code>pi install &lt;source&gt;</code>,
and does not ask for an install location.
</p>
<p>
When machine federation is enabled, <strong>Settings → Pi packages</strong> targets the currently selected
machine. The panel labels whether changes will run on the local/gateway machine or on a selected remote
PI WEB machine. If an older or unavailable remote PI WEB server does not expose package-management routes,
PI WEB reports the package management operation as unsupported or unavailable instead of silently falling
back to the gateway.
</p>
<p>
Use <strong>Settings → PI WEB plugins</strong> to enable or disable discovered PI WEB browser plugins
before the browser imports them. In a federated setup, this plugin enablement surface targets the
currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB
server does not advertise selected-machine settings support, PI WEB reports the plugin settings as
unsupported or unavailable instead of silently falling back to the gateway. After installing, removing, or
updating a Pi package, type <code>/reload</code> in each idle PI WEB session on the target machine to
refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system
prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed
PI WEB browser plugins. A routine session daemon restart is not required.
</p>
</section>
<section id="ask-ai"> <section id="ask-ai">
<h2>What to ask AI to build</h2> <h2>What to ask AI to build</h2>
<p> <p>
@@ -204,7 +243,7 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
plugins appear in <code>/pi-web-plugins/manifest.json</code>. plugins appear in <code>/pi-web-plugins/manifest.json</code>.
</p> </p>
<p> <p>
Built-in plugins can be managed from <strong>Settings → Plugins</strong> or with the top-level Built-in plugins can be managed from <strong>Settings → PI WEB plugins</strong> or with the top-level
<code>plugins</code> config key. <code>plugins</code> config key.
</p> </p>
@@ -285,12 +324,20 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
</section> </section>
<section id="manage-plugins"> <section id="manage-plugins">
<h2>Manage plugins</h2> <h2>Manage PI WEB plugins</h2>
<p> <p>
Open <strong>Settings → Plugins</strong> to review discovered bundled, local, dev, and Pi package plugins Open <strong>Settings → PI WEB plugins</strong> to review discovered bundled, local, dev, and Pi package
for the PI WEB gateway you opened. PI WEB can disable any discovered plugin before the browser imports plugins for the selected PI WEB machine. When the local machine is selected, this is the gateway plugin
it. Core app contributions such as the command palette, base workspace tools, and themes are not managed list; when a remote machine is selected, the list comes from that remote PI WEB server and includes
through this plugin list. disabled discovered plugins it exposes. PI WEB can disable any discovered selected-machine plugin before
the browser imports it. Core app contributions such as the command palette, base workspace tools, and
themes are not managed through this plugin list.
</p>
<p>
This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may
provide plugins or other Pi resources, use <strong>Settings → Pi packages</strong>. In a federated setup,
both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement
still writes the PI WEB <code>plugins</code> config key rather than changing Pi package-manager settings.
</p> </p>
<div class="code-card"> <div class="code-card">
<div class="copy-row"> <div class="copy-row">
@@ -336,7 +383,8 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
</ul> </ul>
<p> <p>
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable 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. one, select that machine and use <strong>Settings → PI WEB plugins</strong> when the remote server exposes
selected-machine settings, or open that machine directly/update its config file.
</p> </p>
<p> <p>
Plugin package metadata can set <code>machineSpecific: true</code>. Use it for plugins like Updates whose Plugin package metadata can set <code>machineSpecific: true</code>. Use it for plugins like Updates whose
+24 -7
View File
@@ -13,6 +13,18 @@ Plugins can currently:
They do **not** run in the session daemon, do not get a server-side hook API, and are not sandboxed. They do **not** run in the session daemon, do not get a server-side hook API, and are not sandboxed.
## Pi packages vs PI WEB plugins
**Pi packages** are packages managed by Pi (`pi install`, `pi remove`, `pi update`). A Pi package can provide extensions, skills, prompt templates, themes, context/system prompt files, and/or PI WEB browser plugins. Many Pi packages do not include a PI WEB plugin.
**PI WEB plugins** are browser-side PI WEB UI modules discovered from bundled, local, dev, and installed Pi-package sources. Enabling or disabling a PI WEB plugin is a PI WEB config task; installing, removing, or updating a Pi package is a Pi package-manager task.
Use **Settings → Pi packages** to view configured Pi packages or install/remove/update a package. Enter only the package source, such as `npm:@scope/package`, a git/URL source, or a local path. PI WEB uses Pi's default package location, equivalent to `pi install <source>`, and does not ask for an install location.
When machine federation is enabled, **Settings → Pi packages** targets the currently selected machine. The panel labels whether changes will run on the local/gateway machine or on a selected remote PI WEB machine. If an older or unavailable remote PI WEB server does not expose package-management routes, PI WEB reports the package management operation as unsupported or unavailable instead of silently falling back to the gateway.
Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. In a federated setup, this plugin enablement surface targets the currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB server does not advertise selected-machine settings support, PI WEB reports the plugin settings as unsupported or unavailable instead of silently falling back to the gateway. After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A routine session daemon restart is not required.
## Trust model ## Trust model
Plugins run as JavaScript in the browser app. Treat them as trusted code: Plugins run as JavaScript in the browser app. Treat them as trusted code:
@@ -140,7 +152,7 @@ When [machine federation](https://pi-web.dev/machines) is enabled, PI WEB also l
- remote theme contributions are ignored for now because themes are app-wide; - 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. - 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. Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable a remote machine plugin, select that machine and use **Settings → PI WEB plugins** when the remote server exposes selected-machine settings, or open that machine directly/update its config file.
Plugin package metadata may set `machineSpecific: true` when the plugin's meaning is tied to the selected PI WEB machine: Plugin package metadata may set `machineSpecific: true` when the plugin's meaning is tied to the selected PI WEB machine:
@@ -155,9 +167,11 @@ const url = new URL("./asset.json", import.meta.url);
If a remote plugin constructs absolute asset URLs, it should use the `pluginId` from `activate()` because PI WEB gives remote plugins a gateway-scoped runtime id. Hard-coded `/pi-web-plugins/<original-id>/...` URLs may point at the gateway instead of the remote machine. If a remote plugin constructs absolute asset URLs, it should use the `pluginId` from `activate()` because PI WEB gives remote plugins a gateway-scoped runtime id. Hard-coded `/pi-web-plugins/<original-id>/...` URLs may point at the gateway instead of the remote machine.
## Manage plugins ## Manage PI WEB plugins
Open **Settings → Plugins** to review discovered bundled, local, dev, and Pi package plugins for the PI WEB gateway you opened. PI WEB can disable any discovered gateway plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list. Open **Settings → PI WEB plugins** to review discovered bundled, local, dev, and Pi package plugins for the selected PI WEB machine. When the local machine is selected, this is the gateway plugin list; when a remote machine is selected, the list comes from that remote PI WEB server and includes disabled discovered plugins it exposes. PI WEB can disable any discovered selected-machine plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list.
This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may provide plugins or other Pi resources, use **Settings → Pi packages**. In a federated setup, both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement still writes the PI WEB `plugins` config key rather than changing Pi package-manager settings.
Plugin preferences are stored under the top-level `plugins` config key in the PI WEB config file: Plugin preferences are stored under the top-level `plugins` config key in the PI WEB config file:
@@ -183,14 +197,14 @@ After changing plugin enablement, reload the PI WEB browser tab. Already-loaded
PI WEB ships core, discoverable plugins in the main `@jmfederico/pi-web` npm package. No separate `pi install` step is required: update PI WEB, reload the browser tab, and the bundled plugins appear in `/pi-web-plugins/manifest.json`. PI WEB ships core, discoverable plugins in the main `@jmfederico/pi-web` npm package. No separate `pi install` step is required: update PI WEB, reload the browser tab, and the bundled plugins appear in `/pi-web-plugins/manifest.json`.
Built-in plugins can be managed from **Settings → Plugins** or with the top-level `plugins` config key. Built-in plugins can be managed from **Settings → PI WEB plugins** or with the top-level `plugins` config key.
### Updates ### Updates
**Plugin id:** `updates` **Plugin id:** `updates`
**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance. **What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance.
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: 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 → PI WEB plugins** or set:
```json ```json
{ {
@@ -206,7 +220,7 @@ Updates is enabled by default. It declares `machineSpecific: true` so the gatewa
**Config file:** `.pi-web/tasks.json` **Config file:** `.pi-web/tasks.json`
**What it does:** adds a **Tasks** workspace tab for running configured shell commands in dedicated PI WEB terminals. **What it does:** adds a **Tasks** workspace tab for running configured shell commands in dedicated PI WEB terminals.
Workspace Tasks is enabled by default. To hide it, disable `workspace-tasks` in **Settings → Plugins** or set: Workspace Tasks is enabled by default. To hide it, disable `workspace-tasks` in **Settings → PI WEB plugins** or set:
```json ```json
{ {
@@ -273,7 +287,7 @@ PI WEB builds the gateway `/pi-web-plugins/manifest.json` from these sources:
Entries may be real directories or symlinks. This is the recommended development workflow. Entries may be real directories or symlinks. This is the recommended development workflow.
3. Installed Pi packages that expose PI WEB plugin metadata. Pi packages may be user or project scoped. 3. Installed Pi packages that expose PI WEB plugin metadata. Pi packages may be user or project scoped. Installing/removing/updating Pi packages is done from **Settings → Pi packages** (or Pi's package manager), not from the PI WEB plugin enable/disable list.
Remote machines expose their own manifests through the gateway at `/api/machines/<machine-id>/pi-web-plugins/manifest.json`. Those plugin modules are rewritten to gateway-scoped asset URLs and registered under machine-scoped runtime ids so duplicate plugin ids on different machines do not collide. Remote machines expose their own manifests through the gateway at `/api/machines/<machine-id>/pi-web-plugins/manifest.json`. Those plugin modules are rewritten to gateway-scoped asset URLs and registered under machine-scoped runtime ids so duplicate plugin ids on different machines do not collide.
@@ -426,10 +440,13 @@ interface PluginAction {
shortcut?: string; shortcut?: string;
group?: string; group?: string;
enabled?: (context: PluginRuntimeContext) => boolean; enabled?: (context: PluginRuntimeContext) => boolean;
disabledReason?: (context: PluginRuntimeContext) => string | undefined;
run: (context: PluginRuntimeContext) => void | Promise<void>; run: (context: PluginRuntimeContext) => void | Promise<void>;
} }
``` ```
If an action is disabled and returns `disabledReason`, PI WEB can keep it visible in the action palette with that explanation instead of hiding it.
Stable runtime context fields: Stable runtime context fields:
```ts ```ts
+216 -333
View File
@@ -1,12 +1,12 @@
{ {
"name": "@jmfederico/pi-web", "name": "@jmfederico/pi-web",
"version": "1.202606.6", "version": "1.202606.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@jmfederico/pi-web", "name": "@jmfederico/pi-web",
"version": "1.202606.6", "version": "1.202606.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.10.3", "@codemirror/commands": "^6.10.3",
@@ -41,9 +41,9 @@
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.31.0", "@changesets/cli": "^2.31.0",
"@earendil-works/pi-agent-core": "^0.79.1", "@earendil-works/pi-agent-core": "^0.80.3",
"@earendil-works/pi-ai": "^0.79.1", "@earendil-works/pi-ai": "^0.80.3",
"@earendil-works/pi-coding-agent": "^0.79.1", "@earendil-works/pi-coding-agent": "^0.80.3",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
@@ -60,9 +60,9 @@
"node": ">=22" "node": ">=22"
}, },
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-agent-core": ">=0.78.0 <1", "@earendil-works/pi-agent-core": ">=0.80.0 <1",
"@earendil-works/pi-ai": ">=0.78.0 <1", "@earendil-works/pi-ai": ">=0.80.0 <1",
"@earendil-works/pi-coding-agent": ">=0.78.0 <1" "@earendil-works/pi-coding-agent": ">=0.80.0 <1"
} }
}, },
"node_modules/@anthropic-ai/sdk": { "node_modules/@anthropic-ai/sdk": {
@@ -86,21 +86,6 @@
} }
} }
}, },
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
"integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/util": "^5.2.0",
"@aws-sdk/types": "^3.222.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@aws-crypto/sha256-browser": { "node_modules/@aws-crypto/sha256-browser": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
@@ -181,18 +166,18 @@
} }
}, },
"node_modules/@aws-sdk/core": { "node_modules/@aws-sdk/core": {
"version": "3.974.22", "version": "3.974.26",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.22.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.26.tgz",
"integrity": "sha512-YofH63shc6YRdXjz80BJkpJW+Bkn0Cuu2dn4Rv7s9G2Idt58tgtzQEWxrR2xVljlVfIBeUjPuULnSVYLke3sUQ==", "integrity": "sha512-wRj7Pthvjk3anees97pUWlxlTa0DUjeGrEQU5fKDZVdWZV0ekaprbof0df2uaE9g8u67t035v2j+ne2AW2UMkA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/types": "^3.973.13", "@aws-sdk/types": "^3.973.15",
"@aws-sdk/xml-builder": "^3.972.30", "@aws-sdk/xml-builder": "^3.972.33",
"@aws/lambda-invoke-store": "^0.2.2", "@aws/lambda-invoke-store": "^0.2.2",
"@smithy/core": "^3.24.6", "@smithy/core": "^3.29.0",
"@smithy/signature-v4": "^5.4.6", "@smithy/signature-v4": "^5.6.1",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"bowser": "^2.11.0", "bowser": "^2.11.0",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
@@ -201,16 +186,16 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-env": { "node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.48", "version": "3.972.52",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.48.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.52.tgz",
"integrity": "sha512-h6FEC95fbexUd6zxm4PdgS82bTcI2PRtUb2ZwMipb/Xr8bPwtf0G8rBo2jp7NA24Mbx2JA8/WingiYpA9RCCyw==", "integrity": "sha512-sxuaHZGHqOgKB8OdL3doXa1NJjqmO60FPfyTnYVKGjX9taRsIEGS9pd+2yALmo06hijZ8L94uSK0kfXZsRmVyA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.974.22", "@aws-sdk/core": "^3.974.26",
"@aws-sdk/types": "^3.973.13", "@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.24.6", "@smithy/core": "^3.29.0",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -218,18 +203,18 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-http": { "node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.50", "version": "3.972.54",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.50.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.54.tgz",
"integrity": "sha512-lJO3OLpjvz5m/RSBQmsG/CEUGsvCy5ruxKwPQaOCqxqCMuyYT2BZwQUTDZVVwqQ9LrZKuK24JSa6r31hL/tvkg==", "integrity": "sha512-e6yz52nq3SpR1oPLcvfsDM7H7k2gIYk/NSn/rwsFqzGXEwr3g0mRMlPbLaKCPCGNZJMU/gZg6/64B3eSam+gBw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.974.22", "@aws-sdk/core": "^3.974.26",
"@aws-sdk/types": "^3.973.13", "@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.24.6", "@smithy/core": "^3.29.0",
"@smithy/fetch-http-handler": "^5.4.6", "@smithy/fetch-http-handler": "^5.6.2",
"@smithy/node-http-handler": "^4.7.6", "@smithy/node-http-handler": "^4.9.2",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -237,14 +222,14 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": {
"version": "4.8.1", "version": "4.9.2",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.1.tgz", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.2.tgz",
"integrity": "sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw==", "integrity": "sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.25.1", "@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.0", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -252,24 +237,24 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-ini": { "node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.972.55", "version": "3.972.59",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.55.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.59.tgz",
"integrity": "sha512-TBoF4buBGYhXjdZAryayY2TrkQj2B2KfE/msG4V53XCt+w0EhEwM2JRjx8p2grJ2C6gtH5++SAwEvGMRdi0yyw==", "integrity": "sha512-9Um/UpruN76AdpiLnvwChVkJJwJ9Vx9ykk/2AeLxxSCM/YYRD8Kkq2towUk9fZQLV7dd9ATlsi87U7hKs0z/iQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.974.22", "@aws-sdk/core": "^3.974.26",
"@aws-sdk/credential-provider-env": "^3.972.48", "@aws-sdk/credential-provider-env": "^3.972.52",
"@aws-sdk/credential-provider-http": "^3.972.50", "@aws-sdk/credential-provider-http": "^3.972.54",
"@aws-sdk/credential-provider-login": "^3.972.54", "@aws-sdk/credential-provider-login": "^3.972.58",
"@aws-sdk/credential-provider-process": "^3.972.48", "@aws-sdk/credential-provider-process": "^3.972.52",
"@aws-sdk/credential-provider-sso": "^3.972.54", "@aws-sdk/credential-provider-sso": "^3.972.58",
"@aws-sdk/credential-provider-web-identity": "^3.972.54", "@aws-sdk/credential-provider-web-identity": "^3.972.58",
"@aws-sdk/nested-clients": "^3.997.22", "@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/types": "^3.973.13", "@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.24.6", "@smithy/core": "^3.29.0",
"@smithy/credential-provider-imds": "^4.3.7", "@smithy/credential-provider-imds": "^4.4.5",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -277,17 +262,17 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-login": { "node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.54", "version": "3.972.58",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.54.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.58.tgz",
"integrity": "sha512-hBWI3wZTdTGiuMfmPts6AWbAjFfRniOQnqx68tc2cQvRKWawFbN9wkLOVPWM1FAOyowZU73mC6Fi+rHSHNyLFw==", "integrity": "sha512-H3q96qF8/DJsPsXMVtMRqSWOc85K5O4zos32untdw+vE5vw0f3a6qJo1YqbND4BsEIKd4iZmzzVUq9kV4LjbHg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.974.22", "@aws-sdk/core": "^3.974.26",
"@aws-sdk/nested-clients": "^3.997.22", "@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/types": "^3.973.13", "@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.24.6", "@smithy/core": "^3.29.0",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -295,22 +280,22 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-node": { "node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.57", "version": "3.972.61",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.57.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.61.tgz",
"integrity": "sha512-u6dClpzNdWf1HGWz4wwhdXi1wiOofCLniM9S4BQQGlLAN9TW7VB+ld5V533GdKrYMaFeBGFqKnj0JCYvynLqwQ==", "integrity": "sha512-2U2KHMRCt1dlZoLU3KZR5g5EL4b0h2HHw96SkaUBK7qvEXPZj5rGRO/3ZTeJmh37dIYQuCnA2273rZOQvmsiHw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.48", "@aws-sdk/credential-provider-env": "^3.972.52",
"@aws-sdk/credential-provider-http": "^3.972.50", "@aws-sdk/credential-provider-http": "^3.972.54",
"@aws-sdk/credential-provider-ini": "^3.972.55", "@aws-sdk/credential-provider-ini": "^3.972.59",
"@aws-sdk/credential-provider-process": "^3.972.48", "@aws-sdk/credential-provider-process": "^3.972.52",
"@aws-sdk/credential-provider-sso": "^3.972.54", "@aws-sdk/credential-provider-sso": "^3.972.58",
"@aws-sdk/credential-provider-web-identity": "^3.972.54", "@aws-sdk/credential-provider-web-identity": "^3.972.58",
"@aws-sdk/types": "^3.973.13", "@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.24.6", "@smithy/core": "^3.29.0",
"@smithy/credential-provider-imds": "^4.3.7", "@smithy/credential-provider-imds": "^4.4.5",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -318,16 +303,16 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-process": { "node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.48", "version": "3.972.52",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.48.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.52.tgz",
"integrity": "sha512-w6VZwojPt12WnEkAUy6Nu4K6sWCbBmR7QX390b0nE6vRvkXbrYr9Lq9VySGkfjiMjpUA87op+J4EgvRmtWIDoQ==", "integrity": "sha512-Aff9Ebs42lz+Ep1wkS+Nlwh5S0eahakpyskPsuKGjiBJ6ExOjNtxbfKJTKovQtQNgJ7oG1BH6esJwGrbs7qgSA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.974.22", "@aws-sdk/core": "^3.974.26",
"@aws-sdk/types": "^3.973.13", "@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.24.6", "@smithy/core": "^3.29.0",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -335,18 +320,18 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-sso": { "node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.972.54", "version": "3.972.58",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.54.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.58.tgz",
"integrity": "sha512-23uZpIpF2SIFDCa1fcWa202tK4gGeyvX6GIIAjiB8WBsvsVRBMnJ/7dCxHzxf7eZT7GToJg837LDIBnZsl/VUg==", "integrity": "sha512-syloC58mXOacUqM2toPNfwd7X3jT+tWj0F/cN7qdW1FQyI0q41J0tPf6DIZ56BF0x82iS9j3ALP45MoBz79YuQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.974.22", "@aws-sdk/core": "^3.974.26",
"@aws-sdk/nested-clients": "^3.997.22", "@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/token-providers": "3.1071.0", "@aws-sdk/token-providers": "3.1078.0",
"@aws-sdk/types": "^3.973.13", "@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.24.6", "@smithy/core": "^3.29.0",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -354,17 +339,17 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
"version": "3.1071.0", "version": "3.1078.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1071.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1078.0.tgz",
"integrity": "sha512-4LDW2Qob6LoLFuqYSYZq2AyTE9koSE9+i+n5UZcm10GpmQOK0zRD9L4uYlzItiTKksIWgC/qMFChAi3RvKYtMg==", "integrity": "sha512-/uyXLBGu3Lw1GbBA2X66hcOMnKtMcqAIF+3/eHfxBQmUeXF2sdqozDPrTfEr/TnSd0D6deZar+eVyhEqqWu29w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.974.22", "@aws-sdk/core": "^3.974.26",
"@aws-sdk/nested-clients": "^3.997.22", "@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/types": "^3.973.13", "@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.24.6", "@smithy/core": "^3.29.0",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -372,17 +357,17 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-web-identity": { "node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.54", "version": "3.972.58",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.54.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.58.tgz",
"integrity": "sha512-0Iv5QttS6wcATlodYKgvQj6B9Db51rx7NU9fqu0PoLeS4BIgdYMc/QK4smwLwpm5RFrs02V/eLyEFp3FklvlNQ==", "integrity": "sha512-pTBImKzcGK+pcMKjL0fAJbnYzzYd1c0UDc7BSIOGNQhF9Nuk66vWlIXfYTYyzNSs+w8Q/vfbbNDDU8zdrouwLg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.974.22", "@aws-sdk/core": "^3.974.26",
"@aws-sdk/nested-clients": "^3.997.22", "@aws-sdk/nested-clients": "^3.997.26",
"@aws-sdk/types": "^3.973.13", "@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.24.6", "@smithy/core": "^3.29.0",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -390,15 +375,15 @@
} }
}, },
"node_modules/@aws-sdk/eventstream-handler-node": { "node_modules/@aws-sdk/eventstream-handler-node": {
"version": "3.972.22", "version": "3.972.25",
"resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.22.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.25.tgz",
"integrity": "sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ==", "integrity": "sha512-df7HN1ozwMrB9+59re9PM7tSLxLAcheMWc5u/KyfCPCAWtN/vP7y7RTUZOy48uT1K9MESisVeOPPzF3O1AW01A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/types": "^3.973.13", "@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.24.6", "@smithy/core": "^3.29.0",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -406,15 +391,15 @@
} }
}, },
"node_modules/@aws-sdk/middleware-eventstream": { "node_modules/@aws-sdk/middleware-eventstream": {
"version": "3.972.18", "version": "3.972.21",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.18.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.21.tgz",
"integrity": "sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A==", "integrity": "sha512-HvLgDnxBLaHi9E5K++6Vuk+1+qqn7Pmn8zrlzd+NXH3jBzwujnuzZtAR9WHPkbUGPO92FkoQWj/M1IsdxTlBmQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/types": "^3.973.13", "@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.24.6", "@smithy/core": "^3.29.0",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -422,18 +407,18 @@
} }
}, },
"node_modules/@aws-sdk/middleware-websocket": { "node_modules/@aws-sdk/middleware-websocket": {
"version": "3.972.30", "version": "3.972.34",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.30.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.34.tgz",
"integrity": "sha512-kH6N4f/Fzi9r/dYap8EQ+Zk4NOz8pl4AtWKhzAoG2C1/4YkIHok9APp/e+75woreWQq264n+LkrJsJVZ0Q+M1Q==", "integrity": "sha512-8dxKLu5bC74SLwwoYV8RIiCD48jMbMt1Ccl3m+xtQJKet6QsZ4xzJlK6UDg7QNEzm/ZCUknJfGsBHmhkgOfuIQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.974.22", "@aws-sdk/core": "^3.974.26",
"@aws-sdk/types": "^3.973.13", "@aws-sdk/types": "^3.973.15",
"@smithy/core": "^3.24.6", "@smithy/core": "^3.29.0",
"@smithy/fetch-http-handler": "^5.4.6", "@smithy/fetch-http-handler": "^5.6.2",
"@smithy/signature-v4": "^5.4.6", "@smithy/signature-v4": "^5.6.1",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -441,21 +426,19 @@
} }
}, },
"node_modules/@aws-sdk/nested-clients": { "node_modules/@aws-sdk/nested-clients": {
"version": "3.997.22", "version": "3.997.26",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.22.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.26.tgz",
"integrity": "sha512-4IwtcYSxEIVw5hcp8ogq0CMbFNZFw7jJUetpfFUhFFeqsa1K8j2Ihg2hnxLyOp3stMZnXda6VzOmPi1AFZQXcg==", "integrity": "sha512-Lwe3F6K7bs+jEubp1LbrvzeMBYb5fMazJ1IxV9TtKWPF8CSh67Fmwyq9fLz3NL/k55Dfpuph5Dimw76JFgr+SA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-crypto/sha256-browser": "5.2.0", "@aws-sdk/core": "^3.974.26",
"@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/signature-v4-multi-region": "^3.996.38",
"@aws-sdk/core": "^3.974.22", "@aws-sdk/types": "^3.973.15",
"@aws-sdk/signature-v4-multi-region": "^3.996.35", "@smithy/core": "^3.29.0",
"@aws-sdk/types": "^3.973.13", "@smithy/fetch-http-handler": "^5.6.2",
"@smithy/core": "^3.24.6", "@smithy/node-http-handler": "^4.9.2",
"@smithy/fetch-http-handler": "^5.4.6", "@smithy/types": "^4.15.1",
"@smithy/node-http-handler": "^4.7.6",
"@smithy/types": "^4.14.3",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -463,14 +446,14 @@
} }
}, },
"node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": {
"version": "4.8.1", "version": "4.9.2",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.1.tgz", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.2.tgz",
"integrity": "sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw==", "integrity": "sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.25.1", "@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.0", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -478,15 +461,15 @@
} }
}, },
"node_modules/@aws-sdk/signature-v4-multi-region": { "node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.35", "version": "3.996.38",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz",
"integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", "integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/types": "^3.973.13", "@aws-sdk/types": "^3.973.15",
"@smithy/signature-v4": "^5.4.6", "@smithy/signature-v4": "^5.6.1",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -512,13 +495,13 @@
} }
}, },
"node_modules/@aws-sdk/types": { "node_modules/@aws-sdk/types": {
"version": "3.973.13", "version": "3.973.15",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz",
"integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", "integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -539,14 +522,13 @@
} }
}, },
"node_modules/@aws-sdk/xml-builder": { "node_modules/@aws-sdk/xml-builder": {
"version": "3.972.30", "version": "3.972.33",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.30.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz",
"integrity": "sha512-StElZPEoBquWwNqw1AcfpzEyZqJvFxouG+mpDNYlcH6ZOrqd2CuIryv+8LV8gNHZUOyKyJF3Dq9vxaXEmDR9TQ==", "integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/types": "^4.14.3", "@smithy/types": "^4.15.1",
"fast-xml-parser": "5.7.3",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -1001,13 +983,13 @@
} }
}, },
"node_modules/@earendil-works/pi-agent-core": { "node_modules/@earendil-works/pi-agent-core": {
"version": "0.79.8", "version": "0.80.3",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.8.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.3.tgz",
"integrity": "sha512-8m5fcqRpoGpq3QY0I/tFXROSTmPwBb1dAuzYZO3XYgjsdCokkRMAGRjA9P8s/UD6Jy9yy69lyE4H6sz/5A1TmQ==", "integrity": "sha512-3qw0/GeRQBU/nlGjDe5Yb7ePKTmoxefx2YxyKMFAviFUMXpFexBG/hS7mBtwFahFvzrrTPPoRT6sFIDjwoDWPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.79.8", "@earendil-works/pi-ai": "^0.80.3",
"ignore": "7.0.5", "ignore": "7.0.5",
"typebox": "1.1.38", "typebox": "1.1.38",
"yaml": "2.9.0" "yaml": "2.9.0"
@@ -1017,9 +999,9 @@
} }
}, },
"node_modules/@earendil-works/pi-ai": { "node_modules/@earendil-works/pi-ai": {
"version": "0.79.8", "version": "0.80.3",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.8.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.3.tgz",
"integrity": "sha512-ZpSwaD7oNpsjn9vtEatZQNT9PSdDJXi6rFeY5Qv+OHQGFDKlmcrfJE4ypm4SAc/fBECPs4Rdi3l+YjVtXYrkKw==", "integrity": "sha512-jPZLMeGL5kkMSEAwAklfXTMHqZvfhsJtCCpKGIr5Duk7mc0n4skjB1dugk7y0z3z8ZHIUCmPAWHdyDqgUz5vdA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1043,16 +1025,16 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent": { "node_modules/@earendil-works/pi-coding-agent": {
"version": "0.79.8", "version": "0.80.3",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.79.8.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.3.tgz",
"integrity": "sha512-wr9oTS/yrwURDXnYrONQgFgV7QDlwslXL/rvKU5X7TRtrGxIhippsRApXqYlRwSeMjb2YzgHMfZ/kAhOqrzoFQ==", "integrity": "sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==",
"dev": true, "dev": true,
"hasShrinkwrap": true, "hasShrinkwrap": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "^0.79.8", "@earendil-works/pi-agent-core": "^0.80.3",
"@earendil-works/pi-ai": "^0.79.8", "@earendil-works/pi-ai": "^0.80.3",
"@earendil-works/pi-tui": "^0.79.8", "@earendil-works/pi-tui": "^0.80.3",
"@silvia-odwyer/photon-node": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2", "chalk": "5.6.2",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
@@ -1542,12 +1524,12 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": {
"version": "0.79.8", "version": "0.80.3",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.8.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.3.tgz",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@earendil-works/pi-ai": "^0.79.8", "@earendil-works/pi-ai": "^0.80.3",
"ignore": "7.0.5", "ignore": "7.0.5",
"typebox": "1.1.38", "typebox": "1.1.38",
"yaml": "2.9.0" "yaml": "2.9.0"
@@ -1557,8 +1539,8 @@
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": {
"version": "0.79.8", "version": "0.80.3",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.8.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.3.tgz",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1575,15 +1557,15 @@
"typebox": "1.1.38" "typebox": "1.1.38"
}, },
"bin": { "bin": {
"pi-ai": "./dist/cli.js" "pi-ai": "dist/cli.js"
}, },
"engines": { "engines": {
"node": ">=22.19.0" "node": ">=22.19.0"
} }
}, },
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": {
"version": "0.79.8", "version": "0.80.3",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.8.tgz", "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.3.tgz",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1698,6 +1680,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1715,6 +1700,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1732,6 +1720,9 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1749,6 +1740,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1766,6 +1760,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4194,19 +4191,6 @@
"@emnapi/runtime": "^1.7.1" "@emnapi/runtime": "^1.7.1"
} }
}, },
"node_modules/@nodable/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -5345,14 +5329,13 @@
] ]
}, },
"node_modules/@smithy/core": { "node_modules/@smithy/core": {
"version": "3.25.1", "version": "3.29.0",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.25.1.tgz", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.0.tgz",
"integrity": "sha512-zpDbpXBCBsxfLtG2GEUyfgvHvSFrw5CwDZSNzL0v52gx/c3oPlPbm+7W7num8xs6vyiUBn+bvYPHcQDOXZynCQ==", "integrity": "sha512-sEvpvkBVoMxjoek35XyJFn2ZD3EJ1RpiZrT47WaZodxzAIWS44zkdvbqGE/ZlugtjiQp62cffYZ9ldyRkjAGnA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.15.1",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -5360,14 +5343,14 @@
} }
}, },
"node_modules/@smithy/credential-provider-imds": { "node_modules/@smithy/credential-provider-imds": {
"version": "4.4.1", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.1.tgz", "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.5.tgz",
"integrity": "sha512-TSAF5NHgxEsllbErYWbK8aLnl5L601NGc5VYJlSPsKnf3YlkhdoBN+geGcaU00oiw2OK3QO5LA3QNXiiWhCidQ==", "integrity": "sha512-LnjUTNG0GgQlKIq7IioeOrPaEmC5xOd1WtAz24TLSiYQnWX2uHr53GrFuQhkrJBktPYCMga/NbUOW7hFbSA2Cg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.25.1", "@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.0", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -5375,14 +5358,14 @@
} }
}, },
"node_modules/@smithy/fetch-http-handler": { "node_modules/@smithy/fetch-http-handler": {
"version": "5.5.1", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.1.tgz", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.2.tgz",
"integrity": "sha512-96JrD1q71anokymx9Iblb+zKmNQYNstlV/25A9ZYIJ2A0rp1r7/GZAIm0bDWSmVvz3DpNOCZuabzsiL+w0UHhw==", "integrity": "sha512-q96PSDOAGw+X+nuELd7Cjebps0SYr+YlPbviEX9sLVw+VM4M7VV8hn1nL1mGS6urDu33eQ5A7WhlphaDO6kUyQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.25.1", "@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.0", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -5418,14 +5401,14 @@
} }
}, },
"node_modules/@smithy/signature-v4": { "node_modules/@smithy/signature-v4": {
"version": "5.5.1", "version": "5.6.1",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.1.tgz", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.1.tgz",
"integrity": "sha512-X9rVls3En0z3NtrmguTmpRM0/NqtWUxBjal6fcAkwtsub+gOdLZ6kD+V7xhUgFMGdG14bHbZ7M5QjaRI1+DatQ==", "integrity": "sha512-SqvuP75p/DmgWWI7jv4kf/UW+V4LFmlUn19s604SgAcRuJRB1vDnWwzZMYCLUcmKxko9wDn6iLgGEIpTNgZbIQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/core": "^3.25.1", "@smithy/core": "^3.29.0",
"@smithy/types": "^4.15.0", "@smithy/types": "^4.15.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
}, },
"engines": { "engines": {
@@ -5433,9 +5416,9 @@
} }
}, },
"node_modules/@smithy/types": { "node_modules/@smithy/types": {
"version": "4.15.0", "version": "4.15.1",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.1.tgz",
"integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", "integrity": "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -6026,19 +6009,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -6861,45 +6831,6 @@
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/fast-xml-builder": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.5.0",
"xml-naming": "^0.1.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz",
"integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^2.1.0",
"fast-xml-builder": "^1.1.7",
"path-expression-matcher": "^1.5.0",
"strnum": "^2.2.3"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastify": { "node_modules/fastify": {
"version": "5.8.5", "version": "5.8.5",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
@@ -7262,9 +7193,9 @@
} }
}, },
"node_modules/google-auth-library": { "node_modules/google-auth-library": {
"version": "10.7.0", "version": "10.9.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz",
"integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==", "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -8211,22 +8142,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/path-expression-matcher": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -8966,22 +8881,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/strnum": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
"integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/style-mod": { "node_modules/style-mod": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
@@ -9985,22 +9884,6 @@
} }
} }
}, },
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+7 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jmfederico/pi-web", "name": "@jmfederico/pi-web",
"version": "1.202606.6", "version": "1.202606.7",
"description": "Web UI for persistent Pi Coding Agent sessions in real workspaces.", "description": "Web UI for persistent Pi Coding Agent sessions in real workspaces.",
"license": "MIT", "license": "MIT",
"author": "Federico Jaramillo Martinez", "author": "Federico Jaramillo Martinez",
@@ -78,9 +78,9 @@
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.31.0", "@changesets/cli": "^2.31.0",
"@earendil-works/pi-agent-core": "^0.79.1", "@earendil-works/pi-agent-core": "^0.80.3",
"@earendil-works/pi-ai": "^0.79.1", "@earendil-works/pi-ai": "^0.80.3",
"@earendil-works/pi-coding-agent": "^0.79.1", "@earendil-works/pi-coding-agent": "^0.80.3",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
@@ -109,9 +109,9 @@
"homepage": "https://pi-web.dev/", "homepage": "https://pi-web.dev/",
"packageManager": "[email protected]", "packageManager": "[email protected]",
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-agent-core": ">=0.78.0 <1", "@earendil-works/pi-agent-core": ">=0.80.0 <1",
"@earendil-works/pi-ai": ">=0.78.0 <1", "@earendil-works/pi-ai": ">=0.80.0 <1",
"@earendil-works/pi-coding-agent": ">=0.78.0 <1" "@earendil-works/pi-coding-agent": ">=0.80.0 <1"
}, },
"keywords": [ "keywords": [
"pi-package", "pi-package",
+2 -2
View File
@@ -115,7 +115,7 @@ function renderUpdatesPanel(html: HtmlTemplateTag, terminal: WorkspacePanelTermi
.updates-command > span { grid-column: 1 / -1; } .updates-command > span { grid-column: 1 / -1; }
} }
</style> </style>
<section class="toolbar"><strong>Updates</strong><span class="stale">beta</span>${messages.length > 0 ? html`<span class="stale">${String(messages.length)}</span>` : null}</section> <section class="toolbar"><strong>Updates</strong>${messages.length > 0 ? html`<span class="stale">${String(messages.length)}</span>` : null}</section>
<section class="viewer updates-status"> <section class="viewer updates-status">
<section> <section>
${messages.length === 0 ? html`<p class="muted">No PI WEB update or restart messages.</p>` : messages.map((message) => html` ${messages.length === 0 ? html`<p class="muted">No PI WEB update or restart messages.</p>` : messages.map((message) => html`
@@ -171,7 +171,7 @@ const plugin: PiWebPlugin = {
visible: (context) => shouldShowUpdatesPanel(context.state, runtimeHint), visible: (context) => shouldShowUpdatesPanel(context.state, runtimeHint),
badge: (context) => { badge: (context) => {
const count = messageCount(context.state); const count = messageCount(context.state);
return html`beta${count > 0 ? html` · ${String(count)}` : null}`; return count > 0 ? count : undefined;
}, },
render: (context) => renderUpdatesPanel(html, context.terminal, context.state), render: (context) => renderUpdatesPanel(html, context.terminal, context.state),
}, },
+3
View File
@@ -11,6 +11,9 @@
<style> <style>
:root { :root {
color-scheme: dark; color-scheme: dark;
--pi-control-font-size: 16px;
--pi-control-font-family: system-ui, sans-serif;
--pi-control-monospace-font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--pi-bg: #0d1117; --pi-bg: #0d1117;
--pi-surface: #161b22; --pi-surface: #161b22;
--pi-surface-hover: #21262d; --pi-surface-hover: #21262d;
+2
View File
@@ -5,5 +5,7 @@ export interface AppAction {
shortcut?: string; shortcut?: string;
group?: string; group?: string;
enabled?: boolean; enabled?: boolean;
/** When present on a disabled action, keep it visible and explain why it cannot run. */
disabledReason?: string;
run: () => void | Promise<void>; run: () => void | Promise<void>;
} }
+2 -2
View File
@@ -1,5 +1,5 @@
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads"; export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads"; export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+152 -2
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes"; import type { PiWebConfigValues, TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
import { filesApi, machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; import { configApi, filesApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
const workspace: Workspace = { const workspace: Workspace = {
id: "w/1", id: "w/1",
@@ -60,7 +60,143 @@ describe("machine-scoped runtime API", () => {
}); });
}); });
describe("settings config and plugin APIs", () => {
it("preserves gateway config and plugin routes by default", async () => {
const fetchMock = stubSequenceFetch([
jsonResponse(piWebConfigResponse({ host: "127.0.0.1" })),
jsonResponse(piWebConfigResponse({ spawnSessions: true })),
jsonResponse(piWebPluginsResponse()),
]);
await expect(configApi.config()).resolves.toMatchObject({ config: { host: "127.0.0.1" } });
await expect(configApi.saveConfig({ spawnSessions: true })).resolves.toMatchObject({ config: { spawnSessions: true } });
await expect(pluginsApi.plugins()).resolves.toEqual(piWebPluginsResponse());
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/config",
"/api/config",
"/api/plugins",
]);
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
});
it("uses machine-scoped config and plugin routes when a machine id is provided", async () => {
const fetchMock = stubSequenceFetch([
jsonResponse(piWebConfigResponse({ spawnSessions: false })),
jsonResponse(piWebConfigResponse({ spawnSessions: true })),
jsonResponse(piWebPluginsResponse()),
]);
await expect(configApi.config("remote a")).resolves.toMatchObject({ config: { spawnSessions: false } });
await expect(configApi.saveConfig({ spawnSessions: true }, "remote a")).resolves.toMatchObject({ config: { spawnSessions: true } });
await expect(pluginsApi.plugins("remote a")).resolves.toEqual(piWebPluginsResponse());
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/machines/remote%20a/config",
"/api/machines/remote%20a/config",
"/api/machines/remote%20a/plugins",
]);
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
});
});
describe("Pi package API", () => {
it("preserves the legacy local Pi package-management routes by default", async () => {
const packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
const fetchMock = stubSequenceFetch([
jsonResponse({ packages }),
jsonResponse({ action: "install", source: "npm:@acme/new-tools", packages }),
jsonResponse({ action: "remove", source: "../project-tools", scope: "project", removed: true, packages }),
jsonResponse({ action: "update", source: "npm:@acme/tools", packages }),
jsonResponse({ action: "update", packages }),
]);
await expect(piPackagesApi.packages()).resolves.toEqual({ packages });
await piPackagesApi.install("npm:@acme/new-tools");
await piPackagesApi.remove("../project-tools", "project");
await piPackagesApi.update("npm:@acme/tools");
await piPackagesApi.update();
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/pi-packages",
"/api/pi-packages/install",
"/api/pi-packages/remove",
"/api/pi-packages/update",
"/api/pi-packages/update",
]);
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ source: "npm:@acme/new-tools" });
expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "../project-tools", scope: "project" });
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "npm:@acme/tools" });
expect(fetchCall(fetchMock, 4)[1]?.body).toBeUndefined();
});
it("uses machine-scoped Pi package-management routes when a machine id is provided", async () => {
const packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
const fetchMock = stubSequenceFetch([
jsonResponse({ packages }),
jsonResponse({ packages }),
jsonResponse({ action: "install", source: "npm:@acme/new-tools", packages }),
jsonResponse({ action: "remove", source: "../project-tools", removed: true, packages }),
jsonResponse({ action: "update", packages }),
]);
await expect(piPackagesApi.packages("local")).resolves.toEqual({ packages });
await expect(piPackagesApi.packages("remote a")).resolves.toEqual({ packages });
await piPackagesApi.install("npm:@acme/new-tools", "remote a");
await piPackagesApi.remove("../project-tools", undefined, "remote a");
await piPackagesApi.update(undefined, "remote a");
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
"/api/machines/local/pi-packages",
"/api/machines/remote%20a/pi-packages",
"/api/machines/remote%20a/pi-packages/install",
"/api/machines/remote%20a/pi-packages/remove",
"/api/machines/remote%20a/pi-packages/update",
]);
expect(JSON.parse(requestBody(fetchCall(fetchMock, 2)[1]))).toEqual({ source: "npm:@acme/new-tools" });
expect(JSON.parse(requestBody(fetchCall(fetchMock, 3)[1]))).toEqual({ source: "../project-tools" });
expect(fetchCall(fetchMock, 4)[1]?.body).toBeUndefined();
});
});
describe("session API compatibility", () => { describe("session API compatibility", () => {
it("posts session cleanup preview and execute requests through the selected machine", async () => {
const preview = { generatedAt: "2026-06-25T12:00:00.000Z", thresholds: { archiveIdleDays: 7 }, projects: [{ cwd: "/repo", archiveCount: 2, deleteCount: 0 }], totals: { archiveCount: 2, deleteCount: 0 } };
const executed = { ...preview, archivedSessionIds: ["s1", "s2"], deletedSessionIds: [] };
const fetchMock = stubSequenceFetch([jsonResponse(preview), jsonResponse(executed)]);
await expect(sessionsApi.cleanupPreview({ archiveIdleDays: 7, deleteArchivedDays: null }, "remote a")).resolves.toEqual(preview);
await expect(sessionsApi.cleanup({ archiveIdleDays: 7, projectCwds: ["/repo"] }, "remote a")).resolves.toEqual(executed);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/sessions/cleanup/preview");
expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ archiveIdleDays: 7, deleteArchivedDays: null });
expect(fetchCall(fetchMock, 1)[0]).toBe("/api/machines/remote%20a/sessions/cleanup");
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ archiveIdleDays: 7, projectCwds: ["/repo"] });
});
it("posts bulk session mutation requests through the selected machine", async () => {
const archived = { archived: true, archivedSessionIds: ["s 1"], failures: [{ sessionId: "s 2", error: "busy" }], generatedAt: "now" };
const deleted = { deleted: true, deletedSessionIds: ["s 1"], failures: [], generatedAt: "later" };
const fetchMock = stubSequenceFetch([jsonResponse(archived), jsonResponse(deleted)]);
await expect(sessionsApi.archiveMany([{ id: "s 1", cwd: "/repo" }, "s 2"], "remote a")).resolves.toEqual(archived);
await expect(sessionsApi.deleteArchivedMany([{ id: "s 1", cwd: "/repo" }], "remote a")).resolves.toEqual(deleted);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/sessions/bulk/archive");
expect(fetchCall(fetchMock, 0)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }, { id: "s 2" }] });
expect(fetchCall(fetchMock, 1)[0]).toBe("/api/machines/remote%20a/sessions/bulk/delete-archived");
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("POST");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] });
});
it("keeps legacy session-id calls free of cwd context", async () => { it("keeps legacy session-id calls free of cwd context", async () => {
const fetchMock = stubJsonFetch({ accepted: true }); const fetchMock = stubJsonFetch({ accepted: true });
@@ -264,6 +400,20 @@ function requestBody(init: RequestInit | undefined): string {
return init.body; return init.body;
} }
function piWebConfigResponse(config: PiWebConfigValues) {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
function piWebPluginsResponse() {
return { plugins: [{ id: "info", module: "/pi-web-plugins/info/plugin.js", source: "test", scope: "local", machineSpecific: false, enabled: true }] };
}
function jsonResponse(value: unknown): Response { function jsonResponse(value: unknown): Response {
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } }); return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
} }
+54 -4
View File
@@ -1,4 +1,4 @@
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes"; import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionRef, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import { request } from "./http"; import { request } from "./http";
import { import {
arrayOf, arrayOf,
@@ -24,6 +24,8 @@ import {
parseModelSelectionResponse, parseModelSelectionResponse,
parseMoveWorkspaceFileResponse, parseMoveWorkspaceFileResponse,
parseOAuthFlowState, parseOAuthFlowState,
parsePiPackageMutationResponse,
parsePiPackagesResponse,
parsePiWebConfigResponse, parsePiWebConfigResponse,
parsePiWebPluginsResponse, parsePiWebPluginsResponse,
parsePiWebRuntimeResponse, parsePiWebRuntimeResponse,
@@ -32,6 +34,10 @@ import {
parseReloaded, parseReloaded,
parseRestored, parseRestored,
parseSavedAttachments, parseSavedAttachments,
parseSessionBulkArchiveResponse,
parseSessionBulkDeleteArchivedResponse,
parseSessionCleanupExecuteResponse,
parseSessionCleanupPreviewResponse,
parseSessionInfo, parseSessionInfo,
parseSessionStatus, parseSessionStatus,
parseSlashCommand, parseSlashCommand,
@@ -83,6 +89,16 @@ function sessionBody(session: SessionLookup, fields: Record<string, unknown> = {
return JSON.stringify(cwd === undefined || cwd === "" ? fields : { cwd, ...fields }); return JSON.stringify(cwd === undefined || cwd === "" ? fields : { cwd, ...fields });
} }
function sessionBulkMutationBody(sessions: readonly SessionLookup[]): string {
return JSON.stringify({ sessions: sessions.map(sessionBulkMutationRef) });
}
function sessionBulkMutationRef(session: SessionLookup): SessionBulkMutationRef {
const id = sessionId(session);
const cwd = sessionCwd(session);
return cwd === undefined || cwd === "" ? { id } : { id, cwd };
}
export const piWebApi = { export const piWebApi = {
piWebStatus: (machineId = "local") => request(machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/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), piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse),
@@ -96,13 +112,42 @@ export const machinesApi = {
runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime), runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
}; };
function configUrl(machineId?: string): string {
return machineId === undefined ? "/api/config" : `${machinePrefix(machineId)}/config`;
}
function pluginsUrl(machineId?: string): string {
return machineId === undefined ? "/api/plugins" : `${machinePrefix(machineId)}/plugins`;
}
export const configApi = { export const configApi = {
config: () => request("/api/config", parsePiWebConfigResponse), config: (machineId?: string) => request(configUrl(machineId), parsePiWebConfigResponse),
saveConfig: (config: PiWebConfigValues) => request("/api/config", parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }), saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configUrl(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
}; };
export const pluginsApi = { export const pluginsApi = {
plugins: () => request("/api/plugins", parsePiWebPluginsResponse), plugins: (machineId?: string) => request(pluginsUrl(machineId), parsePiWebPluginsResponse),
};
function piPackageUrl(endpoint = "", machineId?: string): string {
const baseUrl = machineId === undefined ? "/api/pi-packages" : `${machinePrefix(machineId)}/pi-packages`;
return endpoint === "" ? baseUrl : `${baseUrl}/${endpoint}`;
}
export const piPackagesApi = {
packages: (machineId?: string) => request(piPackageUrl("", machineId), parsePiPackagesResponse),
install: (source: string, machineId?: string) => {
const body: PiPackageInstallRequest = { source };
return request(piPackageUrl("install", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
},
remove: (source: string, scope?: PiPackageScope, machineId?: string) => {
const body: PiPackageRemoveRequest = scope === undefined ? { source } : { source, scope };
return request(piPackageUrl("remove", machineId), parsePiPackageMutationResponse, { method: "POST", body: JSON.stringify(body) });
},
update: (source?: string, machineId?: string) => {
const body: PiPackageUpdateRequest | undefined = source === undefined ? undefined : { source };
return request(piPackageUrl("update", machineId), parsePiPackageMutationResponse, { method: "POST", ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
},
}; };
export const activityApi = { export const activityApi = {
@@ -152,6 +197,10 @@ export const workspacesApi = {
export const sessionsApi = { export const sessionsApi = {
sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)), sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }), startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }),
cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }),
archiveMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/archive`, parseSessionBulkArchiveResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
deleteArchivedMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/delete-archived`, parseSessionBulkDeleteArchivedResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(session, options, machineId), parseMessagePage), messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(session, options, machineId), parseMessagePage),
status: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus), status: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus),
models: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse), models: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse),
@@ -267,6 +316,7 @@ export const api = {
...machinesApi, ...machinesApi,
...configApi, ...configApi,
...pluginsApi, ...pluginsApi,
...piPackagesApi,
...activityApi, ...activityApi,
...projectsApi, ...projectsApi,
...workspacesApi, ...workspacesApi,
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { Workspace } from "../../../shared/apiTypes"; import type { Workspace } from "../../../shared/apiTypes";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes"; import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
import { activityApi, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; import { activityApi, configApi, filesApi, gitApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets"; import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
import { workspaceImagePreviewUrl } from "./urls"; import { workspaceImagePreviewUrl } from "./urls";
@@ -28,6 +28,13 @@ describe("federated route contract", () => {
await Promise.all([ await Promise.all([
ignoreParseFailure(piWebApi.piWebStatus(machineId)), ignoreParseFailure(piWebApi.piWebStatus(machineId)),
ignoreParseFailure(configApi.config(machineId)),
ignoreParseFailure(configApi.saveConfig({ spawnSessions: true }, machineId)),
ignoreParseFailure(pluginsApi.plugins(machineId)),
ignoreParseFailure(piPackagesApi.packages(machineId)),
ignoreParseFailure(piPackagesApi.install("npm:@acme/tools", machineId)),
ignoreParseFailure(piPackagesApi.remove("npm:@acme/tools", "user", machineId)),
ignoreParseFailure(piPackagesApi.update("npm:@acme/tools", machineId)),
ignoreParseFailure(activityApi.workspaceActivity(machineId)), ignoreParseFailure(activityApi.workspaceActivity(machineId)),
ignoreParseFailure(projectsApi.projects(machineId)), ignoreParseFailure(projectsApi.projects(machineId)),
ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)), ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)),
@@ -46,6 +53,10 @@ describe("federated route contract", () => {
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)), ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)), ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
ignoreParseFailure(sessionsApi.startSession("/repo", machineId)), ignoreParseFailure(sessionsApi.startSession("/repo", machineId)),
ignoreParseFailure(sessionsApi.cleanupPreview({ archiveIdleDays: 14 }, machineId)),
ignoreParseFailure(sessionsApi.cleanup({ archiveIdleDays: 14, deleteArchivedDays: 30, projectCwds: ["/repo"] }, machineId)),
ignoreParseFailure(sessionsApi.archiveMany([session], machineId)),
ignoreParseFailure(sessionsApi.deleteArchivedMany([session], machineId)),
ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)), ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)),
ignoreParseFailure(sessionsApi.status(session, machineId)), ignoreParseFailure(sessionsApi.status(session, machineId)),
ignoreParseFailure(sessionsApi.models(session, machineId)), ignoreParseFailure(sessionsApi.models(session, machineId)),
+99 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => { describe("API parsers", () => {
it("parses PI WEB config responses", () => { it("parses PI WEB config responses", () => {
@@ -24,11 +24,33 @@ describe("API parsers", () => {
packageName: "@jmfederico/pi-web", packageName: "@jmfederico/pi-web",
generatedAt: "now", generatedAt: "now",
components: { components: {
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }, web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
sessiond: { component: "sessiond", label: "Session daemon", 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], capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }); })).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
});
it("parses Pi package list and mutation responses", () => {
const packages = [
{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" },
{ source: "../project-tools", scope: "project", filtered: true },
];
expect(parsePiPackagesResponse({ packages })).toEqual({ packages });
expect(parsePiPackageMutationResponse({ action: "remove", source: "../project-tools", scope: "project", removed: true, packages })).toEqual({
action: "remove",
source: "../project-tools",
scope: "project",
removed: true,
packages,
});
});
it("rejects malformed Pi package responses", () => {
expect(() => parsePiPackagesResponse({ packages: [{ source: "npm:@acme/tools", scope: "global", filtered: false }] })).toThrow("Invalid Pi package scope");
expect(() => parsePiPackageMutationResponse({ action: "sync", packages: [] })).toThrow("Invalid Pi package mutation action");
expect(() => parsePiPackagesResponse({ packages: [{ source: "npm:@acme/tools", scope: "user", filtered: "no" }] })).toThrow("Expected boolean field: filtered");
}); });
it("parses Docker PI WEB installation metadata", () => { it("parses Docker PI WEB installation metadata", () => {
@@ -71,9 +93,81 @@ describe("API parsers", () => {
expect(parseMessagePage({ messages: ["c"], start: 3, total: 9 })).toEqual({ messages: ["c"], start: 3, total: 9 }); expect(parseMessagePage({ messages: ["c"], start: 3, total: 9 })).toEqual({ messages: ["c"], start: 3, total: 9 });
}); });
it("parses session cleanup preview and execute responses", () => {
const preview = {
generatedAt: "2026-06-25T12:00:00.000Z",
thresholds: { archiveIdleDays: 14, deleteArchivedDays: 30 },
projects: [
{ cwd: "/repo-a", archiveCount: 2, deleteCount: 1 },
{ cwd: "/repo-b", archiveCount: 0, deleteCount: 3 },
],
totals: { archiveCount: 2, deleteCount: 4 },
skippedBusySessionIds: ["busy-1"],
};
expect(parseSessionCleanupPreviewResponse(preview)).toEqual(preview);
expect(parseSessionCleanupExecuteResponse({ ...preview, archivedSessionIds: ["s1", "s2"], deletedSessionIds: ["a1"] })).toEqual({
...preview,
archivedSessionIds: ["s1", "s2"],
deletedSessionIds: ["a1"],
});
});
it("rejects malformed session cleanup responses", () => {
expect(() => parseSessionCleanupPreviewResponse({ generatedAt: "now", thresholds: {}, projects: [{ cwd: "/repo", archiveCount: "2", deleteCount: 0 }], totals: { archiveCount: 2, deleteCount: 0 } })).toThrow("Expected number field: archiveCount");
expect(() => parseSessionCleanupExecuteResponse({ generatedAt: "now", thresholds: {}, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: ["s1"], deletedSessionIds: [1] })).toThrow("Expected string array field: deletedSessionIds");
});
it("parses bulk session mutation responses", () => {
const failure = { sessionId: "busy", error: "Session is busy" };
expect(parseSessionBulkArchiveResponse({ archived: true, archivedSessionIds: ["s1"], failures: [failure], generatedAt: "now" })).toEqual({
archived: true,
archivedSessionIds: ["s1"],
failures: [failure],
generatedAt: "now",
});
expect(parseSessionBulkDeleteArchivedResponse({ deleted: true, deletedSessionIds: ["s2"], failures: [], generatedAt: "later" })).toEqual({
deleted: true,
deletedSessionIds: ["s2"],
failures: [],
generatedAt: "later",
});
});
it("rejects malformed bulk session mutation responses", () => {
expect(() => parseSessionBulkArchiveResponse({ archived: true, archivedSessionIds: ["s1"], failures: [{ sessionId: "s2" }], generatedAt: "now" })).toThrow("Expected string field: error");
expect(() => parseSessionBulkDeleteArchivedResponse({ deleted: true, deletedSessionIds: [1], failures: [], generatedAt: "now" })).toThrow("Expected string array field: deletedSessionIds");
});
it("parses session info including optional persistence signals", () => {
expect(parseSessionInfo({
id: "s1",
path: "/sessions/s1.jsonl",
cwd: "/repo",
persisted: false,
name: "Draft session",
created: "2026-01-01T00:00:00.000Z",
modified: "2026-01-01T00:01:00.000Z",
messageCount: 0,
firstMessage: "",
})).toEqual({
id: "s1",
path: "/sessions/s1.jsonl",
cwd: "/repo",
persisted: false,
name: "Draft session",
created: "2026-01-01T00:00:00.000Z",
modified: "2026-01-01T00:01:00.000Z",
messageCount: 0,
firstMessage: "",
});
expect(() => parseSessionInfo({ id: "s1", path: "", cwd: "/repo", persisted: "yes", created: "now", modified: "now", messageCount: 0, firstMessage: "" })).toThrow("Expected optional boolean field: persisted");
});
it("validates session status including optional model and nullable context usage", () => { it("validates session status including optional model and nullable context usage", () => {
expect(parseSessionStatus({ expect(parseSessionStatus({
sessionId: "s1", sessionId: "s1",
persisted: true,
isStreaming: false, isStreaming: false,
isCompacting: true, isCompacting: true,
isBashRunning: false, isBashRunning: false,
@@ -87,6 +181,7 @@ describe("API parsers", () => {
thinkingLevel: "medium", thinkingLevel: "medium",
})).toEqual({ })).toEqual({
sessionId: "s1", sessionId: "s1",
persisted: true,
isStreaming: false, isStreaming: false,
isCompacting: true, isCompacting: true,
isBashRunning: false, isBashRunning: false,
+121 -4
View File
@@ -1,5 +1,6 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes"; import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import { isPiWebCapability } from "../../../shared/capabilities"; import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
@@ -156,12 +157,14 @@ function optionalWorkspaceEffectiveConfig(value: unknown): Workspace["effectiveC
export function parseSessionInfo(value: unknown): SessionInfo { export function parseSessionInfo(value: unknown): SessionInfo {
const record = requireRecord(value); const record = requireRecord(value);
const name = optionalString(record, "name"); const name = optionalString(record, "name");
const persisted = parseOptionalBoolean(record["persisted"], "persisted");
const parentSessionPath = optionalString(record, "parentSessionPath"); const parentSessionPath = optionalString(record, "parentSessionPath");
const archivedAt = optionalString(record, "archivedAt"); const archivedAt = optionalString(record, "archivedAt");
return { return {
id: requireString(record, "id"), id: requireString(record, "id"),
path: requireString(record, "path"), path: requireString(record, "path"),
cwd: requireString(record, "cwd"), cwd: requireString(record, "cwd"),
...(persisted === undefined ? {} : { persisted }),
...(name === undefined ? {} : { name }), ...(name === undefined ? {} : { name }),
created: requireString(record, "created"), created: requireString(record, "created"),
modified: requireString(record, "modified"), modified: requireString(record, "modified"),
@@ -177,6 +180,7 @@ export function parseSessionStatus(value: unknown): SessionStatus {
const record = requireRecord(value); const record = requireRecord(value);
return { return {
sessionId: requireString(record, "sessionId"), sessionId: requireString(record, "sessionId"),
...optionalField("persisted", parseOptionalBoolean(record["persisted"], "persisted")),
isStreaming: requireBoolean(record, "isStreaming"), isStreaming: requireBoolean(record, "isStreaming"),
isCompacting: requireBoolean(record, "isCompacting"), isCompacting: requireBoolean(record, "isCompacting"),
isBashRunning: requireBoolean(record, "isBashRunning"), isBashRunning: requireBoolean(record, "isBashRunning"),
@@ -191,6 +195,79 @@ export function parseSessionStatus(value: unknown): SessionStatus {
}; };
} }
export function parseSessionCleanupPreviewResponse(value: unknown): SessionCleanupPreviewResponse {
const record = requireRecord(value);
const skippedBusySessionIds = record["skippedBusySessionIds"] === undefined ? undefined : arrayOfString(record["skippedBusySessionIds"], "skippedBusySessionIds");
return {
generatedAt: requireString(record, "generatedAt"),
thresholds: parseSessionCleanupThresholds(record["thresholds"]),
projects: arrayOf(parseSessionCleanupProjectSummary)(record["projects"]),
totals: parseSessionCleanupTotals(record["totals"]),
...(skippedBusySessionIds === undefined ? {} : { skippedBusySessionIds }),
};
}
export function parseSessionCleanupExecuteResponse(value: unknown): SessionCleanupExecuteResponse {
const record = requireRecord(value);
return {
...parseSessionCleanupPreviewResponse(record),
archivedSessionIds: arrayOfString(record["archivedSessionIds"], "archivedSessionIds"),
deletedSessionIds: arrayOfString(record["deletedSessionIds"], "deletedSessionIds"),
};
}
export function parseSessionBulkArchiveResponse(value: unknown): SessionBulkArchiveResponse {
const record = requireRecord(value);
if (record["archived"] !== true) throw new Error("Expected bulk archived response");
return {
archived: true,
archivedSessionIds: arrayOfString(record["archivedSessionIds"], "archivedSessionIds"),
failures: arrayOf(parseSessionBulkFailure)(record["failures"]),
generatedAt: requireString(record, "generatedAt"),
};
}
export function parseSessionBulkDeleteArchivedResponse(value: unknown): SessionBulkDeleteArchivedResponse {
const record = requireRecord(value);
if (record["deleted"] !== true) throw new Error("Expected bulk deleted response");
return {
deleted: true,
deletedSessionIds: arrayOfString(record["deletedSessionIds"], "deletedSessionIds"),
failures: arrayOf(parseSessionBulkFailure)(record["failures"]),
generatedAt: requireString(record, "generatedAt"),
};
}
function parseSessionBulkFailure(value: unknown): SessionBulkFailure {
const record = requireRecord(value);
return { sessionId: requireString(record, "sessionId"), error: requireString(record, "error") };
}
function parseSessionCleanupThresholds(value: unknown): SessionCleanupThresholds {
const record = requireRecord(value);
return {
...optionalField("archiveIdleDays", optionalNumber(record, "archiveIdleDays")),
...optionalField("deleteArchivedDays", optionalNumber(record, "deleteArchivedDays")),
};
}
function parseSessionCleanupProjectSummary(value: unknown): SessionCleanupProjectSummary {
const record = requireRecord(value);
return {
cwd: requireString(record, "cwd"),
archiveCount: requireNumber(record, "archiveCount"),
deleteCount: requireNumber(record, "deleteCount"),
};
}
function parseSessionCleanupTotals(value: unknown): SessionCleanupTotals {
const record = requireRecord(value);
return {
archiveCount: requireNumber(record, "archiveCount"),
deleteCount: requireNumber(record, "deleteCount"),
};
}
function parseQueuedSessionMessage(value: unknown): QueuedSessionMessage { function parseQueuedSessionMessage(value: unknown): QueuedSessionMessage {
const record = requireRecord(value); const record = requireRecord(value);
const kind = requireString(record, "kind"); const kind = requireString(record, "kind");
@@ -555,6 +632,45 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") }; return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") };
} }
export function parsePiPackagesResponse(value: unknown): PiPackagesResponse {
const record = requireRecord(value);
return { packages: arrayOf(parsePiPackageInfo)(record["packages"]) };
}
export function parsePiPackageMutationResponse(value: unknown): PiPackageMutationResponse {
const record = requireRecord(value);
const source = optionalString(record, "source");
const scope = record["scope"] === undefined ? undefined : parsePiPackageScope(record["scope"]);
const removed = parseOptionalBoolean(record["removed"], "removed");
return {
action: parsePiPackageMutationAction(record["action"]),
...optionalField("source", source),
...optionalField("scope", scope),
...optionalField("removed", removed),
packages: arrayOf(parsePiPackageInfo)(record["packages"]),
};
}
function parsePiPackageInfo(value: unknown): PiPackageInfo {
const record = requireRecord(value);
return {
source: requireString(record, "source"),
scope: parsePiPackageScope(record["scope"]),
filtered: requireBoolean(record, "filtered"),
...optionalField("installedPath", optionalString(record, "installedPath")),
};
}
function parsePiPackageScope(value: unknown): PiPackageScope {
if (value !== "user" && value !== "project") throw new Error("Invalid Pi package scope");
return value;
}
function parsePiPackageMutationAction(value: unknown): PiPackageMutationAction {
if (value !== "install" && value !== "remove" && value !== "update") throw new Error("Invalid Pi package mutation action");
return value;
}
export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse { export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse {
const record = requireRecord(value); const record = requireRecord(value);
return { plugins: arrayOf(parsePiWebPluginInfo)(record["plugins"]) }; return { plugins: arrayOf(parsePiWebPluginInfo)(record["plugins"]) };
@@ -700,8 +816,9 @@ function parsePiWebServiceComponent(value: unknown): PiWebServiceComponent {
} }
function parsePiWebCapabilities(value: unknown): PiWebCapability[] { function parsePiWebCapabilities(value: unknown): PiWebCapability[] {
if (!Array.isArray(value) || !value.every(isPiWebCapability)) throw new Error("Invalid PI WEB capabilities"); const capabilities = parseKnownPiWebCapabilities(value);
return value; if (capabilities === undefined) throw new Error("Invalid PI WEB capabilities");
return capabilities;
} }
function parsePiWebStatusSeverity(value: unknown): PiWebStatusSeverity { function parsePiWebStatusSeverity(value: unknown): PiWebStatusSeverity {
+11 -1
View File
@@ -1,4 +1,4 @@
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 { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { ChatLine } from "./components/shared"; import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/ids"; import type { QualifiedContributionId } from "./plugins/ids";
import type { WorkspaceUploadBatchState } from "./workspaceUploadState"; import type { WorkspaceUploadBatchState } from "./workspaceUploadState";
@@ -20,6 +20,10 @@ export interface AppState {
isReceivingPartialStream: boolean; isReceivingPartialStream: boolean;
/** Sessions with a prompt upload in flight, keyed by sessionId (client-owned). */ /** Sessions with a prompt upload in flight, keyed by sessionId (client-owned). */
sendingPrompts: Record<string, true>; sendingPrompts: Record<string, true>;
/** Client-side queued sends waiting for a just-created backend session, keyed by sessionId. */
clientQueuedSessionMessages: Record<string, QueuedSessionMessage[]>;
/** Client-initiated session creation requests waiting for the server. */
startingSessionCount: number;
isLoadingProjects: boolean; isLoadingProjects: boolean;
isLoadingWorkspaces: boolean; isLoadingWorkspaces: boolean;
selectedProject: Project | undefined; selectedProject: Project | undefined;
@@ -72,6 +76,8 @@ export type AuthDialogState =
export type WorkspaceScopedStateReset = Pick<AppState, export type WorkspaceScopedStateReset = Pick<AppState,
| "sessions" | "sessions"
| "clientQueuedSessionMessages"
| "startingSessionCount"
| "fileTree" | "fileTree"
| "expandedDirs" | "expandedDirs"
| "selectedFilePath" | "selectedFilePath"
@@ -89,6 +95,8 @@ export type WorkspaceScopedStateReset = Pick<AppState,
export function resetWorkspaceScopedState(): WorkspaceScopedStateReset { export function resetWorkspaceScopedState(): WorkspaceScopedStateReset {
return { return {
sessions: [], sessions: [],
clientQueuedSessionMessages: {},
startingSessionCount: 0,
fileTree: [], fileTree: [],
expandedDirs: {}, expandedDirs: {},
selectedFilePath: undefined, selectedFilePath: undefined,
@@ -121,6 +129,8 @@ export function initialAppState(): AppState {
isLoadingEarlierMessages: false, isLoadingEarlierMessages: false,
isReceivingPartialStream: false, isReceivingPartialStream: false,
sendingPrompts: {}, sendingPrompts: {},
clientQueuedSessionMessages: {},
startingSessionCount: 0,
isLoadingProjects: false, isLoadingProjects: false,
isLoadingWorkspaces: false, isLoadingWorkspaces: false,
selectedProject: undefined, selectedProject: undefined,
+1
View File
@@ -48,6 +48,7 @@ export function stripCachedNewSessionMarker(session: SessionInfo): SessionInfo {
id: session.id, id: session.id,
path: session.path, path: session.path,
cwd: session.cwd, cwd: session.cwd,
...(session.persisted === undefined ? {} : { persisted: session.persisted }),
...(session.name === undefined ? {} : { name: session.name }), ...(session.name === undefined ? {} : { name: session.name }),
created: session.created, created: session.created,
modified: session.modified, modified: session.modified,
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import type { AppAction } from "../actions";
import { filterActionPaletteActions } from "./ActionPalette";
describe("filterActionPaletteActions", () => {
it("keeps disabled actions visible when they have an explanation", () => {
const actions: AppAction[] = [
action("enabled", "Enabled action"),
action("hidden", "Disabled without reason", { enabled: false }),
action("explained", "Disabled with reason", { enabled: false, disabledReason: "Update and restart the selected machine." }),
];
expect(filterActionPaletteActions(actions, "").map((item) => item.id)).toEqual(["enabled", "explained"]);
});
it("matches disabled reasons in search", () => {
const actions: AppAction[] = [
action("cleanup", "Clean Up Sessions", { enabled: false, disabledReason: "Selected server does not support cleanup." }),
];
expect(filterActionPaletteActions(actions, "support cleanup").map((item) => item.id)).toEqual(["cleanup"]);
});
});
function action(id: string, title: string, patch: Partial<AppAction> = {}): AppAction {
return { id, title, run: () => undefined, ...patch };
}
+15 -9
View File
@@ -34,10 +34,11 @@ export class ActionPalette extends LitElement {
</header> </header>
<div class="options"> <div class="options">
${actions.length === 0 ? html`<div class="empty">No actions found.</div>` : actions.map((action, index) => html` ${actions.length === 0 ? html`<div class="empty">No actions found.</div>` : actions.map((action, index) => html`
<button class=${index === this.selectedIndex ? "selected" : ""} ${scrollWhenSelected(index === this.selectedIndex, action.id)} @click=${() => { this.run(action); }}> <button class=${`${index === this.selectedIndex ? "selected" : ""} ${action.enabled === false ? "disabled" : ""}`} ?disabled=${action.enabled === false} title=${action.disabledReason ?? action.title} ${scrollWhenSelected(index === this.selectedIndex, action.id)} @click=${() => { this.run(action); }}>
<span class="main"> <span class="main">
<strong>${action.title}</strong> <strong>${action.title}</strong>
${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null} ${action.description !== undefined && action.description !== "" ? html`<small>${action.description}</small>` : null}
${action.enabled === false && action.disabledReason !== undefined ? html`<small class="disabled-reason">${action.disabledReason}</small>` : null}
</span> </span>
${action.shortcut !== undefined ? html`<kbd>${formatShortcut(action.shortcut)}</kbd>` : null} ${action.shortcut !== undefined ? html`<kbd>${formatShortcut(action.shortcut)}</kbd>` : null}
${action.group !== undefined && action.group !== "" ? html`<small class="group">${action.group}</small>` : null} ${action.group !== undefined && action.group !== "" ? html`<small class="group">${action.group}</small>` : null}
@@ -60,14 +61,7 @@ export class ActionPalette extends LitElement {
} }
private filteredActions(): AppAction[] { private filteredActions(): AppAction[] {
const query = this.queryText.trim().toLowerCase(); return filterActionPaletteActions(this.actions, this.queryText);
return this.actions
.filter((action) => action.enabled !== false)
.filter((action) => {
if (query === "") return true;
const haystack = [action.title, action.description ?? "", action.group ?? "", action.shortcut ?? ""].join(" ").toLowerCase();
return haystack.includes(query);
});
} }
private handleKeyDown(event: KeyboardEvent) { private handleKeyDown(event: KeyboardEvent) {
@@ -89,8 +83,20 @@ export class ActionPalette extends LitElement {
} }
private run(action: AppAction) { private run(action: AppAction) {
if (action.enabled === false) return;
this.onRun?.(action); this.onRun?.(action);
} }
static override styles = actionPaletteStyles; static override styles = actionPaletteStyles;
} }
export function filterActionPaletteActions(actions: readonly AppAction[], queryText: string): AppAction[] {
const query = queryText.trim().toLowerCase();
return actions
.filter((action) => action.enabled !== false || action.disabledReason !== undefined)
.filter((action) => {
if (query === "") return true;
const haystack = [action.title, action.description ?? "", action.disabledReason ?? "", action.group ?? "", action.shortcut ?? ""].join(" ").toLowerCase();
return haystack.includes(query);
});
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { chatQueuedMessageSections } from "./ChatView";
describe("chatQueuedMessageSections", () => {
it("labels client-side pending-start sends separately from server queued messages", () => {
const sections = chatQueuedMessageSections(
[{ kind: "followUp", text: "queued before start" }],
[{ kind: "steer", text: "server queued" }],
);
expect(sections).toEqual([
{
heading: "Queued until session starts",
detail: "Will send once the backend session is ready",
messages: [{ kind: "followUp", text: "queued before start" }],
},
{
heading: "Queued messages",
detail: "1 pending · Stop clears the queue",
messages: [{ kind: "steer", text: "server queued" }],
},
]);
});
});
+23 -6
View File
@@ -6,7 +6,7 @@ import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGr
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring"; import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
import { shouldRequestEarlierMessages } from "../chatHistoryLoading"; import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition"; import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
import type { SessionActivity, SessionStatus } from "../api"; import type { QueuedSessionMessage, SessionActivity, SessionStatus } from "../api";
import type { ChatLine, ChatPart } from "./shared"; import type { ChatLine, ChatPart } from "./shared";
import { chatStyles } from "./shared"; import { chatStyles } from "./shared";
import "./ConversationMeter"; import "./ConversationMeter";
@@ -38,6 +38,19 @@ function clampNumber(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
export interface QueuedMessageSection {
heading: string;
detail: string;
messages: QueuedSessionMessage[];
}
export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], serverQueued: QueuedSessionMessage[]): QueuedMessageSection[] {
return [
clientQueued.length === 0 ? undefined : { heading: "Queued until session starts", detail: "Will send once the backend session is ready", messages: clientQueued },
serverQueued.length === 0 ? undefined : { heading: "Queued messages", detail: `${String(serverQueued.length)} pending · Stop clears the queue`, messages: serverQueued },
].filter((section): section is QueuedMessageSection => section !== undefined);
}
@customElement("chat-view") @customElement("chat-view")
export class ChatView extends LitElement { export class ChatView extends LitElement {
@property({ attribute: false }) messages: ChatLine[] = []; @property({ attribute: false }) messages: ChatLine[] = [];
@@ -51,6 +64,7 @@ export class ChatView extends LitElement {
@property({ type: Boolean }) isSendingPrompt = false; @property({ type: Boolean }) isSendingPrompt = false;
@property({ type: Boolean }) isCompacting = false; @property({ type: Boolean }) isCompacting = false;
@property({ type: Number }) pendingMessageCount = 0; @property({ type: Number }) pendingMessageCount = 0;
@property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = [];
@property({ attribute: false }) status?: SessionStatus; @property({ attribute: false }) status?: SessionStatus;
@property({ attribute: false }) activity?: SessionActivity; @property({ attribute: false }) activity?: SessionActivity;
@property({ attribute: false }) onLoadMore?: () => void; @property({ attribute: false }) onLoadMore?: () => void;
@@ -220,15 +234,18 @@ export class ChatView extends LitElement {
} }
private renderQueuedMessages() { private renderQueuedMessages() {
const queued = this.status?.queuedMessages ?? []; const serverQueued = this.status?.queuedMessages ?? [];
if (queued.length === 0) return null; return html`${chatQueuedMessageSections(this.clientQueuedMessages, serverQueued).map((section) => this.renderQueuedMessageList(section))}`;
}
private renderQueuedMessageList(section: QueuedMessageSection) {
return html` return html`
<aside class="queued-messages" aria-live="polite"> <aside class="queued-messages" aria-live="polite">
<div class="queued-header"> <div class="queued-header">
<strong>Queued messages</strong> <strong>${section.heading}</strong>
<small>${queued.length} pending · Stop clears the queue</small> <small>${section.detail}</small>
</div> </div>
${queued.map((message, index) => html` ${section.messages.map((message, index) => html`
<div class="queued-message"> <div class="queued-message">
<span class="queued-kind">${message.kind === "steer" ? "Steer" : "Follow-up"} ${String(index + 1)}</span> <span class="queued-kind">${message.kind === "steer" ? "Steer" : "Follow-up"} ${String(index + 1)}</span>
<formatted-text .text=${message.text}></formatted-text> <formatted-text .text=${message.text}></formatted-text>
+14
View File
@@ -55,6 +55,7 @@ export class CodeViewer extends LitElement {
EditorView.editable.of(false), EditorView.editable.of(false),
EditorView.lineWrapping, EditorView.lineWrapping,
viewerTheme, viewerTheme,
...bidiTextExtensions(this.language),
...languageExtensions(this.language), ...languageExtensions(this.language),
], ],
}), }),
@@ -97,6 +98,19 @@ const viewerTheme = EditorView.theme({
}, },
}); });
const bidiTextTheme = EditorView.theme({
".cm-content": {
textAlign: "start",
},
".cm-line": {
unicodeBidi: "plaintext",
},
});
function bidiTextExtensions(language: string | undefined): Extension[] {
return language === "markdown" ? [EditorView.contentAttributes.of({ dir: "auto" }), bidiTextTheme] : [];
}
function languageExtensions(language: string | undefined): Extension[] { function languageExtensions(language: string | undefined): Extension[] {
if (language === undefined) return []; if (language === undefined) return [];
switch (language) { switch (language) {
+1 -1
View File
@@ -9,7 +9,7 @@ export class FormattedText extends LitElement {
@property() text = ""; @property() text = "";
override render() { override render() {
return html`<div class="formatted" @click=${this.onFormattedClick}>${unsafeHTML(toSafeMarkdownHtml(this.text))}</div>`; return html`<div class="formatted" dir="auto" @click=${this.onFormattedClick}>${unsafeHTML(toSafeMarkdownHtml(this.text))}</div>`;
} }
override updated(): void { override updated(): void {
+1 -1
View File
@@ -137,7 +137,7 @@ export class MachineDialog extends LitElement {
footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; } footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; }
.body { display: grid; gap: 8px; padding: 12px; min-height: 0; overflow: auto; } .body { display: grid; gap: 8px; padding: 12px; min-height: 0; overflow: auto; }
label { display: grid; gap: 6px; color: var(--pi-muted); } label { display: grid; gap: 6px; color: var(--pi-muted); }
input { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } input { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: var(--pi-control-font-size, 16px) var(--pi-control-monospace-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); }
input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; } input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; }
.hint { color: var(--pi-muted); } .hint { color: var(--pi-muted); }
.intro { margin: 4px 0 0; line-height: 1.4; } .intro { margin: 4px 0 0; line-height: 1.4; }
+131 -7
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js"; import { customElement, query, state } from "lit/decorators.js";
import { configApi, effectiveWorkspaceUploadFolder, piWebApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api"; import { configApi, effectiveWorkspaceUploadFolder, piWebApi, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type RealtimeEvent, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
import type { AppAction } from "../actions"; import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState"; import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity"; import { isSessionActive } from "../../../shared/activity";
@@ -19,6 +19,7 @@ import { SessionStorageTerminalSelectionMemory } from "../controllers/terminalSe
import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspaceSelection"; import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspaceSelection";
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types"; import { selectedMachineId } from "../controllers/types";
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
import { RealtimeSocket } from "../sessionSocket"; import { RealtimeSocket } from "../sessionSocket";
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types"; import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme"; import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
@@ -40,6 +41,7 @@ import "./MachineList";
import "./ProjectList"; import "./ProjectList";
import "./WorkspaceList"; import "./WorkspaceList";
import "./SessionList"; import "./SessionList";
import "./SessionCleanupDialog";
import "./ChatView"; import "./ChatView";
import type { ChatView } from "./ChatView"; import type { ChatView } from "./ChatView";
import "./PromptEditor"; import "./PromptEditor";
@@ -77,6 +79,15 @@ const MIN_RESIZABLE_CHAT_WIDTH_PX = 320;
const PANEL_EDGE_COLUMNS_WIDTH_PX = 2; const PANEL_EDGE_COLUMNS_WIDTH_PX = 2;
const DESKTOP_SIDE_BY_SIDE_MEDIA_QUERY = "(min-width: 1181px)"; const DESKTOP_SIDE_BY_SIDE_MEDIA_QUERY = "(min-width: 1181px)";
interface SessionCleanupDialogState {
preview?: SessionCleanupPreviewResponse | undefined;
previewRequest?: SessionCleanupRequest | undefined;
result?: SessionCleanupExecuteResponse | undefined;
loading?: boolean | undefined;
running?: boolean | undefined;
error?: string | undefined;
}
@customElement("pi-web-app") @customElement("pi-web-app")
export class PiWebApp extends LitElement { export class PiWebApp extends LitElement {
@state() private state: AppState = initialAppState(); @state() private state: AppState = initialAppState();
@@ -167,6 +178,7 @@ export class PiWebApp extends LitElement {
private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE; private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE;
@state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID; @state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID;
@state() private isRefreshingApp = false; @state() private isRefreshingApp = false;
@state() private sessionCleanupDialog: SessionCleanupDialogState | undefined;
@state() private settingsSection: SettingsSection | undefined = readSettingsSection(); @state() private settingsSection: SettingsSection | undefined = readSettingsSection();
@state() private shortcutConfig: PiWebShortcutConfig = {}; @state() private shortcutConfig: PiWebShortcutConfig = {};
@state() private workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(undefined); @state() private workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(undefined);
@@ -875,6 +887,7 @@ export class PiWebApp extends LitElement {
this.realtime.close(); this.realtime.close();
this.connectRealtime(); this.connectRealtime();
this.activeTerminalIds.clear(); this.activeTerminalIds.clear();
this.sessionCleanupDialog = undefined;
this.setState({ piWebStatus: undefined }); this.setState({ piWebStatus: undefined });
this.git.updatePolling(); this.git.updatePolling();
void this.loadPluginsForSelectedMachine(); void this.loadPluginsForSelectedMachine();
@@ -1011,6 +1024,11 @@ export class PiWebApp extends LitElement {
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload); return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload);
} }
private canCleanupSessions(): boolean {
const runtime = this.selectedMachineRuntime();
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup);
}
private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean { private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean {
if (machineId === "local") return true; if (machineId === "local") return true;
// COMPAT-CAP workspace.fileSuggestions: remote machines without this // COMPAT-CAP workspace.fileSuggestions: remote machines without this
@@ -1024,10 +1042,60 @@ export class PiWebApp extends LitElement {
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`; return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`;
} }
private sessionCleanupUnavailableMessage(): string {
return sessionCleanupUnavailableMessage(this.state.selectedMachine?.name);
}
private selectedMachineRuntime() { private selectedMachineRuntime() {
return this.state.machineRuntimes[selectedMachineId(this.state)]; return this.state.machineRuntimes[selectedMachineId(this.state)];
} }
private openSessionCleanupDialog(): void {
this.sessionCleanupDialog = { error: "" };
}
private closeSessionCleanupDialog(): void {
this.sessionCleanupDialog = undefined;
}
private async previewSessionCleanup(request: SessionCleanupRequest): Promise<void> {
if (!this.canCleanupSessions()) {
this.sessionCleanupDialog = { ...(this.sessionCleanupDialog ?? {}), error: this.sessionCleanupUnavailableMessage(), preview: undefined, previewRequest: undefined, result: undefined, loading: false };
return;
}
const machineId = selectedMachineId(this.state);
this.sessionCleanupDialog = { ...(this.sessionCleanupDialog ?? {}), loading: true, error: "", preview: undefined, previewRequest: undefined, result: undefined };
try {
const preview = await sessionsApi.cleanupPreview(request, machineId);
if (selectedMachineId(this.state) !== machineId) return;
this.sessionCleanupDialog = { ...this.sessionCleanupDialog, preview, previewRequest: request, result: undefined, loading: false, error: "" };
} catch (error) {
if (selectedMachineId(this.state) === machineId) this.sessionCleanupDialog = { ...this.sessionCleanupDialog, loading: false, error: `Failed to preview cleanup: ${errorMessage(error)}` };
}
}
private async runSessionCleanup(request: SessionCleanupRequest): Promise<void> {
const dialog = this.sessionCleanupDialog;
if (dialog?.preview === undefined || sessionCleanupRequestKey(dialog.previewRequest) !== sessionCleanupRequestKey(request)) {
this.sessionCleanupDialog = { ...(dialog ?? {}), error: "Preview cleanup before running it." };
return;
}
if (!this.canCleanupSessions()) {
this.sessionCleanupDialog = { ...dialog, error: this.sessionCleanupUnavailableMessage(), running: false };
return;
}
const machineId = selectedMachineId(this.state);
this.sessionCleanupDialog = { ...dialog, running: true, error: "" };
try {
const result = await sessionsApi.cleanup(request, machineId);
if (selectedMachineId(this.state) !== machineId) return;
this.sessionCleanupDialog = { ...this.sessionCleanupDialog, preview: result, previewRequest: request, result, running: false, error: "" };
await this.sessions.applySessionCleanupResult(result, machineId);
} catch (error) {
if (selectedMachineId(this.state) === machineId) this.sessionCleanupDialog = { ...this.sessionCleanupDialog, running: false, error: `Failed to run cleanup: ${errorMessage(error)}` };
}
}
private renderNavigationPanel() { private renderNavigationPanel() {
return html` return html`
<app-navigation-panel <app-navigation-panel
@@ -1051,10 +1119,13 @@ export class PiWebApp extends LitElement {
.sessionActivities=${this.state.sessionActivities} .sessionActivities=${this.state.sessionActivities}
.sendingPrompts=${this.state.sendingPrompts} .sendingPrompts=${this.state.sendingPrompts}
.selectedSession=${this.state.selectedSession} .selectedSession=${this.state.selectedSession}
.startingSessionCount=${this.state.startingSessionCount}
.canStartSession=${!!this.state.selectedWorkspace} .canStartSession=${!!this.state.selectedWorkspace}
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()} .canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
.canReloadSessions=${this.canReloadSessions()} .canReloadSessions=${this.canReloadSessions()}
.canCleanupSessions=${this.canCleanupSessions()}
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage()}
.cleanupUnavailableMessage=${this.sessionCleanupUnavailableMessage()}
.collapsible=${true} .collapsible=${true}
.compact=${this.appShell.isMobileNavigationLayout} .compact=${this.appShell.isMobileNavigationLayout}
.projectsCollapsed=${this.navigationSections.isCollapsed("projects")} .projectsCollapsed=${this.navigationSections.isCollapsed("projects")}
@@ -1071,7 +1142,7 @@ export class PiWebApp extends LitElement {
.onSelectWorkspace=${(workspace: Workspace) => this.selectNavigationItem("workspaces", "sessions", () => this.workspaces.selectWorkspace(workspace))} .onSelectWorkspace=${(workspace: Workspace) => this.selectNavigationItem("workspaces", "sessions", () => this.workspaces.selectWorkspace(workspace))}
.onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }} .onDeleteWorkspace=${(workspace: Workspace) => { void this.deleteWorkspace(workspace); }}
.onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }} .onArchivedCollapsed=${() => { this.sessions.clearSelectionAfterArchivedCollapse(); }}
.onStartSession=${() => this.selectNavigationItem("sessions", "chat", () => this.sessions.startSession())} .onStartSession=${() => this.startSessionFromNavigation()}
.onSelectSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.selectSession(session))} .onSelectSession=${(session: SessionInfo) => this.selectNavigationItem("sessions", "chat", () => this.sessions.selectSession(session))}
.onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onArchiveSession=${(session: SessionInfo) => this.sessions.archiveSession(session)}
.onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)} .onArchiveSessionWithDescendants=${(session: SessionInfo) => this.sessions.archiveSessionWithDescendants(session)}
@@ -1082,6 +1153,7 @@ export class PiWebApp extends LitElement {
.onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)} .onDeleteArchivedSessions=${(sessions: SessionInfo[]) => this.sessions.deleteArchivedSessions(sessions)}
.onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)} .onDetachParentSession=${(session: SessionInfo) => this.sessions.detachParent(session)}
.onReloadSession=${(session: SessionInfo) => this.sessions.reloadSession(session)} .onReloadSession=${(session: SessionInfo) => this.sessions.reloadSession(session)}
.onCleanupSessions=${() => { this.openSessionCleanupDialog(); }}
.onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }} .onFocusNavigationTarget=${(target: NavigationFocusTarget) => { void this.focusNavigationTarget(target); }}
.onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }} .onCancelKeyboardNavigation=${() => { void this.focusChatComposer(); }}
></app-navigation-panel> ></app-navigation-panel>
@@ -1105,6 +1177,24 @@ export class PiWebApp extends LitElement {
await this.focusNavigationTarget(nextTarget); await this.focusNavigationTarget(nextTarget);
} }
private async startSessionFromNavigation(): Promise<void> {
const seq = ++this.navigationSelectionSeq;
const isCurrentSelection = () => seq === this.navigationSelectionSeq;
this.navigationSections.advanceAfterSelection("sessions");
await this.startSessionAndOpenChat(isCurrentSelection);
}
private async startSessionAndOpenChat(shouldComplete: () => boolean = () => true): Promise<void> {
// `startSession()` remains in flight until the backend session resolves;
// open the chat as soon as the controller has inserted the temporary row.
const start = this.sessions.startSession().catch((error: unknown) => {
if (shouldComplete()) this.setState({ error: String(error) });
});
if (shouldComplete()) await this.focusChatComposer();
void start;
}
private async focusNavigationTarget(target: NavigationFocusTarget): Promise<void> { private async focusNavigationTarget(target: NavigationFocusTarget): Promise<void> {
if (target === "chat") { if (target === "chat") {
await this.focusChatComposer(); await this.focusChatComposer();
@@ -1293,7 +1383,21 @@ export class PiWebApp extends LitElement {
} }
private getDefaultActions(): AppAction[] { private getDefaultActions(): AppAction[] {
return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.navigationFocusActions(), ...this.panelLayoutActions()]; return [...this.plugins.getActions(this.createPluginRuntimeContext()), ...this.sessionActions(), ...this.navigationFocusActions(), ...this.panelLayoutActions()];
}
private sessionActions(): AppAction[] {
const canCleanup = this.canCleanupSessions();
return [
{
id: "app.sessions.cleanup",
title: "Clean Up Sessions",
description: "Preview and manually clean up idle or archived sessions on the selected machine",
group: "Sessions",
...(canCleanup ? {} : { enabled: false, disabledReason: this.sessionCleanupUnavailableMessage() }),
run: () => { this.openSessionCleanupDialog(); },
},
];
} }
private panelLayoutActions(): AppAction[] { private panelLayoutActions(): AppAction[] {
@@ -1462,7 +1566,7 @@ export class PiWebApp extends LitElement {
refreshAppData: () => this.refreshAppData(), refreshAppData: () => this.refreshAppData(),
reloadPage: () => { this.hardReloadApp(); }, reloadPage: () => { this.hardReloadApp(); },
deleteWorkspace: (workspace) => this.deleteWorkspace(workspace), deleteWorkspace: (workspace) => this.deleteWorkspace(workspace),
startSession: () => this.withChatScrollTransition(() => this.sessions.startSession()), startSession: () => this.withChatScrollTransition(() => this.startSessionAndOpenChat()),
archiveSession: () => this.sessions.archiveSession(), archiveSession: () => this.sessions.archiveSession(),
reloadSession: () => this.sessions.reloadSession(), reloadSession: () => this.sessions.reloadSession(),
deleteCachedNewSession: () => this.sessions.deleteCachedNewSession(), deleteCachedNewSession: () => this.sessions.deleteCachedNewSession(),
@@ -1745,6 +1849,25 @@ export class PiWebApp extends LitElement {
void this.sessions.send(text, streamingBehavior, attachments, delivery); void this.sessions.send(text, streamingBehavior, attachments, delivery);
} }
// Stable handler identities for <prompt-editor>. Inlined arrow closures would
// be a fresh reference on every render, forcing Lit to re-commit the bindings
// each time the app re-renders; bound class fields keep them constant.
private readonly handleSendPrompt = (text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery): void => {
this.sendPrompt(text, streamingBehavior, attachments, delivery);
};
private readonly handleStopActiveWork = (): void => {
void this.sessions.stopActiveWork();
};
private readonly handleSelectModel = (): void => {
void this.openModelDialog();
};
private readonly handleSelectThinking = (): void => {
void this.openThinkingDialog();
};
private renderContextBar() { private renderContextBar() {
if (!this.appShell.isMobileNavigationLayout) return null; if (!this.appShell.isMobileNavigationLayout) return null;
return html` return html`
@@ -1803,8 +1926,8 @@ export class PiWebApp extends LitElement {
${state.error ? html`<div class="error">${state.error}</div>` : null} ${state.error ? html`<div class="error">${state.error}</div>` : null}
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div> <div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
${state.selectedSession ? html` ${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} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view> <chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[state.selectedSession.id] ?? []} .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)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .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} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor> <prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .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} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${this.handleSendPrompt} .onStop=${this.handleStopActiveWork} .onSelectModel=${this.handleSelectModel} .onSelectThinking=${this.handleSelectThinking}></prompt-editor>
<status-bar .status=${state.status}></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.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.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}
@@ -1817,8 +1940,9 @@ export class PiWebApp extends LitElement {
${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null} ${state.actionPaletteOpen ? html`<action-palette .actions=${this.getActions()} .onRun=${(action: AppAction) => { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}></action-palette>` : null}
${state.projectDialogOpen ? html`<project-dialog .machineId=${selectedMachineId(state)} .onSubmit=${(path: string, create: boolean) => this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}></project-dialog>` : null} ${state.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.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-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} ${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.getDefaultActions()} .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} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
</div> </div>
`; `;
} }
+1 -1
View File
@@ -131,7 +131,7 @@ export class ProjectDialog extends LitElement {
footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; } footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; }
.body { display: grid; gap: 12px; padding: 12px; min-height: 0; } .body { display: grid; gap: 12px; padding: 12px; min-height: 0; }
label { display: grid; gap: 6px; color: var(--pi-muted); } label { display: grid; gap: 6px; color: var(--pi-muted); }
input[type="text"], input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } input[type="text"], input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: var(--pi-control-font-size, 16px) var(--pi-control-monospace-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); }
.check { display: flex; grid-template-columns: auto 1fr; align-items: center; color: var(--pi-text); } .check { display: flex; grid-template-columns: auto 1fr; align-items: center; color: var(--pi-text); }
.suggestions { min-height: 90px; max-height: 320px; overflow: auto; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); } .suggestions { min-height: 90px; max-height: 320px; overflow: auto; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); }
.suggestions button { display: block; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 0; border-bottom: 1px solid var(--pi-border); border-radius: 0; background: transparent; color: var(--pi-text); padding: 8px 10px; text-align: left; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .suggestions button { display: block; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 0; border-bottom: 1px solid var(--pi-border); border-radius: 0; background: transparent; color: var(--pi-text); padding: 8px 10px; text-align: left; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
+144 -42
View File
@@ -7,25 +7,19 @@ import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js"; import { customElement, property, query, state } from "lit/decorators.js";
import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api"; import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api";
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
import { captureImageAttachments } from "../promptAttachmentCapture"; import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture";
import { inputModeForDraft } from "../inputModes"; import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes";
import { machineSessionKey } from "../machineKeys"; import { machineSessionKey } from "../machineKeys";
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences"; import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences";
import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnterShortcut, shouldUsePromptEnterShiftShortcut } from "../promptEnterBehavior";
import { promptEditorStyles, type CompletionItem } from "./shared"; import { promptEditorStyles, type CompletionItem } from "./shared";
import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons"; import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons";
import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels"; import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels";
import "./AutocompleteMenu"; import "./AutocompleteMenu";
interface PendingAttachment { type PendingAttachment = CapturedAttachment & { id: string };
id: string;
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
}
@customElement("prompt-editor") @customElement("prompt-editor")
export class PromptEditor extends LitElement { export class PromptEditor extends LitElement {
@@ -48,7 +42,14 @@ export class PromptEditor extends LitElement {
@property({ attribute: false }) availableThinkingLevels: readonly string[] = []; @property({ attribute: false }) availableThinkingLevels: readonly string[] = [];
@query(".markdown-editor") private editorHost?: HTMLDivElement; @query(".markdown-editor") private editorHost?: HTMLDivElement;
@query(".attachment-input") private attachmentInput?: HTMLInputElement; @query(".attachment-input") private attachmentInput?: HTMLInputElement;
@state() private draft = ""; // `draft` is the live document text but is intentionally NOT reactive: it
// changes on every keystroke and the visible text is owned by CodeMirror, not
// by Lit's render. Re-rendering the surrounding template on each keystroke is
// wasted work and, on iOS, can interrupt an in-progress touch gesture (the
// long-press edit/paste callout). Only `currentInputMode` (shell vs. normal)
// is reactive, since that is the only draft-derived value the template shows.
private draft = "";
@state() private currentInputMode: InputMode = { kind: "normal" };
@state() private completions: CompletionItem[] = []; @state() private completions: CompletionItem[] = [];
@state() private selectedIndex = 0; @state() private selectedIndex = 0;
@state() private attachments: PendingAttachment[] = []; @state() private attachments: PendingAttachment[] = [];
@@ -59,6 +60,8 @@ export class PromptEditor extends LitElement {
private editor: EditorView | undefined; private editor: EditorView | undefined;
private readonly editableCompartment = new Compartment(); private readonly editableCompartment = new Compartment();
private readonly readOnlyCompartment = new Compartment(); private readonly readOnlyCompartment = new Compartment();
private readonly mobilePromptEnterMedia = createMobilePromptEnterMedia();
private explicitShiftKeyActive = false;
protected override willUpdate(changed: PropertyValues<this>) { protected override willUpdate(changed: PropertyValues<this>) {
if (!changed.has("sessionId") && !changed.has("machineId")) return; if (!changed.has("sessionId") && !changed.has("machineId")) return;
@@ -68,17 +71,29 @@ export class PromptEditor extends LitElement {
if (previousKey !== undefined) saveDraft(previousKey, this.draft); if (previousKey !== undefined) saveDraft(previousKey, this.draft);
const currentKey = draftStorageKey(this.machineId, this.sessionId); const currentKey = draftStorageKey(this.machineId, this.sessionId);
this.draft = currentKey !== undefined ? loadDraft(currentKey) : ""; this.draft = currentKey !== undefined ? loadDraft(currentKey) : "";
this.currentInputMode = inputModeForDraft(this.draft);
this.completions = []; this.completions = [];
this.selectedIndex = 0; this.selectedIndex = 0;
} }
protected override shouldUpdate(changed: PropertyValues<this>): boolean {
// Status updates churn once per token during streaming and hand us a fresh
// object reference each time. When nothing else changed, only re-render if a
// status field the template actually displays differs, so streaming does not
// disturb the editor DOM (and any in-progress touch gesture survives).
if (changed.has("status") && changed.size === 1) {
return !sessionStatusRenderEqual(changed.get("status"), this.status);
}
return true;
}
override firstUpdated(): void { override firstUpdated(): void {
this.createEditor(); this.createEditor();
} }
protected override updated(changed: PropertyValues) { protected override updated(changed: PropertyValues) {
if (changed.has("disabled")) this.updateEditorDisabledState(); if (changed.has("disabled")) this.updateEditorDisabledState();
if (changed.has("draft") || changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc(); if (changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc();
} }
override disconnectedCallback(): void { override disconnectedCallback(): void {
@@ -88,17 +103,17 @@ export class PromptEditor extends LitElement {
} }
override render() { override render() {
const inputMode = inputModeForDraft(this.draft); const shellInputMode = this.currentInputMode.kind === "shell" ? this.currentInputMode : undefined;
const shellMode = inputMode.kind === "shell"; const shellMode = shellInputMode !== undefined;
const queuesInput = this.canSteer || this.isCompacting; const queuesInput = this.canSteer || this.isCompacting;
const busy = this.disabled || this.sending; const busy = this.disabled || this.sending;
return html` return html`
<footer class=${shellMode ? "shell-mode" : ""} @paste=${(event: ClipboardEvent) => { void this.handlePaste(event); }} @dragover=${(event: DragEvent) => { this.handleDragOver(event); }} @drop=${(event: DragEvent) => { void this.handleDrop(event); }}> <footer class=${shellMode ? "shell-mode" : ""} @paste=${(event: ClipboardEvent) => { void this.handlePaste(event); }} @dragover=${(event: DragEvent) => { this.handleDragOver(event); }} @drop=${(event: DragEvent) => { void this.handleDrop(event); }}>
<div class="editor-wrap"> <div class="editor-wrap">
<div class=${`markdown-editor${this.disabled ? " markdown-editor-disabled" : ""}`} aria-label="Message pi" aria-disabled=${this.disabled ? "true" : "false"}></div> <div class=${`markdown-editor${this.disabled ? " markdown-editor-disabled" : ""}`} aria-label="Message pi" aria-disabled=${this.disabled ? "true" : "false"}></div>
<input class="attachment-input" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} /> <input class="attachment-input" type="file" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} />
<button class="editor-attach icon-button" ?disabled=${busy} title="Attach images" aria-label="Attach images" @click=${() => { this.attachmentInput?.click(); }}>${renderAttachIcon()}</button> <button class="editor-attach icon-button" ?disabled=${busy} title="Attach files" aria-label="Attach files" @click=${() => { this.attachmentInput?.click(); }}>${renderAttachIcon()}</button>
${shellMode ? html`<div class="mode-hint">Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null} ${shellMode ? html`<div class="mode-hint">Shell command${shellInputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null} ${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null}
${this.renderAttachments()} ${this.renderAttachments()}
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu> <autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu>
@@ -137,18 +152,20 @@ export class PromptEditor extends LitElement {
private renderAttachments() { private renderAttachments() {
if (this.attachments.length === 0 && this.attachmentError === undefined) return null; if (this.attachments.length === 0 && this.attachmentError === undefined) return null;
const canUseInlineDelivery = promptAttachmentsCanUseInlineDelivery(this.attachments);
const delivery = this.effectiveAttachmentDelivery();
return html` return html`
<div class="attachments" aria-label="Pending attachments"> <div class="attachments" aria-label="Pending attachments">
${this.attachments.map((attachment) => html` ${this.attachments.map((attachment) => html`
<div class="attachment-chip" title=${attachment.name}> <div class=${`attachment-chip ${isInlinePromptAttachment(attachment) ? "attachment-chip-image" : "attachment-chip-file"}`} title=${attachment.name}>
<img src=${`data:${attachment.mimeType};base64,${attachment.data}`} alt=${attachment.name} /> ${this.renderAttachmentPreview(attachment)}
<button type="button" class="attachment-remove" title="Remove attachment" aria-label=${`Remove ${attachment.name}`} @click=${() => { this.removeAttachment(attachment.id); }}>×</button> <button type="button" class="attachment-remove" title="Remove attachment" aria-label=${`Remove ${attachment.name}`} @click=${() => { this.removeAttachment(attachment.id); }}>×</button>
</div> </div>
`)} `)}
${this.attachments.length > 0 ? html` ${this.attachments.length > 0 ? html`
<label class="attachment-delivery" title="How attachments are delivered to the agent"> <label class="attachment-delivery" title=${canUseInlineDelivery ? "How attachments are delivered to the agent" : "General files are saved and mentioned from the workspace"}>
<select .value=${this.attachmentDelivery} @change=${(event: Event) => { this.changeDelivery(event); }}> <select .value=${delivery} @change=${(event: Event) => { this.changeDelivery(event); }}>
<option value="inline">Attach to message</option> <option value="inline" ?disabled=${!canUseInlineDelivery}>Attach to message${canUseInlineDelivery ? "" : " (images only)"}</option>
<option value="folder">Save to .pi-web/attachments</option> <option value="folder">Save to .pi-web/attachments</option>
</select> </select>
</label> </label>
@@ -158,9 +175,24 @@ export class PromptEditor extends LitElement {
`; `;
} }
private renderAttachmentPreview(attachment: PendingAttachment) {
if (isInlinePromptAttachment(attachment)) {
return html`<img src=${`data:${attachment.mimeType};base64,${attachment.data}`} alt=${attachment.name} />`;
}
return html`
<div class="attachment-file-preview" aria-hidden="true">${fileExtensionLabel(attachment.name)}</div>
<span class="attachment-file-name">${attachment.name}</span>
`;
}
private changeDelivery(event: Event) { private changeDelivery(event: Event) {
if (!(event.target instanceof HTMLSelectElement)) return; if (!(event.target instanceof HTMLSelectElement)) return;
this.attachmentDelivery = event.target.value === "folder" ? "folder" : "inline"; const requested = event.target.value === "folder" ? "folder" : "inline";
if (requested === "inline" && !promptAttachmentsCanUseInlineDelivery(this.attachments)) {
event.target.value = "folder";
return;
}
this.attachmentDelivery = requested;
saveAttachmentDelivery(this.attachmentDelivery); saveAttachmentDelivery(this.attachmentDelivery);
} }
@@ -169,7 +201,7 @@ export class PromptEditor extends LitElement {
} }
private async handlePaste(event: ClipboardEvent) { private async handlePaste(event: ClipboardEvent) {
const files = imageFilesFromDataTransfer(event.clipboardData); const files = filesFromDataTransfer(event.clipboardData);
if (files.length === 0) return; if (files.length === 0) return;
event.preventDefault(); event.preventDefault();
await this.addAttachmentFiles(files); await this.addAttachmentFiles(files);
@@ -177,13 +209,11 @@ export class PromptEditor extends LitElement {
private handleDragOver(event: DragEvent) { private handleDragOver(event: DragEvent) {
if (event.dataTransfer === null) return; if (event.dataTransfer === null) return;
if (Array.from(event.dataTransfer.items).some((item) => item.kind === "file" && item.type.startsWith("image/"))) { if (dataTransferHasFiles(event.dataTransfer)) event.preventDefault();
event.preventDefault();
}
} }
private async handleDrop(event: DragEvent) { private async handleDrop(event: DragEvent) {
const files = imageFilesFromDataTransfer(event.dataTransfer); const files = filesFromDataTransfer(event.dataTransfer);
if (files.length === 0) return; if (files.length === 0) return;
event.preventDefault(); event.preventDefault();
await this.addAttachmentFiles(files); await this.addAttachmentFiles(files);
@@ -198,7 +228,7 @@ export class PromptEditor extends LitElement {
private async addAttachmentFiles(files: File[]) { private async addAttachmentFiles(files: File[]) {
this.attachmentError = undefined; this.attachmentError = undefined;
const { attachments, error } = await captureImageAttachments(files, readFileAsBase64); const { attachments, error } = await capturePromptAttachments(files, readFileAsBase64);
if (attachments.length > 0) { if (attachments.length > 0) {
this.attachments = [...this.attachments, ...attachments.map((attachment) => ({ id: `attachment-${String(++this.attachmentSeq)}`, ...attachment }))]; this.attachments = [...this.attachments, ...attachments.map((attachment) => ({ id: `attachment-${String(++this.attachmentSeq)}`, ...attachment }))];
} }
@@ -206,12 +236,11 @@ export class PromptEditor extends LitElement {
} }
private currentAttachments(): PromptAttachment[] { private currentAttachments(): PromptAttachment[] {
return this.attachments.map((attachment) => ({ return this.attachments.map((attachment) => pendingToPromptAttachment(attachment));
kind: "image", }
mimeType: attachment.mimeType,
data: attachment.data, private effectiveAttachmentDelivery(): PromptAttachmentDelivery {
name: attachment.name, return effectivePromptAttachmentDelivery(this.attachmentDelivery, this.attachments);
}));
} }
private createEditor() { private createEditor() {
@@ -228,6 +257,10 @@ export class PromptEditor extends LitElement {
syntaxHighlighting(defaultHighlightStyle, { fallback: true }), syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))), EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))),
EditorView.domEventHandlers({
keyup: (event) => this.handleEditorKeyUp(event),
blur: () => this.resetEditorModifierState(),
}),
placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"), placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"),
this.editableCompartment.of(EditorView.editable.of(!this.disabled)), this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)), this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
@@ -235,11 +268,10 @@ export class PromptEditor extends LitElement {
if (update.docChanged) this.updateDraft(update.state.doc.toString()); if (update.docChanged) this.updateDraft(update.state.doc.toString());
}), }),
keymap.of([ keymap.of([
{ any: (view, event) => this.handleEditorKeyDown(event, view) },
{ key: "ArrowDown", run: () => this.moveCompletion(1) }, { key: "ArrowDown", run: () => this.moveCompletion(1) },
{ key: "ArrowUp", run: () => this.moveCompletion(-1) }, { key: "ArrowUp", run: () => this.moveCompletion(-1) },
{ key: "Escape", run: () => this.closeCompletions() }, { key: "Escape", run: () => this.closeCompletions() },
{ key: "Enter", run: () => this.handleEditorEnter() },
{ key: "Shift-Enter", run: (view) => insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view) },
{ key: "Tab", run: (view) => this.handleEditorTab(view) }, { key: "Tab", run: (view) => this.handleEditorTab(view) },
{ key: "Shift-Tab", run: (view) => indentWithTab.shift?.(view) ?? false }, { key: "Shift-Tab", run: (view) => indentWithTab.shift?.(view) ?? false },
{ key: "Backspace", run: (view) => deleteMarkupBackward(view) }, { key: "Backspace", run: (view) => deleteMarkupBackward(view) },
@@ -275,6 +307,8 @@ export class PromptEditor extends LitElement {
this.draft = value; this.draft = value;
const key = draftStorageKey(this.machineId, this.sessionId); const key = draftStorageKey(this.machineId, this.sessionId);
if (key !== undefined) saveDraft(key, this.draft); if (key !== undefined) saveDraft(key, this.draft);
const nextInputMode = inputModeForDraft(this.draft);
if (!inputModesEqual(nextInputMode, this.currentInputMode)) this.currentInputMode = nextInputMode;
void this.refreshCompletions(); void this.refreshCompletions();
} }
@@ -335,12 +369,41 @@ export class PromptEditor extends LitElement {
return true; return true;
} }
private handleEditorEnter(): boolean { private handleEditorKeyDown(event: KeyboardEvent, view: EditorView): boolean {
if (this.completions.length) { if (event.key === "Shift") {
this.explicitShiftKeyActive = true;
return false;
}
if (event.key !== "Enter") {
this.explicitShiftKeyActive = false;
return false;
}
if (event.defaultPrevented || event.isComposing || view.composing) return false;
const shiftKey = shouldUsePromptEnterShiftShortcut(event.shiftKey, this.explicitShiftKeyActive, this.mobilePromptEnterMedia);
this.explicitShiftKeyActive = false;
return this.handleEditorEnter(view, shiftKey);
}
private handleEditorKeyUp(event: KeyboardEvent): boolean {
if (event.key === "Shift") this.explicitShiftKeyActive = false;
return false;
}
private resetEditorModifierState(): boolean {
this.explicitShiftKeyActive = false;
return false;
}
private handleEditorEnter(view: EditorView, shiftKey: boolean): boolean {
if (!shiftKey && this.completions.length) {
const completion = this.completions[this.selectedIndex]; const completion = this.completions[this.selectedIndex];
if (completion !== undefined) this.pick(completion); if (completion !== undefined) this.pick(completion);
return true; return true;
} }
if (!shouldSendPromptOnEnterShortcut(shiftKey, this.mobilePromptEnterMedia, readPromptEnterPreference())) {
return insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view);
}
this.send(this.canSteer || this.isCompacting ? "followUp" : undefined); this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
return true; return true;
} }
@@ -380,7 +443,7 @@ export class PromptEditor extends LitElement {
if (text === "" && pending.length === 0) return; if (text === "" && pending.length === 0) return;
const behavior = this.canSteer || this.isCompacting ? streamingBehavior : undefined; const behavior = this.canSteer || this.isCompacting ? streamingBehavior : undefined;
const attachments = pending.length > 0 ? this.currentAttachments() : undefined; const attachments = pending.length > 0 ? this.currentAttachments() : undefined;
const delivery = this.attachmentDelivery; const delivery = this.effectiveAttachmentDelivery();
this.resetComposer(); this.resetComposer();
// Sending is owned by the controller (it drives the chat activity dock and, // Sending is owned by the controller (it drives the chat activity dock and,
// for folder mode, orchestrates the upload + reference rewrite), so this is // for folder mode, orchestrates the upload + reference rewrite), so this is
@@ -390,16 +453,33 @@ export class PromptEditor extends LitElement {
private resetComposer() { private resetComposer() {
this.draft = ""; this.draft = "";
this.currentInputMode = { kind: "normal" };
const key = draftStorageKey(this.machineId, this.sessionId); const key = draftStorageKey(this.machineId, this.sessionId);
if (key !== undefined) clearDraft(key); if (key !== undefined) clearDraft(key);
this.completions = []; this.completions = [];
this.attachments = []; this.attachments = [];
this.attachmentError = undefined; this.attachmentError = undefined;
// `draft` is not reactive, so the cleared text will not flow to CodeMirror
// via `updated()`; push it to the editor document explicitly.
this.syncEditorDoc();
} }
static override styles = promptEditorStyles; static override styles = promptEditorStyles;
} }
// The only `status` fields the template reads directly are the model identity
// and thinking level (shown in renderCompactStatus). Everything else the editor
// cares about (canSteer/canStop/isCompacting/sending) is passed as a separate
// property that Lit already diffs by value. Comparing just these fields lets us
// ignore the per-token status churn that does not change anything on screen.
function sessionStatusRenderEqual(a: SessionStatus | undefined, b: SessionStatus | undefined): boolean {
if (a === b) return true;
if (a === undefined || b === undefined) return false;
return a.model?.id === b.model?.id
&& a.model?.provider === b.model?.provider
&& a.thinkingLevel === b.thinkingLevel;
}
function draftStorageKey(machineId: unknown, sessionId: unknown): string | undefined { function draftStorageKey(machineId: unknown, sessionId: unknown): string | undefined {
if (typeof machineId !== "string" || machineId === "") return undefined; if (typeof machineId !== "string" || machineId === "") return undefined;
if (typeof sessionId !== "string" || sessionId === "") return undefined; if (typeof sessionId !== "string" || sessionId === "") return undefined;
@@ -414,9 +494,29 @@ function emptyFileSuggestions(): FileSuggestion[] {
return []; return [];
} }
function imageFilesFromDataTransfer(data: DataTransfer | null): File[] { function filesFromDataTransfer(data: DataTransfer | null): File[] {
if (data === null) return []; if (data === null) return [];
return Array.from(data.files).filter((file) => file.type.startsWith("image/")); return Array.from(data.files);
}
function dataTransferHasFiles(data: DataTransfer): boolean {
const items = Array.from(data.items);
if (items.length > 0) return items.some((item) => item.kind === "file");
return Array.from(data.types).includes("Files");
}
function pendingToPromptAttachment(attachment: PendingAttachment): PromptAttachment {
if (attachment.kind === "image") {
return { kind: "image", mimeType: attachment.mimeType, data: attachment.data, name: attachment.name };
}
return { kind: "file", mimeType: attachment.mimeType, data: attachment.data, name: attachment.name };
}
function fileExtensionLabel(name: string): string {
const trimmed = name.trim();
const dotIndex = trimmed.lastIndexOf(".");
if (dotIndex >= 0 && dotIndex < trimmed.length - 1) return trimmed.slice(dotIndex + 1, dotIndex + 5).toUpperCase();
return "FILE";
} }
function readFileAsBase64(file: File): Promise<string> { function readFileAsBase64(file: File): Promise<string> {
@@ -438,6 +538,7 @@ const proseInputAssistanceAttributes: Record<string, string> = {
autocorrect: "on", autocorrect: "on",
autocapitalize: "sentences", autocapitalize: "sentences",
writingsuggestions: "true", writingsuggestions: "true",
dir: "auto",
}; };
const codeLikeInputAssistanceAttributes: Record<string, string> = { const codeLikeInputAssistanceAttributes: Record<string, string> = {
@@ -445,6 +546,7 @@ const codeLikeInputAssistanceAttributes: Record<string, string> = {
autocorrect: "off", autocorrect: "off",
autocapitalize: "off", autocapitalize: "off",
writingsuggestions: "false", writingsuggestions: "false",
dir: "auto",
}; };
function inputAssistanceContentAttributes(draftBeforeCursor: string): Record<string, string> { function inputAssistanceContentAttributes(draftBeforeCursor: string): Record<string, string> {
@@ -0,0 +1,280 @@
import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest } from "../api";
import { canRunSessionCleanup, confirmSessionCleanup, DEFAULT_SESSION_CLEANUP_DRAFT, selectedSessionCleanupProjectCwds, sessionCleanupPreviewForSelectedProjects, sessionCleanupPreviewHasTargets, sessionCleanupRequestKey, validateSessionCleanupDraft, type SessionCleanupDraft } from "../sessionCleanupUi";
@customElement("session-cleanup-dialog")
export class SessionCleanupDialog extends LitElement {
@property({ type: Boolean }) canCleanup = true;
@property({ type: String }) unavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions.";
@property({ attribute: false }) preview?: SessionCleanupPreviewResponse;
@property({ attribute: false }) previewRequest?: SessionCleanupRequest;
@property({ attribute: false }) result?: SessionCleanupExecuteResponse;
@property({ type: Boolean }) loading = false;
@property({ type: Boolean }) running = false;
@property({ type: String }) error = "";
@property({ attribute: false }) onPreview?: (request: SessionCleanupRequest) => void | Promise<void>;
@property({ attribute: false }) onRun?: (request: SessionCleanupRequest) => void | Promise<void>;
@property({ attribute: false }) onClose?: () => void;
@state() private draft: SessionCleanupDraft = { ...DEFAULT_SESSION_CLEANUP_DRAFT };
@state() private formError = "";
@state() private selectedProjectCwds: string[] | undefined;
override willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has("preview")) this.selectedProjectCwds = this.preview?.projects.map((project) => project.cwd);
}
override render(): TemplateResult {
const validation = validateSessionCleanupDraft(this.draft);
const selectedPreview = this.selectedPreview();
const runEnabled = canRunSessionCleanup({ canCleanup: this.canCleanup, draft: this.draft, preview: selectedPreview, previewRequest: this.previewRequest, loading: this.loading, running: this.running });
const runTitle = runEnabled ? "Run cleanup" : selectedPreview !== undefined && !sessionCleanupPreviewHasTargets(selectedPreview) ? "Select at least one project to run cleanup" : "Preview cleanup before running it";
return html`
<div class="backdrop" @mousedown=${() => { this.onClose?.(); }}>
<section role="dialog" aria-modal="true" aria-label="Clean up sessions" @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }} @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}>
<header>
<div>
<span class="eyebrow">Sessions</span>
<h1>Clean up sessions</h1>
</div>
<button class="close-button" title="Close cleanup" aria-label="Close cleanup" @click=${() => { this.onClose?.(); }}>×</button>
</header>
<div class="body">
<p class="intro">Preview manual cleanup for this machine before archiving idle sessions or permanently deleting old archived sessions.</p>
${this.canCleanup ? this.renderForm(validation.ok ? "" : validation.error) : this.renderUnavailable()}
${this.renderMessage()}
${this.preview === undefined ? null : this.renderPreview(this.preview)}
${this.result === undefined ? null : this.renderResult(this.result)}
</div>
<footer>
<button @click=${() => { this.onClose?.(); }}>${this.result === undefined ? "Cancel" : "Close"}</button>
<button ?disabled=${!this.canCleanup || this.loading || this.running} @click=${() => { this.previewCleanup(); }}>${this.loading ? "Previewing…" : "Preview"}</button>
<button class="danger" ?disabled=${!runEnabled} title=${runTitle} @click=${() => { this.runCleanup(); }}>${this.running ? "Running…" : "Run cleanup"}</button>
</footer>
</section>
</div>
`;
}
private renderForm(validationError: string): TemplateResult {
const disabled = this.loading || this.running;
const validation = validateSessionCleanupDraft(this.draft);
const previewOutOfDate = this.preview !== undefined && validation.ok && sessionCleanupRequestKey(validation.request) !== sessionCleanupRequestKey(this.previewRequest) && sessionCleanupPreviewHasTargets(this.preview);
return html`
<fieldset ?disabled=${disabled}>
<label class="toggle-row">
<input type="checkbox" .checked=${this.draft.archiveIdleEnabled} @change=${(event: Event) => { this.updateDraft({ archiveIdleEnabled: checkedValue(event) }); }}>
<span>Archive non-archived sessions idle for more than</span>
<input class="days" type="number" min="0" step="1" inputmode="numeric" .value=${this.draft.archiveIdleDays} ?disabled=${disabled || !this.draft.archiveIdleEnabled} @input=${(event: Event) => { this.updateDraft({ archiveIdleDays: inputValue(event) }); }}>
<span>days</span>
</label>
<label class="toggle-row delete-row">
<input type="checkbox" .checked=${this.draft.deleteArchivedEnabled} @change=${(event: Event) => { this.updateDraft({ deleteArchivedEnabled: checkedValue(event) }); }}>
<span>Delete archived sessions archived for more than</span>
<input class="days" type="number" min="0" step="1" inputmode="numeric" .value=${this.draft.deleteArchivedDays} ?disabled=${disabled || !this.draft.deleteArchivedEnabled} @input=${(event: Event) => { this.updateDraft({ deleteArchivedDays: inputValue(event) }); }}>
<span>days</span>
</label>
</fieldset>
<p class="warning"><strong>Deletion is permanent.</strong> Cleanup only deletes sessions that are already archived.</p>
${validationError === "" ? null : html`<div class="dialog-error" role="alert">${validationError}</div>`}
${previewOutOfDate ? html`<div class="hint" role="status">Thresholds changed. Preview again before running cleanup.</div>` : null}
`;
}
private renderUnavailable(): TemplateResult {
return html`<div class="unavailable" role="status">${this.unavailableMessage}</div>`;
}
private renderMessage(): TemplateResult | null {
const message = this.formError || this.error;
return message === "" ? null : html`<div class="dialog-error" role="alert">${message}</div>`;
}
private renderPreview(preview: SessionCleanupPreviewResponse): TemplateResult {
const selectedCwds = this.selectedProjectCwdsForPreview();
const selected = new Set(selectedCwds);
const selectedPreview = sessionCleanupPreviewForSelectedProjects(preview, selectedCwds);
return html`
<section class="preview" aria-label="Cleanup preview">
<h2>Preview</h2>
${preview.projects.length === 0 ? html`<p class="empty">No sessions match these thresholds.</p>` : html`
${this.renderSelectionControls(preview, selectedCwds)}
<div class="table-scroll" tabindex="0" aria-label="Cleanup projects table">
<table>
<thead>
<tr><th>Clean up</th><th>Project/workspace path</th><th>Archive</th><th>Delete archived</th></tr>
</thead>
<tbody>
${preview.projects.map((project) => this.renderProjectRow(project, selected.has(project.cwd)))}
</tbody>
<tfoot>
<tr><th colspan="2">Selected totals</th><td>${selectedPreview.totals.archiveCount}</td><td>${selectedPreview.totals.deleteCount}</td></tr>
</tfoot>
</table>
</div>
`}
${preview.skippedBusySessionIds === undefined || preview.skippedBusySessionIds.length === 0 ? null : html`<p class="hint">${preview.skippedBusySessionIds.length} busy ${preview.skippedBusySessionIds.length === 1 ? "session was" : "sessions were"} skipped.</p>`}
</section>
`;
}
private renderSelectionControls(preview: SessionCleanupPreviewResponse, selectedCwds: readonly string[]): TemplateResult {
const disabled = this.loading || this.running;
return html`
<div class="selection-controls" role="group" aria-label="Project selection">
<span>${selectedCwds.length} of ${preview.projects.length} projects selected</span>
<button ?disabled=${disabled || selectedCwds.length === preview.projects.length} @click=${() => { this.selectAllProjects(); }}>Select all</button>
<button ?disabled=${disabled || selectedCwds.length === 0} @click=${() => { this.deselectAllProjects(); }}>Deselect all</button>
</div>
${selectedCwds.length === 0 ? html`<p class="hint" role="status">Select at least one project to run cleanup.</p>` : null}
`;
}
private renderProjectRow(project: SessionCleanupProjectSummary, selected: boolean): TemplateResult {
return html`
<tr class=${selected ? "" : "unselected"}>
<td class="select-cell"><input type="checkbox" aria-label=${`Clean up ${project.cwd}`} .checked=${selected} ?disabled=${this.running} @change=${(event: Event) => { this.setProjectSelected(project.cwd, checkedValue(event)); }}></td>
<th title=${project.cwd} dir="auto">${project.cwd}</th>
<td>${project.archiveCount}</td>
<td>${project.deleteCount}</td>
</tr>
`;
}
private renderResult(result: SessionCleanupExecuteResponse): TemplateResult {
return html`
<section class="result" aria-label="Cleanup result">
<h2>Cleanup complete</h2>
<p>Archived ${result.archivedSessionIds.length} ${result.archivedSessionIds.length === 1 ? "session" : "sessions"}; permanently deleted ${result.deletedSessionIds.length} archived ${result.deletedSessionIds.length === 1 ? "session" : "sessions"}.</p>
</section>
`;
}
private updateDraft(patch: Partial<SessionCleanupDraft>): void {
this.draft = { ...this.draft, ...patch };
this.formError = "";
}
private selectedPreview(): SessionCleanupPreviewResponse | undefined {
return this.preview === undefined ? undefined : sessionCleanupPreviewForSelectedProjects(this.preview, this.selectedProjectCwdsForPreview());
}
private selectedProjectCwdsForPreview(): string[] {
return this.preview === undefined ? [] : selectedSessionCleanupProjectCwds(this.preview, this.selectedProjectCwds);
}
private selectAllProjects(): void {
this.selectedProjectCwds = this.preview?.projects.map((project) => project.cwd) ?? [];
this.formError = "";
}
private deselectAllProjects(): void {
this.selectedProjectCwds = [];
this.formError = "";
}
private setProjectSelected(cwd: string, selected: boolean): void {
const preview = this.preview;
if (preview === undefined) return;
const selectedCwds = new Set(this.selectedProjectCwdsForPreview());
if (selected) selectedCwds.add(cwd);
else selectedCwds.delete(cwd);
this.selectedProjectCwds = preview.projects.map((project) => project.cwd).filter((projectCwd) => selectedCwds.has(projectCwd));
this.formError = "";
}
private previewCleanup(): void {
const validation = validateSessionCleanupDraft(this.draft);
if (!validation.ok) {
this.formError = validation.error;
return;
}
this.formError = "";
void this.onPreview?.(validation.request);
}
private runCleanup(): void {
const validation = validateSessionCleanupDraft(this.draft);
if (!validation.ok) {
this.formError = validation.error;
return;
}
const selectedPreview = this.selectedPreview();
const selectedProjectCwds = this.selectedProjectCwdsForPreview();
if (!canRunSessionCleanup({ canCleanup: this.canCleanup, draft: this.draft, preview: selectedPreview, previewRequest: this.previewRequest })) {
this.formError = selectedPreview !== undefined && !sessionCleanupPreviewHasTargets(selectedPreview) ? "Select at least one project to run cleanup." : "Preview cleanup before running it.";
return;
}
if (selectedPreview === undefined || !confirmSessionCleanup(selectedPreview, (message) => confirm(message))) return;
this.formError = "";
void this.onRun?.({ ...validation.request, projectCwds: selectedProjectCwds });
}
private handleKeyDown(event: KeyboardEvent): void {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
this.onClose?.();
}
static override styles = css`
:host { position: fixed; inset: 0; z-index: 30; color: var(--pi-text); font: 14px system-ui, sans-serif; }
.backdrop { box-sizing: border-box; width: 100%; height: 100dvh; display: grid; place-items: center; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); background: var(--pi-overlay); overflow: hidden; }
section[role="dialog"] { width: min(760px, 100%); max-height: min(760px, 100%); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; border: 1px solid var(--pi-border); border-radius: 14px; background: var(--pi-bg); box-shadow: 0 20px 60px var(--pi-shadow-strong); overflow: hidden; }
header, footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--pi-border); }
footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; }
.body { min-height: 0; overflow: auto; display: grid; gap: 14px; padding: 16px; }
.eyebrow { display: block; color: var(--pi-muted); font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
h1, h2, p { margin: 0; }
h1 { font-size: 20px; line-height: 1.2; }
h2 { font-size: 15px; }
.intro, .hint, .empty { color: var(--pi-muted); }
fieldset { margin: 0; padding: 0; border: 0; display: grid; gap: 10px; }
.toggle-row { display: grid; grid-template-columns: auto minmax(0, max-content) 88px auto; align-items: center; gap: 8px; color: var(--pi-text); }
input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--pi-accent); }
input.days { box-sizing: border-box; width: 88px; min-width: 0; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; font: var(--pi-control-font-size, 16px) var(--pi-control-font-family, system-ui, sans-serif); }
input.days:disabled { opacity: .55; }
.warning, .unavailable, .dialog-error, .result { border: 1px solid var(--pi-border); border-radius: 10px; padding: 10px 12px; }
.warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-text); }
.unavailable { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-warning); }
.dialog-error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); color: var(--pi-danger); }
.result { border-color: var(--pi-success-border); background: var(--pi-success-bg); }
.preview { display: grid; gap: 10px; min-width: 0; }
.selection-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.selection-controls span { color: var(--pi-muted); }
.selection-controls button { padding: 5px 7px; font-size: 12px; }
.table-scroll { max-width: 100%; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; -webkit-overflow-scrolling: touch; border: 1px solid var(--pi-border); border-radius: 10px; }
table { width: 100%; min-width: 620px; border-collapse: collapse; }
th, td { border-bottom: 1px solid var(--pi-border-muted); padding: 8px 10px; text-align: right; }
thead th:first-child, td.select-cell { width: 72px; text-align: center; }
th:nth-child(2), td:nth-child(2) { text-align: left; }
tbody tr.unselected { opacity: .58; }
tbody th { max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 500; }
tfoot th, tfoot td { border-bottom: 0; font-weight: 700; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; font: inherit; cursor: pointer; }
button:disabled { opacity: .5; cursor: not-allowed; }
button.danger { color: var(--pi-danger); }
button.danger:not(:disabled):hover { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
.close-button { width: 34px; height: 34px; display: grid; place-items: center; border: 0; background: transparent; color: var(--pi-muted); padding: 0; font-size: 24px; }
.close-button:hover, .close-button:focus { color: var(--pi-text); background: var(--pi-surface-hover); }
@media (max-width: 680px) {
.backdrop { padding: 0; place-items: stretch; }
section[role="dialog"] { width: 100%; height: 100dvh; max-height: none; border: 0; border-radius: 0; }
.toggle-row { grid-template-columns: auto minmax(0, 1fr); }
.toggle-row input.days { grid-column: 2; }
.toggle-row span:last-child { grid-column: 2; }
table { min-width: 560px; }
thead th:first-child, td.select-cell { width: 58px; }
}
`;
}
function checkedValue(event: Event): boolean {
return event.target instanceof HTMLInputElement ? event.target.checked : false;
}
function inputValue(event: Event): string {
return event.target instanceof HTMLInputElement ? event.target.value : "";
}
+44 -1
View File
@@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { SessionInfo, SessionStatus } from "../api"; import type { SessionInfo, SessionStatus } from "../api";
import { markCachedNewSessionInfo } from "../cachedNewSessions"; import { markCachedNewSessionInfo } from "../cachedNewSessions";
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
import { sessionRowActivityKind, sessionRowsForCurrentTree } from "./SessionList"; import { sessionRowActivityKind, sessionRowsForCurrentTree } from "./SessionList";
describe("sessionRowActivityKind", () => { describe("sessionRowActivityKind", () => {
const idle: SessionStatus = { sessionId: "s", isStreaming: false, isCompacting: false, isBashRunning: false, pendingMessageCount: 0, queuedMessages: [], tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }; const idle = sessionStatus("s");
it("reports 'sending' for an uploading session, taking precedence over server activity", () => { it("reports 'sending' for an uploading session, taking precedence over server activity", () => {
expect(sessionRowActivityKind(session("s"), idle, undefined, true)).toBe("sending"); expect(sessionRowActivityKind(session("s"), idle, undefined, true)).toBe("sending");
@@ -25,6 +26,34 @@ describe("sessionRowActivityKind", () => {
}); });
}); });
describe("session action eligibility", () => {
it("requires a persisted server signal before archiving", () => {
expect(isArchivableSessionInfo(session("persisted", { persisted: true }))).toBe(true);
expect(isArchivableSessionInfo(session("unknown"))).toBe(false);
expect(isArchivableSessionInfo(session("transient", { persisted: false }))).toBe(false);
expect(isArchivableSessionInfo({ ...session("archived", { persisted: true }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" })).toBe(false);
});
it("allows deleting transient non-archived sessions from server or browser-cached signals", () => {
expect(isTransientNewSessionInfo(session("transient", { persisted: false }))).toBe(true);
expect(isTransientNewSessionInfo(markCachedNewSessionInfo(session("cached")))).toBe(true);
expect(isTransientNewSessionInfo(session("persisted", { persisted: true }))).toBe(false);
expect(isTransientNewSessionInfo({ ...session("archived", { persisted: false }), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" })).toBe(false);
});
it("uses matching status as the freshest persistence signal", () => {
const staleTransient = session("s", { persisted: false });
expect(isArchivableSessionInfo(staleTransient, sessionStatus("s", { persisted: true }))).toBe(true);
expect(isTransientNewSessionInfo(staleTransient, sessionStatus("s", { persisted: true }))).toBe(false);
const stalePersisted = session("s", { persisted: true });
expect(isArchivableSessionInfo(stalePersisted, sessionStatus("s", { persisted: false }))).toBe(false);
expect(isTransientNewSessionInfo(stalePersisted, sessionStatus("s", { persisted: false }))).toBe(true);
expect(isArchivableSessionInfo(staleTransient, sessionStatus("other", { persisted: true }))).toBe(false);
});
});
describe("sessionRowsForCurrentTree", () => { describe("sessionRowsForCurrentTree", () => {
it("keeps archived ancestors visible while they have unarchived descendants", () => { it("keeps archived ancestors visible while they have unarchived descendants", () => {
const parent = { ...session("parent"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" }; const parent = { ...session("parent"), archived: true, archivedAt: "2026-06-09T00:00:00.000Z" };
@@ -58,6 +87,20 @@ function rowSummaries(rows: ReturnType<typeof sessionRowsForCurrentTree>) {
return rows.map((row) => ({ id: row.session.id, depth: row.depth, hasMissingParent: row.hasMissingParent })); return rows.map((row) => ({ id: row.session.id, depth: row.depth, hasMissingParent: row.hasMissingParent }));
} }
function sessionStatus(sessionId: string, overrides: Partial<SessionStatus> = {}): SessionStatus {
return {
sessionId,
isStreaming: false,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
...overrides,
};
}
function session(id: string, overrides: Partial<SessionInfo> = {}): SessionInfo { function session(id: string, overrides: Partial<SessionInfo> = {}): SessionInfo {
return { return {
id, id,
+61 -13
View File
@@ -2,6 +2,8 @@ import { LitElement, css, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import type { SessionActivity, SessionInfo, SessionStatus } from "../api"; import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
import { isCachedNewSessionInfo } from "../cachedNewSessions"; import { isCachedNewSessionInfo } from "../cachedNewSessions";
import { shortSessionId } from "../sessionLabels";
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
import { isSessionActive } from "../../../shared/activity"; import { isSessionActive } from "../../../shared/activity";
import { actionMenuPanelStyle } from "./actionMenu"; import { actionMenuPanelStyle } from "./actionMenu";
import { renderActionActivityIndicator, type ActivityIndicatorKind } from "./activityBadge"; import { renderActionActivityIndicator, type ActivityIndicatorKind } from "./activityBadge";
@@ -11,7 +13,7 @@ import { listStyles } from "./shared";
function sessionLabel(session: SessionInfo): string { function sessionLabel(session: SessionInfo): string {
if (session.name !== undefined && session.name !== "") return session.name; if (session.name !== undefined && session.name !== "") return session.name;
return session.firstMessage !== "" ? session.firstMessage : session.id.slice(0, 8); return session.firstMessage !== "" ? session.firstMessage : shortSessionId(session.id);
} }
export interface SessionRow { export interface SessionRow {
@@ -29,10 +31,13 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) activities: Record<string, SessionActivity> = {}; @property({ attribute: false }) activities: Record<string, SessionActivity> = {};
@property({ attribute: false }) sending: Record<string, true> = {}; @property({ attribute: false }) sending: Record<string, true> = {};
@property({ attribute: false }) selected?: SessionInfo; @property({ attribute: false }) selected?: SessionInfo;
@property({ type: Number }) startingCount = 0;
@property({ type: Boolean }) canStart = false; @property({ type: Boolean }) canStart = false;
@property({ type: Boolean }) canDeleteArchived = false; @property({ type: Boolean }) canDeleteArchived = false;
@property({ type: Boolean }) canReload = false; @property({ type: Boolean }) canReload = false;
@property({ type: Boolean }) canCleanup = false;
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
@property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions.";
@property({ type: Boolean, reflect: true }) collapsible = false; @property({ type: Boolean, reflect: true }) collapsible = false;
@property({ type: Boolean, reflect: true }) collapsed = false; @property({ type: Boolean, reflect: true }) collapsed = false;
@property({ attribute: false }) onSelect?: (session: SessionInfo) => void; @property({ attribute: false }) onSelect?: (session: SessionInfo) => void;
@@ -51,6 +56,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>; @property({ attribute: false }) onDeleteArchivedMany?: (sessions: SessionInfo[]) => void | Promise<void>;
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void; @property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
@property({ attribute: false }) onReload?: (session: SessionInfo) => void; @property({ attribute: false }) onReload?: (session: SessionInfo) => void;
@property({ attribute: false }) onCleanup?: () => void;
@state() private openMenuSessionId: string | undefined; @state() private openMenuSessionId: string | undefined;
@state() private menuStyle = ""; @state() private menuStyle = "";
@@ -106,6 +112,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
${this.collapsed ? null : html` ${this.collapsed ? null : html`
<div class="list-body"> <div class="list-body">
${this.renderCurrentSelectionToolbar(currentSelectableSessions)} ${this.renderCurrentSelectionToolbar(currentSelectableSessions)}
${this.startingCount > 0 ? this.renderStartingSession() : null}
${currentRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0, "current"))} ${currentRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0, "current"))}
${archivedRows.length > 0 ? html` ${archivedRows.length > 0 ? html`
${this.renderArchivedHeading(archivedRows.map((row) => row.session))} ${this.renderArchivedHeading(archivedRows.map((row) => row.session))}
@@ -126,7 +133,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
<h2> <h2>
Sessions Sessions
${this.renderCurrentSelectionButton(currentSessions)} ${this.renderCurrentSelectionButton(currentSessions)}
<button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button> ${this.renderCleanupButton()}
${this.renderStartButton()}
</h2> </h2>
`; `;
} }
@@ -134,10 +142,11 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
const selectedTitle = this.selected?.path ?? selectedSummary; const selectedTitle = this.selected?.path ?? selectedSummary;
return html` return html`
<h2> <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>${this.collapsed ? html`<small class="section-selected" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></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" dir="auto" title=${selectedTitle}>${selectedSummary}</small>` : null}</span></button>
${this.renderCurrentSelectionButton(currentSessions)} ${this.renderCurrentSelectionButton(currentSessions)}
<small class="section-count">${sessionCount}</small> <small class="section-count">${sessionCount}</small>
<button ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button> ${this.renderCleanupButton()}
${this.renderStartButton()}
</h2> </h2>
`; `;
} }
@@ -148,6 +157,27 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
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>`; 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 renderCleanupButton() {
return html`<button class="cleanup-entry" title=${this.canCleanup ? "Preview session cleanup" : this.cleanupUnavailableMessage} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onCleanup?.(); }}>Clean up</button>`;
}
private renderStartButton() {
const title = this.startingCount > 0 ? "Start another session" : "Start a new session";
return html`<button class="start-session-button" title=${title} aria-label=${title} ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>`;
}
private renderStartingSession() {
const plural = this.startingCount !== 1;
return html`
<div class="pending-session-row starting-session" role="status" aria-live="polite">
<div class="action-main">
<span class="action-name"><span class="activity-indicator sending" aria-hidden="true"></span>${plural ? `Starting ${String(this.startingCount)} sessions…` : "Starting session…"}</span>
<small>Waiting for ${plural ? "new sessions" : "the new session"} to be created</small>
</div>
</div>
`;
}
private renderArchivedHeading(archivedSessions: SessionInfo[]) { private renderArchivedHeading(archivedSessions: SessionInfo[]) {
const active = this.selectionScopes.has("archived"); const active = this.selectionScopes.has("archived");
return html` return html`
@@ -163,7 +193,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
if (visibleSessions.length === 0 || !this.selectionScopes.has("current")) return null; if (visibleSessions.length === 0 || !this.selectionScopes.has("current")) return null;
const selectedSessions = this.selectedSessions("current"); const selectedSessions = this.selectedSessions("current");
const archivableSessions = selectedSessions.filter((session) => !isCachedNewSessionInfo(session)); const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id]));
const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id)); const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id));
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length; const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
return html` return html`
@@ -202,6 +232,11 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
const selectionActive = this.selectionScopes.has(scope); const selectionActive = this.selectionScopes.has(scope);
const showsCheckbox = selectionActive && canBulkSelect; const showsCheckbox = selectionActive && canBulkSelect;
const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id); const bulkSelected = showsCheckbox && this.selectedSessionIds.has(session.id);
const status = this.statuses[session.id];
const activity = this.activities[session.id];
const canArchive = isArchivableSessionInfo(session, status);
const canDeleteTransient = isTransientNewSessionInfo(session, status);
const canReloadSession = canArchive && this.canReload;
return html` return html`
<div <div
class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${selectionActive ? "selecting" : ""}" class="action-row ${this.selected?.id === session.id ? "selected" : ""} ${bulkSelected ? "bulk-selected" : ""} ${session.archived === true ? "archived" : ""} ${selectionActive ? "selecting" : ""}"
@@ -213,25 +248,27 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
> >
<div class="action-main ${selectionActive ? "selecting" : ""}"> <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} ${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> <span class="action-name" dir="auto">${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, status, activity)}${String(session.messageCount)} messages</small>
${this.renderActivity(session)} ${this.renderActivity(session)}
</div> </div>
<div class="action-menu"> <div class="action-menu">
<button class="action-menu-toggle" title="Session actions" @click=${(event: MouseEvent) => { event.stopPropagation(); this.toggleMenu(session.id, event.currentTarget); }}>⋯</button> <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` ${this.openMenuSessionId === session.id ? html`
<div class="action-menu-panel" style=${this.menuStyle}> <div class="action-menu-panel" style=${this.menuStyle}>
${isCachedNewSessionInfo(session) ${session.archived === true
? html`<button title="Delete browser-cached new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
: session.archived === true
? html` ? html`
<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button> <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> <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>
` `
: canDeleteTransient
? html`<button title="Delete transient new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
: html` : html`
${canArchive ? html`
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button> <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} ${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}
` : null}
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null} ${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
${this.canReload ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading" : "Reload session from disk"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload</button>` : null} ${canReloadSession ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading from disk" : "Reload session from disk without refreshing Pi runtime resources"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload from disk</button>` : null}
`} `}
</div> </div>
` : null} ` : null}
@@ -278,7 +315,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
} }
private archiveSelectedCurrent(): void { private archiveSelectedCurrent(): void {
const sessions = this.selectedSessions("current").filter((session) => !isCachedNewSessionInfo(session)); const sessions = this.selectedSessions("current").filter((session) => isArchivableSessionInfo(session, this.statuses[session.id]));
this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id)); this.selectedSessionIds = removeSessionIds(this.selectedSessionIds, sessions.map((session) => session.id));
void this.onArchiveMany?.(sessions); void this.onArchiveMany?.(sessions);
} }
@@ -357,8 +394,12 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
this.renderRoot.querySelector<HTMLElement>(".action-row.selected")?.scrollIntoView({ block: "nearest" }); this.renderRoot.querySelector<HTMLElement>(".action-row.selected")?.scrollIntoView({ block: "nearest" });
} }
private renderSessionMetaPrefix(session: SessionInfo) { private renderSessionMetaPrefix(session: SessionInfo, status: SessionStatus | undefined, activity: SessionActivity | undefined) {
if (isCachedNewSessionInfo(session)) return "new · "; if (isTransientNewSessionInfo(session, status)) {
if (activity?.phase === "active") return "creating · ";
if (activity?.phase === "error") return "error · ";
return "new · ";
}
if (session.archived === true) return "read-only · "; if (session.archived === true) return "read-only · ";
return ""; return "";
} }
@@ -372,14 +413,21 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
h2 { min-height: 30px; } h2 { min-height: 30px; }
h2 > .section-count { flex: 0 0 auto; display: inline; color: var(--pi-muted); font-size: inherit; } 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-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; }
.start-session-button { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; min-width: 30px; height: 30px; padding: 0 9px; }
.cleanup-entry { flex: 0 0 auto; padding: 5px 7px; font-size: 12px; text-transform: none; }
.bulk-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; } .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 button { padding: 5px 7px; font-size: 12px; }
.bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); } .bulk-row small { display: inline; min-width: 0; color: var(--pi-muted); }
.action-name, .section-selected { text-align: start; unicode-bidi: plaintext; }
.bulk-row .capability-hint { flex: 1 0 100%; color: var(--pi-warning); } .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); } .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, .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); } 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-row.bulk-selected .action-main { border-color: var(--pi-accent); box-shadow: inset 3px 0 0 var(--pi-accent); }
.pending-session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr); margin: 6px 0; cursor: default; }
.pending-session-row.starting-session .action-main { border-radius: 8px; border-style: dashed; color: var(--pi-muted); }
.pending-session-row.starting-session .action-name { display: flex; align-items: center; gap: 6px; max-height: none; -webkit-line-clamp: 1; }
.pending-session-row.starting-session .activity-indicator { flex: 0 0 auto; margin: 0; }
.action-main.selecting { padding-left: calc(32px + var(--depth, 0) * 16px); } .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; } .session-checkbox { position: absolute; top: 9px; left: calc(8px + var(--depth, 0) * 16px); z-index: 2; margin: 0; }
`]; `];
@@ -0,0 +1,625 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TemplateResult } from "lit";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { configApi, pluginsApi, type Machine, type MachineRuntime, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginInfo, type PiWebPluginsResponse } from "../api";
import { SettingsDialog } from "./SettingsDialog";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("settings-dialog session daemon machine targeting", () => {
it("keeps gateway settings loads on the gateway config/plugin endpoints", async () => {
const config = configResponse({ host: "127.0.0.1" });
const plugins: PiWebPluginsResponse = { plugins: [] };
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
const dialog = new SettingsDialog();
await callDialogPromise(dialog, "loadConfig");
expect(configSpy.mock.calls).toEqual([[]]);
expect(pluginsSpy.mock.calls).toEqual([[]]);
expect(getDialogProperty(dialog, "configResponse")).toBe(config);
expect(getDialogProperty(dialog, "pluginsResponse")).toBe(plugins);
expect(getDialogProperty(dialog, "error")).toBe("");
expect(getDialogProperty(dialog, "loading")).toBe(false);
});
it("loads session-daemon config from the selected machine", async () => {
const config = configResponse({ spawnSessions: false, subsessions: true });
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false });
const savedConfig = configResponse({ spawnSessions: true });
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const dialog = new SettingsDialog();
setDialogProperty(dialog, "configResponse", gatewayConfig);
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
expect(saveSpy.mock.calls).toEqual([[{ spawnSessions: true }, "local"]]);
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ config: { host: "127.0.0.1", spawnSessions: true, subsessions: false } });
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("ignores stale session-daemon load responses after the selected machine changes", async () => {
const load = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const loadPromise = callDialogPromise(dialog, "loadSessiondConfigForTarget");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
load.resolve(configResponse({ spawnSessions: false }));
await loadPromise;
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
it("ignores stale session-daemon save responses after the selected machine changes", async () => {
stubWindowTimers();
const save = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const savePromise = callDialogPromise(dialog, "saveSessiondConfig", { subsessions: true });
expect(getDialogProperty(dialog, "saving")).toBe(true);
dialog.machine = secondRemoteMachine;
save.resolve(configResponse({ subsessions: true }));
await savePromise;
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("skips selected-machine settings loads when the remote runtime does not advertise support", async () => {
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(configResponse({ spawnSessions: true }));
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", true)]));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
await callDialogPromise(dialog, "loadAccessConfigForTarget");
await callDialogPromise(dialog, "loadPluginsForTarget");
expect(configSpy).not.toHaveBeenCalled();
expect(pluginsSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
});
it("does not save remote selected-machine settings when runtime support is missing", async () => {
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ spawnSessions: true }));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
await callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] } });
await callDialogPromise(dialog, "togglePlugin", "info", false);
expect(saveSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
});
it("shows selected-machine settings errors with the selected target name", async () => {
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
expect(getDialogProperty(dialog, "sessiondError")).toBe("Failed to load session-daemon config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
});
describe("settings-dialog general settings machine targeting", () => {
it("renders the active settings panel without the old global scope note", () => {
const dialog = new SettingsDialog();
dialog.section = "general";
dialog.machine = remoteMachine;
const strings = collectTemplateStrings(dialog.render()).join("");
expect(strings).toContain("<settings-general-panel");
expect(strings).not.toContain("scope-note");
expect(strings).not.toContain("This tab edits:");
});
it("keeps gateway server config saves on the gateway config endpoint", async () => {
stubWindowTimers();
const savedConfig = configResponse({ host: "0.0.0.0", port: 9000, allowedHosts: true });
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const onConfigSaved = vi.fn();
const dialog = new SettingsDialog();
dialog.onConfigSaved = onConfigSaved;
await callDialogPromise(dialog, "saveConfig", { host: "0.0.0.0", port: 9000, allowedHosts: true });
expect(saveSpy.mock.calls).toEqual([[{ host: "0.0.0.0", port: 9000, allowedHosts: true }]]);
expect(getDialogProperty(dialog, "configResponse")).toBe(savedConfig);
expect(onConfigSaved).toHaveBeenCalledWith({ host: "0.0.0.0", port: 9000, allowedHosts: true });
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("loads file access and upload config from the selected machine", async () => {
const config = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } });
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadAccessConfigForTarget");
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "accessError")).toBe("");
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
});
it("saves selected-machine file access and upload config through the selected-machine endpoint", async () => {
stubWindowTimers();
const patch = { pathAccess: { allowedPaths: ["/mnt/share", "~/SDKs"] }, uploads: { defaultFolder: "manual/uploads" } };
const savedConfig = configResponse(patch);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
expect(saveSpy.mock.calls).toEqual([[patch, "remote-a"]]);
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "configResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("merges local selected-machine access saves into gateway config without dropping gateway-only values", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["/old"] },
uploads: { defaultFolder: "old/uploads" },
maxUploadBytes: 1234,
});
const patch = { pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {} };
const savedConfig = configResponse({ pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {}, maxUploadBytes: 5678 });
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const onConfigSaved = vi.fn();
const dialog = new SettingsDialog();
dialog.onConfigSaved = onConfigSaved;
setDialogProperty(dialog, "configResponse", gatewayConfig);
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
expect(saveSpy.mock.calls).toEqual([[patch, "local"]]);
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
config: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: {},
maxUploadBytes: 5678,
},
effectiveConfig: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: {},
maxUploadBytes: 5678,
},
});
expect(onConfigSaved).toHaveBeenCalledWith({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: {},
maxUploadBytes: 5678,
});
});
it("ignores stale file access load responses after the selected machine changes", async () => {
const load = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const loadPromise = callDialogPromise(dialog, "loadAccessConfigForTarget");
expect(getDialogProperty(dialog, "accessLoading")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
load.resolve(configResponse({ pathAccess: { allowedPaths: ["/stale"] } }));
await loadPromise;
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "accessError")).toBe("");
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
});
it("ignores stale file access save responses after the selected machine changes", async () => {
const save = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const savePromise = callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } });
expect(getDialogProperty(dialog, "saving")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
save.resolve(configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } }));
await savePromise;
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("shows selected-machine file access errors with the selected target name", async () => {
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadAccessConfigForTarget");
expect(getDialogProperty(dialog, "accessError")).toBe("Failed to load file access/upload config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
});
});
describe("settings-dialog plugin settings machine targeting", () => {
it("loads plugin config and plugin list from the selected machine", async () => {
const config = configResponse({ plugins: { info: { enabled: true } } });
const plugins = pluginsResponse([pluginInfo("info", true)]);
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadPluginsForTarget");
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(plugins);
expect(getDialogProperty(dialog, "pluginError")).toBe("");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
});
it("keeps fulfilled plugin config when the selected machine plugin list is unsupported", async () => {
const config = configResponse({ plugins: { info: { enabled: true } } });
vi.spyOn(configApi, "config").mockResolvedValue(config);
vi.spyOn(pluginsApi, "plugins").mockRejectedValue(new Error("route GET:/api/plugins not found"));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
await callDialogPromise(dialog, "loadPluginsForTarget");
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "pluginError")).toBe("Failed to load PI WEB plugin settings from Lab Mac (remote machine): PI WEB plugins: Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
});
it("saves selected-machine plugin toggles as plugin-only patches and refreshes the selected machine plugin list", async () => {
stubWindowTimers();
const baseConfig = configResponse({
plugins: {
keep: { enabled: true, settings: { level: 1 } },
info: { settings: { color: "blue" } },
},
});
const savedConfig = configResponse({
plugins: {
keep: { enabled: true, settings: { level: 1 } },
info: { enabled: false, settings: { color: "blue" } },
},
});
const refreshedPlugins = pluginsResponse([pluginInfo("info", false), pluginInfo("keep", true)]);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
setDialogProperty(dialog, "selectedPluginConfigResponse", baseConfig);
await callDialogPromise(dialog, "togglePlugin", "info", false);
expect(saveSpy.mock.calls).toEqual([[
{
plugins: {
keep: { enabled: true, settings: { level: 1 } },
info: { enabled: false, settings: { color: "blue" } },
},
},
"remote-a",
]]);
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("merges local selected-machine plugin saves into gateway config without dropping gateway-only values", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: false }, gateway: { settings: { theme: "dark" } } },
});
const savedConfig = configResponse({ plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } } });
const refreshedPlugins = pluginsResponse([pluginInfo("info", true)]);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
const onConfigSaved = vi.fn();
const dialog = new SettingsDialog();
dialog.onConfigSaved = onConfigSaved;
setDialogProperty(dialog, "configResponse", gatewayConfig);
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: false } } }));
await callDialogPromise(dialog, "togglePlugin", "info", true);
expect(saveSpy.mock.calls).toEqual([[{ plugins: { info: { enabled: true } } }, "local"]]);
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
config: {
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
},
effectiveConfig: {
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
},
});
expect(onConfigSaved).toHaveBeenCalledWith({
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
});
});
it("ignores stale plugin load responses after the selected machine changes", async () => {
const configLoad = deferred<PiWebConfigResponse>();
const pluginsLoad = deferred<PiWebPluginsResponse>();
vi.spyOn(configApi, "config").mockReturnValue(configLoad.promise);
vi.spyOn(pluginsApi, "plugins").mockReturnValue(pluginsLoad.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
const loadPromise = callDialogPromise(dialog, "loadPluginsForTarget");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
configLoad.resolve(configResponse({ plugins: { info: { enabled: true } } }));
pluginsLoad.resolve(pluginsResponse([pluginInfo("info", true)]));
await loadPromise;
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "pluginError")).toBe("");
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
});
it("ignores stale plugin save responses after the selected machine changes", async () => {
const save = deferred<PiWebConfigResponse>();
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", false)]));
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
const savePromise = callDialogPromise(dialog, "togglePlugin", "info", false);
expect(getDialogProperty(dialog, "saving")).toBe(true);
dialog.machine = secondRemoteMachine;
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
save.resolve(configResponse({ plugins: { info: { enabled: false } } }));
await savePromise;
expect(pluginsSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
});
const remoteMachine: Machine = {
id: "remote-a",
name: "Lab Mac",
kind: "remote",
baseUrl: "https://lab.example.test",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
};
const secondRemoteMachine: Machine = {
id: "remote-b",
name: "Build Box",
kind: "remote",
baseUrl: "https://build.example.test",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
};
const runtimeWithoutSelectedMachineSettings: MachineRuntime = {
machineId: "remote-a",
ok: true,
checkedAt: "2026-07-01T00:00:00.000Z",
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage],
};
function getDialogProperty(dialog: SettingsDialog, property: string): unknown {
return Reflect.get(dialog, property);
}
function setDialogProperty(dialog: SettingsDialog, property: string, value: unknown): void {
if (!Reflect.set(dialog, property, value)) throw new Error(`Failed to set SettingsDialog property ${property}`);
}
async function callDialogPromise(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): Promise<void> {
const result = callDialogMethod(dialog, methodName, ...args);
if (!(result instanceof Promise)) throw new Error(`SettingsDialog.${methodName} did not return a promise`);
await result;
}
function callDialogUpdated(dialog: SettingsDialog, changed: Map<string, unknown>): void {
const result = callDialogMethod(dialog, "updated", changed);
if (result !== undefined) throw new Error("SettingsDialog.updated returned an unexpected value");
}
function callDialogMethod(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): unknown {
const method: unknown = Reflect.get(dialog, methodName);
if (!isDialogMethod(method)) throw new Error(`SettingsDialog.${methodName} is not callable`);
return method.call(dialog, ...args);
}
function isDialogMethod(value: unknown): value is (this: SettingsDialog, ...args: readonly unknown[]) => unknown {
return typeof value === "function";
}
function collectTemplateStrings(template: TemplateResult): string[] {
const strings: string[] = [];
visitTemplate(template);
return strings;
function visitTemplate(current: TemplateResult): void {
strings.push(...templateStrings(current));
for (const value of templateValues(current)) {
if (Array.isArray(value)) {
for (const item of value) if (isTemplateResult(item)) visitTemplate(item);
} else if (isTemplateResult(value)) {
visitTemplate(value);
}
}
}
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
function pluginsResponse(plugins: PiWebPluginInfo[]): PiWebPluginsResponse {
return { plugins };
}
function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo {
return {
id,
module: `/pi-web-plugins/${id}/plugin.js`,
source: "test",
scope: "local",
machineSpecific: false,
enabled,
};
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolveDeferred: ((value: T) => void) | undefined;
let rejectDeferred: ((error: unknown) => void) | undefined;
const promise = new Promise<T>((resolve, reject) => {
resolveDeferred = resolve;
rejectDeferred = reject;
});
if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized");
return { promise, resolve: resolveDeferred, reject: rejectDeferred };
}
function stubWindowTimers(): void {
vi.stubGlobal("window", {
clearTimeout: vi.fn(),
setTimeout: vi.fn(() => 1),
});
}
+459 -35
View File
@@ -1,31 +1,65 @@
import { css, html, LitElement, type TemplateResult } from "lit"; import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import type { AppAction } from "../actions"; import type { AppAction } from "../actions";
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api"; import { configApi, piPackagesApi, pluginsApi, type Machine, type MachineRuntime, type PiPackageMutationResponse, type PiPackageScope, type PiPackagesResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
import type { SettingsSection } from "../settingsRoute"; import type { SettingsSection } from "../settingsRoute";
import "./settings/SettingsGeneralPanel"; import "./settings/SettingsGeneralPanel";
import "./settings/SettingsSessiondPanel"; import "./settings/SettingsSessiondPanel";
import "./settings/SettingsPackagesPanel";
import "./settings/SettingsPluginsPanel"; import "./settings/SettingsPluginsPanel";
import "./settings/SettingsShortcutsPanel"; import "./settings/SettingsShortcutsPanel";
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading";
import { mergeSelectedMachineAccessConfig } from "./settings/settingsMachineAccessConfig";
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget";
import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settings/settingsPluginConfig";
import { mergeSelectedMachineSessiondConfig } from "./settings/settingsSessiondConfig";
@customElement("settings-dialog") @customElement("settings-dialog")
export class SettingsDialog extends LitElement { export class SettingsDialog extends LitElement {
@property({ attribute: false }) section: SettingsSection = "general"; @property({ attribute: false }) section: SettingsSection = "general";
@property({ attribute: false }) actions: AppAction[] = []; @property({ attribute: false }) actions: AppAction[] = [];
@property({ attribute: false }) machine: Machine | undefined;
@property({ attribute: false }) machineRuntime: MachineRuntime | undefined;
@property({ attribute: false }) onNavigate?: (section: SettingsSection) => void; @property({ attribute: false }) onNavigate?: (section: SettingsSection) => void;
@property({ attribute: false }) onClose?: () => void; @property({ attribute: false }) onClose?: () => void;
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void; @property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
@state() private configResponse: PiWebConfigResponse | undefined; @state() private configResponse: PiWebConfigResponse | undefined;
@state() private accessConfigResponse: PiWebConfigResponse | undefined;
@state() private sessiondConfigResponse: PiWebConfigResponse | undefined;
@state() private pluginsResponse: PiWebPluginsResponse | undefined; @state() private pluginsResponse: PiWebPluginsResponse | undefined;
@state() private selectedPluginConfigResponse: PiWebConfigResponse | undefined;
@state() private selectedPluginsResponse: PiWebPluginsResponse | undefined;
@state() private packagesResponse: PiPackagesResponse | undefined;
@state() private loading = true; @state() private loading = true;
@state() private accessLoading = true;
@state() private sessiondLoading = true;
@state() private pluginLoading = true;
@state() private packageLoading = true;
@state() private saving = false; @state() private saving = false;
@state() private packageOperation: PiPackageOperationState | undefined;
@state() private error = ""; @state() private error = "";
@state() private accessError = "";
@state() private sessiondError = "";
@state() private pluginError = "";
@state() private packageError = "";
@state() private savedMessage = ""; @state() private savedMessage = "";
@state() private packageMessage = "";
private savedMessageTimer: number | undefined; private savedMessageTimer: number | undefined;
private loadRequestSeq = 0;
private accessLoadRequestSeq = 0;
private sessiondLoadRequestSeq = 0;
private pluginLoadRequestSeq = 0;
private packageLoadRequestSeq = 0;
private packageMutationSeq = 0;
override connectedCallback(): void { override connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
void this.loadConfig(); void this.loadConfig();
void this.loadAccessConfigForTarget();
void this.loadSessiondConfigForTarget();
void this.loadPluginsForTarget();
void this.loadPackagesForTarget();
} }
override disconnectedCallback(): void { override disconnectedCallback(): void {
@@ -34,6 +68,37 @@ export class SettingsDialog extends LitElement {
super.disconnectedCallback(); super.disconnectedCallback();
} }
protected override updated(changed: PropertyValues<this>): void {
const currentTarget = this.settingsTarget();
if (changed.has("machine")) {
const previousTarget = settingsMachineTarget(changed.get("machine"));
if (previousTarget.id !== currentTarget.id) {
this.resetAccessStateForTargetChange();
if (this.isConnected) void this.loadAccessConfigForTarget(currentTarget);
this.resetSessiondStateForTargetChange();
if (this.isConnected) void this.loadSessiondConfigForTarget(currentTarget);
this.resetPluginStateForTargetChange();
if (this.isConnected) void this.loadPluginsForTarget(currentTarget);
this.resetPackageStateForTargetChange();
if (this.isConnected) void this.loadPackagesForTarget(currentTarget);
return;
}
}
if (!changed.has("machineRuntime")) return;
if (this.selectedMachineSettingsSupportNeedsReload(changed.get("machineRuntime"), currentTarget)) {
this.resetAccessStateForTargetChange();
if (this.isConnected) void this.loadAccessConfigForTarget(currentTarget);
this.resetSessiondStateForTargetChange();
if (this.isConnected) void this.loadSessiondConfigForTarget(currentTarget);
this.resetPluginStateForTargetChange();
if (this.isConnected) void this.loadPluginsForTarget(currentTarget);
}
if (!this.packageManagementSupportNeedsReload(changed.get("machineRuntime"), currentTarget)) return;
this.resetPackageStateForTargetChange();
if (this.isConnected) void this.loadPackagesForTarget(currentTarget);
}
override render(): TemplateResult { override render(): TemplateResult {
return html` return html`
<div class="backdrop" @mousedown=${() => this.onClose?.()}> <div class="backdrop" @mousedown=${() => this.onClose?.()}>
@@ -47,10 +112,11 @@ export class SettingsDialog extends LitElement {
</header> </header>
<div class="settings-body"> <div class="settings-body">
<nav class="settings-nav" aria-label="Settings sections"> <nav class="settings-nav" aria-label="Settings sections">
${this.renderNavButton("general", "General", "Server config")} ${this.renderNavButton("general", "General", "Gateway + selected machine")}
${this.renderNavButton("sessiond", "Session daemon", "Runtime settings")} ${this.renderNavButton("sessiond", "Session daemon", "Selected machine")}
${this.renderNavButton("plugins", "Plugins", "Enable and disable")} ${this.renderNavButton("packages", "Pi packages", "Selected machine")}
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")} ${this.renderNavButton("plugins", "PI WEB plugins", "Selected machine")}
${this.renderNavButton("shortcuts", "Keyboard", "Gateway shortcuts")}
</nav> </nav>
<main class="settings-content"> <main class="settings-content">
${this.renderActiveSection()} ${this.renderActiveSection()}
@@ -65,13 +131,14 @@ export class SettingsDialog extends LitElement {
if (this.section === "sessiond") { if (this.section === "sessiond") {
return html` return html`
<settings-sessiond-panel <settings-sessiond-panel
.configResponse=${this.configResponse} .configResponse=${this.sessiondConfigResponse}
.loading=${this.loading} .loading=${this.sessiondLoading}
.saving=${this.saving} .saving=${this.saving}
.error=${this.error} .error=${this.sessiondError}
.savedMessage=${this.savedMessage} .savedMessage=${this.savedMessage}
.onReload=${() => this.loadConfig()} .targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)} .onReload=${() => this.loadSessiondConfigForTarget()}
.onSave=${(config: PiWebConfigValues) => this.saveSessiondConfig(config)}
></settings-sessiond-panel> ></settings-sessiond-panel>
`; `;
} }
@@ -89,16 +156,34 @@ export class SettingsDialog extends LitElement {
></settings-shortcuts-panel> ></settings-shortcuts-panel>
`; `;
} }
if (this.section === "packages") {
return html`
<settings-packages-panel
.packagesResponse=${this.packagesResponse}
.targetMachine=${this.packageTarget()}
.managementSupport=${this.packageManagementSupport()}
.loading=${this.packageLoading}
.operation=${this.packageOperation}
.error=${this.packageError}
.operationMessage=${this.packageMessage}
.onReload=${() => this.loadPackagesForTarget()}
.onInstallPackage=${(source: string) => this.installPiPackage(source)}
.onRemovePackage=${(source: string, scope: PiPackageScope) => this.removePiPackage(source, scope)}
.onUpdatePackage=${(source?: string) => this.updatePiPackage(source)}
></settings-packages-panel>
`;
}
if (this.section === "plugins") { if (this.section === "plugins") {
return html` return html`
<settings-plugins-panel <settings-plugins-panel
.configResponse=${this.configResponse} .configResponse=${this.selectedPluginConfigResponse}
.pluginsResponse=${this.pluginsResponse} .pluginsResponse=${this.selectedPluginsResponse}
.loading=${this.loading} .loading=${this.pluginLoading}
.saving=${this.saving} .saving=${this.saving}
.error=${this.error} .error=${this.pluginError}
.savedMessage=${this.savedMessage} .savedMessage=${this.savedMessage}
.onReload=${() => this.loadConfig()} .targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
.onReload=${() => this.loadPluginsForTarget()}
.onTogglePlugin=${(pluginId: string, enabled: boolean) => this.togglePlugin(pluginId, enabled)} .onTogglePlugin=${(pluginId: string, enabled: boolean) => this.togglePlugin(pluginId, enabled)}
></settings-plugins-panel> ></settings-plugins-panel>
`; `;
@@ -106,12 +191,18 @@ export class SettingsDialog extends LitElement {
return html` return html`
<settings-general-panel <settings-general-panel
.configResponse=${this.configResponse} .configResponse=${this.configResponse}
.machineConfigResponse=${this.accessConfigResponse}
.loading=${this.loading} .loading=${this.loading}
.machineLoading=${this.accessLoading}
.saving=${this.saving} .saving=${this.saving}
.error=${this.error} .error=${this.error}
.machineError=${this.accessError}
.savedMessage=${this.savedMessage} .savedMessage=${this.savedMessage}
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
.onReload=${() => this.loadConfig()} .onReload=${() => this.loadConfig()}
.onReloadMachine=${() => this.loadAccessConfigForTarget()}
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)} .onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
.onSaveMachineConfig=${(config: PiWebConfigValues) => this.saveMachineAccessConfig(config)}
></settings-general-panel> ></settings-general-panel>
`; `;
} }
@@ -131,31 +222,152 @@ export class SettingsDialog extends LitElement {
} }
private async loadConfig(): Promise<void> { private async loadConfig(): Promise<void> {
const requestSeq = ++this.loadRequestSeq;
this.loading = true; this.loading = true;
this.error = ""; this.error = "";
try { try {
const [config, plugins] = await Promise.all([configApi.config(), pluginsApi.plugins()]); const result = await loadGatewaySettingsData({
this.configResponse = config; loadConfig: () => configApi.config(),
this.pluginsResponse = plugins; loadPlugins: () => pluginsApi.plugins(),
} catch (error) { });
this.error = `Failed to load settings: ${errorMessage(error)}`; if (!this.isCurrentLoad(requestSeq)) return;
if (result.config !== undefined) this.configResponse = result.config;
if (result.plugins !== undefined) this.pluginsResponse = result.plugins;
this.error = result.error;
} finally { } finally {
this.loading = false; if (this.isCurrentLoad(requestSeq)) this.loading = false;
}
}
private async loadAccessConfigForTarget(target = this.settingsTarget()): Promise<void> {
const requestSeq = ++this.accessLoadRequestSeq;
const support = this.selectedMachineSettingsSupport(target);
if (isSelectedMachineSettingsUnsupported(support)) {
this.accessConfigResponse = undefined;
this.accessLoading = false;
this.accessError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
this.accessLoading = true;
this.accessError = "";
try {
const response = await configApi.config(target.id);
if (!this.isCurrentAccessLoad(requestSeq, target)) return;
this.accessConfigResponse = response;
} catch (error) {
if (this.isCurrentAccessLoad(requestSeq, target)) {
this.accessError = `Failed to load file access/upload config from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
}
} finally {
if (this.isCurrentAccessLoad(requestSeq, target)) this.accessLoading = false;
}
}
private async loadSessiondConfigForTarget(target = this.settingsTarget()): Promise<void> {
const requestSeq = ++this.sessiondLoadRequestSeq;
const support = this.selectedMachineSettingsSupport(target);
if (isSelectedMachineSettingsUnsupported(support)) {
this.sessiondConfigResponse = undefined;
this.sessiondLoading = false;
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
this.sessiondLoading = true;
this.sessiondError = "";
try {
const response = await configApi.config(target.id);
if (!this.isCurrentSessiondLoad(requestSeq, target)) return;
this.sessiondConfigResponse = response;
} catch (error) {
if (this.isCurrentSessiondLoad(requestSeq, target)) {
this.sessiondError = `Failed to load session-daemon config from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
}
} finally {
if (this.isCurrentSessiondLoad(requestSeq, target)) this.sessiondLoading = false;
}
}
private async loadPluginsForTarget(target = this.settingsTarget()): Promise<void> {
const requestSeq = ++this.pluginLoadRequestSeq;
const support = this.selectedMachineSettingsSupport(target);
if (isSelectedMachineSettingsUnsupported(support)) {
this.selectedPluginConfigResponse = undefined;
this.selectedPluginsResponse = undefined;
this.pluginLoading = false;
this.pluginError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
this.pluginLoading = true;
this.pluginError = "";
try {
const [config, plugins] = await Promise.allSettled([configApi.config(target.id), pluginsApi.plugins(target.id)]);
if (!this.isCurrentPluginLoad(requestSeq, target)) return;
const errors: string[] = [];
if (config.status === "fulfilled") this.selectedPluginConfigResponse = config.value;
else errors.push(`config: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(config.reason), target)}`);
if (plugins.status === "fulfilled") this.selectedPluginsResponse = plugins.value;
else errors.push(`PI WEB plugins: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(plugins.reason), target)}`);
this.pluginError = errors.length === 0 ? "" : `Failed to load PI WEB plugin settings from ${settingsMachineTargetLabel(target)}: ${errors.join("; ")}`;
} finally {
if (this.isCurrentPluginLoad(requestSeq, target)) this.pluginLoading = false;
}
}
private async loadPackagesForTarget(target = this.packageTarget()): Promise<void> {
const requestSeq = ++this.packageLoadRequestSeq;
this.packageLoading = true;
this.packageError = "";
this.packageMessage = "";
try {
const result = await loadPiPackagesData(target, (targetId) => piPackagesApi.packages(targetId), this.packageManagementSupport(target));
if (!this.isCurrentPackageLoad(requestSeq, target)) return;
this.packagesResponse = result.packagesResponse;
this.packageError = result.error;
} finally {
if (this.isCurrentPackageLoad(requestSeq, target)) this.packageLoading = false;
} }
} }
private async togglePlugin(pluginId: string, enabled: boolean): Promise<void> { private async togglePlugin(pluginId: string, enabled: boolean): Promise<void> {
const baseConfig = this.configResponse?.config ?? {}; if (this.saving) return;
const currentPlugins = baseConfig.plugins ?? {}; const target = this.settingsTarget();
const currentPluginConfig = currentPlugins[pluginId] ?? {}; const support = this.selectedMachineSettingsSupport(target);
await this.saveConfig({ if (isSelectedMachineSettingsUnsupported(support)) {
...baseConfig, this.pluginError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
plugins: { return;
...currentPlugins, }
[pluginId]: { ...currentPluginConfig, enabled }, if (this.selectedPluginConfigResponse === undefined) {
}, this.pluginError = `Plugin config is not loaded for ${settingsMachineTargetLabel(target)}. Reload before changing plugin enablement.`;
}); return;
await this.refreshPlugins(); }
const patch = pluginEnabledConfigPatch(this.selectedPluginConfigResponse.config, pluginId, enabled);
this.saving = true;
this.pluginError = "";
this.savedMessage = "";
try {
const response = await configApi.saveConfig(patch, target.id);
if (!this.isCurrentSettingsTarget(target)) return;
this.selectedPluginConfigResponse = response;
if (target.kind === "local" && this.configResponse !== undefined) {
this.configResponse = mergeSelectedMachinePluginConfig(this.configResponse, response);
this.onConfigSaved?.(this.configResponse.effectiveConfig);
}
const pluginRefreshError = await this.refreshPluginsForTarget(target);
if (!this.isCurrentSettingsTarget(target)) return;
if (pluginRefreshError !== undefined) this.pluginError = pluginRefreshError;
this.showSavedMessage();
} catch (error) {
if (this.isCurrentSettingsTarget(target)) {
this.pluginError = `Failed to save PI WEB plugin config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
}
} finally {
this.saving = false;
}
} }
private async saveConfig(config: PiWebConfigValues): Promise<void> { private async saveConfig(config: PiWebConfigValues): Promise<void> {
@@ -175,14 +387,226 @@ export class SettingsDialog extends LitElement {
} }
} }
private async refreshPlugins(): Promise<void> { private async saveMachineAccessConfig(config: PiWebConfigValues): Promise<void> {
if (this.saving) return;
const target = this.settingsTarget();
const support = this.selectedMachineSettingsSupport(target);
if (isSelectedMachineSettingsUnsupported(support)) {
this.accessError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
this.saving = true;
this.accessError = "";
this.savedMessage = "";
try {
const response = await configApi.saveConfig(config, target.id);
if (!this.isCurrentSettingsTarget(target)) return;
this.accessConfigResponse = response;
if (target.kind === "local" && this.configResponse !== undefined) {
this.configResponse = mergeSelectedMachineAccessConfig(this.configResponse, response);
this.onConfigSaved?.(this.configResponse.effectiveConfig);
}
this.showSavedMessage();
} catch (error) {
if (this.isCurrentSettingsTarget(target)) {
this.accessError = `Failed to save file access/upload config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
}
} finally {
this.saving = false;
}
}
private async saveSessiondConfig(config: PiWebConfigValues): Promise<void> {
if (this.saving) return;
const target = this.settingsTarget();
const support = this.selectedMachineSettingsSupport(target);
if (isSelectedMachineSettingsUnsupported(support)) {
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
this.saving = true;
this.sessiondError = "";
this.savedMessage = "";
try {
const response = await configApi.saveConfig(config, target.id);
if (!this.isCurrentSettingsTarget(target)) return;
this.sessiondConfigResponse = response;
if (target.kind === "local" && this.configResponse !== undefined) this.configResponse = mergeSelectedMachineSessiondConfig(this.configResponse, response);
this.showSavedMessage();
} catch (error) {
if (this.isCurrentSettingsTarget(target)) {
this.sessiondError = `Failed to save session-daemon config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
}
} finally {
this.saving = false;
}
}
private async installPiPackage(source: string): Promise<void> {
const target = this.packageTarget();
await this.runPiPackageMutation({ kind: "install", source }, "install Pi package", target, () => piPackagesApi.install(source, target.id));
}
private async removePiPackage(source: string, scope: PiPackageScope): Promise<void> {
const target = this.packageTarget();
await this.runPiPackageMutation({ kind: "remove", source }, "remove Pi package", target, () => piPackagesApi.remove(source, scope, target.id));
}
private async updatePiPackage(source?: string): Promise<void> {
const target = this.packageTarget();
await this.runPiPackageMutation(source === undefined ? { kind: "update-all" } : { kind: "update", source }, "update Pi packages", target, () => piPackagesApi.update(source, target.id));
}
private async runPiPackageMutation(operation: PiPackageOperationState, label: string, target: PiPackageTargetContext, mutate: () => Promise<PiPackageMutationResponse>): Promise<void> {
const support = this.packageManagementSupport(target);
if (isPiPackageManagementUnsupported(support)) {
this.packageError = support.message ?? `Pi package management is not available on ${piPackageTargetLabel(target)}.`;
throw new Error(this.packageError);
}
if (this.saving) throw new Error("A settings operation is already running.");
const requestSeq = ++this.packageMutationSeq;
this.packageLoadRequestSeq += 1;
this.packageLoading = false;
this.saving = true;
this.packageOperation = operation;
this.packageError = "";
this.packageMessage = "";
try {
const response = await mutate();
if (!this.isCurrentPackageMutation(requestSeq, target)) return;
this.packagesResponse = { packages: response.packages };
const pluginRefreshError = shouldRefreshGatewayPluginsAfterPiPackageMutation(target) ? await this.refreshGatewayPlugins() : undefined;
if (!this.isCurrentPackageMutation(requestSeq, target)) return;
if (pluginRefreshError !== undefined) this.packageError = pluginRefreshError;
this.packageMessage = piPackageMutationFollowUpMessage(response.action, target);
} catch (error) {
if (this.isCurrentPackageMutation(requestSeq, target)) this.packageError = `Failed to ${label} on ${piPackageTargetLabel(target)}: ${friendlyPiPackageErrorMessage(errorMessage(error), target)}`;
throw error;
} finally {
if (this.packageMutationSeq === requestSeq) {
this.packageOperation = undefined;
this.saving = false;
}
}
}
private async refreshGatewayPlugins(): Promise<string | undefined> {
try { try {
this.pluginsResponse = await pluginsApi.plugins(); this.pluginsResponse = await pluginsApi.plugins();
return undefined;
} catch (error) { } catch (error) {
this.error = `Failed to refresh plugins: ${errorMessage(error)}`; return `Failed to refresh gateway PI WEB plugins: ${errorMessage(error)}`;
} }
} }
private async refreshPluginsForTarget(target: SettingsMachineTarget): Promise<string | undefined> {
try {
const response = await pluginsApi.plugins(target.id);
if (this.isCurrentSettingsTarget(target)) this.selectedPluginsResponse = response;
return undefined;
} catch (error) {
return `Config saved, but failed to refresh PI WEB plugins from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
}
}
private settingsTarget(): SettingsMachineTarget {
return settingsMachineTarget(this.machine);
}
private packageTarget(): PiPackageTargetContext {
return this.settingsTarget();
}
private selectedMachineSettingsSupport(target = this.settingsTarget()): SelectedMachineSettingsSupport {
return selectedMachineSettingsSupport(target, this.machineRuntime);
}
private selectedMachineSettingsSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: SettingsMachineTarget): boolean {
const previousSupport = selectedMachineSettingsSupport(target, previousRuntime);
const currentSupport = this.selectedMachineSettingsSupport(target);
return selectedMachineSettingsSupportKey(previousSupport) !== selectedMachineSettingsSupportKey(currentSupport);
}
private packageManagementSupport(target = this.packageTarget()): PiPackageManagementSupport {
return piPackageManagementSupport(target, this.machineRuntime);
}
private packageManagementSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: PiPackageTargetContext): boolean {
const previousSupport = piPackageManagementSupport(target, previousRuntime);
const currentSupport = this.packageManagementSupport(target);
if (piPackageManagementSupportKey(previousSupport) === piPackageManagementSupportKey(currentSupport)) return false;
return previousSupport.state === "unsupported" || currentSupport.state === "unsupported";
}
private isCurrentLoad(requestSeq: number): boolean {
return requestSeq === this.loadRequestSeq;
}
private isCurrentAccessLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
return requestSeq === this.accessLoadRequestSeq && this.isCurrentSettingsTarget(target);
}
private isCurrentSessiondLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
return requestSeq === this.sessiondLoadRequestSeq && this.isCurrentSettingsTarget(target);
}
private isCurrentPluginLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
return requestSeq === this.pluginLoadRequestSeq && this.isCurrentSettingsTarget(target);
}
private isCurrentPackageLoad(requestSeq: number, target: PiPackageTargetContext): boolean {
return requestSeq === this.packageLoadRequestSeq && this.isCurrentPackageTarget(target);
}
private isCurrentPackageMutation(requestSeq: number, target: PiPackageTargetContext): boolean {
return requestSeq === this.packageMutationSeq && this.isCurrentPackageTarget(target);
}
private isCurrentPackageTarget(target: PiPackageTargetContext): boolean {
return this.packageTarget().id === target.id;
}
private isCurrentSettingsTarget(target: SettingsMachineTarget): boolean {
return this.settingsTarget().id === target.id;
}
private resetAccessStateForTargetChange(): void {
this.accessLoadRequestSeq += 1;
this.accessLoading = false;
this.accessError = "";
this.accessConfigResponse = undefined;
this.savedMessage = "";
}
private resetSessiondStateForTargetChange(): void {
this.sessiondLoadRequestSeq += 1;
this.sessiondLoading = false;
this.sessiondError = "";
this.sessiondConfigResponse = undefined;
this.savedMessage = "";
}
private resetPluginStateForTargetChange(): void {
this.pluginLoadRequestSeq += 1;
this.pluginLoading = false;
this.pluginError = "";
this.selectedPluginConfigResponse = undefined;
this.selectedPluginsResponse = undefined;
this.savedMessage = "";
}
private resetPackageStateForTargetChange(): void {
const hadPackageOperation = this.packageOperation !== undefined;
this.packageLoadRequestSeq += 1;
this.packageMutationSeq += 1;
this.packageLoading = false;
this.packageOperation = undefined;
this.packageMessage = "";
this.packageError = "";
this.packagesResponse = undefined;
if (hadPackageOperation) this.saving = false;
}
private showSavedMessage(): void { private showSavedMessage(): void {
this.savedMessage = "Config saved."; this.savedMessage = "Config saved.";
if (this.savedMessageTimer !== undefined) window.clearTimeout(this.savedMessageTimer); if (this.savedMessageTimer !== undefined) window.clearTimeout(this.savedMessageTimer);
+51 -12
View File
@@ -4,6 +4,11 @@ import type { ToolExecutionPart } from "./shared";
const MAX_COLLAPSED_DIFF_LINES = 180; const MAX_COLLAPSED_DIFF_LINES = 180;
interface ToolTarget {
label: "Command" | "File" | "Input";
text: string;
}
@customElement("tool-execution-view") @customElement("tool-execution-view")
export class ToolExecutionView extends LitElement { export class ToolExecutionView extends LitElement {
@property({ attribute: false }) execution: ToolExecutionPart | undefined; @property({ attribute: false }) execution: ToolExecutionPart | undefined;
@@ -15,7 +20,6 @@ export class ToolExecutionView extends LitElement {
const execution = this.execution; const execution = this.execution;
if (execution === undefined) return null; if (execution === undefined) return null;
const edit = execution.toolName === "edit";
const path = pathFromArgs(execution.args); const path = pathFromArgs(execution.args);
const actualDiff = diffFromDetails(execution.details); const actualDiff = diffFromDetails(execution.details);
const preview = execution.preview; const preview = execution.preview;
@@ -24,6 +28,7 @@ export class ToolExecutionView extends LitElement {
const previewMismatch = actualDiff !== undefined && preview?.diff !== undefined && actualDiff !== preview.diff; const previewMismatch = actualDiff !== undefined && preview?.diff !== undefined && actualDiff !== preview.diff;
const errorText = execution.status === "error" ? execution.resultText : preview?.error; const errorText = execution.status === "error" ? execution.resultText : preview?.error;
const bodyText = visibleDiff === undefined ? execution.resultText : undefined; const bodyText = visibleDiff === undefined ? execution.resultText : undefined;
const target = toolTarget(execution, path);
return html` return html`
<section class=${`tool-card ${execution.status}`}> <section class=${`tool-card ${execution.status}`}>
@@ -31,7 +36,7 @@ export class ToolExecutionView extends LitElement {
<div class="tool-title"> <div class="tool-title">
<span class="status-icon" aria-hidden="true">${statusIcon(execution.status)}</span> <span class="status-icon" aria-hidden="true">${statusIcon(execution.status)}</span>
<strong>${execution.toolName}</strong> <strong>${execution.toolName}</strong>
${path === undefined ? html`<span class="summary">${execution.summary}</span>` : html`<span class="path">${path}</span>`} ${this.renderHeaderTarget(target)}
</div> </div>
<div class="tool-meta"> <div class="tool-meta">
${editCountLabel(execution) === undefined ? null : html`<span>${editCountLabel(execution)}</span>`} ${editCountLabel(execution) === undefined ? null : html`<span>${editCountLabel(execution)}</span>`}
@@ -42,23 +47,44 @@ export class ToolExecutionView extends LitElement {
${previewMismatch ? html`<p class="notice">Applied diff differs from the preview.</p>` : null} ${previewMismatch ? html`<p class="notice">Applied diff differs from the preview.</p>` : null}
${errorText === undefined || errorText === "" ? null : html`<pre class="error-text">${errorText}</pre>`} ${errorText === undefined || errorText === "" ? null : html`<pre class="error-text">${errorText}</pre>`}
${visibleDiff === undefined ? this.renderTextBody(bodyText, execution.status === "error") : this.renderDiffBody(visibleDiff, actualDiff === undefined ? "Preview diff" : "Applied diff")} ${visibleDiff === undefined ? this.renderTextBody(bodyText, execution.status === "error", target) : this.renderDiffBody(visibleDiff, actualDiff === undefined ? "Preview diff" : "Applied diff", target)}
${!edit && visibleDiff === undefined && (bodyText === undefined || bodyText === "") ? html`<p class="muted">${execution.summary}</p>` : null}
</section> </section>
`; `;
} }
private renderTextBody(text: string | undefined, open: boolean) { private renderHeaderTarget(target: ToolTarget | undefined) {
if (text === undefined || text === "") return null; if (target === undefined) return null;
const className = target.label === "File" ? "path" : "summary";
return html`<span class=${className} title=${target.text} aria-label=${`${target.label}: ${target.text}`}>${target.text}</span>`;
}
private renderExpandedTarget(target: ToolTarget | undefined) {
if (target === undefined) return null;
return html`
<div class="detail-target">
<span class="detail-label">${target.label}</span>
<pre class="detail-target-value">${target.text}</pre>
</div>
`;
}
private renderTextBody(text: string | undefined, open: boolean, target: ToolTarget | undefined) {
if ((text === undefined || text === "") && target === undefined) return null;
return html` return html`
<details class="text-body" ?open=${open}> <details class="text-body" ?open=${open}>
<summary>Result</summary> <summary>Details</summary>
${this.renderExpandedTarget(target)}
${text === undefined || text === "" ? null : html`
<div class="detail-result">
<span class="detail-label">Result</span>
<pre>${text}</pre> <pre>${text}</pre>
</div>
`}
</details> </details>
`; `;
} }
private renderDiffBody(diff: string, label: string) { private renderDiffBody(diff: string, label: string, target: ToolTarget | undefined) {
const lines = diff.split("\n"); const lines = diff.split("\n");
const truncated = !this.showFullDiff && lines.length > MAX_COLLAPSED_DIFF_LINES; const truncated = !this.showFullDiff && lines.length > MAX_COLLAPSED_DIFF_LINES;
const visibleLines = truncated ? lines.slice(0, MAX_COLLAPSED_DIFF_LINES) : lines; const visibleLines = truncated ? lines.slice(0, MAX_COLLAPSED_DIFF_LINES) : lines;
@@ -68,6 +94,7 @@ export class ToolExecutionView extends LitElement {
<span>${label}</span> <span>${label}</span>
<small>${String(lines.length)} ${lines.length === 1 ? "line" : "lines"}</small> <small>${String(lines.length)} ${lines.length === 1 ? "line" : "lines"}</small>
</summary> </summary>
${this.renderExpandedTarget(target)}
<div class="diff-toolbar"> <div class="diff-toolbar">
<span>${truncated ? `Showing ${String(visibleLines.length)} of ${String(lines.length)} lines` : "Full diff"}</span> <span>${truncated ? `Showing ${String(visibleLines.length)} of ${String(lines.length)} lines` : "Full diff"}</span>
<button type="button" @click=${() => { void this.copyDiff(diff); }}>${this.copied ? "Copied" : "Copy diff"}</button> <button type="button" @click=${() => { void this.copyDiff(diff); }}>${this.copied ? "Copied" : "Copy diff"}</button>
@@ -104,10 +131,10 @@ export class ToolExecutionView extends LitElement {
.tool-card.success { border-color: var(--pi-success-border); background: var(--pi-success-bg); } .tool-card.success { border-color: var(--pi-success-border); background: var(--pi-success-bg); }
.tool-card.error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); } .tool-card.error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); }
.tool-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; min-width: 0; } .tool-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; min-width: 0; }
.tool-title { display: inline-flex; align-items: baseline; gap: 7px; min-width: 0; } .tool-title { flex: 1 1 auto; display: inline-flex; align-items: baseline; gap: 7px; min-width: 0; }
.status-icon { flex: 0 0 auto; color: var(--pi-muted); } .status-icon { flex: 0 0 auto; color: var(--pi-muted); }
strong { color: var(--pi-text); } strong { flex: 0 0 auto; color: var(--pi-text); }
.path, .summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pi-accent); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .path, .summary { display: block; flex: 1 1 auto; min-width: 0; max-width: 100%; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; white-space: pre; color: var(--pi-accent); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; direction: ltr; text-align: left; unicode-bidi: isolate; }
.summary { color: var(--pi-muted); font-family: inherit; } .summary { color: var(--pi-muted); font-family: inherit; }
.tool-meta { flex: 0 0 auto; display: inline-flex; align-items: baseline; gap: 8px; color: var(--pi-muted); font-size: 12px; } .tool-meta { flex: 0 0 auto; display: inline-flex; align-items: baseline; gap: 8px; color: var(--pi-muted); font-size: 12px; }
.diff-stats { display: inline-flex; gap: 3px; } .diff-stats { display: inline-flex; gap: 3px; }
@@ -118,7 +145,11 @@ export class ToolExecutionView extends LitElement {
.muted { margin: 0; color: var(--pi-muted); } .muted { margin: 0; color: var(--pi-muted); }
.error-text { margin: 0; border: 1px solid var(--pi-danger); border-radius: 7px; background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); color: var(--pi-danger); padding: 8px; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .error-text { margin: 0; border: 1px solid var(--pi-danger); border-radius: 7px; background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-bg)); color: var(--pi-danger); padding: 8px; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.text-body { border-top: 1px solid var(--pi-border-muted); padding-top: 6px; } .text-body { border-top: 1px solid var(--pi-border-muted); padding-top: 6px; }
.text-body pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--pi-text); } .detail-target, .detail-result { display: grid; gap: 4px; margin-top: 8px; min-width: 0; }
.detail-label { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
.text-body pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--pi-text); }
.detail-result pre { box-sizing: border-box; max-width: 100%; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; border: 1px solid var(--pi-border-muted); border-radius: 7px; background: var(--pi-bg); padding: 8px; white-space: pre; overflow-wrap: normal; direction: ltr; text-align: left; unicode-bidi: isolate; }
.detail-target-value { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--pi-accent); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; direction: ltr; text-align: left; unicode-bidi: isolate; }
.diff-details { min-width: 0; max-width: 100%; border-top: 1px solid var(--pi-border-muted); padding-top: 6px; } .diff-details { min-width: 0; max-width: 100%; border-top: 1px solid var(--pi-border-muted); padding-top: 6px; }
.diff-details > summary { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; color: var(--pi-muted); cursor: pointer; } .diff-details > summary { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; color: var(--pi-muted); cursor: pointer; }
.diff-details > summary span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .diff-details > summary span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -140,6 +171,14 @@ export class ToolExecutionView extends LitElement {
`; `;
} }
function toolTarget(execution: ToolExecutionPart, path: string | undefined): ToolTarget | undefined {
if (path !== undefined && path !== "") return { label: "File", text: path };
const command = getString(execution.args, "command");
if (command !== undefined && command !== "") return { label: "Command", text: command };
if (execution.summary !== "") return { label: "Input", text: execution.summary };
return undefined;
}
function pathFromArgs(args: unknown): string | undefined { function pathFromArgs(args: unknown): string | undefined {
return getString(args, "path") ?? getString(args, "file_path"); return getString(args, "path") ?? getString(args, "file_path");
} }
@@ -364,7 +364,7 @@ export class WorkspaceFilesPanel extends LitElement {
form { min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: auto; padding: 16px; } form { min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: auto; padding: 16px; }
form > label { display: grid; gap: 6px; } form > label { display: grid; gap: 6px; }
form > label > span, .review-files > strong { font-weight: 600; } form > label > span, .review-files > strong { font-weight: 600; }
input[type="text"], form > label > input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 8px 9px; font: inherit; } input[type="text"], form > label > input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 8px 9px; font: var(--pi-control-font-size, 16px) var(--pi-control-font-family, system-ui, sans-serif); }
input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; } input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; }
.dialog-options { display: grid; gap: 8px; } .dialog-options { display: grid; gap: 8px; }
.dialog-options label { display: flex; align-items: center; gap: 8px; color: var(--pi-text); } .dialog-options label { display: flex; align-items: center; gap: 8px; color: var(--pi-text); }
@@ -1,6 +1,7 @@
import { LitElement, css, html } from "lit"; import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators.js"; import { customElement, property, query, state } from "lit/decorators.js";
import type { Machine, Project, SessionInfo, Workspace } from "../../api"; import type { Machine, Project, SessionInfo, Workspace } from "../../api";
import { shortSessionId } from "../../sessionLabels";
import type { NavigationSection } from "../../appShell/navigationState"; import type { NavigationSection } from "../../appShell/navigationState";
@customElement("app-context-bar") @customElement("app-context-bar")
@@ -193,7 +194,7 @@ function workspaceContextTitle(workspace: Workspace | undefined): string {
function sessionContextLabel(session: SessionInfo | undefined): string { function sessionContextLabel(session: SessionInfo | undefined): string {
const name = session?.name?.trim(); const name = session?.name?.trim();
const firstMessage = session?.firstMessage.trim(); const firstMessage = session?.firstMessage.trim();
return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session?.id.slice(0, 8) ?? "No session"; return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session === undefined ? "No session" : shortSessionId(session.id);
} }
function sessionContextTitle(session: SessionInfo | undefined): string { function sessionContextTitle(session: SessionInfo | undefined): string {
@@ -39,10 +39,13 @@ export class AppNavigationPanel extends LitElement {
@property({ type: Boolean }) projectsCollapsed = false; @property({ type: Boolean }) projectsCollapsed = false;
@property({ type: Boolean }) workspacesCollapsed = false; @property({ type: Boolean }) workspacesCollapsed = false;
@property({ type: Boolean }) sessionsCollapsed = false; @property({ type: Boolean }) sessionsCollapsed = false;
@property({ type: Number }) startingSessionCount = 0;
@property({ type: Boolean }) canStartSession = false; @property({ type: Boolean }) canStartSession = false;
@property({ type: Boolean }) canDeleteArchivedSessions = false; @property({ type: Boolean }) canDeleteArchivedSessions = false;
@property({ type: Boolean }) canReloadSessions = false; @property({ type: Boolean }) canReloadSessions = false;
@property({ type: Boolean }) canCleanupSessions = false;
@property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions."; @property({ type: String }) archivedDeleteUnavailableMessage = "Update and restart Pi-Web on this machine to delete archived sessions.";
@property({ type: String }) cleanupUnavailableMessage = "Update and restart Pi-Web on this machine to clean up sessions.";
@property({ attribute: false }) onShowActions?: () => void; @property({ attribute: false }) onShowActions?: () => void;
@property({ attribute: false }) onToggleMachines?: () => void; @property({ attribute: false }) onToggleMachines?: () => void;
@property({ attribute: false }) onToggleProjects?: () => void; @property({ attribute: false }) onToggleProjects?: () => void;
@@ -63,6 +66,7 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) onDeleteArchivedSessions?: (sessions: 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 }) onDetachParentSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise<void>; @property({ attribute: false }) onReloadSession?: (session: SessionInfo) => void | Promise<void>;
@property({ attribute: false }) onCleanupSessions?: () => void | Promise<void>;
@property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>; @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise<void>;
@property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>; @property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise<void>;
@property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>; @property({ attribute: false }) onRemoveMachine?: (machine: Machine) => void | Promise<void>;
@@ -156,10 +160,13 @@ export class AppNavigationPanel extends LitElement {
.activities=${this.sessionActivities} .activities=${this.sessionActivities}
.sending=${this.sendingPrompts} .sending=${this.sendingPrompts}
.selected=${this.selectedSession} .selected=${this.selectedSession}
.startingCount=${this.startingSessionCount}
.canStart=${this.canStartSession} .canStart=${this.canStartSession}
.canDeleteArchived=${this.canDeleteArchivedSessions} .canDeleteArchived=${this.canDeleteArchivedSessions}
.canReload=${this.canReloadSessions} .canReload=${this.canReloadSessions}
.canCleanup=${this.canCleanupSessions}
.archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage} .archivedDeleteUnavailableMessage=${this.archivedDeleteUnavailableMessage}
.cleanupUnavailableMessage=${this.cleanupUnavailableMessage}
.collapsible=${this.collapsible} .collapsible=${this.collapsible}
.collapsed=${this.sessionsCollapsed} .collapsed=${this.sessionsCollapsed}
.onToggleCollapsed=${() => { this.onToggleSessions?.(); }} .onToggleCollapsed=${() => { this.onToggleSessions?.(); }}
@@ -175,6 +182,7 @@ export class AppNavigationPanel extends LitElement {
.onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)} .onDeleteArchivedMany=${(sessions: SessionInfo[]) => this.onDeleteArchivedSessions?.(sessions)}
.onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)} .onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)}
.onReload=${(session: SessionInfo) => this.onReloadSession?.(session)} .onReload=${(session: SessionInfo) => this.onReloadSession?.(session)}
.onCleanup=${() => this.onCleanupSessions?.()}
.onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }} .onFocusPreviousSection=${() => { this.focusPreviousFrom("sessions"); }}
.onFocusNextSection=${() => { this.focusNextFrom("sessions"); }} .onFocusNextSection=${() => { this.focusNextFrom("sessions"); }}
.onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }} .onCancelKeyboardNavigation=${() => { this.cancelKeyboardNavigation(); }}
@@ -0,0 +1,254 @@
import { describe, expect, it, vi } from "vitest";
import type { TemplateResult } from "lit";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { SettingsGeneralPanel } from "./SettingsGeneralPanel";
import type { GatewayServerConfigDraft, MachineAccessConfigDraft } from "./settingsConfigDraft";
describe("settings-general-panel copy", () => {
it("uses factual scope copy for gateway and selected-machine settings", () => {
const panel = new SettingsGeneralPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.configResponse = configResponse({ host: "127.0.0.1" });
panel.machineConfigResponse = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } });
const template = panel.render();
const strings = collectTemplateStrings(template).join("");
const values = collectTemplateValues(template);
expect(strings).toContain("<settings-panel-frame");
expect(strings).toContain("Gateway server fields edit this local gateway. File access and upload defaults edit ");
expect(strings).toContain("Host, port, and allowed hosts are saved in the gateway config.");
expect(strings).toContain("External filesystem roots and upload defaults are saved on ");
expect(values.filter((value) => value === "Lab Mac (remote machine)")).toHaveLength(4);
});
it("shows reload copy when selected-machine access config is unavailable", () => {
const panel = new SettingsGeneralPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.configResponse = configResponse({ host: "127.0.0.1" });
panel.machineError = "Failed to load file access/upload config from Lab Mac (remote machine): unsupported";
const template = panel.render();
const values = collectTemplateValues(template);
expect(values).toContain("Save gateway server config");
expect(values).not.toContain("Save file/upload config");
expect(values).toContain("Selected-machine file access config is unavailable. Reload before saving file/upload settings.");
expect(values).toContain("Failed to load file access/upload config from Lab Mac (remote machine): unsupported");
});
it("uses frame notices for saved and gateway messages while keeping selected-machine errors scoped", () => {
const panel = new SettingsGeneralPanel();
panel.error = "Gateway failed";
panel.machineError = "Selected-machine failed";
panel.savedMessage = "Config saved.";
const values = collectTemplateValues(panel.render());
const notices = values.find(isSettingsNoticeArray);
expect(notices).toEqual([
{ type: "error", title: "Gateway server", content: "Gateway failed" },
{ type: "success", content: "Config saved." },
]);
expect(values).toContain("Selected-machine failed");
});
});
describe("settings-general-panel save payloads", () => {
it("saves gateway server fields through the gateway save callback only", async () => {
const panel = new SettingsGeneralPanel();
const onSave = vi.fn();
const onSaveMachineConfig = vi.fn();
const event = new Event("submit", { cancelable: true });
panel.configResponse = configResponse({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["old.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/gateway"] },
uploads: { defaultFolder: "gateway/uploads" },
spawnSessions: true,
});
panel.onSave = onSave;
panel.onSaveMachineConfig = onSaveMachineConfig;
setPanelProperty(panel, "gatewayDraft", {
host: " 0.0.0.0 ",
port: "9000",
allowedHostsMode: "all",
allowedHostsText: "ignored.local",
} satisfies GatewayServerConfigDraft);
await callPanelPromise(panel, "saveGatewayConfig", event);
expect(event.defaultPrevented).toBe(true);
expect(onSave.mock.calls).toEqual([[
{
host: "0.0.0.0",
port: 9000,
allowedHosts: true,
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/gateway"] },
uploads: { defaultFolder: "gateway/uploads" },
spawnSessions: true,
},
]]);
expect(onSaveMachineConfig).not.toHaveBeenCalled();
expect(getPanelProperty(panel, "gatewayLocalError")).toBe("");
});
it("saves external roots and upload defaults through the selected-machine save callback only", async () => {
const panel = new SettingsGeneralPanel();
const onSave = vi.fn();
const onSaveMachineConfig = vi.fn();
const event = new Event("submit", { cancelable: true });
panel.onSave = onSave;
panel.onSaveMachineConfig = onSaveMachineConfig;
setPanelProperty(panel, "machineDraft", {
allowedPathsText: "/tmp\n~/SDKs\n",
uploadDefaultFolder: " manual\\uploads/. ",
} satisfies MachineAccessConfigDraft);
await callPanelPromise(panel, "saveMachineAccessConfig", event);
expect(event.defaultPrevented).toBe(true);
expect(onSaveMachineConfig.mock.calls).toEqual([[
{
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
},
]]);
expect(onSave).not.toHaveBeenCalled();
expect(getPanelProperty(panel, "machineLocalError")).toBe("");
});
it("clears upload defaults with a selected-machine-safe patch", async () => {
const panel = new SettingsGeneralPanel();
const onSaveMachineConfig = vi.fn();
panel.onSaveMachineConfig = onSaveMachineConfig;
setPanelProperty(panel, "machineDraft", {
allowedPathsText: "",
uploadDefaultFolder: "",
} satisfies MachineAccessConfigDraft);
await callPanelPromise(panel, "saveMachineAccessConfig", new Event("submit", { cancelable: true }));
expect(onSaveMachineConfig.mock.calls).toEqual([[
{
pathAccess: { allowedPaths: [] },
uploads: {},
},
]]);
});
it("keeps invalid upload folders local and does not save selected-machine config", async () => {
const panel = new SettingsGeneralPanel();
const onSaveMachineConfig = vi.fn();
panel.onSaveMachineConfig = onSaveMachineConfig;
setPanelProperty(panel, "machineDraft", {
allowedPathsText: "",
uploadDefaultFolder: "/tmp/uploads",
} satisfies MachineAccessConfigDraft);
await callPanelPromise(panel, "saveMachineAccessConfig", new Event("submit", { cancelable: true }));
expect(onSaveMachineConfig).not.toHaveBeenCalled();
expect(getPanelProperty(panel, "machineLocalError")).toBe("Upload default folder must be workspace-relative.");
});
});
function collectTemplateStrings(template: TemplateResult): string[] {
const strings: string[] = [];
visitTemplate(template);
return strings;
function visitTemplate(current: TemplateResult): void {
strings.push(...templateStrings(current));
for (const value of templateValues(current)) {
if (Array.isArray(value)) {
for (const item of value) if (isTemplateResult(item)) visitTemplate(item);
} else if (isTemplateResult(value)) {
visitTemplate(value);
}
}
}
}
function collectTemplateValues(template: TemplateResult): unknown[] {
const values: unknown[] = [];
visit(template);
return values;
function visit(current: unknown): void {
if (Array.isArray(current)) {
for (const item of current) visit(item);
return;
}
if (!isTemplateResult(current)) return;
for (const value of templateValues(current)) {
values.push(value);
visit(value);
}
}
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isSettingsNoticeArray(value: unknown): value is readonly { type: string; content: unknown; title?: string }[] {
return Array.isArray(value)
&& value.length > 0
&& value.every((item: unknown) => typeof item === "object" && item !== null && typeof Reflect.get(item, "type") === "string" && Reflect.has(item, "content"));
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function setPanelProperty(panel: SettingsGeneralPanel, property: string, value: unknown): void {
if (!Reflect.set(panel, property, value)) throw new Error(`Failed to set SettingsGeneralPanel property ${property}`);
}
function getPanelProperty(panel: SettingsGeneralPanel, property: string): unknown {
return Reflect.get(panel, property);
}
async function callPanelPromise(panel: SettingsGeneralPanel, methodName: string, ...args: readonly unknown[]): Promise<void> {
const result = callPanelMethod(panel, methodName, ...args);
if (!(result instanceof Promise)) throw new Error(`SettingsGeneralPanel.${methodName} did not return a promise`);
await result;
}
function callPanelMethod(panel: SettingsGeneralPanel, methodName: string, ...args: readonly unknown[]): unknown {
const method: unknown = Reflect.get(panel, methodName);
if (!isPanelMethod(method)) throw new Error(`SettingsGeneralPanel.${methodName} is not callable`);
return method.call(panel, ...args);
}
function isPanelMethod(value: unknown): value is (this: SettingsGeneralPanel, ...args: readonly unknown[]) => unknown {
return typeof value === "function";
}
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
@@ -1,51 +1,93 @@
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit"; import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../../api"; import { DEFAULT_WORKSPACE_UPLOADS_FOLDER, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues } from "../../api";
import { configFromDraft, draftFromConfig, emptyConfigDraft, type ConfigDraft } from "./settingsConfigDraft"; import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
import {
emptyGatewayServerConfigDraft,
emptyMachineAccessConfigDraft,
gatewayServerConfigFromDraft,
gatewayServerDraftFromConfig,
machineAccessConfigPatchFromDraft,
machineAccessDraftFromConfig,
type GatewayServerConfigDraft,
type MachineAccessConfigDraft,
} from "./settingsConfigDraft";
function generalDescription(targetLabel: string): TemplateResult {
return html`Gateway server fields edit this local gateway. File access and upload defaults edit ${targetLabel}.`;
}
@customElement("settings-general-panel") @customElement("settings-general-panel")
export class SettingsGeneralPanel extends LitElement { export class SettingsGeneralPanel extends LitElement {
@property({ attribute: false }) configResponse: PiWebConfigResponse | undefined; @property({ attribute: false }) configResponse: PiWebConfigResponse | undefined;
@property({ attribute: false }) machineConfigResponse: PiWebConfigResponse | undefined;
@property({ type: Boolean }) loading = false; @property({ type: Boolean }) loading = false;
@property({ type: Boolean }) machineLoading = false;
@property({ type: Boolean }) saving = false; @property({ type: Boolean }) saving = false;
@property() error = ""; @property() error = "";
@property() machineError = "";
@property() savedMessage = ""; @property() savedMessage = "";
@property() targetLabel = "selected machine";
@property({ attribute: false }) onReload?: () => void | Promise<void>; @property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onReloadMachine?: () => void | Promise<void>;
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>; @property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
@state() private draft: ConfigDraft = emptyConfigDraft(); @property({ attribute: false }) onSaveMachineConfig?: (config: PiWebConfigValues) => void | Promise<void>;
@state() private localError = ""; @state() private gatewayDraft: GatewayServerConfigDraft = emptyGatewayServerConfigDraft();
@state() private machineDraft: MachineAccessConfigDraft = emptyMachineAccessConfigDraft();
@state() private gatewayLocalError = "";
@state() private machineLocalError = "";
protected override willUpdate(changed: PropertyValues<this>): void { protected override willUpdate(changed: PropertyValues<this>): void {
if (changed.has("configResponse") && this.configResponse !== undefined) { if (changed.has("configResponse") && this.configResponse !== undefined) {
this.draft = draftFromConfig(this.configResponse.config); this.gatewayDraft = gatewayServerDraftFromConfig(this.configResponse.config);
this.localError = ""; this.gatewayLocalError = "";
}
if (changed.has("machineConfigResponse") && this.machineConfigResponse !== undefined) {
this.machineDraft = machineAccessDraftFromConfig(this.machineConfigResponse.config);
this.machineLocalError = "";
} }
} }
override render(): TemplateResult { override render(): TemplateResult {
return html`
<settings-panel-frame
heading="General configuration"
.description=${generalDescription(this.targetLabel)}
actionLabel="Reload"
.actionDisabled=${this.loading || this.machineLoading}
.notices=${this.panelNotices()}
.onAction=${() => { this.reloadAll(); }}
>
<div class="settings-sections">
${this.renderGatewayServerSettings()}
${this.renderSelectedMachineAccessSettings()}
</div>
</settings-panel-frame>
`;
}
private renderGatewayServerSettings(): TemplateResult {
const config = this.configResponse; const config = this.configResponse;
return html` return html`
<div class="section-heading"> <section class="settings-card" aria-label="Gateway server settings">
<div> <div class="card-heading">
<h2>General configuration</h2> <h3>Gateway server</h3>
<p>Update the JSON config file PI WEB is using. Host and port changes are saved immediately, but require the web service to restart before the running server binds to the new address.</p> <p>Host, port, and allowed hosts are saved in the gateway config. Address changes require the web service to restart before the running server binds to the new address.</p>
</div> </div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button> ${config === undefined && this.loading ? html`<div class="loading-card">Loading gateway configuration…</div>` : html`
</div>
${this.renderMessages()}
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
<div class="config-path-card"> <div class="config-path-card">
<span>Config file</span> <span>Gateway config file</span>
<code>${config?.path ?? "Unknown"}</code> <code>${config?.path ?? "Unknown"}</code>
<small>${config?.exists === true ? "Existing file" : "This file will be created on save"}</small> <small>${config?.exists === true ? "Existing file" : "This file will be created on save"}</small>
</div> </div>
<form class="config-form" @submit=${(event: Event) => { void this.saveConfig(event); }}> <form class="config-form" @submit=${(event: Event) => { void this.saveGatewayConfig(event); }}>
<label class="field"> <label class="field">
<span class="field-heading"> <span class="field-heading">
<span>Host</span> <span>Host</span>
${this.renderOverrideBadge("host")} ${this.renderOverrideBadge("host")}
</span> </span>
<input .value=${this.draft.host} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ host: inputValue(event) }); }}> <input .value=${this.gatewayDraft.host} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateGatewayDraft({ host: inputValue(event) }); }}>
<small>Address the web server should bind to. Leave empty to use PI WEB's default.</small> <small>Address the web server should bind to. Leave empty to use PI WEB's default.</small>
</label> </label>
@@ -54,7 +96,7 @@ export class SettingsGeneralPanel extends LitElement {
<span>Port</span> <span>Port</span>
${this.renderOverrideBadge("port")} ${this.renderOverrideBadge("port")}
</span> </span>
<input .value=${this.draft.port} inputmode="numeric" pattern="[0-9]*" placeholder="8504" autocomplete="off" @input=${(event: Event) => { this.updateDraft({ port: inputValue(event) }); }}> <input .value=${this.gatewayDraft.port} inputmode="numeric" pattern="[0-9]*" placeholder="8504" autocomplete="off" @input=${(event: Event) => { this.updateGatewayDraft({ port: inputValue(event) }); }}>
<small>TCP port from 1 to 65535. Leave empty to use PI WEB's default.</small> <small>TCP port from 1 to 65535. Leave empty to use PI WEB's default.</small>
</label> </label>
@@ -63,37 +105,80 @@ export class SettingsGeneralPanel extends LitElement {
<span>Allowed hosts</span> <span>Allowed hosts</span>
${this.renderOverrideBadge("allowedHosts")} ${this.renderOverrideBadge("allowedHosts")}
</span> </span>
<select .value=${this.draft.allowedHostsMode} @change=${(event: Event) => { this.updateDraft({ allowedHostsMode: selectValue(event) === "all" ? "all" : "list" }); }}> <select .value=${this.gatewayDraft.allowedHostsMode} @change=${(event: Event) => { this.updateGatewayDraft({ allowedHostsMode: selectValue(event) === "all" ? "all" : "list" }); }}>
<option value="list">Only listed hosts</option> <option value="list">Only listed hosts</option>
<option value="all">Allow every host</option> <option value="all">Allow every host</option>
</select> </select>
<textarea .value=${this.draft.allowedHostsText} ?disabled=${this.draft.allowedHostsMode === "all"} rows="4" placeholder="example.local&#10;192.168.1.20" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedHostsText: textAreaValue(event) }); }}></textarea> <textarea .value=${this.gatewayDraft.allowedHostsText} ?disabled=${this.gatewayDraft.allowedHostsMode === "all"} rows="4" placeholder="example.local&#10;192.168.1.20" spellcheck="false" @input=${(event: Event) => { this.updateGatewayDraft({ allowedHostsText: textAreaValue(event) }); }}></textarea>
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small> <small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
</div> </div>
${this.renderGatewayEffectiveConfig()}
<footer class="form-actions">
<button class="primary" ?disabled=${this.loading || this.saving}>${this.saving ? "Saving…" : "Save gateway server config"}</button>
</footer>
</form>
`}
</section>
`;
}
private renderSelectedMachineAccessSettings(): TemplateResult {
const config = this.machineConfigResponse;
return html`
<section class="settings-card" aria-label="Selected machine file access and upload settings">
<div class="card-heading">
<h3>Selected machine file access and uploads</h3>
<p>External filesystem roots and upload defaults are saved on ${this.targetLabel}.</p>
</div>
${this.renderMachineMessages()}
${config === undefined ? html`<div class="loading-card">${this.machineLoading ? "Loading selected-machine file access config…" : "Selected-machine file access config is unavailable. Reload before saving file/upload settings."}</div>` : html`
<div class="config-path-card">
<span>Selected machine config file</span>
<code>${config.path}</code>
<small>${config.exists ? "Existing file" : "This file will be created on save"}</small>
</div>
<form class="config-form" @submit=${(event: Event) => { void this.saveMachineAccessConfig(event); }}>
<label class="field"> <label class="field">
<span class="field-heading"> <span class="field-heading">
<span>External filesystem roots</span> <span>External filesystem roots</span>
</span> </span>
<textarea .value=${this.draft.allowedPathsText} rows="4" placeholder="~/SDKs&#10;/opt/reference" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedPathsText: textAreaValue(event) }); }}></textarea> <textarea .value=${this.machineDraft.allowedPathsText} rows="4" placeholder="~/SDKs&#10;/opt/reference" spellcheck="false" @input=${(event: Event) => { this.updateMachineDraft({ allowedPathsText: textAreaValue(event) }); }}></textarea>
<small>Global allowlist for absolute <code>@</code> completions and file explorer reads outside a workspace. Enter one absolute path, Windows absolute path, or <code>~</code>-prefixed path per line. Leave empty to deny external paths by default.</small> <small>Allowlist for absolute <code>@</code> completions and file explorer reads outside a workspace on ${this.targetLabel}. Enter one absolute path, Windows absolute path, or <code>~</code>-prefixed path per line. Leave empty to deny external paths by default.</small>
</label> </label>
${this.renderEffectiveConfig()} <label class="field">
<span class="field-heading">
<span>Default upload folder</span>
</span>
<input .value=${this.machineDraft.uploadDefaultFolder} placeholder=${DEFAULT_WORKSPACE_UPLOADS_FOLDER} autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateMachineDraft({ uploadDefaultFolder: inputValue(event) }); }}>
<small>Workspace-relative folder for manual file uploads on ${this.targetLabel}. Leave empty to use PI WEB's default <code>${DEFAULT_WORKSPACE_UPLOADS_FOLDER}</code>.</small>
</label>
${this.renderMachineEffectiveConfig()}
<footer class="form-actions"> <footer class="form-actions">
<button class="primary" ?disabled=${this.loading || this.saving}>${this.saving ? "Saving…" : "Save config"}</button> <button class="primary" ?disabled=${this.machineLoading || this.saving}>${this.saving ? "Saving…" : "Save file/upload config"}</button>
</footer> </footer>
</form> </form>
`} `}
</section>
`; `;
} }
private renderMessages(): TemplateResult | null { private panelNotices(): readonly SettingsNotice[] {
const error = this.localError || this.error; const notices: SettingsNotice[] = [];
if (error !== "") return html`<div class="message error-message">${error}</div>`; const gatewayError = this.gatewayLocalError || this.error;
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`; if (gatewayError !== "") notices.push({ type: "error", title: "Gateway server", content: gatewayError });
return null; if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage });
return notices;
}
private renderMachineMessages(): TemplateResult | null {
const error = this.machineLocalError || this.machineError;
if (error === "") return null;
return html`<div class="message error-message">${error}</div>`;
} }
private renderOverrideBadge(key: keyof PiWebConfigEnvOverrides): TemplateResult | null { private renderOverrideBadge(key: keyof PiWebConfigEnvOverrides): TemplateResult | null {
@@ -101,63 +186,94 @@ export class SettingsGeneralPanel extends LitElement {
return html`<span class="override-badge">environment override</span>`; return html`<span class="override-badge">environment override</span>`;
} }
private renderEffectiveConfig(): TemplateResult { private renderGatewayEffectiveConfig(): TemplateResult {
const effective = this.configResponse?.effectiveConfig ?? {}; const effective = this.configResponse?.effectiveConfig ?? {};
return html` return html`
<section class="effective-card" aria-label="Effective configuration summary"> <section class="effective-card" aria-label="Effective gateway configuration summary">
<h3>Effective after environment overrides</h3> <h3>Effective gateway settings after environment overrides</h3>
<dl> <dl>
<div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div> <div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div>
<div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div> <div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div>
<div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div> <div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div>
<div><dt>External roots</dt><dd>${formatAllowedPaths(effective.pathAccess?.allowedPaths)}</dd></div>
</dl> </dl>
</section> </section>
`; `;
} }
private async saveConfig(event: Event): Promise<void> { private renderMachineEffectiveConfig(): TemplateResult {
const effective = this.machineConfigResponse?.effectiveConfig ?? {};
return html`
<section class="effective-card" aria-label="Effective selected machine file access and upload summary">
<h3>Effective selected-machine settings</h3>
<dl>
<div><dt>External roots</dt><dd>${formatAllowedPaths(effective.pathAccess?.allowedPaths)}</dd></div>
<div><dt>Upload folder</dt><dd>${effective.uploads?.defaultFolder ?? html`<span class="muted">${DEFAULT_WORKSPACE_UPLOADS_FOLDER} default</span>`}</dd></div>
</dl>
</section>
`;
}
private reloadAll(): void {
void this.onReload?.();
void this.onReloadMachine?.();
}
private async saveGatewayConfig(event: Event): Promise<void> {
event.preventDefault(); event.preventDefault();
this.localError = ""; this.gatewayLocalError = "";
try { try {
await this.onSave?.(configFromDraft(this.draft, this.configResponse?.config ?? {})); await this.onSave?.(gatewayServerConfigFromDraft(this.gatewayDraft, this.configResponse?.config ?? {}));
} catch (error) { } catch (error) {
this.localError = errorMessage(error); this.gatewayLocalError = errorMessage(error);
} }
} }
private updateDraft(patch: Partial<ConfigDraft>): void { private async saveMachineAccessConfig(event: Event): Promise<void> {
this.draft = { ...this.draft, ...patch }; event.preventDefault();
this.localError = ""; this.machineLocalError = "";
try {
await this.onSaveMachineConfig?.(machineAccessConfigPatchFromDraft(this.machineDraft));
} catch (error) {
this.machineLocalError = errorMessage(error);
}
}
private updateGatewayDraft(patch: Partial<GatewayServerConfigDraft>): void {
this.gatewayDraft = { ...this.gatewayDraft, ...patch };
this.gatewayLocalError = "";
}
private updateMachineDraft(patch: Partial<MachineAccessConfigDraft>): void {
this.machineDraft = { ...this.machineDraft, ...patch };
this.machineLocalError = "";
} }
static override styles = css` static override styles = css`
:host { display: block; } :host { display: block; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } .card-heading { display: grid; gap: 6px; min-width: 0; }
.section-heading > div { display: grid; gap: 6px; min-width: 0; } h3, p { margin: 0; }
h2, h3, p { margin: 0; }
h2 { font-size: 17px; line-height: 1.25; }
h3 { font-size: 13px; line-height: 1.3; } h3 { font-size: 13px; line-height: 1.3; }
p { color: var(--pi-muted); line-height: 1.45; } p { color: var(--pi-muted); line-height: 1.45; }
button, input, select, textarea { font: inherit; } button, input, select, textarea { 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 { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
button:disabled { opacity: .55; cursor: not-allowed; } button:disabled { opacity: .55; cursor: not-allowed; }
.secondary { flex: 0 0 auto; } .settings-sections { display: grid; gap: 14px; }
.message, .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } .settings-card, .message, .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.settings-card { display: grid; gap: 14px; }
.message { margin-bottom: 12px; } .message { margin-bottom: 12px; }
.settings-card .message { margin-bottom: 0; }
.error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } .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 { color: var(--pi-muted); } .loading-card { color: var(--pi-muted); }
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; } .config-path-card { display: grid; gap: 5px; }
.config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } .config-path-card span, .field-heading, dt { 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; } 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; }
.config-path-card small, .field small { color: var(--pi-muted); } .config-path-card small, .field small { color: var(--pi-muted); }
.config-form { display: grid; gap: 14px; } .config-form { display: grid; gap: 14px; }
.field { display: grid; gap: 7px; } .field { display: grid; gap: 7px; }
.field-heading { display: flex; align-items: center; gap: 8px; } .field-heading { display: flex; align-items: center; gap: 8px; }
input, select, textarea { 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: 9px 10px; outline: none; } input, select, textarea { 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: 9px 10px; outline: none; font: var(--pi-control-font-size, 16px) var(--pi-control-font-family, system-ui, sans-serif); }
input:focus, select:focus, textarea:focus { border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); } input:focus, select:focus, textarea:focus { border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); }
textarea { resize: vertical; min-height: 94px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } textarea { resize: vertical; min-height: 94px; font-family: var(--pi-control-monospace-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); }
textarea:disabled { opacity: .55; } textarea:disabled { opacity: .55; }
.override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; } .override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; }
.effective-card { display: grid; gap: 10px; } .effective-card { display: grid; gap: 10px; }
@@ -169,8 +285,6 @@ export class SettingsGeneralPanel extends LitElement {
.primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); } .primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); }
@media (max-width: 760px) { @media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; }
.section-heading .secondary { justify-self: start; }
.effective-card dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; } .effective-card dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; }
} }
`; `;
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import type { TemplateResult } from "lit";
import type { PiPackageInfo } from "../../api";
import { SettingsPackagesPanel } from "./SettingsPackagesPanel";
import type { SettingsNotice } from "./SettingsPanelFrame";
import type { PiPackageManagementSupport, PiPackageTargetContext } from "./piPackageSettings";
const remoteTarget: PiPackageTargetContext = { id: "lab-mac", name: "Lab Mac", kind: "remote" };
const unsupportedMessage = "Pi package management is not available on Lab Mac. Update and restart Pi-Web on that machine, then try again.";
describe("settings-packages-panel layout", () => {
it("suppresses package controls and trust warnings when package management is unsupported", () => {
const panel = new SettingsPackagesPanel();
panel.targetMachine = remoteTarget;
panel.managementSupport = unsupportedPackageManagement();
panel.error = unsupportedMessage;
const rendered = flattenTemplateContent(panel.render());
expect(rendered).toContain(unsupportedMessage);
expect(rendered).not.toContain("Trusted code warning");
expect(rendered).not.toContain("Pi package source");
expect(rendered).not.toContain("Configured Pi packages");
expect(rendered).not.toContain("No Pi packages configured");
});
it("shows a load-unavailable state instead of an empty package state when no response loaded", () => {
const panel = new SettingsPackagesPanel();
panel.targetMachine = remoteTarget;
panel.error = "Failed to load Pi packages from Lab Mac (remote machine): Could not reach Lab Mac.";
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"Failed to load Pi packages from Lab Mac (remote machine): Could not reach Lab Mac.",
"Pi package list unavailable for Lab Mac (remote machine). Use Reload to try again.",
]);
expect(rendered).not.toContain("No Pi packages configured");
expect(rendered).not.toContain("Trusted code warning");
expect(rendered).not.toContain("Pi package source");
expect(rendered).not.toContain("Configured Pi packages");
});
it("shows trust guidance, install controls, and empty state only after a package response loaded", () => {
const panel = new SettingsPackagesPanel();
panel.packagesResponse = { packages: [] };
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"Pi packages",
"Managing Pi packages on ",
"local (local gateway)",
"Trusted code warning:",
"Pi package source",
"Configured Pi packages",
"No Pi packages configured in Pi settings on local (local gateway) yet.",
]);
expect(rendered).not.toContain("Pi package list unavailable");
});
it("orders package load errors before the trusted-code warning while preserving loaded data", () => {
const panel = new SettingsPackagesPanel();
panel.targetMachine = remoteTarget;
panel.packagesResponse = { packages: [packageInfo("npm:@acme/tools")] };
panel.error = "Failed to refresh gateway PI WEB plugins after updating packages.";
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"Failed to refresh gateway PI WEB plugins after updating packages.",
"Trusted code warning:",
"Pi package source",
"Configured Pi packages",
"npm:@acme/tools",
]);
});
});
function flattenTemplateContent(template: TemplateResult): string {
const chunks: string[] = [];
visitTemplate(template);
return chunks.join("");
function visitTemplate(current: TemplateResult): void {
const strings = templateStrings(current);
const values = templateValues(current);
for (let index = 0; index < values.length; index += 1) {
const staticChunk = strings[index];
if (staticChunk !== undefined) chunks.push(staticChunk);
visitValue(values[index]);
}
const finalChunk = strings[values.length];
if (finalChunk !== undefined) chunks.push(finalChunk);
}
function visitValue(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visitValue(item);
return;
}
if (isSettingsNotice(value)) {
visitValue(value.title);
visitValue(value.content);
return;
}
if (isTemplateResult(value)) {
visitTemplate(value);
return;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
chunks.push(String(value));
}
}
}
function expectTextOrder(content: string, labels: readonly string[]): void {
let previousIndex = -1;
for (const label of labels) {
const currentIndex = content.indexOf(label, previousIndex + 1);
if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`);
expect(currentIndex).toBeGreaterThan(previousIndex);
previousIndex = currentIndex;
}
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isSettingsNotice(value: unknown): value is SettingsNotice {
return typeof value === "object" && value !== null && typeof Reflect.get(value, "type") === "string" && Reflect.has(value, "content");
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function unsupportedPackageManagement(): PiPackageManagementSupport {
return { state: "unsupported", message: unsupportedMessage };
}
function packageInfo(source: string): PiPackageInfo {
return { source, scope: "user", filtered: false, installedPath: `/pi/packages/${source}` };
}
@@ -0,0 +1,239 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { PiPackageInfo, PiPackageScope, PiPackagesResponse } from "../../api";
import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
import { isPiPackageManagementUnsupported, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageInstalledPathLabel, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageTargetContext, piPackageTargetLabel, piPackageUpdateDisabledReason, updateAllPiPackagesDisabledReason, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./piPackageSettings";
@customElement("settings-packages-panel")
export class SettingsPackagesPanel extends LitElement {
@property({ attribute: false }) packagesResponse: PiPackagesResponse | undefined;
@property({ type: Boolean }) loading = false;
@property({ attribute: false }) operation: PiPackageOperationState | undefined;
@property({ attribute: false }) targetMachine: PiPackageTargetContext | undefined;
@property({ attribute: false }) managementSupport: PiPackageManagementSupport | undefined;
@property() error = "";
@property() operationMessage = "";
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onInstallPackage?: (source: string) => void | Promise<void>;
@property({ attribute: false }) onRemovePackage?: (source: string, scope: PiPackageScope) => void | Promise<void>;
@property({ attribute: false }) onUpdatePackage?: (source?: string) => void | Promise<void>;
@state() private installSource = "";
@state() private validationMessage = "";
override render(): TemplateResult {
const packages = this.packagesResponse?.packages ?? [];
const target = this.packageTarget;
const targetLabel = piPackageTargetLabel(target);
const packageManagementUnavailable = this.packageManagementUnavailable;
const showPackageControls = this.packagesResponse !== undefined && !packageManagementUnavailable;
return html`
<settings-panel-frame
heading="Pi packages"
.description=${packagesDescription(targetLabel)}
actionLabel="Reload"
actionTitle=${packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : `Reload Pi packages from ${targetLabel}`}
.actionDisabled=${this.loading || this.isOperating || packageManagementUnavailable}
.notices=${this.panelNotices(targetLabel, showPackageControls)}
.onAction=${this.onReload}
>
${this.renderPanelContent(packages, target, targetLabel)}
</settings-panel-frame>
`;
}
private panelNotices(targetLabel: string, showTrustedCodeWarning: boolean): readonly SettingsNotice[] {
const notices: SettingsNotice[] = [];
if (this.packageManagementUnavailable) {
notices.push({ type: "availability", content: this.packageManagementUnavailableMessage(targetLabel) });
} else if (this.error !== "") {
notices.push({ type: "error", content: this.error });
}
if (this.operationMessage !== "") notices.push({ type: "success", content: this.operationMessage });
if (showTrustedCodeWarning) {
notices.push({
type: "security",
content: html`<strong>Trusted code warning:</strong> Pi packages and PI WEB plugins can run with your user permissions. Install packages and enable plugins only from sources you trust.`,
});
}
return notices;
}
private renderPanelContent(packages: PiPackageInfo[], target: PiPackageTargetContext, targetLabel: string): TemplateResult | null {
if (this.packageManagementUnavailable) return null;
if (this.packagesResponse === undefined) {
return html`<div class="loading-card">${this.loading ? `Loading Pi packages from ${targetLabel}` : `Pi package list unavailable for ${targetLabel}. Use Reload to try again.`}</div>`;
}
return html`
${this.renderInstallForm(targetLabel)}
${this.renderPackageList(packages, target)}
`;
}
private renderInstallForm(targetLabel: string): TemplateResult {
return html`
<form class="install-card" @submit=${(event: Event) => { void this.installPackage(event); }}>
<label for="package-source">Pi package source</label>
<div class="install-row">
<input id="package-source" .value=${this.installSource} ?disabled=${this.isOperating} placeholder="npm:@scope/package, git URL, or local path" @input=${(event: Event) => { this.updateInstallSource(event); }}>
<button type="submit" title="Install this Pi package" ?disabled=${this.isOperating}>${isPiPackageOperationPending(this.operation, "install") ? "Installing…" : "Install"}</button>
</div>
${this.validationMessage === "" ? null : html`<div class="field-error">${this.validationMessage}</div>`}
<small>Installs run on ${targetLabel} and use Pi's default package location, equivalent to <code>pi install &lt;source&gt;</code>. PI WEB does not ask you to choose an install location.</small>
</form>
`;
}
private renderPackageList(packages: PiPackageInfo[], target: PiPackageTargetContext): TemplateResult {
const targetLabel = piPackageTargetLabel(target);
const packageManagementUnavailable = this.packageManagementUnavailable;
const updateAllReason = packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : updateAllPiPackagesDisabledReason(packages);
const showUpdateAllReason = updateAllReason !== undefined && packages.length > 0;
const updateAllTitle = updateAllReason ?? "Update all user-scope Pi packages";
return html`
<section class="package-section" aria-label="Configured Pi packages">
<div class="package-toolbar">
<div>
<h3>Configured Pi packages</h3>
<p>This list comes from Pi's package manager settings on ${targetLabel}.</p>
</div>
<button class="secondary" title=${updateAllTitle} ?disabled=${this.isOperating || updateAllReason !== undefined} @click=${() => { void this.updatePackage(); }}>
${isPiPackageOperationPending(this.operation, "update-all") ? "Updating…" : "Update all"}
</button>
</div>
${showUpdateAllReason ? html`<div class="action-note">${updateAllReason}</div>` : null}
${this.loading && packages.length > 0 ? html`<div class="action-note">Refreshing Pi packages from ${targetLabel}…</div>` : null}
${this.renderPackageListContent(packages, targetLabel)}
</section>
`;
}
private renderPackageListContent(packages: PiPackageInfo[], targetLabel: string): TemplateResult {
if (this.loading && packages.length === 0) return html`<div class="loading-card">Loading Pi packages from ${targetLabel}…</div>`;
if (packages.length === 0) return html`<div class="loading-card">No Pi packages configured in Pi settings on ${targetLabel} yet.</div>`;
return html`
<div class="package-list">
${packages.map((packageInfo) => this.renderPackage(packageInfo))}
</div>
`;
}
private renderPackage(packageInfo: PiPackageInfo): TemplateResult {
const targetLabel = piPackageTargetLabel(this.packageTarget);
const packageManagementUnavailable = this.packageManagementUnavailable;
const updateReason = packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : piPackageUpdateDisabledReason(packageInfo);
const removeReason = packageManagementUnavailable ? this.packageManagementUnavailableMessage(targetLabel) : "Remove this Pi package";
const updating = isPiPackageOperationPending(this.operation, "update", packageInfo.source);
const removing = isPiPackageOperationPending(this.operation, "remove", packageInfo.source);
return html`
<article class=${`package-card${packageInfo.filtered ? " filtered" : ""}`}>
<div class="package-main">
<strong>${packageInfo.source}</strong>
<small>${piPackageScopeLabel(packageInfo)} · ${piPackageFilteredLabel(packageInfo)}</small>
<small>Installed path: <code>${piPackageInstalledPathLabel(packageInfo)}</code></small>
${updateReason === undefined ? null : html`<small class="action-note">${updateReason}</small>`}
</div>
<div class="package-actions">
<button class="secondary" title=${updateReason ?? "Update this Pi package"} ?disabled=${this.isOperating || updateReason !== undefined} @click=${() => { void this.updatePackage(packageInfo.source); }}>${updating ? "Updating…" : "Update"}</button>
<button class="danger" title=${removeReason} ?disabled=${this.isOperating || packageManagementUnavailable} @click=${() => { void this.removePackage(packageInfo); }}>${removing ? "Removing…" : "Remove"}</button>
</div>
</article>
`;
}
private updateInstallSource(event: Event): void {
this.installSource = event.target instanceof HTMLInputElement ? event.target.value : "";
this.validationMessage = "";
}
private async installPackage(event: Event): Promise<void> {
event.preventDefault();
const validationMessage = piPackageSourceValidationMessage(this.installSource);
if (validationMessage !== undefined) {
this.validationMessage = validationMessage;
return;
}
const source = normalizePiPackageSource(this.installSource);
try {
await this.onInstallPackage?.(source);
this.installSource = "";
this.validationMessage = "";
} catch {
// The parent owns network error presentation so package errors are consistent across Settings.
}
}
private async removePackage(packageInfo: PiPackageInfo): Promise<void> {
try {
await this.onRemovePackage?.(packageInfo.source, packageInfo.scope);
} catch {
// The parent owns network error presentation so package errors are consistent across Settings.
}
}
private async updatePackage(source?: string): Promise<void> {
try {
await this.onUpdatePackage?.(source);
} catch {
// The parent owns network error presentation so package errors are consistent across Settings.
}
}
private get packageTarget(): PiPackageTargetContext {
return this.targetMachine ?? piPackageTargetContext(undefined);
}
private get packageManagementUnavailable(): boolean {
return isPiPackageManagementUnsupported(this.managementSupport);
}
private packageManagementUnavailableMessage(targetLabel: string): string {
return this.managementSupport?.message ?? `Pi package management is not available on ${targetLabel}.`;
}
private get isOperating(): boolean {
return this.operation !== undefined;
}
static override styles = css`
:host { display: block; }
.package-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.package-toolbar > div, .package-main { display: grid; gap: 6px; min-width: 0; }
h3, p { margin: 0; }
h3 { font-size: 15px; line-height: 1.25; }
p, small { 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; }
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; }
label { font-weight: 700; }
.secondary { flex: 0 0 auto; }
.danger { border-color: color-mix(in srgb, var(--pi-danger) 55%, var(--pi-border)); color: var(--pi-danger); }
.loading-card, .install-card, .package-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.field-error { color: var(--pi-danger); font-size: 12px; }
.install-card { display: grid; gap: 8px; }
.install-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; }
.package-section { display: block; }
.loading-card, .action-note { color: var(--pi-muted); }
.action-note { margin-bottom: 10px; font-size: 12px; }
.package-list { display: grid; gap: 10px; }
.package-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; }
.package-card.filtered { opacity: .82; }
.package-main strong, .package-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.package-actions { display: flex; align-items: center; gap: 8px; }
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; }
@media (max-width: 760px) {
.package-toolbar { display: grid; gap: 12px; }
.package-toolbar .secondary { justify-self: start; }
.install-row, .package-card { grid-template-columns: minmax(0, 1fr); align-items: start; }
.package-actions { justify-self: start; flex-wrap: wrap; }
.package-main strong, .package-main small { white-space: normal; }
}
`;
}
function packagesDescription(targetLabel: string): TemplateResult {
return html`Managing Pi packages on <strong>${targetLabel}</strong>. Install, remove, and update packages managed by Pi on the selected machine. Pi packages can provide extensions, skills, prompt templates, themes, context/system prompt files, and PI WEB browser plugins.`;
}
@@ -0,0 +1,147 @@
import { html, type TemplateResult } from "lit";
import { describe, expect, it } from "vitest";
import { SettingsPanelFrame, settingsNoticeTone, type SettingsNotice } from "./SettingsPanelFrame";
describe("settings-panel-frame", () => {
it("renders header, ordered notices, and settings content in the shared order", () => {
const frame = new SettingsPanelFrame();
frame.heading = "Pi packages";
frame.description = "Manage packages on Lab Mac.";
frame.actionLabel = "Reload";
frame.notices = [
{ type: "availability", title: "Unavailable", content: "Package management is unavailable." },
{ type: "success", content: "Saved package settings." },
{ type: "security", content: html`<strong>Trusted code warning:</strong> Install packages only from sources you trust.` },
];
const rendered = flattenTemplateContent(frame.render());
expectTextOrder(rendered, [
"Pi packages",
"Manage packages on Lab Mac.",
"Reload",
"Unavailable",
"Package management is unavailable.",
"Saved package settings.",
"Trusted code warning:",
]);
expect(rendered.indexOf('class="notice-stack"')).toBeLessThan(rendered.indexOf('class="content"'));
});
it("maps notice types to consistent default tones and roles", () => {
const notices: readonly SettingsNotice[] = [
{ type: "availability", content: "Configuration unavailable." },
{ type: "success", content: "Saved." },
{ type: "security", content: "Trusted code warning." },
{ type: "info", content: "Loading…" },
];
const frame = new SettingsPanelFrame();
frame.notices = notices;
const values = collectTemplateValues(frame.render());
expect(notices.map(settingsNoticeTone)).toEqual(["error", "success", "warning", "info"]);
expect(values).toEqual(expect.arrayContaining(["notice error", "alert", "notice success", "status", "notice warning", "note", "notice info"]));
});
it("wires the default header action through the frame", () => {
const frame = new SettingsPanelFrame();
let reloads = 0;
frame.actionLabel = "Reload";
frame.actionTitle = "Reload settings";
frame.actionDisabled = true;
frame.onAction = () => { reloads += 1; };
const values = collectTemplateValues(frame.render());
const action = values.find(isActionHandler);
expect(values).toEqual(expect.arrayContaining(["Reload settings", true, "Reload"]));
if (action === undefined) throw new Error("Action handler was not rendered");
action();
expect(reloads).toBe(1);
});
});
function flattenTemplateContent(template: TemplateResult): string {
const chunks: string[] = [];
visitTemplate(template);
return chunks.join("");
function visitTemplate(current: TemplateResult): void {
const strings = templateStrings(current);
const values = templateValues(current);
for (let index = 0; index < values.length; index += 1) {
const staticChunk = strings[index];
if (staticChunk !== undefined) chunks.push(staticChunk);
visitValue(values[index]);
}
const finalChunk = strings[values.length];
if (finalChunk !== undefined) chunks.push(finalChunk);
}
function visitValue(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visitValue(item);
return;
}
if (isTemplateResult(value)) {
visitTemplate(value);
return;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
chunks.push(String(value));
}
}
}
function collectTemplateValues(template: TemplateResult): unknown[] {
const values: unknown[] = [];
visit(template);
return values;
function visit(current: unknown): void {
if (Array.isArray(current)) {
for (const item of current) visit(item);
return;
}
if (!isTemplateResult(current)) return;
for (const value of templateValues(current)) {
values.push(value);
visit(value);
}
}
}
function expectTextOrder(content: string, labels: readonly string[]): void {
let previousIndex = -1;
for (const label of labels) {
const currentIndex = content.indexOf(label, previousIndex + 1);
if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`);
expect(currentIndex).toBeGreaterThan(previousIndex);
previousIndex = currentIndex;
}
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function isActionHandler(value: unknown): value is () => void {
return typeof value === "function";
}
@@ -0,0 +1,137 @@
import { css, html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
export const SETTINGS_NOTICE_TONES = ["error", "success", "warning", "info"] as const;
export type SettingsNoticeTone = (typeof SETTINGS_NOTICE_TONES)[number];
export const SETTINGS_NOTICE_TYPES = ["availability", "error", "success", "security", "warning", "info"] as const;
export type SettingsNoticeType = (typeof SETTINGS_NOTICE_TYPES)[number];
export type SettingsNoticeRole = "alert" | "note" | "status";
export type SettingsNoticeContent = string | TemplateResult;
export interface SettingsNotice {
readonly type: SettingsNoticeType;
readonly content: SettingsNoticeContent;
readonly tone?: SettingsNoticeTone;
readonly title?: string;
readonly role?: SettingsNoticeRole;
}
const DEFAULT_NOTICE_TONE: Record<SettingsNoticeType, SettingsNoticeTone> = {
availability: "error",
error: "error",
success: "success",
security: "warning",
warning: "warning",
info: "info",
};
export function settingsNoticeTone(notice: SettingsNotice): SettingsNoticeTone {
return notice.tone ?? DEFAULT_NOTICE_TONE[notice.type];
}
@customElement("settings-panel-frame")
export class SettingsPanelFrame extends LitElement {
@property() heading = "";
@property({ attribute: false }) description: SettingsNoticeContent = "";
@property() actionLabel = "";
@property() actionTitle = "";
@property({ type: Boolean }) actionDisabled = false;
@property({ attribute: false }) notices: readonly SettingsNotice[] = [];
@property({ attribute: false }) onAction?: () => void | Promise<void>;
override render(): TemplateResult {
return html`
<section class="panel" aria-label=${this.heading || "Settings panel"}>
<header class="section-heading">
<div class="heading-copy">
${this.heading === "" ? nothing : html`<h2>${this.heading}</h2>`}
<div class="description"><slot name="description">${this.description}</slot></div>
</div>
<div class="heading-actions"><slot name="actions">${this.renderDefaultAction()}</slot></div>
</header>
${this.renderNoticeStack()}
<div class="content"><slot></slot></div>
</section>
`;
}
private renderDefaultAction(): TemplateResult | typeof nothing {
if (this.actionLabel === "") return nothing;
return html`
<button
class="secondary"
title=${this.actionTitle || this.actionLabel}
?disabled=${this.actionDisabled}
@click=${() => { void this.onAction?.(); }}
>${this.actionLabel}</button>
`;
}
private renderNoticeStack(): TemplateResult | typeof nothing {
if (this.notices.length === 0) return nothing;
return html`
<div class="notice-stack" aria-label="Settings notices">
${this.notices.map((notice) => this.renderNotice(notice))}
</div>
`;
}
private renderNotice(notice: SettingsNotice): TemplateResult {
const tone = settingsNoticeTone(notice);
const role = notice.role ?? defaultNoticeRole(tone);
const title = notice.title;
return html`
<article class=${`notice ${tone}`} role=${role}>
${title === undefined || title === "" ? nothing : html`<strong class="notice-title">${title}</strong>`}
<div class="notice-content">${notice.content}</div>
</article>
`;
}
static override styles = css`
:host { display: block; }
.panel { display: block; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.heading-copy { display: grid; gap: 6px; min-width: 0; }
.heading-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; }
h2 { margin: 0; font-size: 17px; line-height: 1.25; }
.description { color: var(--pi-muted); line-height: 1.45; }
.description ::slotted(*) { margin: 0; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; font: inherit; cursor: pointer; }
button:disabled { opacity: .55; cursor: not-allowed; }
.secondary { flex: 0 0 auto; }
.notice-stack { display: grid; gap: 12px; margin-bottom: 14px; }
.notice { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; line-height: 1.45; }
.notice.error { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
.notice.success { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
.notice.warning { border-color: var(--pi-warning-border); color: var(--pi-text); background: var(--pi-warning-surface); }
.notice.info { color: var(--pi-muted); }
.notice-title { display: block; margin-bottom: 4px; color: inherit; }
.notice-content { min-width: 0; }
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; }
.content { display: grid; gap: 14px; min-width: 0; }
.content ::slotted(*) { min-width: 0; }
@media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; }
.heading-actions { justify-self: start; }
}
`;
}
function defaultNoticeRole(tone: SettingsNoticeTone): SettingsNoticeRole {
switch (tone) {
case "error": return "alert";
case "success": return "status";
case "warning":
case "info": return "note";
}
}
declare global {
interface HTMLElementTagNameMap {
"settings-panel-frame": SettingsPanelFrame;
}
}
@@ -0,0 +1,178 @@
import { describe, expect, it } from "vitest";
import type { TemplateResult } from "lit";
import type { PiWebConfigResponse, PiWebConfigValues, PiWebPluginInfo } from "../../api";
import { SettingsPluginsPanel } from "./SettingsPluginsPanel";
import type { SettingsNotice } from "./SettingsPanelFrame";
describe("settings-plugins-panel layout", () => {
it("orders load and save notices before the trusted-code warning and plugin content", () => {
const panel = new SettingsPluginsPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.configResponse = configResponse({ plugins: { "remote-enabled": { enabled: true } } });
panel.pluginsResponse = { plugins: [pluginInfo("remote-enabled", true)] };
panel.error = "Failed to load PI WEB plugin settings from Lab Mac: PI WEB plugins: timed out.";
panel.savedMessage = "Config saved.";
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"PI WEB plugins",
"Enable or disable discovered PI WEB browser plugins on ",
"Lab Mac (remote machine)",
"Failed to load PI WEB plugin settings from Lab Mac: PI WEB plugins: timed out.",
"Config saved. Reload the browser tab to apply plugin changes.",
"Trusted code warning:",
"Config key on Lab Mac (remote machine):",
"remote-enabled",
]);
});
it("does not show a false empty state when the plugin response is missing", () => {
const panel = new SettingsPluginsPanel();
panel.targetLabel = "Lab Mac (remote machine)";
const rendered = flattenTemplateContent(panel.render());
expect(rendered).toContain("PI WEB plugin list unavailable for Lab Mac (remote machine). Use Reload to try again.");
expect(rendered).not.toContain("No PI WEB browser plugins discovered");
expect(rendered).not.toContain("Trusted code warning");
});
it("shows the empty plugin state only after a plugin response has loaded", () => {
const panel = new SettingsPluginsPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.pluginsResponse = { plugins: [] };
const rendered = flattenTemplateContent(panel.render());
expect(rendered).toContain("No PI WEB browser plugins discovered on Lab Mac (remote machine).");
expect(rendered).not.toContain("PI WEB plugin list unavailable");
expect(rendered).not.toContain("Trusted code warning");
});
it("keeps loaded plugins visible but disabled when selected-machine config is unavailable", () => {
const panel = new SettingsPluginsPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.pluginsResponse = { plugins: [pluginInfo("remote-disabled", false)] };
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"Configuration is unavailable. Reload to try again before changing plugin enablement.",
"Trusted code warning:",
"remote-disabled",
]);
expect(countOccurrences(rendered, "Configuration is unavailable. Reload to try again before changing plugin enablement.")).toBe(1);
expect(templateValues(renderPluginTemplate(panel, pluginInfo("remote-disabled", false))).filter(isBoolean)).toEqual([false, true]);
});
});
function renderPluginTemplate(panel: SettingsPluginsPanel, plugin: PiWebPluginInfo): TemplateResult {
const renderPlugin: unknown = Reflect.get(panel, "renderPlugin");
if (!isPanelRenderPlugin(renderPlugin)) throw new Error("SettingsPluginsPanel.renderPlugin is not callable");
return renderPlugin.call(panel, plugin);
}
function isPanelRenderPlugin(value: unknown): value is (this: SettingsPluginsPanel, plugin: PiWebPluginInfo) => TemplateResult {
return typeof value === "function";
}
function flattenTemplateContent(template: TemplateResult): string {
const chunks: string[] = [];
visitTemplate(template);
return chunks.join("");
function visitTemplate(current: TemplateResult): void {
const strings = templateStrings(current);
const values = templateValues(current);
for (let index = 0; index < values.length; index += 1) {
const staticChunk = strings[index];
if (staticChunk !== undefined) chunks.push(staticChunk);
visitValue(values[index]);
}
const finalChunk = strings[values.length];
if (finalChunk !== undefined) chunks.push(finalChunk);
}
function visitValue(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visitValue(item);
return;
}
if (isSettingsNotice(value)) {
visitValue(value.title);
visitValue(value.content);
return;
}
if (isTemplateResult(value)) {
visitTemplate(value);
return;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
chunks.push(String(value));
}
}
}
function expectTextOrder(content: string, labels: readonly string[]): void {
let previousIndex = -1;
for (const label of labels) {
const currentIndex = content.indexOf(label, previousIndex + 1);
if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`);
expect(currentIndex).toBeGreaterThan(previousIndex);
previousIndex = currentIndex;
}
}
function countOccurrences(content: string, needle: string): number {
return content.split(needle).length - 1;
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isSettingsNotice(value: unknown): value is SettingsNotice {
return typeof value === "object" && value !== null && typeof Reflect.get(value, "type") === "string" && Reflect.has(value, "content");
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function isBoolean(value: unknown): value is boolean {
return typeof value === "boolean";
}
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo {
return {
id,
module: `/pi-web-plugins/${id}/plugin.js`,
source: "test",
scope: "local",
machineSpecific: false,
enabled,
};
}
@@ -1,6 +1,8 @@
import { css, html, LitElement, type TemplateResult } from "lit"; import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import type { PiWebConfigResponse, PiWebPluginInfo, PiWebPluginsResponse } from "../../api"; import type { PiWebConfigResponse, PiWebPluginInfo, PiWebPluginsResponse } from "../../api";
import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
@customElement("settings-plugins-panel") @customElement("settings-plugins-panel")
export class SettingsPluginsPanel extends LitElement { export class SettingsPluginsPanel extends LitElement {
@@ -10,33 +12,61 @@ export class SettingsPluginsPanel extends LitElement {
@property({ type: Boolean }) saving = false; @property({ type: Boolean }) saving = false;
@property() error = ""; @property() error = "";
@property() savedMessage = ""; @property() savedMessage = "";
@property() targetLabel = "local (local gateway)";
@property({ attribute: false }) onReload?: () => void | Promise<void>; @property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onTogglePlugin?: (pluginId: string, enabled: boolean) => void | Promise<void>; @property({ attribute: false }) onTogglePlugin?: (pluginId: string, enabled: boolean) => void | Promise<void>;
override render(): TemplateResult { override render(): TemplateResult {
const plugins = this.pluginsResponse?.plugins ?? []; const plugins = this.pluginsResponse?.plugins ?? [];
const hasPluginResponse = this.pluginsResponse !== undefined;
return html` return html`
<div class="section-heading"> <settings-panel-frame
<div> heading="PI WEB plugins"
<h2>Plugins</h2> .description=${pluginsDescription(this.targetLabel)}
<p>Enable or disable discovered PI WEB plugins. Changes apply after reloading the browser tab; already-loaded plugin code is not unloaded from the current page.</p> actionLabel="Reload"
</div> actionTitle=${`Reload PI WEB plugins from ${this.targetLabel}`}
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button> .actionDisabled=${this.loading}
</div> .notices=${this.panelNotices(plugins.length > 0)}
${this.renderMessages()} .onAction=${this.onReload}
<div class="plugin-note">Config key: <code>plugins</code>. Plugins are enabled unless their entry sets <code>enabled</code> to <code>false</code>.</div> >
${this.loading && plugins.length === 0 ? html`<div class="loading-card">Loading plugins…</div>` : plugins.length === 0 ? html`<div class="loading-card">No external or bundled plugins discovered.</div>` : html` ${this.renderPanelContent(plugins, hasPluginResponse)}
<div class="plugin-list"> </settings-panel-frame>
${plugins.map((plugin) => this.renderPlugin(plugin))}
</div>
`}
`; `;
} }
private renderMessages(): TemplateResult | null { private panelNotices(showTrustedCodeWarning: boolean): readonly SettingsNotice[] {
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`; const notices: SettingsNotice[] = [];
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage} Reload the browser tab to apply plugin changes.</div>`; if (this.error !== "") notices.push({ type: "error", content: this.error });
return null; if (this.shouldShowConfigUnavailableNotice(showTrustedCodeWarning)) {
notices.push({ type: "availability", content: "Configuration is unavailable. Reload to try again before changing plugin enablement." });
}
if (this.savedMessage !== "") notices.push({ type: "success", content: `${this.savedMessage} Reload the browser tab to apply plugin changes.` });
if (showTrustedCodeWarning) {
notices.push({
type: "security",
content: html`<strong>Trusted code warning:</strong> PI WEB plugins and Pi packages can run with your user permissions. Enable plugins only from sources you trust.`,
});
}
return notices;
}
private shouldShowConfigUnavailableNotice(hasLoadedPlugins: boolean): boolean {
return hasLoadedPlugins && this.configResponse === undefined && !this.loading && this.error === "";
}
private renderPanelContent(plugins: PiWebPluginInfo[], hasPluginResponse: boolean): TemplateResult {
if (!hasPluginResponse) {
return html`<div class="loading-card">${this.loading ? "Loading PI WEB plugins…" : `PI WEB plugin list unavailable for ${this.targetLabel}. Use Reload to try again.`}</div>`;
}
if (plugins.length === 0) {
return html`<div class="loading-card">No PI WEB browser plugins discovered on ${this.targetLabel}.</div>`;
}
return html`
<div class="plugin-note">Config key on ${this.targetLabel}: <code>plugins</code>. Plugins are enabled unless their entry sets <code>enabled</code> to <code>false</code>.</div>
<div class="plugin-list">
${plugins.map((plugin) => this.renderPlugin(plugin))}
</div>
`;
} }
private renderPlugin(plugin: PiWebPluginInfo): TemplateResult { private renderPlugin(plugin: PiWebPluginInfo): TemplateResult {
@@ -50,7 +80,7 @@ export class SettingsPluginsPanel extends LitElement {
<small>${configuredState}</small> <small>${configuredState}</small>
</div> </div>
<label class="toggle"> <label class="toggle">
<input type="checkbox" .checked=${plugin.enabled} ?disabled=${this.saving} @change=${(event: Event) => { void this.togglePlugin(plugin, event); }}> <input type="checkbox" .checked=${plugin.enabled} ?disabled=${this.saving || this.configResponse === undefined} @change=${(event: Event) => { void this.togglePlugin(plugin, event); }}>
<span>${plugin.enabled ? "Enabled" : "Disabled"}</span> <span>${plugin.enabled ? "Enabled" : "Disabled"}</span>
</label> </label>
</article> </article>
@@ -64,21 +94,10 @@ export class SettingsPluginsPanel extends LitElement {
static override styles = css` static override styles = css`
:host { display: block; } :host { display: block; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } input { font: inherit; }
.section-heading > div { display: grid; gap: 6px; min-width: 0; } input:disabled { opacity: .55; cursor: not-allowed; }
h2, p { margin: 0; } .loading-card, .plugin-note, .plugin-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
h2 { font-size: 17px; line-height: 1.25; }
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; }
.secondary { flex: 0 0 auto; }
.message, .loading-card, .plugin-note, .plugin-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, .plugin-note { color: var(--pi-muted); } .loading-card, .plugin-note { color: var(--pi-muted); }
.plugin-note { margin-bottom: 14px; }
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; } 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; }
.plugin-list { display: grid; gap: 10px; } .plugin-list { display: grid; gap: 10px; }
.plugin-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; } .plugin-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; }
@@ -90,10 +109,12 @@ export class SettingsPluginsPanel extends LitElement {
.toggle input { width: 18px; height: 18px; accent-color: var(--pi-accent); } .toggle input { width: 18px; height: 18px; accent-color: var(--pi-accent); }
@media (max-width: 760px) { @media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; }
.section-heading .secondary { justify-self: start; }
.plugin-card { grid-template-columns: minmax(0, 1fr); align-items: start; } .plugin-card { grid-template-columns: minmax(0, 1fr); align-items: start; }
.toggle { justify-self: start; } .toggle { justify-self: start; }
} }
`; `;
} }
function pluginsDescription(targetLabel: string): TemplateResult {
return html`Enable or disable discovered PI WEB browser plugins on <strong>${targetLabel}</strong>. This is separate from installing Pi packages. Reload the browser tab to apply plugin runtime changes.`;
}
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import type { TemplateResult } from "lit";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { SettingsSessiondPanel } from "./SettingsSessiondPanel";
import type { SettingsNotice } from "./SettingsPanelFrame";
describe("settings-sessiond-panel layout", () => {
it("names the selected machine in the scope and restart notice when config is available", () => {
const panel = new SettingsSessiondPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.configResponse = configResponse({ spawnSessions: true, subsessions: false });
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"Session daemon",
"These settings affect the long-lived session runtime on Lab Mac (remote machine).",
"Reload",
"Restart required on Lab Mac (remote machine)",
"run <code>pi-web restart</code> on that machine",
"Config file",
"Allow agents to start sessions",
]);
});
it("orders save/load notices before the restart notice and settings content", () => {
const panel = new SettingsSessiondPanel();
panel.configResponse = configResponse({ spawnSessions: false });
panel.error = "Failed to save session-daemon config.";
panel.savedMessage = "Session daemon settings saved.";
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"Failed to save session-daemon config.",
"Session daemon settings saved.",
"Restart required on local (local gateway)",
"Config file",
]);
});
it("shows one blocked content state without restart guidance or toggles when config is unavailable", () => {
const panel = new SettingsSessiondPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.error = "Selected-machine settings are not available on Lab Mac.";
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"Selected-machine settings are not available on Lab Mac.",
"Configuration is unavailable. Reload to try again.",
]);
expect(countOccurrences(rendered, "Configuration is unavailable. Reload to try again.")).toBe(1);
expect(rendered).not.toContain("Restart required on");
expect(rendered).not.toContain("Allow agents to start sessions");
expect(rendered).not.toContain("Effective after environment overrides");
});
});
function flattenTemplateContent(template: TemplateResult): string {
const chunks: string[] = [];
visitTemplate(template);
return chunks.join("");
function visitTemplate(current: TemplateResult): void {
const strings = templateStrings(current);
const values = templateValues(current);
for (let index = 0; index < values.length; index += 1) {
const staticChunk = strings[index];
if (staticChunk !== undefined) chunks.push(staticChunk);
visitValue(values[index]);
}
const finalChunk = strings[values.length];
if (finalChunk !== undefined) chunks.push(finalChunk);
}
function visitValue(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visitValue(item);
return;
}
if (isSettingsNotice(value)) {
visitValue(value.title);
visitValue(value.content);
return;
}
if (isTemplateResult(value)) {
visitTemplate(value);
return;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
chunks.push(String(value));
}
}
}
function expectTextOrder(content: string, labels: readonly string[]): void {
let previousIndex = -1;
for (const label of labels) {
const currentIndex = content.indexOf(label, previousIndex + 1);
if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`);
expect(currentIndex).toBeGreaterThan(previousIndex);
previousIndex = currentIndex;
}
}
function countOccurrences(content: string, needle: string): number {
return content.split(needle).length - 1;
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isSettingsNotice(value: unknown): value is SettingsNotice {
return typeof value === "object" && value !== null && typeof Reflect.get(value, "type") === "string" && Reflect.has(value, "content");
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
@@ -1,6 +1,9 @@
import { css, html, LitElement, type TemplateResult } from "lit"; import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api"; import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
import { spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
@customElement("settings-sessiond-panel") @customElement("settings-sessiond-panel")
export class SettingsSessiondPanel extends LitElement { export class SettingsSessiondPanel extends LitElement {
@@ -9,6 +12,7 @@ export class SettingsSessiondPanel extends LitElement {
@property({ type: Boolean }) saving = false; @property({ type: Boolean }) saving = false;
@property() error = ""; @property() error = "";
@property() savedMessage = ""; @property() savedMessage = "";
@property() targetLabel = "local (local gateway)";
@property({ attribute: false }) onReload?: () => void | Promise<void>; @property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>; @property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
@@ -22,19 +26,18 @@ export class SettingsSessiondPanel extends LitElement {
// Beta, off by default; also requires spawn to be enabled. // Beta, off by default; also requires spawn to be enabled.
const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn; const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn;
return html` return html`
<div class="section-heading"> <settings-panel-frame
<div> heading="Session daemon"
<h2>Session daemon</h2> .description=${sessiondDescription(this.targetLabel)}
<p>These settings affect the long-lived session runtime. Changes are saved to the config file immediately but only take effect after the session daemon restarts.</p> actionLabel="Reload"
</div> .actionDisabled=${this.loading}
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button> .notices=${this.panelNotices(config)}
</div> .onAction=${this.onReload}
${this.renderMessages()} >
<div class="restart-note" role="note">Restart required: run <code>pi-web restart</code> (or restart the session daemon service) after changing these settings.</div> ${config === undefined ? this.renderUnavailableConfigState() : html`
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
<div class="config-path-card"> <div class="config-path-card">
<span>Config file</span> <span>Config file</span>
<code>${config?.path ?? "Unknown"}</code> <code>${config.path}</code>
</div> </div>
<div class="field"> <div class="field">
<span class="field-heading"> <span class="field-heading">
@@ -77,49 +80,50 @@ export class SettingsSessiondPanel extends LitElement {
</dl> </dl>
</section> </section>
`} `}
</settings-panel-frame>
`; `;
} }
private renderMessages(): TemplateResult | null { private panelNotices(config: PiWebConfigResponse | undefined): readonly SettingsNotice[] {
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`; const notices: SettingsNotice[] = [];
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`; if (this.error !== "") notices.push({ type: "error", content: this.error });
return null; if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage });
if (config !== undefined) {
notices.push({
type: "warning",
title: `Restart required on ${this.targetLabel}`,
content: html`run <code>pi-web restart</code> on that machine (or restart its session daemon service) after changing these settings.`,
});
}
return notices;
}
private renderUnavailableConfigState(): TemplateResult {
return html`<div class="loading-card">${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}</div>`;
} }
private async toggleSpawnSessions(event: Event): Promise<void> { private async toggleSpawnSessions(event: Event): Promise<void> {
const enabled = event.target instanceof HTMLInputElement && event.target.checked; const enabled = event.target instanceof HTMLInputElement && event.target.checked;
const baseConfig = this.configResponse?.config ?? {}; await this.onSave?.(spawnSessionsConfigPatch(enabled));
await this.onSave?.({ ...baseConfig, spawnSessions: enabled });
} }
private async toggleSubsessions(event: Event): Promise<void> { private async toggleSubsessions(event: Event): Promise<void> {
const enabled = event.target instanceof HTMLInputElement && event.target.checked; const enabled = event.target instanceof HTMLInputElement && event.target.checked;
const baseConfig = this.configResponse?.config ?? {}; await this.onSave?.(subsessionsConfigPatch(enabled));
await this.onSave?.({ ...baseConfig, subsessions: enabled });
} }
static override styles = css` static override styles = css`
:host { display: block; } :host { display: block; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } h3 { margin: 0; font-size: 13px; line-height: 1.3; }
.section-heading > div { display: grid; gap: 6px; min-width: 0; }
h2, h3, p { margin: 0; }
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, 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 { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
button:disabled { opacity: .55; cursor: not-allowed; } button:disabled { opacity: .55; cursor: not-allowed; }
.secondary { flex: 0 0 auto; } .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message, .loading-card, .config-path-card, .effective-card, .restart-note { 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 { color: var(--pi-muted); } .loading-card { color: var(--pi-muted); }
.restart-note { margin-bottom: 14px; border-color: var(--pi-warning-border); color: var(--pi-warning); background: var(--pi-warning-surface); line-height: 1.45; } .config-path-card { display: grid; gap: 5px; }
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
.config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } .config-path-card span, .field-heading, dt { 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; } 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; }
.field { display: grid; gap: 7px; margin-bottom: 14px; } .field { display: grid; gap: 7px; }
.field small { color: var(--pi-muted); line-height: 1.45; } .field small { color: var(--pi-muted); line-height: 1.45; }
.field-heading { display: flex; align-items: center; gap: 8px; } .field-heading { display: flex; align-items: center; gap: 8px; }
.toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; } .toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; }
@@ -134,9 +138,11 @@ export class SettingsSessiondPanel extends LitElement {
.muted { color: var(--pi-muted); } .muted { color: var(--pi-muted); }
@media (max-width: 760px) { @media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; }
.section-heading .secondary { justify-self: start; }
.effective-card dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; } .effective-card dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; }
} }
`; `;
} }
function sessiondDescription(targetLabel: string): string {
return `These settings affect the long-lived session runtime on ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`;
}
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import type { TemplateResult } from "lit";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { SettingsShortcutsPanel } from "./SettingsShortcutsPanel";
import type { SettingsNotice } from "./SettingsPanelFrame";
describe("settings-shortcuts-panel layout", () => {
it("renders header, ordered notices, and shortcut settings through the shared frame", () => {
const panel = new SettingsShortcutsPanel();
panel.configResponse = configResponse({ shortcuts: {} });
panel.error = "Failed to load shortcut settings.";
panel.savedMessage = "Shortcut settings saved.";
const template = panel.render();
const rendered = flattenTemplateContent(template);
expect(rendered).toContain("<settings-panel-frame");
expect(frameNotices(template).map((notice) => notice.type)).toEqual(["error", "success"]);
expectTextOrder(rendered, [
"Keyboard shortcuts",
"Edit app shortcuts by action.",
"<code>mod+k</code>",
"Reload",
"Failed to load shortcut settings.",
"Shortcut settings saved.",
"Chat composer",
"Config file",
"No actions registered.",
]);
});
it("keeps the prompt-enter card before the loading shortcuts state", () => {
const panel = new SettingsShortcutsPanel();
panel.loading = true;
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, ["Keyboard shortcuts", "Chat composer", "Loading shortcuts…"]);
expect(rendered).not.toContain("Config file");
});
});
function frameNotices(template: TemplateResult): readonly SettingsNotice[] {
const notices = collectTemplateValues(template).find(isSettingsNoticeArray);
if (notices === undefined) throw new Error("Expected settings-panel-frame notices to be rendered");
return notices;
}
function flattenTemplateContent(template: TemplateResult): string {
const chunks: string[] = [];
visitTemplate(template);
return chunks.join("");
function visitTemplate(current: TemplateResult): void {
const strings = templateStrings(current);
const values = templateValues(current);
for (let index = 0; index < values.length; index += 1) {
const staticChunk = strings[index];
if (staticChunk !== undefined) chunks.push(staticChunk);
visitValue(values[index]);
}
const finalChunk = strings[values.length];
if (finalChunk !== undefined) chunks.push(finalChunk);
}
function visitValue(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visitValue(item);
return;
}
if (isSettingsNotice(value)) {
visitValue(value.title);
visitValue(value.content);
return;
}
if (isTemplateResult(value)) {
visitTemplate(value);
return;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
chunks.push(String(value));
}
}
}
function collectTemplateValues(template: TemplateResult): unknown[] {
const values: unknown[] = [];
visit(template);
return values;
function visit(current: unknown): void {
if (Array.isArray(current)) {
for (const item of current) visit(item);
return;
}
if (!isTemplateResult(current)) return;
for (const value of templateValues(current)) {
values.push(value);
visit(value);
}
}
}
function expectTextOrder(content: string, labels: readonly string[]): void {
let previousIndex = -1;
for (const label of labels) {
const currentIndex = content.indexOf(label, previousIndex + 1);
if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`);
expect(currentIndex).toBeGreaterThan(previousIndex);
previousIndex = currentIndex;
}
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isTemplateResult(value: unknown): value is TemplateResult {
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
}
function isSettingsNotice(value: unknown): value is SettingsNotice {
return typeof value === "object" && value !== null && typeof Reflect.get(value, "type") === "string" && Reflect.has(value, "content");
}
function isSettingsNoticeArray(value: unknown): value is readonly SettingsNotice[] {
return Array.isArray(value) && value.every(isSettingsNotice);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
@@ -3,9 +3,34 @@ import { customElement, property, state } from "lit/decorators.js";
import type { AppAction } from "../../actions"; import type { AppAction } from "../../actions";
import type { PiWebConfigResponse, PiWebConfigValues, PiWebShortcutConfig } from "../../api"; import type { PiWebConfigResponse, PiWebConfigValues, PiWebShortcutConfig } from "../../api";
import { formatShortcut, isShortcutSequenceStarter, parseShortcutInput, resolveShortcutBindings, shortcutSequenceTimeoutMs, shortcutTokenFromEvent, type ShortcutBindingResolution } from "../../keyboardShortcuts"; import { formatShortcut, isShortcutSequenceStarter, parseShortcutInput, resolveShortcutBindings, shortcutSequenceTimeoutMs, shortcutTokenFromEvent, type ShortcutBindingResolution } from "../../keyboardShortcuts";
import { readPromptEnterPreference, writePromptEnterPreference, type PromptEnterPreference } from "../../promptEnterBehavior";
import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
const RECORD_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const; const RECORD_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
const PROMPT_ENTER_OPTIONS: readonly { value: PromptEnterPreference; label: string; description: string }[] = [
{
value: "auto",
label: "Auto/default",
description: "Desktop-like Enter sends; mobile, coarse pointer, or narrow screens insert a new line.",
},
{
value: "send",
label: "Enter sends message",
description: "Enter sends the chat message; Shift+Enter adds a new line when supported.",
},
{
value: "newline",
label: "Enter inserts new line",
description: "Enter adds a line break; Shift+Enter sends the chat message when supported.",
},
];
function renderShortcutsDescription(): TemplateResult {
return html`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.`;
}
@customElement("settings-shortcuts-panel") @customElement("settings-shortcuts-panel")
export class SettingsShortcutsPanel extends LitElement { export class SettingsShortcutsPanel extends LitElement {
@property({ attribute: false }) actions: AppAction[] = []; @property({ attribute: false }) actions: AppAction[] = [];
@@ -18,6 +43,7 @@ export class SettingsShortcutsPanel extends LitElement {
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>; @property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
@state() private drafts: Record<string, string> = {}; @state() private drafts: Record<string, string> = {};
@state() private localError = ""; @state() private localError = "";
@state() private promptEnterPreference: PromptEnterPreference = readPromptEnterPreference();
@state() private recording: RecordingState | undefined; @state() private recording: RecordingState | undefined;
private recordingTimer: number | undefined; private recordingTimer: number | undefined;
private recordingListenerActive = false; private recordingListenerActive = false;
@@ -67,14 +93,15 @@ export class SettingsShortcutsPanel extends LitElement {
const groups = shortcutGroups(this.actions); const groups = shortcutGroups(this.actions);
const shortcutResolutions = this.shortcutResolutions(); const shortcutResolutions = this.shortcutResolutions();
return html` return html`
<div class="section-heading"> <settings-panel-frame
<div> heading="Keyboard shortcuts"
<h2>Keyboard shortcuts</h2> .description=${renderShortcutsDescription()}
<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> actionLabel="Reload"
</div> .actionDisabled=${this.loading}
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button> .notices=${this.panelNotices()}
</div> .onAction=${this.onReload}
${this.renderMessages()} >
${this.renderPromptEnterPreferenceCard()}
${this.configResponse === undefined && this.loading ? html`<div class="loading-card">Loading shortcuts…</div>` : html` ${this.configResponse === undefined && this.loading ? html`<div class="loading-card">Loading shortcuts…</div>` : html`
<div class="config-path-card"> <div class="config-path-card">
<span>Config file</span> <span>Config file</span>
@@ -90,14 +117,50 @@ export class SettingsShortcutsPanel extends LitElement {
</section> </section>
`)} `)}
`} `}
</settings-panel-frame>
`; `;
} }
private renderMessages(): TemplateResult | null { private panelNotices(): readonly SettingsNotice[] {
const notices: SettingsNotice[] = [];
const error = this.localError || this.error; const error = this.localError || this.error;
if (error !== "") return html`<div class="message error-message">${error}</div>`; if (error !== "") notices.push({ type: "error", content: error });
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`; if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage });
return null; return notices;
}
private renderPromptEnterPreferenceCard(): TemplateResult {
return html`
<section class="prompt-enter-card" aria-labelledby="prompt-enter-preference-title">
<div class="prompt-enter-copy">
<span class="card-eyebrow">Chat composer</span>
<h3 id="prompt-enter-preference-title">Enter key behavior</h3>
<p>Choose what Enter does in this browser. Shift+Enter does the opposite when supported; automatic touch-keyboard capitalization is ignored to avoid accidental sends.</p>
</div>
<div class="prompt-enter-options" role="radiogroup" aria-label="Enter and Shift Enter behavior in the chat composer">
${PROMPT_ENTER_OPTIONS.map((option) => html`
<label class="prompt-enter-option">
<input
type="radio"
name="prompt-enter-preference"
.value=${option.value}
.checked=${this.promptEnterPreference === option.value}
@change=${() => { this.updatePromptEnterPreference(option.value); }}
>
<span>
<strong>${option.label}</strong>
<small>${option.description}</small>
</span>
</label>
`)}
</div>
</section>
`;
}
private updatePromptEnterPreference(preference: PromptEnterPreference): void {
this.promptEnterPreference = preference;
writePromptEnterPreference(preference);
} }
private renderShortcutRow(action: AppAction, resolution: ShortcutBindingResolution | undefined): TemplateResult { private renderShortcutRow(action: AppAction, resolution: ShortcutBindingResolution | undefined): TemplateResult {
@@ -280,26 +343,28 @@ export class SettingsShortcutsPanel extends LitElement {
static override styles = css` static override styles = css`
:host { display: block; } :host { display: block; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } h3, p { margin: 0; }
.section-heading > div { display: grid; gap: 6px; min-width: 0; }
h2, h3, p { margin: 0; }
h2 { font-size: 17px; line-height: 1.25; }
h3 { font-size: 13px; line-height: 1.3; } h3 { font-size: 13px; line-height: 1.3; }
p { color: var(--pi-muted); line-height: 1.45; } p { color: var(--pi-muted); line-height: 1.45; }
button, input { font: inherit; } 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 { 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; } 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); } .primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); }
.secondary { flex: 0 0 auto; } .loading-card, .config-path-card, .prompt-enter-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.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); } .loading-card, .config-path-card { color: var(--pi-muted); }
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; } .config-path-card { display: grid; gap: 5px; }
.config-path-card span { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } .config-path-card span, .card-eyebrow { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
.prompt-enter-card { display: grid; grid-template-columns: minmax(0, .85fr) minmax(260px, 1fr); gap: 12px; align-items: start; }
.prompt-enter-copy { display: grid; gap: 5px; min-width: 0; }
.prompt-enter-copy p, .prompt-enter-option small { font-size: 12px; }
.prompt-enter-options { display: grid; gap: 7px; }
.prompt-enter-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; align-items: start; color: var(--pi-text); }
.prompt-enter-option input { box-sizing: border-box; width: 14px; min-width: 14px; height: 14px; margin: 3px 0 0; padding: 0; border: 0; background: transparent; accent-color: var(--pi-accent); font-family: inherit; }
.prompt-enter-option input:focus { border-color: transparent; box-shadow: none; outline: 2px solid var(--pi-accent-border); outline-offset: 2px; }
.prompt-enter-option span { display: grid; gap: 2px; }
.prompt-enter-option small { color: var(--pi-muted); line-height: 1.35; }
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; } 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; }
.shortcut-group { margin: 0 0 16px; } .shortcut-group { margin: 0; }
.shortcut-group h3 { margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } .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-list { border: 1px solid var(--pi-border); border-radius: 10px; overflow: hidden; }
.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 { 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); }
@@ -320,7 +385,7 @@ export class SettingsShortcutsPanel extends LitElement {
.shortcut-status small.conflict.shadowed { color: var(--pi-warning); } .shortcut-status small.conflict.shadowed { color: var(--pi-warning); }
.shortcut-input-label { min-width: 0; display: grid; gap: 5px; } .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; } .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 { 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: var(--pi-control-font-size, 16px) var(--pi-control-monospace-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); } 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; } .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; } 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; }
@@ -328,8 +393,7 @@ export class SettingsShortcutsPanel extends LitElement {
.recording-hint { color: var(--pi-accent); font-size: 12px; } .recording-hint { color: var(--pi-accent); font-size: 12px; }
@media (max-width: 760px) { @media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; } .prompt-enter-card { grid-template-columns: minmax(0, 1fr); }
.section-heading .secondary { justify-self: start; }
.shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; } .shortcut-row { grid-template-columns: minmax(0, 1fr); align-items: start; }
.shortcut-status, .shortcut-actions { justify-content: flex-start; } .shortcut-status, .shortcut-actions { justify-content: flex-start; }
} }
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../../shared/capabilities";
import type { MachineRuntime, PiPackageInfo } from "../../api";
import { canUpdateAllPiPackages, friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, isPiPackageOperationPending, normalizePiPackageSource, piPackageFilteredLabel, piPackageManagementSupport, piPackageMutationFollowUpMessage, piPackageScopeLabel, piPackageSourceValidationMessage, piPackageTargetContext, piPackageTargetLabel, piPackageUpdateDisabledReason, shouldRefreshGatewayPluginsAfterPiPackageMutation, updateAllPiPackagesDisabledReason, type PiPackageTargetContext } from "./piPackageSettings";
const userPackage: PiPackageInfo = { source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" };
const projectPackage: PiPackageInfo = { source: "../project-tools", scope: "project", filtered: true };
const localTarget: PiPackageTargetContext = { id: "local", name: "local", kind: "local" };
const remoteTarget: PiPackageTargetContext = { id: "remote-a", name: "Lab Mac", kind: "remote" };
const runtimeWithPackageManagement: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.piPackagesManage] };
const runtimeWithoutPackageManagement: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] };
const unavailableRuntime: MachineRuntime = { machineId: "remote-a", ok: false, checkedAt: "now", error: "Remote runtime returned HTTP 404" };
describe("Pi package settings helpers", () => {
it("normalizes and validates install sources without adding location choices", () => {
expect(normalizePiPackageSource(" npm:@acme/tools ")).toBe("npm:@acme/tools");
expect(piPackageSourceValidationMessage(" npm:@acme/tools ")).toBeUndefined();
expect(piPackageSourceValidationMessage(" ")).toContain("Pi package source accepted by Pi");
});
it("formats package metadata with Pi package terminology", () => {
expect(piPackageScopeLabel(userPackage)).toBe("User scope");
expect(piPackageScopeLabel(projectPackage)).toBe("Project scope");
expect(piPackageFilteredLabel(userPackage)).toBe("Available in this PI WEB process");
expect(piPackageFilteredLabel(projectPackage)).toBe("Filtered by current Pi package settings");
});
it("allows updates for user-scope packages and explains project-scope limits", () => {
expect(piPackageUpdateDisabledReason(userPackage)).toBeUndefined();
expect(piPackageUpdateDisabledReason(projectPackage)).toContain("user-scope Pi packages");
expect(canUpdateAllPiPackages([userPackage])).toBe(true);
expect(canUpdateAllPiPackages([userPackage, projectPackage])).toBe(false);
expect(updateAllPiPackagesDisabledReason([])).toBe("No Pi packages are configured yet.");
expect(updateAllPiPackagesDisabledReason([userPackage, projectPackage])).toContain("project-scope Pi packages");
});
it("matches pending operations by action and source", () => {
expect(isPiPackageOperationPending({ kind: "remove", source: "npm:@acme/tools" }, "remove", "npm:@acme/tools")).toBe(true);
expect(isPiPackageOperationPending({ kind: "remove", source: "npm:@acme/tools" }, "remove", "npm:@acme/other")).toBe(false);
expect(isPiPackageOperationPending({ kind: "update-all" }, "update-all")).toBe(true);
});
it("labels package targets and gateway plugin refresh scope", () => {
expect(piPackageTargetContext(undefined)).toEqual(localTarget);
expect(piPackageTargetLabel(localTarget)).toBe("local (local gateway)");
expect(piPackageTargetLabel(remoteTarget)).toBe("Lab Mac (remote machine)");
expect(shouldRefreshGatewayPluginsAfterPiPackageMutation(localTarget)).toBe(true);
expect(shouldRefreshGatewayPluginsAfterPiPackageMutation(remoteTarget)).toBe(false);
});
it("uses runtime capabilities as package-management UX guidance without blocking older remotes", () => {
expect(piPackageManagementSupport(localTarget, undefined)).toEqual({ state: "supported" });
expect(piPackageManagementSupport(remoteTarget, runtimeWithPackageManagement)).toEqual({ state: "supported" });
const unsupported = piPackageManagementSupport(remoteTarget, runtimeWithoutPackageManagement);
expect(isPiPackageManagementUnsupported(unsupported)).toBe(true);
expect(unsupported.message).toContain("Update and restart Pi-Web on that machine");
expect(piPackageManagementSupport(remoteTarget, undefined)).toEqual({ state: "unknown" });
expect(piPackageManagementSupport(remoteTarget, unavailableRuntime)).toEqual({ state: "unknown" });
});
it("describes the browser and session reload follow-up without requiring sessiond restarts", () => {
const message = piPackageMutationFollowUpMessage("install");
expect(message).toContain("Type /reload in each idle PI WEB session");
expect(message).toContain("extensions, skills, prompt templates, themes, and context/system prompt files");
expect(message).toContain("Reload the browser page separately for PI WEB browser plugin changes");
expect(message).not.toContain("session daemon");
expect(message).not.toContain("sessiond");
});
it("scopes remote package mutation follow-up copy to the selected machine", () => {
const message = piPackageMutationFollowUpMessage("update", remoteTarget);
expect(message).toContain("Pi package updated on Lab Mac");
expect(message).toContain("each idle PI WEB session on Lab Mac");
expect(message).toContain("PI WEB browser plugin changes served by Lab Mac");
});
it("turns older remote route failures into package-management compatibility guidance", () => {
expect(friendlyPiPackageErrorMessage("Not Found", remoteTarget)).toBe("Pi package management is not available on Lab Mac. Update and restart Pi-Web on that machine, then try again.");
expect(friendlyPiPackageErrorMessage("Remote machine unavailable", remoteTarget)).toBe("Could not reach Lab Mac for Pi package management. Check the machine connection and try again.");
expect(friendlyPiPackageErrorMessage("Remote machine timeout", remoteTarget)).toContain("may still be running remotely");
expect(friendlyPiPackageErrorMessage("Not Found", localTarget)).toBe("Not Found");
});
});
@@ -0,0 +1,128 @@
import type { Machine, MachineKind, MachineRuntime, PiPackageInfo, PiPackageMutationAction } from "../../api";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities";
export type PiPackageOperationKind = PiPackageMutationAction | "update-all";
export interface PiPackageOperationState {
kind: PiPackageOperationKind;
source?: string;
}
export interface PiPackageTargetContext {
id: string;
name: string;
kind: MachineKind;
}
export type PiPackageManagementSupportState = "supported" | "unsupported" | "unknown";
export interface PiPackageManagementSupport {
state: PiPackageManagementSupportState;
message?: string;
}
export function piPackageTargetContext(machine: Pick<Machine, "id" | "name" | "kind"> | undefined): PiPackageTargetContext {
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
return { id: "local", name: "local", kind: "local" };
}
export function piPackageTargetLabel(target: PiPackageTargetContext): string {
return target.kind === "local" ? `${target.name} (local gateway)` : `${target.name} (remote machine)`;
}
export function piPackageManagementSupport(target: PiPackageTargetContext, runtime: Pick<MachineRuntime, "ok" | "capabilities"> | undefined): PiPackageManagementSupport {
if (target.kind === "local") return { state: "supported" };
if (runtime?.ok !== true) return { state: "unknown" };
if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.piPackagesManage)) return { state: "supported" };
return { state: "unsupported", message: piPackageManagementUnavailableMessage(target) };
}
export function piPackageManagementSupportKey(support: PiPackageManagementSupport): string {
return `${support.state}:${support.message ?? ""}`;
}
export function isPiPackageManagementUnsupported(support: PiPackageManagementSupport | undefined): support is PiPackageManagementSupport & { state: "unsupported" } {
return support?.state === "unsupported";
}
export function piPackageManagementUnavailableMessage(target: PiPackageTargetContext): string {
return `Pi package management is not available on ${target.name}. Update and restart Pi-Web on that machine, then try again.`;
}
export function shouldRefreshGatewayPluginsAfterPiPackageMutation(target: PiPackageTargetContext): boolean {
return target.kind === "local";
}
export function normalizePiPackageSource(source: string): string {
return source.trim();
}
export function piPackageSourceValidationMessage(source: string): string | undefined {
if (normalizePiPackageSource(source) !== "") return undefined;
return "Enter a Pi package source accepted by Pi, such as npm:@scope/package, a git/URL source, or a local path.";
}
export function piPackageScopeLabel(packageInfo: Pick<PiPackageInfo, "scope">): string {
return packageInfo.scope === "project" ? "Project scope" : "User scope";
}
export function piPackageFilteredLabel(packageInfo: Pick<PiPackageInfo, "filtered">): string {
return packageInfo.filtered ? "Filtered by current Pi package settings" : "Available in this PI WEB process";
}
export function piPackageInstalledPathLabel(packageInfo: Pick<PiPackageInfo, "installedPath">): string {
return packageInfo.installedPath ?? "Installed path not reported by Pi";
}
export function canUpdatePiPackage(packageInfo: Pick<PiPackageInfo, "scope">): boolean {
return packageInfo.scope === "user";
}
export function piPackageUpdateDisabledReason(packageInfo: Pick<PiPackageInfo, "scope">): string | undefined {
if (canUpdatePiPackage(packageInfo)) return undefined;
return "Project-scope Pi packages are listed for visibility, but PI WEB only updates user-scope Pi packages safely from this view.";
}
export function canUpdateAllPiPackages(packages: readonly Pick<PiPackageInfo, "scope">[]): boolean {
return packages.length > 0 && packages.every(canUpdatePiPackage);
}
export function updateAllPiPackagesDisabledReason(packages: readonly Pick<PiPackageInfo, "scope">[]): string | undefined {
if (packages.length === 0) return "No Pi packages are configured yet.";
if (canUpdateAllPiPackages(packages)) return undefined;
return "Update all is disabled while project-scope Pi packages are listed; update user-scope packages individually.";
}
export function isPiPackageOperationPending(operation: PiPackageOperationState | undefined, kind: PiPackageOperationKind, source?: string): boolean {
if (operation?.kind !== kind) return false;
return source === undefined || operation.source === source;
}
export function piPackageMutationFollowUpMessage(action: PiPackageMutationAction, target = piPackageTargetContext(undefined)): string {
const verb = action === "install" ? "installed" : action === "remove" ? "removed" : "updated";
const targetSuffix = target.kind === "local" ? "" : ` on ${target.name}`;
const sessionScope = target.kind === "local" ? "each idle PI WEB session" : `each idle PI WEB session on ${target.name}`;
const pluginScope = target.kind === "local" ? "PI WEB browser plugin changes" : `PI WEB browser plugin changes served by ${target.name}`;
return `Pi package ${verb}${targetSuffix}. Type /reload in ${sessionScope} to rediscover Pi runtime resources: extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for ${pluginScope}.`;
}
export function friendlyPiPackageErrorMessage(message: string, target: PiPackageTargetContext): string {
const normalized = message.trim();
if (target.kind !== "remote") return normalized;
if (isUnsupportedRemotePiPackageRouteMessage(normalized)) {
return piPackageManagementUnavailableMessage(target);
}
if (normalized === "Remote machine timeout") {
return `Timed out while contacting ${target.name} for Pi package management. The package operation may still be running remotely; reload the package list before retrying.`;
}
if (normalized === "Remote machine unavailable") {
return `Could not reach ${target.name} for Pi package management. Check the machine connection and try again.`;
}
return normalized;
}
function isUnsupportedRemotePiPackageRouteMessage(message: string): boolean {
return message === "Not Found"
|| /route\s+(GET|POST):?\/api\/pi-packages\b.*not found/iu.test(message)
|| /cannot\s+(GET|POST)\s+.*\/api\/pi-packages\b/iu.test(message);
}
@@ -1,7 +1,82 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { configFromDraft, draftFromConfig } from "./settingsConfigDraft"; import {
configFromDraft,
draftFromConfig,
gatewayServerConfigFromDraft,
gatewayServerDraftFromConfig,
machineAccessConfigPatchFromDraft,
machineAccessDraftFromConfig,
} from "./settingsConfigDraft";
describe("settings config drafts", () => { describe("settings config drafts", () => {
it("splits gateway server and selected-machine access drafts", () => {
const config = {
host: "0.0.0.0",
port: 8504,
allowedHosts: ["example.local", "192.168.1.20"],
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
};
expect(gatewayServerDraftFromConfig(config)).toEqual({
host: "0.0.0.0",
port: "8504",
allowedHostsMode: "list",
allowedHostsText: "example.local\n192.168.1.20",
});
expect(machineAccessDraftFromConfig(config)).toEqual({
allowedPathsText: "/tmp\n~/SDKs",
uploadDefaultFolder: "manual/uploads",
});
});
it("builds gateway server saves without changing selected-machine-safe config values", () => {
expect(gatewayServerConfigFromDraft({
host: " gateway.local ",
port: "9000",
allowedHostsMode: "all",
allowedHostsText: "ignored.local",
}, {
pathAccess: { allowedPaths: ["/old"] },
uploads: { defaultFolder: "old/uploads" },
maxUploadBytes: 1234,
spawnSessions: true,
})).toEqual({
host: "gateway.local",
port: 9000,
allowedHosts: true,
pathAccess: { allowedPaths: ["/old"] },
uploads: { defaultFolder: "old/uploads" },
maxUploadBytes: 1234,
spawnSessions: true,
});
});
it("builds selected-machine access/upload patches only from selected-machine-safe fields", () => {
const patch = machineAccessConfigPatchFromDraft({
allowedPathsText: "/tmp\n~/SDKs\n",
uploadDefaultFolder: " manual\\uploads/. ",
});
expect(Object.keys(patch)).toEqual(["pathAccess", "uploads"]);
expect(patch).toEqual({
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
});
});
it("clears selected-machine access/upload settings with safe default patches", () => {
expect(machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "" })).toEqual({
pathAccess: { allowedPaths: [] },
uploads: {},
});
});
it("rejects invalid selected-machine upload default folders before saving", () => {
expect(() => machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "/tmp/uploads" })).toThrow("Upload default folder must be workspace-relative.");
expect(() => machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "../secret" })).toThrow("Upload default folder must not contain path traversal.");
});
it("converts PI WEB config values to editable general settings drafts", () => { it("converts PI WEB config values to editable general settings drafts", () => {
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"], pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } })).toEqual({ expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"], pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } })).toEqual({
host: "0.0.0.0", host: "0.0.0.0",
@@ -1,36 +1,51 @@
import type { PiWebConfigValues } from "../../api"; import type { PiWebConfigValues } from "../../api";
export interface ConfigDraft { export interface GatewayServerConfigDraft {
host: string; host: string;
port: string; port: string;
allowedHostsMode: "list" | "all"; allowedHostsMode: "list" | "all";
allowedHostsText: string; allowedHostsText: string;
}
export interface MachineAccessConfigDraft {
allowedPathsText: string;
uploadDefaultFolder: string;
}
export interface ConfigDraft extends GatewayServerConfigDraft {
allowedPathsText: string; allowedPathsText: string;
} }
export function emptyConfigDraft(): ConfigDraft { export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft {
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "", allowedPathsText: "" }; return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
} }
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft { export function emptyMachineAccessConfigDraft(): MachineAccessConfigDraft {
return { allowedPathsText: "", uploadDefaultFolder: "" };
}
export function gatewayServerDraftFromConfig(config: PiWebConfigValues): GatewayServerConfigDraft {
return { return {
host: config.host ?? "", host: config.host ?? "",
port: config.port === undefined ? "" : String(config.port), port: config.port === undefined ? "" : String(config.port),
allowedHostsMode: config.allowedHosts === true ? "all" : "list", allowedHostsMode: config.allowedHosts === true ? "all" : "list",
allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "", allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "",
allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
}; };
} }
export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues { export function machineAccessDraftFromConfig(config: PiWebConfigValues): MachineAccessConfigDraft {
const config: PiWebConfigValues = { return {
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }), allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }), uploadDefaultFolder: config.uploads?.defaultFolder ?? "",
...(baseConfig.uploads === undefined ? {} : { uploads: baseConfig.uploads }),
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
}; };
}
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
return { ...gatewayServerDraftFromConfig(config), allowedPathsText: machineAccessDraftFromConfig(config).allowedPathsText };
}
export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
const config = preservedGatewayConfigRemainder(baseConfig);
const host = draft.host.trim(); const host = draft.host.trim();
const port = draft.port.trim(); const port = draft.port.trim();
if (host !== "") config.host = host; if (host !== "") config.host = host;
@@ -40,11 +55,38 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
config.port = parsed; config.port = parsed;
} }
config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText); config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText);
return config;
}
export function machineAccessConfigPatchFromDraft(draft: MachineAccessConfigDraft): PiWebConfigValues {
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
const uploadDefaultFolder = normalizeWorkspaceRelativeFolder(draft.uploadDefaultFolder);
return {
pathAccess: { allowedPaths },
uploads: uploadDefaultFolder === "" ? {} : { defaultFolder: uploadDefaultFolder },
};
}
export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
const config = gatewayServerConfigFromDraft(draft, baseConfig);
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText); const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
if (allowedPaths.length > 0) config.pathAccess = { allowedPaths }; if (allowedPaths.length > 0) config.pathAccess = { allowedPaths };
else delete config.pathAccess;
return config; return config;
} }
function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebConfigValues {
return {
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }),
...(baseConfig.pathAccess === undefined ? {} : { pathAccess: baseConfig.pathAccess }),
...(baseConfig.uploads === undefined ? {} : { uploads: baseConfig.uploads }),
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
};
}
function parseAllowedHostsText(value: string): string[] { function parseAllowedHostsText(value: string): string[] {
return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== ""); return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== "");
} }
@@ -56,6 +98,21 @@ function parseAllowedPathsText(value: string): string[] {
return paths; return paths;
} }
function normalizeWorkspaceRelativeFolder(value: string): string {
const trimmed = value.trim();
if (trimmed === "") return "";
if (isAbsoluteLike(trimmed)) throw new Error("Upload default folder must be workspace-relative.");
const parts = trimmed.split(/[\\/]+/u).filter((part) => part !== "" && part !== ".");
if (parts.length === 0) return "";
if (parts.some((part) => part === "..")) throw new Error("Upload default folder must not contain path traversal.");
return parts.join("/");
}
function isAbsoluteishAllowedPath(path: string): boolean { function isAbsoluteishAllowedPath(path: string): boolean {
return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(path); return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(path);
} }
function isAbsoluteLike(value: string): boolean {
const withForwardSlashes = value.replace(/\\/g, "/");
return withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//u.test(withForwardSlashes);
}
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
import type { PiPackagesResponse, PiWebConfigResponse, PiWebPluginsResponse } from "../../api";
import { loadGatewaySettingsData, loadPiPackagesData } from "./settingsDataLoading";
import type { PiPackageManagementSupport } from "./piPackageSettings";
const configResponse: PiWebConfigResponse = {
path: "/home/test/.config/pi-web/config.json",
exists: true,
config: { host: "127.0.0.1" },
effectiveConfig: { host: "127.0.0.1" },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
const pluginsResponse: PiWebPluginsResponse = { plugins: [] };
const packagesResponse: PiPackagesResponse = { packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }] };
const remoteTarget = { id: "remote-a", name: "Lab Mac", kind: "remote" } as const;
const unsupportedPackageManagement: PiPackageManagementSupport = {
state: "unsupported",
message: "Pi package management is not available on Lab Mac. Update and restart Pi-Web on that machine, then try again.",
};
describe("settings data loading helpers", () => {
it("loads gateway settings without depending on Pi package data", async () => {
const result = await loadGatewaySettingsData({
loadConfig: () => Promise.resolve(configResponse),
loadPlugins: () => Promise.resolve(pluginsResponse),
});
expect(result).toEqual({ config: configResponse, plugins: pluginsResponse, error: "" });
});
it("keeps gateway settings errors scoped to gateway config and plugins", async () => {
const result = await loadGatewaySettingsData({
loadConfig: () => Promise.resolve(configResponse),
loadPlugins: () => Promise.reject(new Error("plugin scan failed")),
});
expect(result.config).toBe(configResponse);
expect(result.plugins).toBeUndefined();
expect(result.error).toBe("Failed to load settings: PI WEB plugins: plugin scan failed");
});
it("loads Pi packages for the selected target with package-scoped errors", async () => {
const requestedTargets: string[] = [];
const success = await loadPiPackagesData(remoteTarget, (targetId) => {
requestedTargets.push(targetId);
return Promise.resolve(packagesResponse);
});
const failure = await loadPiPackagesData(remoteTarget, (targetId) => {
requestedTargets.push(targetId);
return Promise.reject(new Error("Remote machine unavailable"));
});
expect(requestedTargets).toEqual(["remote-a", "remote-a"]);
expect(success).toEqual({ packagesResponse, error: "" });
expect(failure.packagesResponse).toBeUndefined();
expect(failure.error).toBe("Failed to load Pi packages from Lab Mac (remote machine): Could not reach Lab Mac for Pi package management. Check the machine connection and try again.");
});
it("skips package listing only when runtime data confirms package management is unsupported", async () => {
const requestedTargets: string[] = [];
const loadPackages = vi.fn((targetId: string) => {
requestedTargets.push(targetId);
return Promise.resolve(packagesResponse);
});
const blocked = await loadPiPackagesData(remoteTarget, loadPackages, unsupportedPackageManagement);
const unknown = await loadPiPackagesData(remoteTarget, loadPackages, { state: "unknown" });
expect(blocked).toEqual({ error: unsupportedPackageManagement.message, skipped: true });
expect(unknown).toEqual({ packagesResponse, error: "" });
expect(requestedTargets).toEqual(["remote-a"]);
});
});
@@ -0,0 +1,50 @@
import type { PiPackagesResponse, PiWebConfigResponse, PiWebPluginsResponse } from "../../api";
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageTargetLabel, type PiPackageManagementSupport, type PiPackageTargetContext } from "./piPackageSettings";
export interface GatewaySettingsLoaders {
loadConfig: () => Promise<PiWebConfigResponse>;
loadPlugins: () => Promise<PiWebPluginsResponse>;
}
export interface GatewaySettingsLoadResult {
config?: PiWebConfigResponse;
plugins?: PiWebPluginsResponse;
error: string;
}
export interface PiPackagesLoadResult {
packagesResponse?: PiPackagesResponse;
error: string;
skipped?: boolean;
}
export async function loadGatewaySettingsData(loaders: GatewaySettingsLoaders): Promise<GatewaySettingsLoadResult> {
const [config, plugins] = await Promise.allSettled([loaders.loadConfig(), loaders.loadPlugins()]);
const result: GatewaySettingsLoadResult = { error: "" };
const errors: string[] = [];
if (config.status === "fulfilled") result.config = config.value;
else errors.push(`config: ${errorMessage(config.reason)}`);
if (plugins.status === "fulfilled") result.plugins = plugins.value;
else errors.push(`PI WEB plugins: ${errorMessage(plugins.reason)}`);
if (errors.length > 0) result.error = `Failed to load settings: ${errors.join("; ")}`;
return result;
}
export async function loadPiPackagesData(target: PiPackageTargetContext, loadPackages: (targetId: string) => Promise<PiPackagesResponse>, support?: PiPackageManagementSupport): Promise<PiPackagesLoadResult> {
if (isPiPackageManagementUnsupported(support)) {
return { error: support.message ?? `Pi package management is not available on ${piPackageTargetLabel(target)}.`, skipped: true };
}
try {
return { packagesResponse: await loadPackages(target.id), error: "" };
} catch (error) {
return { error: `Failed to load Pi packages from ${piPackageTargetLabel(target)}: ${friendlyPiPackageErrorMessage(errorMessage(error), target)}` };
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { mergeSelectedMachineAccessConfig } from "./settingsMachineAccessConfig";
describe("selected-machine access config helpers", () => {
it("merges local selected-machine file/upload config into gateway config without dropping gateway-only values", () => {
const gateway = configResponse({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["/old"] },
uploads: { defaultFolder: "old/uploads" },
maxUploadBytes: 1234,
});
const selectedMachine = configResponse({
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
maxUploadBytes: 5678,
});
expect(mergeSelectedMachineAccessConfig(gateway, selectedMachine)).toEqual({
...gateway,
config: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
maxUploadBytes: 5678,
},
effectiveConfig: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
pathAccess: { allowedPaths: ["~/SDKs"] },
uploads: { defaultFolder: "manual/uploads" },
maxUploadBytes: 5678,
},
});
});
it("merges cleared selected-machine access/upload defaults without clearing gateway-only values", () => {
const gateway = configResponse({
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
pathAccess: { allowedPaths: ["/old"] },
uploads: { defaultFolder: "old/uploads" },
});
const selectedMachine = configResponse({ pathAccess: { allowedPaths: [] }, uploads: {} });
expect(mergeSelectedMachineAccessConfig(gateway, selectedMachine)).toEqual({
...gateway,
config: {
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
pathAccess: { allowedPaths: [] },
uploads: {},
},
effectiveConfig: {
host: "127.0.0.1",
shortcuts: { "core:view.chat": "mod+1" },
pathAccess: { allowedPaths: [] },
uploads: {},
},
});
});
});
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
@@ -0,0 +1,18 @@
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
export function mergeSelectedMachineAccessConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
return {
...base,
config: mergeAccessConfig(base.config, selectedMachine.config),
effectiveConfig: mergeAccessConfig(base.effectiveConfig, selectedMachine.effectiveConfig),
};
}
function mergeAccessConfig(base: PiWebConfigValues, selectedMachine: PiWebConfigValues): PiWebConfigValues {
return {
...base,
...(selectedMachine.pathAccess === undefined ? {} : { pathAccess: selectedMachine.pathAccess }),
...(selectedMachine.uploads === undefined ? {} : { uploads: selectedMachine.uploads }),
...(selectedMachine.maxUploadBytes === undefined ? {} : { maxUploadBytes: selectedMachine.maxUploadBytes }),
};
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import type { Machine, MachineRuntime } from "../../api";
import { PI_WEB_CAPABILITIES } from "../../../../shared/capabilities";
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget";
const remoteMachine: Machine = {
id: "remote-a",
name: "Lab Mac",
kind: "remote",
baseUrl: "https://lab.example.test",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
};
describe("selected-machine settings target helpers", () => {
it("uses the selected machine when present and falls back to the local gateway", () => {
expect(settingsMachineTarget(undefined)).toEqual({ id: "local", name: "local", kind: "local" });
expect(settingsMachineTarget(remoteMachine)).toEqual({ id: "remote-a", name: "Lab Mac", kind: "remote" });
});
it("labels local and remote settings targets factually", () => {
expect(settingsMachineTargetLabel({ id: "local", name: "local", kind: "local" })).toBe("local (local gateway)");
expect(settingsMachineTargetLabel(settingsMachineTarget(remoteMachine))).toBe("Lab Mac (remote machine)");
});
it("gates remote selected-machine settings on advertised runtime support", () => {
const target = settingsMachineTarget(remoteMachine);
const supportedRuntime: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings] };
const unsupportedRuntime: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.piPackagesManage] };
expect(selectedMachineSettingsSupport({ id: "local", name: "local", kind: "local" }, undefined)).toEqual({ state: "supported" });
expect(selectedMachineSettingsSupport(target, undefined)).toEqual({ state: "unknown" });
expect(selectedMachineSettingsSupport(target, { ok: false })).toEqual({ state: "unknown" });
expect(selectedMachineSettingsSupport(target, supportedRuntime)).toEqual({ state: "supported" });
const unsupported = selectedMachineSettingsSupport(target, unsupportedRuntime);
expect(isSelectedMachineSettingsUnsupported(unsupported)).toBe(true);
expect(unsupported.message).toBe(selectedMachineSettingsUnavailableMessage(target));
expect(selectedMachineSettingsSupportKey(unsupported)).toBe(`unsupported:${selectedMachineSettingsUnavailableMessage(target)}`);
});
it("turns older remote config route failures into selected-machine compatibility guidance", () => {
const target = settingsMachineTarget(remoteMachine);
expect(selectedMachineSettingsUnavailableMessage(target)).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(friendlySelectedMachineSettingsErrorMessage("Not Found", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
expect(friendlySelectedMachineSettingsErrorMessage("route GET:/api/config not found", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
expect(friendlySelectedMachineSettingsErrorMessage("Cannot PUT /api/config", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
expect(friendlySelectedMachineSettingsErrorMessage("route GET:/api/plugins not found", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
expect(friendlySelectedMachineSettingsErrorMessage("Cannot GET /api/plugins", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
});
it("scopes remote reachability errors to selected-machine settings", () => {
const target = settingsMachineTarget(remoteMachine);
expect(friendlySelectedMachineSettingsErrorMessage("Remote machine unavailable", target)).toBe("Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
expect(friendlySelectedMachineSettingsErrorMessage("Remote machine timeout", target)).toBe("Timed out while contacting Lab Mac for selected-machine settings. The operation may still be running remotely; reload before retrying.");
expect(friendlySelectedMachineSettingsErrorMessage("Not Found", { id: "local", name: "local", kind: "local" })).toBe("Not Found");
});
});
@@ -0,0 +1,64 @@
import type { Machine, MachineKind, MachineRuntime } from "../../api";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities";
export interface SettingsMachineTarget {
id: string;
name: string;
kind: MachineKind;
}
export type SelectedMachineSettingsSupportState = "supported" | "unsupported" | "unknown";
export interface SelectedMachineSettingsSupport {
state: SelectedMachineSettingsSupportState;
message?: string;
}
export function settingsMachineTarget(machine: Pick<Machine, "id" | "name" | "kind"> | undefined): SettingsMachineTarget {
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
return { id: "local", name: "local", kind: "local" };
}
export function settingsMachineTargetLabel(target: SettingsMachineTarget): string {
return target.kind === "local" ? `${target.name} (local gateway)` : `${target.name} (remote machine)`;
}
export function selectedMachineSettingsSupport(target: SettingsMachineTarget, runtime: Pick<MachineRuntime, "ok" | "capabilities"> | undefined): SelectedMachineSettingsSupport {
if (target.kind === "local") return { state: "supported" };
if (runtime?.ok !== true) return { state: "unknown" };
if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.selectedMachineSettings)) return { state: "supported" };
return { state: "unsupported", message: selectedMachineSettingsUnavailableMessage(target) };
}
export function selectedMachineSettingsSupportKey(support: SelectedMachineSettingsSupport): string {
return `${support.state}:${support.message ?? ""}`;
}
export function isSelectedMachineSettingsUnsupported(support: SelectedMachineSettingsSupport | undefined): support is SelectedMachineSettingsSupport & { state: "unsupported" } {
return support?.state === "unsupported";
}
export function selectedMachineSettingsUnavailableMessage(target: SettingsMachineTarget): string {
return `Selected-machine settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`;
}
export function friendlySelectedMachineSettingsErrorMessage(message: string, target: SettingsMachineTarget): string {
const normalized = message.trim();
if (target.kind !== "remote") return normalized;
if (isUnsupportedRemoteSelectedMachineSettingsRouteMessage(normalized)) {
return selectedMachineSettingsUnavailableMessage(target);
}
if (normalized === "Remote machine timeout") {
return `Timed out while contacting ${target.name} for selected-machine settings. The operation may still be running remotely; reload before retrying.`;
}
if (normalized === "Remote machine unavailable") {
return `Could not reach ${target.name} for selected-machine settings. Check the machine connection and try again.`;
}
return normalized;
}
function isUnsupportedRemoteSelectedMachineSettingsRouteMessage(message: string): boolean {
return message === "Not Found"
|| /route\s+(GET|PUT):?\/api\/(config|plugins)\b.*not found/iu.test(message)
|| /cannot\s+(GET|PUT)\s+.*\/api\/(config|plugins)\b/iu.test(message);
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settingsPluginConfig";
describe("plugin settings config helpers", () => {
it("builds plugin-only save patches while preserving existing plugin config", () => {
const patch = pluginEnabledConfigPatch(
{
host: "127.0.0.1",
plugins: {
info: { enabled: true, settings: { theme: "dark" }, custom: "keep" },
metrics: { enabled: false },
},
},
"info",
false,
);
expect(Object.keys(patch)).toEqual(["plugins"]);
expect(patch).toEqual({
plugins: {
info: { enabled: false, settings: { theme: "dark" }, custom: "keep" },
metrics: { enabled: false },
},
});
});
it("merges local selected-machine plugin config into gateway config without dropping gateway-only values", () => {
const gateway = configResponse({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: false } },
});
const selectedMachine = configResponse({ plugins: { info: { enabled: true }, metrics: { enabled: false } } });
expect(mergeSelectedMachinePluginConfig(gateway, selectedMachine)).toEqual({
...gateway,
config: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, metrics: { enabled: false } },
},
effectiveConfig: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
spawnSessions: false,
plugins: { info: { enabled: true }, metrics: { enabled: false } },
},
});
});
});
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
}
@@ -0,0 +1,25 @@
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
export function pluginEnabledConfigPatch(baseConfig: PiWebConfigValues, pluginId: string, enabled: boolean): PiWebConfigValues {
const currentPlugins = baseConfig.plugins ?? {};
const currentPluginConfig = currentPlugins[pluginId] ?? {};
return {
plugins: {
...currentPlugins,
[pluginId]: { ...currentPluginConfig, enabled },
},
};
}
export function mergeSelectedMachinePluginConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
return {
...base,
config: mergePluginConfig(base.config, selectedMachine.config),
effectiveConfig: mergePluginConfig(base.effectiveConfig, selectedMachine.effectiveConfig),
};
}
function mergePluginConfig(base: PiWebConfigValues, selectedMachine: PiWebConfigValues): PiWebConfigValues {
if (selectedMachine.plugins === undefined) return base;
return { ...base, plugins: selectedMachine.plugins };
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
describe("session daemon settings config helpers", () => {
it("builds daemon-only save patches for the sessiond toggles", () => {
expect(spawnSessionsConfigPatch(false)).toEqual({ spawnSessions: false });
expect(Object.keys(spawnSessionsConfigPatch(false))).toEqual(["spawnSessions"]);
expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true });
expect(Object.keys(subsessionsConfigPatch(true))).toEqual(["subsessions"]);
});
it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => {
const gateway = configResponse({
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: false,
subsessions: false,
});
const selectedMachine = configResponse({ spawnSessions: true, subsessions: true }, { spawnSessions: true, subsessions: false });
expect(mergeSelectedMachineSessiondConfig(gateway, selectedMachine)).toEqual({
...gateway,
config: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: true,
subsessions: true,
},
effectiveConfig: {
host: "127.0.0.1",
port: 8504,
allowedHosts: ["gateway.local"],
shortcuts: { "core:view.chat": "mod+1" },
plugins: { info: { enabled: true } },
spawnSessions: true,
subsessions: true,
},
envOverrides: {
host: false,
port: false,
allowedHosts: false,
spawnSessions: true,
subsessions: false,
},
});
});
});
function configResponse(config: PiWebConfigValues, overrides: Partial<PiWebConfigResponse["envOverrides"]> = {}): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, ...overrides },
};
}
@@ -0,0 +1,22 @@
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
export function spawnSessionsConfigPatch(enabled: boolean): PiWebConfigValues {
return { spawnSessions: enabled };
}
export function subsessionsConfigPatch(enabled: boolean): PiWebConfigValues {
return { subsessions: enabled };
}
export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
return {
...base,
config: { ...base.config, ...selectedMachine.config },
effectiveConfig: { ...base.effectiveConfig, ...selectedMachine.effectiveConfig },
envOverrides: {
...base.envOverrides,
spawnSessions: selectedMachine.envOverrides.spawnSessions,
subsessions: selectedMachine.envOverrides.subsessions,
},
};
}
+20 -13
View File
@@ -345,6 +345,7 @@ export const chatStyles = css`
.msg-meta:focus::before, .msg-meta.expanded::before { content: ""; } .msg-meta:focus::before, .msg-meta.expanded::before { content: ""; }
} }
formatted-text.part { display: block; } formatted-text.part { display: block; }
formatted-text.part, .queued-message formatted-text { text-align: start; unicode-bidi: plaintext; }
.part { max-width: 100%; min-width: 0; box-sizing: border-box; overflow: visible; } .part { max-width: 100%; min-width: 0; box-sizing: border-box; overflow: visible; }
.part + .part { margin-top: 10px; } .part + .part { margin-top: 10px; }
.tool-line { color: var(--pi-warning); } .tool-line { color: var(--pi-warning); }
@@ -355,22 +356,22 @@ export const chatStyles = css`
.skill-invocation > summary, .skill-read > strong { color: var(--pi-purple); } .skill-invocation > summary, .skill-read > strong { color: var(--pi-purple); }
.skill-invocation > small, .skill-read > small { display: block; margin: 6px 0 0; color: var(--pi-muted); } .skill-invocation > small, .skill-read > small { display: block; margin: 6px 0 0; color: var(--pi-muted); }
summary { cursor: pointer; color: var(--pi-muted); } summary { cursor: pointer; color: var(--pi-muted); }
pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; } pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; direction: ltr; text-align: left; unicode-bidi: isolate; }
.shell-output { color: var(--pi-text); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; } .shell-output { color: var(--pi-text); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; direction: ltr; text-align: left; unicode-bidi: isolate; }
@keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } } @keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } }
`; `;
export const formattedTextStyles = css` export const formattedTextStyles = css`
:host { display: block; } :host { display: block; }
.formatted { white-space: normal; overflow-wrap: anywhere; line-height: 1.45; } .formatted { white-space: normal; overflow-wrap: anywhere; line-height: 1.45; text-align: start; unicode-bidi: plaintext; }
p, ul, ol, pre, blockquote, table, .code-block-wrapper { margin: 0 0 10px; } p, ul, ol, pre, blockquote, table, .code-block-wrapper { margin: 0 0 10px; }
:is(p, ul, ol, pre, blockquote, table, .code-block-wrapper):last-child { margin-bottom: 0; } :is(p, ul, ol, pre, blockquote, table, .code-block-wrapper):last-child { margin-bottom: 0; }
ul, ol { padding-left: 22px; } ul, ol { padding-left: 22px; }
li + li { margin-top: 3px; } li + li { margin-top: 3px; }
code { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-bg); padding: 1px 4px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } code { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-bg); padding: 1px 4px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; direction: ltr; text-align: left; unicode-bidi: isolate; }
.code-block-wrapper { position: relative; } .code-block-wrapper { position: relative; }
.code-block-wrapper pre { margin: 0; padding-right: 40px; } .code-block-wrapper pre { margin: 0; padding-right: 40px; }
pre { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); padding: 10px; overflow-x: auto; overflow-y: hidden; } pre { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); padding: 10px; overflow-x: auto; overflow-y: hidden; direction: ltr; text-align: left; unicode-bidi: isolate; }
pre code { border: 0; padding: 0; background: transparent; } pre code { border: 0; padding: 0; background: transparent; }
.code-copy-button { position: absolute; top: 6px; right: 6px; z-index: 1; display: inline-grid; place-items: center; width: 24px; height: 24px; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 0; font: 14px system-ui, sans-serif; line-height: 1; cursor: pointer; } .code-copy-button { position: absolute; top: 6px; right: 6px; z-index: 1; display: inline-grid; place-items: center; width: 24px; height: 24px; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 0; font: 14px system-ui, sans-serif; line-height: 1; cursor: pointer; }
.code-copy-button:hover, .code-copy-button:focus { color: var(--pi-text); border-color: var(--pi-accent); } .code-copy-button:hover, .code-copy-button:focus { color: var(--pi-text); border-color: var(--pi-accent); }
@@ -417,7 +418,7 @@ export const commandPickerStyles = css`
.options { min-height: 0; overflow: auto; outline: none; } .options { min-height: 0; overflow: auto; outline: none; }
button { border: 0; background: transparent; color: var(--pi-text); cursor: pointer; } button { border: 0; background: transparent; color: var(--pi-text); cursor: pointer; }
header button { font-size: 20px; color: var(--pi-muted); } header button { font-size: 20px; color: var(--pi-muted); }
input { margin: 10px 12px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); font: 14px system-ui, sans-serif; padding: 8px 10px; outline: none; } input { margin: 10px 12px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); font: var(--pi-control-font-size, 16px) var(--pi-control-font-family, system-ui, sans-serif); padding: 8px 10px; outline: none; }
input:focus { border-color: var(--pi-accent); } input:focus { border-color: var(--pi-accent); }
.options button { display: block; width: 100%; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); text-align: left; } .options button { display: block; width: 100%; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); text-align: left; }
.options button.selected, .options button:hover { background: var(--pi-selection-bg); } .options button.selected, .options button:hover { background: var(--pi-selection-bg); }
@@ -430,16 +431,19 @@ export const actionPaletteStyles = css`
.backdrop { --palette-top: min(12dvh, 90px); --palette-bottom: max(20px, env(safe-area-inset-bottom)); display: grid; align-items: start; justify-items: center; width: 100%; height: 100dvh; background: var(--pi-overlay); padding: var(--palette-top) 20px var(--palette-bottom); box-sizing: border-box; overflow: hidden; } .backdrop { --palette-top: min(12dvh, 90px); --palette-bottom: max(20px, env(safe-area-inset-bottom)); display: grid; align-items: start; justify-items: center; width: 100%; height: 100dvh; background: var(--pi-overlay); padding: var(--palette-top) 20px var(--palette-bottom); box-sizing: border-box; overflow: hidden; }
section { width: min(720px, 100%); max-height: min(640px, calc(100dvh - var(--palette-top) - var(--palette-bottom))); display: flex; flex-direction: column; border: 1px solid var(--pi-border); border-radius: 12px; background: var(--pi-bg); box-shadow: 0 20px 60px var(--pi-shadow-strong); overflow: hidden; } section { width: min(720px, 100%); max-height: min(640px, calc(100dvh - var(--palette-top) - var(--palette-bottom))); display: flex; flex-direction: column; border: 1px solid var(--pi-border); border-radius: 12px; background: var(--pi-bg); box-shadow: 0 20px 60px var(--pi-shadow-strong); overflow: hidden; }
header { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px; border-bottom: 1px solid var(--pi-border); } header { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px; border-bottom: 1px solid var(--pi-border); }
input { min-width: 0; border: 0; outline: none; background: transparent; color: var(--pi-text); font: 16px system-ui, sans-serif; padding: 8px; } input { min-width: 0; border: 0; outline: none; background: transparent; color: var(--pi-text); font: var(--pi-control-font-size, 16px) var(--pi-control-font-family, system-ui, sans-serif); padding: 8px; }
input::placeholder { color: var(--pi-dim); } input::placeholder { color: var(--pi-dim); }
button { border: 0; background: transparent; color: var(--pi-text); cursor: pointer; } button { border: 0; background: transparent; color: var(--pi-text); cursor: pointer; }
header button { color: var(--pi-muted); font-size: 22px; padding: 2px 8px; } header button { color: var(--pi-muted); font-size: 22px; padding: 2px 8px; }
.options { flex: 1 1 auto; min-height: 0; overflow: auto; } .options { flex: 1 1 auto; min-height: 0; overflow: auto; }
.options button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 12px; width: 100%; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); text-align: left; } .options button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 12px; width: 100%; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); text-align: left; }
.options button.selected, .options button:hover { background: var(--pi-selection-bg); } .options button.selected, .options button:hover:not(:disabled) { background: var(--pi-selection-bg); }
.options button:disabled { cursor: not-allowed; opacity: .68; }
.options button.disabled.selected { background: color-mix(in srgb, var(--pi-selection-bg) 55%, transparent); }
.main { min-width: 0; } .main { min-width: 0; }
strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
small { display: block; color: var(--pi-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } small { display: block; color: var(--pi-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.disabled-reason { color: var(--pi-warning); }
.group { grid-column: 1 / -1; font-size: 12px; } .group { grid-column: 1 / -1; font-size: 12px; }
kbd { align-self: center; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 2px 6px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; } kbd { align-self: center; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 2px 6px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
.empty { padding: 24px; color: var(--pi-muted); text-align: center; } .empty { padding: 24px; color: var(--pi-muted); text-align: center; }
@@ -463,11 +467,11 @@ export const promptEditorStyles = css`
.select-thinking .prompt-thinking-gauge .gauge-bar-active { opacity: 1; } .select-thinking .prompt-thinking-gauge .gauge-bar-active { opacity: 1; }
.editor-attach { position: absolute; right: 8px; bottom: 8px; z-index: 2; width: 30px; height: 30px; } .editor-attach { position: absolute; right: 8px; bottom: 8px; z-index: 2; width: 30px; height: 30px; }
.editor-attach .prompt-action-icon { width: 16px; height: 16px; } .editor-attach .prompt-action-icon { width: 16px; height: 16px; }
textarea, .markdown-editor .cm-editor { box-sizing: border-box; width: 100%; min-height: 54px; max-height: 220px; resize: none; overflow: hidden; border-radius: 8px; border: 1px solid var(--pi-border); background: var(--pi-bg); color: var(--pi-text); font: 16px/1.4 system-ui, sans-serif; } textarea, .markdown-editor .cm-editor { box-sizing: border-box; width: 100%; min-height: 54px; max-height: 220px; resize: none; overflow: hidden; border-radius: 8px; border: 1px solid var(--pi-border); background: var(--pi-bg); color: var(--pi-text); font: var(--pi-control-font-size, 16px)/1.4 var(--pi-control-font-family, system-ui, sans-serif); }
textarea { overflow-y: auto; padding: 8px; } textarea { overflow-y: auto; padding: 8px; }
.markdown-editor .cm-scroller { max-height: 220px; overflow-y: auto; font-family: system-ui, sans-serif; line-height: 1.4; } .markdown-editor .cm-scroller { max-height: 220px; overflow-y: auto; font-family: var(--pi-control-font-family, system-ui, sans-serif); line-height: 1.4; }
.markdown-editor .cm-content { min-height: 38px; padding: 8px 44px 8px 8px; caret-color: var(--pi-text); } .markdown-editor .cm-content { min-height: 38px; padding: 8px 44px 8px 8px; caret-color: var(--pi-text); text-align: start; unicode-bidi: plaintext; }
.markdown-editor .cm-line { padding: 0; } .markdown-editor .cm-line { padding: 0; unicode-bidi: plaintext; }
.markdown-editor .cm-placeholder { color: var(--pi-dim); } .markdown-editor .cm-placeholder { color: var(--pi-dim); }
.markdown-editor .cm-focused { outline: none; } .markdown-editor .cm-focused { outline: none; }
.shell-mode textarea, .shell-mode .markdown-editor .cm-editor { border-color: var(--pi-success); box-shadow: 0 0 0 1px var(--pi-success-ring); } .shell-mode textarea, .shell-mode .markdown-editor .cm-editor { border-color: var(--pi-success); box-shadow: 0 0 0 1px var(--pi-success-ring); }
@@ -475,8 +479,11 @@ export const promptEditorStyles = css`
.attachments { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; } .attachments { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
.attachment-chip { position: relative; width: 56px; height: 56px; border: 1px solid var(--pi-border); border-radius: 8px; overflow: hidden; background: var(--pi-bg); } .attachment-chip { position: relative; width: 56px; height: 56px; border: 1px solid var(--pi-border); border-radius: 8px; overflow: hidden; background: var(--pi-bg); }
.attachment-chip img { width: 100%; height: 100%; object-fit: cover; display: block; } .attachment-chip img { width: 100%; height: 100%; object-fit: cover; display: block; }
.attachment-chip-file { display: grid; place-items: center; }
.attachment-file-preview { display: grid; place-items: center; width: 34px; height: 26px; border: 1px solid var(--pi-border-muted); border-radius: 4px; background: var(--pi-surface); color: var(--pi-muted); font: 700 10px/1 system-ui, sans-serif; letter-spacing: .03em; }
.attachment-file-name { position: absolute; right: 4px; bottom: 3px; left: 4px; overflow: hidden; color: var(--pi-muted); font-size: 10px; line-height: 1.2; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.attachment-remove { position: absolute; top: 1px; right: 1px; width: 18px; height: 18px; padding: 0; line-height: 16px; border-radius: 50%; border: 1px solid var(--pi-border); background: var(--pi-surface); color: var(--pi-text); font-size: 13px; cursor: pointer; } .attachment-remove { position: absolute; top: 1px; right: 1px; width: 18px; height: 18px; padding: 0; line-height: 16px; border-radius: 50%; border: 1px solid var(--pi-border); background: var(--pi-surface); color: var(--pi-text); font-size: 13px; cursor: pointer; }
.attachment-delivery select { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; font: 12px system-ui, sans-serif; } .attachment-delivery select { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; font: var(--pi-control-font-size, 16px) var(--pi-control-font-family, system-ui, sans-serif); }
.attachment-error { flex-basis: 100%; color: var(--pi-danger); font-size: 12px; } .attachment-error { flex-basis: 100%; color: var(--pi-danger); font-size: 12px; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } 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, textarea:disabled, .markdown-editor-disabled .cm-editor { opacity: .5; cursor: not-allowed; } button:disabled, textarea:disabled, .markdown-editor-disabled .cm-editor { opacity: .5; cursor: not-allowed; }
File diff suppressed because it is too large Load Diff
+640 -97
View File
@@ -1,6 +1,6 @@
import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus } from "../api"; import { api as defaultApi, type CommandResult, type PromptAttachment, type QueuedSessionMessage, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus, type Workspace } from "../api";
import type { AppState } from "../appState"; import type { AppState } from "../appState";
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions"; import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
import { textMessage } from "../chatMessages"; import { textMessage } from "../chatMessages";
import { machineSessionKey } from "../machineKeys"; import { machineSessionKey } from "../machineKeys";
import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage"; import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage";
@@ -8,12 +8,15 @@ import { ChatTranscriptStore } from "../chatTranscriptStore";
import { isShellInput } from "../inputModes"; import { isShellInput } from "../inputModes";
import { fileCompletionInsertText } from "../promptCompletions"; import { fileCompletionInsertText } from "../promptCompletions";
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket"; import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../sessionPersistence";
import { isSessionActive } from "../../../shared/activity"; import { isSessionActive } from "../../../shared/activity";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities"; import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection"; import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types"; import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
const MESSAGE_PAGE_SIZE = 100; const MESSAGE_PAGE_SIZE = 100;
const BULK_FALLBACK_CONCURRENCY = 4;
export interface SessionEventSocket { export interface SessionEventSocket {
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId?: string): void; connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId?: string): void;
@@ -27,6 +30,36 @@ export interface SessionControllerDependencies {
transcripts?: ChatTranscriptStore; transcripts?: ChatTranscriptStore;
} }
interface BulkSessionMutationResult {
succeededIds: string[];
failures: string[];
generatedAt?: string;
}
type ClientPendingStartSessionInfo = SessionInfo & { clientPendingStart: true; machineId: string };
type QueuedPendingSessionSendInput =
| { type: "prompt"; text: string; streamingBehavior?: "steer" | "followUp" | undefined; attachments?: PromptAttachment[] | undefined; delivery: PromptAttachmentDelivery }
| { type: "shell"; text: string }
| { type: "command"; text: string };
type QueuedPendingSessionSend = QueuedPendingSessionSendInput & { id: string };
interface PendingSessionStart {
tempId: string;
workspaceId: string;
cwd: string;
machineId: string;
session: ClientPendingStartSessionInfo;
queuedSends: QueuedPendingSessionSend[];
discarded: boolean;
}
interface SuppressedCreatedSession {
session: SessionInfo;
machineId: string;
}
export class SessionController { export class SessionController {
private readonly socket: SessionEventSocket; private readonly socket: SessionEventSocket;
private readonly api: typeof defaultApi; private readonly api: typeof defaultApi;
@@ -34,7 +67,13 @@ export class SessionController {
private selectionSeq = 0; private selectionSeq = 0;
private catchupStreamSessionId: string | undefined; private catchupStreamSessionId: string | undefined;
private pendingTranscriptEvents: SessionUiEvent[] = []; private pendingTranscriptEvents: SessionUiEvent[] = [];
private pendingTranscriptFrame: number | undefined; private pendingStatusBySession = new Map<string, SessionStatus>();
private pendingActivityBySession = new Map<string, SessionActivity>();
private pendingFrame: number | undefined;
private pendingSessionStartSeq = 0;
private pendingQueuedSendSeq = 0;
private readonly pendingSessionStarts = new Map<string, PendingSessionStart>();
private readonly suppressedCreatedSessions = new Map<string, SuppressedCreatedSession>();
constructor( constructor(
private readonly getState: GetState, private readonly getState: GetState,
@@ -49,22 +88,22 @@ export class SessionController {
} }
applyGlobalEvent(event: GlobalSessionEvent): void { applyGlobalEvent(event: GlobalSessionEvent): void {
if (event.type === "status.update") this.applyStatus(event.status); if (event.type === "status.update") this.queueStatusUpdate(event.status);
else if (event.type === "activity.update") this.applyActivity(event.activity); else if (event.type === "activity.update") this.queueActivityUpdate(event.activity);
else if (event.type === "session.created") this.applyCreatedSession(event.session); else if (event.type === "session.created") this.applyCreatedSession(event.session);
else this.applySessionName(event.sessionId, event.name); else this.applySessionName(event.sessionId, event.name);
} }
dispose() { dispose() {
this.socket.close(); this.socket.close();
this.clearPendingTranscriptEvents(); this.clearPendingUpdates();
} }
clearActiveSession() { clearActiveSession() {
this.selectionSeq += 1; this.selectionSeq += 1;
this.socket.close(); this.socket.close();
this.catchupStreamSessionId = undefined; this.catchupStreamSessionId = undefined;
this.clearPendingTranscriptEvents(); this.clearPendingUpdates();
// Note: sendingPrompts is intentionally NOT cleared here. Deselecting a // Note: sendingPrompts is intentionally NOT cleared here. Deselecting a
// session must not cancel the in-flight upload indicator of the session // session must not cancel the in-flight upload indicator of the session
// that is still sending; the per-session entry is cleared by send()'s // that is still sending; the per-session entry is cleared by send()'s
@@ -89,18 +128,15 @@ export class SessionController {
async startSession() { async startSession() {
const workspace = this.getState().selectedWorkspace; const workspace = this.getState().selectedWorkspace;
if (!workspace) return; if (!workspace) return;
try {
const machineId = selectedMachineId(this.getState()); const machineId = selectedMachineId(this.getState());
const pending = this.createPendingSessionStart(workspace, machineId);
this.pendingSessionStarts.set(pending.tempId, pending);
this.insertAndSelectPendingSession(pending.session);
try {
const session = await this.api.startSession(workspace.path, machineId); const session = await this.api.startSession(workspace.path, machineId);
rememberCachedNewSession(session, machineId); await this.resolvePendingSessionStart(pending.tempId, session);
const cachedSession = markCachedNewSessionInfo(session, machineId);
// Drop any entry the session.created broadcast may have inserted for this
// same session before the HTTP response resolved, so the cached marker
// (and its delete action) wins instead of leaving a duplicate badge.
this.setState({ sessions: [cachedSession, ...this.getState().sessions.filter((candidate) => candidate.id !== cachedSession.id)] });
await this.selectSession(cachedSession);
} catch (error) { } catch (error) {
this.setState({ error: String(error) }); this.failPendingSessionStart(pending.tempId, error);
} }
} }
@@ -109,11 +145,15 @@ export class SessionController {
} }
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) { async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) {
if (isClientPendingStartSessionInfo(session)) {
this.selectClientPendingStartSession(session, options);
return;
}
this.sessionSelection.rememberSession({ ...session, cwd: this.workspaceSelectionKey(session.cwd) }); this.sessionSelection.rememberSession({ ...session, cwd: this.workspaceSelectionKey(session.cwd) });
const seq = ++this.selectionSeq; const seq = ++this.selectionSeq;
this.socket.close(); this.socket.close();
this.catchupStreamSessionId = undefined; this.catchupStreamSessionId = undefined;
this.clearPendingTranscriptEvents(); this.clearPendingUpdates();
const transcriptKey = this.sessionCacheKey(session.id); const transcriptKey = this.sessionCacheKey(session.id);
const cached = this.transcripts.cachedView(transcriptKey); const cached = this.transcripts.cachedView(transcriptKey);
this.setState({ this.setState({
@@ -176,37 +216,25 @@ export class SessionController {
} }
} }
async send(text: string, streamingBehavior?: "steer" | "followUp", attachments?: PromptAttachment[], delivery: "inline" | "folder" = "inline") { async send(text: string, streamingBehavior?: "steer" | "followUp", attachments?: PromptAttachment[], delivery: PromptAttachmentDelivery = "inline") {
const trimmed = text.trim();
const hasAttachments = attachments !== undefined && attachments.length > 0;
if (!hasAttachments && trimmed.startsWith("/")) return this.runCommand(text);
if (!hasAttachments && isShellInput(text)) return this.runShell(text);
const session = this.getState().selectedSession; const session = this.getState().selectedSession;
if (!session || session.archived === true) return; if (!session || session.archived === true) return;
const trimmed = text.trim();
const hasAttachments = attachments !== undefined && attachments.length > 0;
if (isClientPendingStartSessionInfo(session)) {
if (!hasAttachments && trimmed.startsWith("/")) this.enqueuePendingSessionSend(session, { type: "command", text });
else if (!hasAttachments && isShellInput(text)) this.enqueuePendingSessionSend(session, { type: "shell", text });
else this.enqueuePendingSessionSend(session, { type: "prompt", text, streamingBehavior, attachments, delivery });
return;
}
if (!hasAttachments && trimmed.startsWith("/")) return this.runCommand(text);
if (!hasAttachments && isShellInput(text)) return this.runShell(text);
// Capture the originating session/machine before any await so the request // Capture the originating session/machine before any await so the request
// and its sending indicator stay bound to the right session even if the // and its sending indicator stay bound to the right session even if the
// user navigates elsewhere mid-upload. // user navigates elsewhere mid-upload.
const sessionId = session.id; await this.deliverPromptToSession(session, text, streamingBehavior, attachments, delivery, selectedMachineId(this.getState()), { markSending: hasAttachments });
const machineId = selectedMachineId(this.getState());
// Surface a per-session optimistic sending state. It covers the pre-receipt
// window (upload, server-side image resizing, first-session open) and is
// superseded by real server activity/messages once api.prompt resolves.
if (hasAttachments) this.markSendingPrompt(sessionId, true);
try {
if (hasAttachments && delivery === "folder") {
const saved = await this.api.saveAttachments(session, attachments, machineId);
const references = saved.map((file) => fileCompletionInsertText(file.path, false)).join(" ");
const body = text === "" ? references : `${text}\n\n${references}`;
await this.api.prompt(session, body, streamingBehavior, machineId);
} else {
await this.api.prompt(session, text, streamingBehavior, machineId, attachments);
}
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ error: String(error) });
} finally {
if (hasAttachments) this.markSendingPrompt(sessionId, false);
}
} }
private markSendingPrompt(sessionId: string, sending: boolean): void { private markSendingPrompt(sessionId: string, sending: boolean): void {
@@ -221,36 +249,124 @@ export class SessionController {
async runShell(text: string) { async runShell(text: string) {
const session = this.getState().selectedSession; const session = this.getState().selectedSession;
if (!session || session.archived === true) return; if (!session || session.archived === true) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] }); if (isClientPendingStartSessionInfo(session)) {
try { this.enqueuePendingSessionSend(session, { type: "shell", text });
await this.api.shell(session, text, selectedMachineId(this.getState())); return;
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
} }
await this.deliverShellToSession(session, text, selectedMachineId(this.getState()), { optimisticLine: true });
} }
async runCommand(text: string) { async runCommand(text: string) {
const session = this.getState().selectedSession; const session = this.getState().selectedSession;
if (!session || session.archived === true) return; if (!session || session.archived === true) return;
if (isClientPendingStartSessionInfo(session)) {
this.enqueuePendingSessionSend(session, { type: "command", text });
return;
}
await this.deliverCommandToSession(session, text, selectedMachineId(this.getState()), { applyResult: true });
}
private enqueuePendingSessionSend(session: ClientPendingStartSessionInfo, input: QueuedPendingSessionSendInput): void {
const pending = this.pendingSessionStarts.get(session.id);
if (pending === undefined || pending.discarded) {
this.setState({ error: "The backend session is not ready for queued sends. Copy your message before discarding this failed start." });
return;
}
const queued: QueuedPendingSessionSend = { ...input, id: `pending-send-${String(++this.pendingQueuedSendSeq)}` };
pending.queuedSends.push(queued);
const state = this.getState();
const current = state.clientQueuedSessionMessages[session.id] ?? [];
const activity = creatingPendingSessionActivity(session.id, pending.queuedSends.length);
this.setState({
clientQueuedSessionMessages: { ...state.clientQueuedSessionMessages, [session.id]: [...current, queuedSessionMessagePreview(queued)] },
sessionActivities: { ...state.sessionActivities, [session.id]: activity },
activity: state.selectedSession?.id === session.id ? activity : state.activity,
error: "",
});
}
private async flushQueuedPendingSends(session: SessionInfo, machineId: string, queuedSends: readonly QueuedPendingSessionSend[]): Promise<void> {
for (const queued of queuedSends) {
const delivered = await this.deliverQueuedPendingSend(session, machineId, queued);
if (!delivered) return;
this.dropNextQueuedSessionMessage(session.id);
}
}
private async deliverQueuedPendingSend(session: SessionInfo, machineId: string, queued: QueuedPendingSessionSend): Promise<boolean> {
if (queued.type === "prompt") return this.deliverPromptToSession(session, queued.text, queued.streamingBehavior, queued.attachments, queued.delivery, machineId, { markSending: true });
if (queued.type === "shell") return this.deliverShellToSession(session, queued.text, machineId, { optimisticLine: true });
return this.deliverCommandToSession(session, queued.text, machineId, { applyResult: true });
}
private async deliverPromptToSession(session: SessionInfo, text: string, streamingBehavior: "steer" | "followUp" | undefined, attachments: PromptAttachment[] | undefined, delivery: PromptAttachmentDelivery, machineId: string, options: { markSending: boolean }): Promise<boolean> {
const hasAttachments = attachments !== undefined && attachments.length > 0;
if (options.markSending) this.markSendingPrompt(session.id, true);
try {
if (hasAttachments && delivery === "folder") {
const saved = await this.api.saveAttachments(session, attachments, machineId);
const references = saved.map((file) => fileCompletionInsertText(file.path, false)).join(" ");
const body = text === "" ? references : `${text}\n\n${references}`;
await this.api.prompt(session, body, streamingBehavior, machineId);
} else {
await this.api.prompt(session, text, streamingBehavior, machineId, attachments);
}
this.markCachedNewSessionPersisted(session);
return true;
} catch (error) {
this.setState({ error: String(error) });
return false;
} finally {
if (options.markSending) this.markSendingPrompt(session.id, false);
}
}
private async deliverShellToSession(session: SessionInfo, text: string, machineId: string, options: { optimisticLine: boolean }): Promise<boolean> {
if (options.optimisticLine && this.getState().selectedSession?.id === session.id) {
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
}
try {
await this.api.shell(session, text, machineId);
this.markCachedNewSessionPersisted(session);
return true;
} catch (error) {
if (this.getState().selectedSession?.id === session.id) this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))] });
this.setState({ error: String(error) });
return false;
}
}
private async deliverCommandToSession(session: SessionInfo, text: string, machineId: string, options: { applyResult: boolean }): Promise<boolean> {
// Commands are not inserted into the transcript optimistically: a builtin // Commands are not inserted into the transcript optimistically: a builtin
// command produces its own result line, and a runtime/skill command is // command produces its own result line, and a runtime/skill command is
// forwarded to the agent, which streams back the canonical (expanded) // forwarded to the agent, which streams back the canonical (expanded)
// message. Inserting the raw text here would leave a line that doesn't // message. Inserting the raw text here would leave a line that doesn't
// converge with server history and disappears on reload. Surface the same // converge with server history and disappears on reload. Surface the same
// per-session sending indicator that send() uses for the pre-receipt window. // per-session sending indicator that send() uses for the pre-receipt window.
const sessionId = session.id; this.markSendingPrompt(session.id, true);
this.markSendingPrompt(sessionId, true);
try { try {
this.applyCommandResult(await this.api.runCommand(session, text, selectedMachineId(this.getState()))); const result = await this.api.runCommand(session, text, machineId);
if (options.applyResult && this.getState().selectedSession?.id === session.id) this.applyCommandResult(result);
else if (result.type === "select") this.setState({ error: `Queued command “${text}” needs input; open the session and run it again.` });
this.markCachedNewSessionPersisted(session); this.markCachedNewSessionPersisted(session);
return true;
} catch (error) { } catch (error) {
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) }); if (this.getState().selectedSession?.id === session.id) this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))] });
this.setState({ error: String(error) });
return false;
} finally { } finally {
this.markSendingPrompt(sessionId, false); this.markSendingPrompt(session.id, false);
} }
} }
private dropNextQueuedSessionMessage(sessionId: string): void {
const state = this.getState();
const current = state.clientQueuedSessionMessages[sessionId] ?? [];
if (current.length === 0) return;
const remaining = current.slice(1);
this.setState({ clientQueuedSessionMessages: remaining.length === 0 ? omitKey(state.clientQueuedSessionMessages, sessionId) : { ...state.clientQueuedSessionMessages, [sessionId]: remaining } });
}
async respondToCommand(requestId: string, value: string) { async respondToCommand(requestId: string, value: string) {
const session = this.getState().selectedSession; const session = this.getState().selectedSession;
if (!session) return; if (!session) return;
@@ -272,10 +388,12 @@ export class SessionController {
async archiveSession(session = this.getState().selectedSession) { async archiveSession(session = this.getState().selectedSession) {
if (!session) return; if (!session) return;
if (isCachedNewSessionInfo(session)) { const status = this.statusForSession(session);
if (isTransientNewSessionInfo(session, status)) {
await this.deleteCachedNewSession(session); await this.deleteCachedNewSession(session);
return; return;
} }
if (!isArchivableSessionInfo(session, status)) return;
try { try {
await this.api.archive(session, selectedMachineId(this.getState())); await this.api.archive(session, selectedMachineId(this.getState()));
const state = this.getState(); const state = this.getState();
@@ -291,7 +409,7 @@ export class SessionController {
} }
async archiveSessionWithDescendants(session = this.getState().selectedSession) { async archiveSessionWithDescendants(session = this.getState().selectedSession) {
if (!session || isCachedNewSessionInfo(session)) return; if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session))) return;
try { try {
const response = await this.api.archiveWithDescendants(session, selectedMachineId(this.getState())); const response = await this.api.archiveWithDescendants(session, selectedMachineId(this.getState()));
const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id]; const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id];
@@ -308,25 +426,25 @@ export class SessionController {
} }
async archiveSessions(sessions: readonly SessionInfo[]): Promise<void> { async archiveSessions(sessions: readonly SessionInfo[]): Promise<void> {
const candidates = uniqueSessionsById(sessions).filter((session) => session.archived !== true && !isCachedNewSessionInfo(session)); const candidates = uniqueSessionsById(sessions).filter((session) => isArchivableSessionInfo(session, this.statusForSession(session)));
if (candidates.length === 0) return; if (candidates.length === 0) return;
try {
const machineId = selectedMachineId(this.getState()); const machineId = selectedMachineId(this.getState());
const results = await Promise.allSettled(candidates.map(async (session) => { const { succeededIds: archivedIds, failures, generatedAt } = await this.archiveSessionBatch(candidates, machineId);
await this.api.archive(session, machineId);
return session.id;
}));
const archivedIds = fulfilledValues(results);
if (archivedIds.length > 0) { if (archivedIds.length > 0) {
const state = this.getState(); const state = this.getState();
const nextSessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString()); const nextSessions = markSessionsArchived(state.sessions, archivedIds, generatedAt ?? new Date().toISOString());
const selectionChange = selectionAfterArchivingSessions(nextSessions, state.selectedSession?.id, archivedIds); const selectionChange = selectionAfterArchivingSessions(nextSessions, state.selectedSession?.id, archivedIds);
this.setState({ sessions: nextSessions }); this.setState({ sessions: nextSessions });
if (selectionChange.type === "select") await this.selectSession(selectionChange.session); if (selectionChange.type === "select") await this.selectSession(selectionChange.session);
else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true }); else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true });
} }
this.applyBulkSessionError("Archive", results); this.applyBulkSessionFailures("Archive", failures);
} catch (error) {
this.setState({ error: `Archive failed: ${errorMessage(error)}` });
}
} }
async deleteArchivedSessions(sessions: readonly SessionInfo[]): Promise<void> { async deleteArchivedSessions(sessions: readonly SessionInfo[]): Promise<void> {
@@ -339,11 +457,8 @@ export class SessionController {
this.setState({ error: "Deleting archived sessions requires an updated Pi-Web runtime on this machine." }); this.setState({ error: "Deleting archived sessions requires an updated Pi-Web runtime on this machine." });
return; return;
} }
const results = await Promise.allSettled(candidates.map(async (session) => { try {
await this.api.deleteArchived(session, machineId); const { succeededIds: deletedIds, failures } = await this.deleteArchivedSessionBatch(candidates, machineId);
return session.id;
}));
const deletedIds = fulfilledValues(results);
if (deletedIds.length > 0) { if (deletedIds.length > 0) {
const deletedIdSet = new Set(deletedIds); const deletedIdSet = new Set(deletedIds);
const state = this.getState(); const state = this.getState();
@@ -355,18 +470,117 @@ export class SessionController {
else this.deselectSession({ forgetRememberedSelection: true }); else this.deselectSession({ forgetRememberedSelection: true });
} }
} }
this.applyBulkSessionError("Delete", results); this.applyBulkSessionFailures("Delete", failures);
} catch (error) {
this.setState({ error: `Delete failed: ${errorMessage(error)}` });
}
}
private async archiveSessionBatch(sessions: readonly SessionInfo[], machineId: string): Promise<BulkSessionMutationResult> {
const runtime = this.getState().machineRuntimes[machineId];
if (runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsBulkMutations)) {
const response = await this.api.archiveMany(sessions, machineId);
return { succeededIds: response.archivedSessionIds, failures: bulkFailureMessages(response.failures), generatedAt: response.generatedAt };
}
const results = await allSettledWithConcurrency(sessions, BULK_FALLBACK_CONCURRENCY, async (session) => {
await this.api.archive(session, machineId);
return session.id;
});
return { succeededIds: fulfilledValues(results), failures: settledSessionFailureMessages(sessions, results) };
}
private async deleteArchivedSessionBatch(sessions: readonly SessionInfo[], machineId: string): Promise<BulkSessionMutationResult> {
const runtime = this.getState().machineRuntimes[machineId];
if (runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsBulkMutations)) {
const response = await this.api.deleteArchivedMany(sessions, machineId);
return { succeededIds: response.deletedSessionIds, failures: bulkFailureMessages(response.failures) };
}
const results = await allSettledWithConcurrency(sessions, BULK_FALLBACK_CONCURRENCY, async (session) => {
await this.api.deleteArchived(session, machineId);
return session.id;
});
return { succeededIds: fulfilledValues(results), failures: settledSessionFailureMessages(sessions, results) };
}
async applySessionCleanupResult(result: SessionCleanupExecuteResponse, machineId = selectedMachineId(this.getState())): Promise<void> {
if (selectedMachineId(this.getState()) !== machineId) return;
const archivedIds = result.archivedSessionIds;
const deletedIds = result.deletedSessionIds;
if (archivedIds.length > 0 || deletedIds.length > 0) {
const state = this.getState();
const deletedIdSet = new Set(deletedIds);
const affectedIds = [...archivedIds, ...deletedIds];
const nextSessions = markSessionsArchived(state.sessions, archivedIds, result.generatedAt).filter((session) => !deletedIdSet.has(session.id));
const selectedAffected = state.selectedSession !== undefined && affectedIds.includes(state.selectedSession.id);
this.setState({
sessions: nextSessions,
sessionStatuses: omitKeys(state.sessionStatuses, affectedIds),
sessionActivities: omitKeys(state.sessionActivities, affectedIds),
...(selectedAffected ? { status: undefined, activity: undefined } : {}),
});
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 });
} else {
const selectionChange = selectionAfterArchivingSessions(nextSessions, state.selectedSession?.id, archivedIds);
if (selectionChange.type === "select") await this.selectSession(selectionChange.session);
else if (selectionChange.type === "clear") this.deselectSession({ forgetRememberedSelection: true });
}
}
await this.refreshCurrentWorkspaceSessions(machineId);
}
async refreshCurrentWorkspaceSessions(machineId = selectedMachineId(this.getState())): Promise<void> {
const workspace = this.getState().selectedWorkspace;
if (workspace === undefined) return;
try {
const listedSessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path, machineId), machineId)
.filter((session) => !this.isSuppressedCreatedSession(session, machineId));
if (selectedMachineId(this.getState()) !== machineId || this.getState().selectedWorkspace?.id !== workspace.id) return;
const sessions = this.mergePendingStartSessions(workspace.path, listedSessions, machineId);
const selectedSession = this.getState().selectedSession;
this.setState({ sessions });
if (selectedSession === undefined) return;
const refreshedSelected = sessions.find((session) => session.id === selectedSession.id);
if (refreshedSelected !== undefined) {
if (refreshedSelected !== selectedSession) this.setState({ selectedSession: refreshedSelected });
return;
}
const next = sessions.find((session) => session.archived !== true) ?? sessions[0];
if (next !== undefined) await this.selectSession(next);
else this.deselectSession({ forgetRememberedSelection: true });
} catch (error) {
if (selectedMachineId(this.getState()) === machineId && this.getState().selectedWorkspace?.id === workspace.id) this.setState({ error: String(error) });
}
} }
async deleteCachedNewSession(session = this.getState().selectedSession) { async deleteCachedNewSession(session = this.getState().selectedSession) {
if (!isCachedNewSessionInfo(session)) return; if (session === undefined || !isTransientNewSessionInfo(session, this.statusForSession(session))) return;
const pendingStart = isClientPendingStartSessionInfo(session) ? this.pendingSessionStarts.get(session.id) : undefined;
if (pendingStart !== undefined) {
pendingStart.discarded = true;
pendingStart.queuedSends = [];
}
else {
void this.api.stop(session, selectedMachineId(this.getState())).catch(() => { void this.api.stop(session, selectedMachineId(this.getState())).catch(() => {
// Best-effort cleanup for browser-cached sessions that may not exist server-side anymore. // Best-effort cleanup for transient sessions that may not exist server-side anymore.
}); });
}
forgetCachedNewSession(session.id, selectedMachineId(this.getState())); forgetCachedNewSession(session.id, selectedMachineId(this.getState()));
clearDraft(this.sessionCacheKey(session.id)); clearDraft(this.sessionCacheKey(session.id));
const sessions = this.getState().sessions.filter((candidate) => candidate.id !== session.id); const state = this.getState();
this.setState({ sessions }); const sessions = state.sessions.filter((candidate) => candidate.id !== session.id);
this.setState({
sessions,
sessionStatuses: omitKey(state.sessionStatuses, session.id),
sessionActivities: omitSessionActivity(state.sessionActivities, session.id),
sendingPrompts: omitKey(state.sendingPrompts, session.id),
clientQueuedSessionMessages: omitKey(state.clientQueuedSessionMessages, session.id),
});
if (this.getState().selectedSession?.id !== session.id) return; if (this.getState().selectedSession?.id !== session.id) return;
const next = sessions.find((candidate) => candidate.archived !== true) ?? sessions[0]; const next = sessions.find((candidate) => candidate.archived !== true) ?? sessions[0];
if (next !== undefined) await this.selectSession(next); if (next !== undefined) await this.selectSession(next);
@@ -391,11 +605,11 @@ export class SessionController {
} }
async reloadSession(session = this.getState().selectedSession) { async reloadSession(session = this.getState().selectedSession) {
if (session === undefined || isCachedNewSessionInfo(session) || session.archived === true) return; if (session === undefined || !isArchivableSessionInfo(session, this.statusForSession(session))) return;
const machineId = selectedMachineId(this.getState()); const machineId = selectedMachineId(this.getState());
const runtime = this.getState().machineRuntimes[machineId]; const runtime = this.getState().machineRuntimes[machineId];
if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) { if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) {
this.setState({ error: "Reloading sessions requires an updated Pi-Web runtime on this machine." }); this.setState({ error: "Reloading sessions from disk requires an updated Pi-Web runtime on this machine." });
return; return;
} }
try { try {
@@ -509,9 +723,9 @@ export class SessionController {
async refreshSelectedSession(sessionId = this.getState().selectedSession?.id): Promise<void> { async refreshSelectedSession(sessionId = this.getState().selectedSession?.id): Promise<void> {
const session = this.getState().selectedSession; const session = this.getState().selectedSession;
if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return; if (sessionId === undefined || session?.id !== sessionId || session.archived === true || isClientPendingStartSessionInfo(session)) return;
try { try {
this.flushPendingTranscriptEvents(); this.flushPendingUpdates();
const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]); const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]);
if (this.getState().selectedSession?.id !== sessionId) return; if (this.getState().selectedSession?.id !== sessionId) return;
const history = this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page); const history = this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page);
@@ -527,8 +741,7 @@ export class SessionController {
} }
} }
private applyBulkSessionError(action: string, results: readonly PromiseSettledResult<string>[]): void { private applyBulkSessionFailures(action: string, failures: readonly string[]): void {
const failures = rejectedReasons(results);
if (failures.length === 0) return; if (failures.length === 0) return;
this.setState({ error: `${action} failed for ${String(failures.length)} session${failures.length === 1 ? "" : "s"}: ${failures.join("; ")}` }); this.setState({ error: `${action} failed for ${String(failures.length)} session${failures.length === 1 ? "" : "s"}: ${failures.join("; ")}` });
} }
@@ -537,6 +750,13 @@ export class SessionController {
return machineSessionKey(selectedMachineId(this.getState()), sessionId); return machineSessionKey(selectedMachineId(this.getState()), sessionId);
} }
private statusForSession(session: SessionInfo | undefined): SessionStatus | undefined {
if (session === undefined) return undefined;
const state = this.getState();
if (state.status?.sessionId === session.id && state.selectedSession?.id === session.id) return state.status;
return state.sessionStatuses[session.id];
}
private workspaceSelectionKey(cwd: string): string { private workspaceSelectionKey(cwd: string): string {
return `${selectedMachineId(this.getState())}:${cwd}`; return `${selectedMachineId(this.getState())}:${cwd}`;
} }
@@ -549,6 +769,169 @@ export class SessionController {
}); });
} }
private createPendingSessionStart(workspace: Workspace, machineId: string): PendingSessionStart {
const tempId = `pending-session-${String(++this.pendingSessionStartSeq)}-${Date.now().toString(36)}`;
const now = new Date().toISOString();
const session: ClientPendingStartSessionInfo = {
id: tempId,
path: `pi-web://pending-session/${tempId}`,
cwd: workspace.path,
persisted: false,
name: "New session",
created: now,
modified: now,
messageCount: 0,
firstMessage: "",
clientPendingStart: true,
machineId,
};
return { tempId, workspaceId: workspace.id, cwd: workspace.path, machineId, session, queuedSends: [], discarded: false };
}
private insertAndSelectPendingSession(session: ClientPendingStartSessionInfo): void {
const state = this.getState();
this.selectClientPendingStartSession(session, {
activity: creatingPendingSessionActivity(session.id),
sessions: [session, ...state.sessions.filter((candidate) => candidate.id !== session.id)],
});
}
private selectClientPendingStartSession(session: ClientPendingStartSessionInfo, options?: { updateUrl?: boolean | undefined; activity?: SessionActivity | undefined; sessions?: SessionInfo[] | undefined }): void {
this.sessionSelection.rememberSession({ ...session, cwd: this.workspaceSelectionKey(session.cwd) });
this.selectionSeq += 1;
this.socket.close();
this.catchupStreamSessionId = undefined;
this.clearPendingUpdates();
const state = this.getState();
const pendingStart = this.pendingSessionStarts.get(session.id);
const activity = options?.activity ?? state.sessionActivities[session.id] ?? (pendingStart !== undefined ? creatingPendingSessionActivity(session.id, pendingStart.queuedSends.length) : undefined);
this.setState({
...(options?.sessions === undefined ? {} : { sessions: options.sessions }),
selectedSession: session,
messages: [],
messagePageStart: 0,
messagePageEnd: 0,
messagePageTotal: 0,
isLoadingEarlierMessages: false,
isReceivingPartialStream: false,
status: undefined,
activity,
availableThinkingLevels: [],
...(activity === undefined ? {} : { sessionActivities: { ...state.sessionActivities, [session.id]: activity } }),
error: "",
});
if (options?.updateUrl !== false) this.updateUrl();
}
private async resolvePendingSessionStart(tempId: string, session: SessionInfo): Promise<void> {
const pending = this.pendingSessionStarts.get(tempId);
if (pending === undefined) return;
this.pendingSessionStarts.delete(tempId);
const queuedSends = pending.queuedSends.splice(0);
const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId, session.id);
if (pending.discarded) {
clearDraft(machineSessionKey(pending.machineId, tempId));
this.setState({ clientQueuedSessionMessages: omitKey(this.getState().clientQueuedSessionMessages, tempId) });
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
void this.api.stop(session, pending.machineId).catch(() => {
// Best-effort cleanup for a backend session whose temporary UI row was discarded before creation finished.
});
return;
}
rememberCachedNewSession(session, pending.machineId);
moveDraft(machineSessionKey(pending.machineId, tempId), machineSessionKey(pending.machineId, session.id));
const cachedSession = markCachedNewSessionInfo(session, pending.machineId);
if (!this.isCurrentPendingStart(pending)) {
this.setState({ clientQueuedSessionMessages: omitKey(this.getState().clientQueuedSessionMessages, tempId) });
await this.flushQueuedPendingSends(cachedSession, pending.machineId, queuedSends);
return;
}
const state = this.getState();
const wasSelected = state.selectedSession?.id === tempId;
this.setState({
sessions: replacePendingSessionInList(state.sessions, tempId, cachedSession),
sessionActivities: omitSessionActivity(state.sessionActivities, tempId),
sendingPrompts: moveRecordKey(state.sendingPrompts, tempId, cachedSession.id),
clientQueuedSessionMessages: moveRecordKey(state.clientQueuedSessionMessages, tempId, cachedSession.id),
...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id] } : {}),
error: "",
});
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
if (wasSelected) {
this.updateUrl({ replace: true });
await this.selectSession(cachedSession, { updateUrl: false });
}
await this.flushQueuedPendingSends(cachedSession, pending.machineId, queuedSends);
}
private failPendingSessionStart(tempId: string, error: unknown): void {
const pending = this.pendingSessionStarts.get(tempId);
if (pending === undefined) return;
this.pendingSessionStarts.delete(tempId);
const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId);
const isCurrentPendingStart = this.isCurrentPendingStart(pending);
if (pending.discarded || !isCurrentPendingStart) {
if (isCurrentPendingStart) this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
return;
}
const state = this.getState();
const message = errorMessage(error);
const activity = failedPendingSessionActivity(tempId, message, pending.queuedSends.length);
const hasPendingRow = state.sessions.some((session) => session.id === tempId);
this.setState({
sessions: hasPendingRow ? state.sessions : [pending.session, ...state.sessions],
sessionActivities: { ...state.sessionActivities, [tempId]: activity },
activity: state.selectedSession?.id === tempId ? activity : state.activity,
error: `Failed to start session: ${message}`,
});
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
}
private isCurrentPendingStart(pending: PendingSessionStart): boolean {
const state = this.getState();
return selectedMachineId(state) === pending.machineId && state.selectedWorkspace?.id === pending.workspaceId;
}
private hasPendingStartFor(cwd: string, machineId: string): boolean {
return Array.from(this.pendingSessionStarts.values()).some((pending) => pending.cwd === cwd && pending.machineId === machineId);
}
private isSuppressedCreatedSession(session: SessionInfo, machineId: string): boolean {
const suppressed = this.suppressedCreatedSessions.get(session.id);
return suppressed?.session.cwd === session.cwd && suppressed.machineId === machineId;
}
private takeSuppressedCreatedSessionsFor(cwd: string, machineId: string, resolvedSessionId?: string): SessionInfo[] {
if (resolvedSessionId !== undefined) this.suppressedCreatedSessions.delete(resolvedSessionId);
if (this.hasPendingStartFor(cwd, machineId)) return [];
const released: SessionInfo[] = [];
for (const [sessionId, suppressed] of this.suppressedCreatedSessions) {
if (suppressed.session.cwd !== cwd || suppressed.machineId !== machineId) continue;
this.suppressedCreatedSessions.delete(sessionId);
released.push(suppressed.session);
}
return released;
}
private applyReleasedCreatedSessions(sessions: readonly SessionInfo[], machineId: string): void {
if (sessions.length === 0 || selectedMachineId(this.getState()) !== machineId) return;
const state = this.getState();
if (state.selectedWorkspace === undefined) return;
const existingIds = new Set(state.sessions.map((session) => session.id));
const released = sessions.filter((session) => session.cwd === state.selectedWorkspace?.path && !existingIds.has(session.id));
if (released.length === 0) return;
this.setState({ sessions: [...released.reverse(), ...state.sessions] });
}
private mergePendingStartSessions(cwd: string, sessions: SessionInfo[], machineId: string): SessionInfo[] {
const pending = this.getState().sessions.filter((session): session is ClientPendingStartSessionInfo => isClientPendingStartSessionInfo(session) && session.cwd === cwd && session.machineId === machineId);
if (pending.length === 0) return sessions;
const pendingIds = new Set(pending.map((session) => session.id));
return [...pending, ...sessions.filter((session) => !pendingIds.has(session.id))];
}
private async recreateCachedNewSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }): Promise<void> { private async recreateCachedNewSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }): Promise<void> {
try { try {
const machineId = selectedMachineId(this.getState()); const machineId = selectedMachineId(this.getState());
@@ -594,6 +977,11 @@ export class SessionController {
// the optimistic insert from startSession in this same tab). // the optimistic insert from startSession in this same tab).
if (state.selectedWorkspace?.path !== session.cwd) return; if (state.selectedWorkspace?.path !== session.cwd) return;
if (state.sessions.some((candidate) => candidate.id === session.id)) return; if (state.sessions.some((candidate) => candidate.id === session.id)) return;
const machineId = selectedMachineId(state);
if (this.hasPendingStartFor(session.cwd, machineId)) {
this.suppressedCreatedSessions.set(session.id, { session, machineId });
return;
}
this.setState({ sessions: [session, ...state.sessions] }); this.setState({ sessions: [session, ...state.sessions] });
} }
@@ -642,19 +1030,29 @@ export class SessionController {
if (isTranscriptEvent(event)) return; if (isTranscriptEvent(event)) return;
} }
// Status and activity arrive once per token (the server republishes them on
// every transcript event). Buffer them alongside high-frequency transcript
// deltas so the host component renders at most once per animation frame
// instead of once per token. Coalescing these here is what keeps the prompt
// editor's DOM stable during streaming, so in-progress touch gestures (e.g.
// the iOS long-press edit/paste callout) are not interrupted by a re-render.
if (event.type === "status.update") {
this.queueStatusUpdate(event.status);
return;
}
if (event.type === "activity.update") {
this.queueActivityUpdate(event.activity);
return;
}
if (isHighFrequencyTranscriptEvent(event)) { if (isHighFrequencyTranscriptEvent(event)) {
this.queueTranscriptEvent(event); this.queueTranscriptEvent(event);
return; return;
} }
this.flushPendingTranscriptEvents(); this.flushPendingUpdates();
const transcript = this.transcripts.applyLiveEvent(this.getState().messages, event); const transcript = this.transcripts.applyLiveEvent(this.getState().messages, event);
if (transcript) { if (transcript) {
this.setState({ messages: transcript }); this.setState({ messages: transcript });
} else if (event.type === "status.update") {
this.applyStatus(event.status);
} else if (event.type === "activity.update") {
this.applyActivity(event.activity);
} else if (event.type === "session.name") { } else if (event.type === "session.name") {
this.applySessionName(event.sessionId, event.name); this.applySessionName(event.sessionId, event.name);
} }
@@ -662,27 +1060,60 @@ export class SessionController {
private queueTranscriptEvent(event: SessionUiEvent): void { private queueTranscriptEvent(event: SessionUiEvent): void {
this.pendingTranscriptEvents.push(event); this.pendingTranscriptEvents.push(event);
if (this.pendingTranscriptFrame !== undefined) return; this.schedulePendingFlush();
this.pendingTranscriptFrame = requestAnimationFrame(() => { }
this.pendingTranscriptFrame = undefined;
this.flushPendingTranscriptEvents(); private queueStatusUpdate(status: SessionStatus): void {
this.pendingStatusBySession.set(status.sessionId, status);
this.schedulePendingFlush();
}
private queueActivityUpdate(activity: SessionActivity): void {
this.pendingActivityBySession.set(activity.sessionId, activity);
this.schedulePendingFlush();
}
private schedulePendingFlush(): void {
if (this.pendingFrame !== undefined) return;
this.pendingFrame = requestAnimationFrame(() => {
this.pendingFrame = undefined;
this.flushPendingUpdates();
}); });
} }
private flushPendingTranscriptEvents(): void { // Apply buffered transcript deltas, activity, and status in one task. Activity
if (this.pendingTranscriptEvents.length === 0) return; // is applied before status to mirror the server's publish order, so an idle
// status can clear the now-stale active activity it supersedes. Status and
// activity are last-write-wins per session, so iterating the maps applies only
// the latest buffered value per session. These writes run in a single task, so
// Lit batches them into one render.
flushPendingUpdates(): void {
if (this.pendingTranscriptEvents.length > 0) {
const events = this.pendingTranscriptEvents; const events = this.pendingTranscriptEvents;
this.pendingTranscriptEvents = []; this.pendingTranscriptEvents = [];
let messages = this.getState().messages; let messages = this.getState().messages;
for (const event of events) messages = this.transcripts.applyLiveEvent(messages, event) ?? messages; for (const event of events) messages = this.transcripts.applyLiveEvent(messages, event) ?? messages;
if (messages !== this.getState().messages) this.setState({ messages }); if (messages !== this.getState().messages) this.setState({ messages });
} }
if (this.pendingActivityBySession.size > 0) {
const activities = Array.from(this.pendingActivityBySession.values());
this.pendingActivityBySession.clear();
for (const activity of activities) this.applyActivity(activity);
}
if (this.pendingStatusBySession.size > 0) {
const statuses = Array.from(this.pendingStatusBySession.values());
this.pendingStatusBySession.clear();
for (const status of statuses) this.applyStatus(status);
}
}
private clearPendingTranscriptEvents(): void { private clearPendingUpdates(): void {
this.pendingTranscriptEvents = []; this.pendingTranscriptEvents = [];
if (this.pendingTranscriptFrame === undefined) return; this.pendingStatusBySession.clear();
cancelAnimationFrame(this.pendingTranscriptFrame); this.pendingActivityBySession.clear();
this.pendingTranscriptFrame = undefined; if (this.pendingFrame === undefined) return;
cancelAnimationFrame(this.pendingFrame);
this.pendingFrame = undefined;
} }
// Stream catch-up is a single mode with two coupled facets that must never // Stream catch-up is a single mode with two coupled facets that must never
@@ -727,6 +1158,87 @@ function omitKey<T>(record: Record<string, T>, key: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([id]) => id !== key)); return Object.fromEntries(Object.entries(record).filter(([id]) => id !== key));
} }
function omitKeys<T>(record: Record<string, T>, keys: readonly string[]): Record<string, T> {
if (keys.length === 0) return record;
const removed = new Set(keys);
return Object.fromEntries(Object.entries(record).filter(([id]) => !removed.has(id)));
}
function moveRecordKey<T>(record: Record<string, T>, fromKey: string, toKey: string): Record<string, T> {
if (fromKey === toKey || !(fromKey in record)) return record;
const value = record[fromKey];
if (value === undefined) return record;
return { ...omitKey(record, fromKey), [toKey]: value };
}
function replacePendingSessionInList(sessions: readonly SessionInfo[], pendingSessionId: string, resolvedSession: SessionInfo): SessionInfo[] {
const next: SessionInfo[] = [];
let inserted = false;
for (const session of sessions) {
if (session.id === pendingSessionId) {
if (!inserted) {
next.push(resolvedSession);
inserted = true;
}
continue;
}
if (session.id === resolvedSession.id) continue;
next.push(session);
}
if (!inserted) return [resolvedSession, ...next];
return next;
}
function isClientPendingStartSessionInfo(session: SessionInfo | undefined): session is ClientPendingStartSessionInfo {
return session !== undefined && "clientPendingStart" in session && session.clientPendingStart === true;
}
function creatingPendingSessionActivity(sessionId: string, queuedCount = 0): SessionActivity {
return {
sessionId,
phase: "active",
label: "Creating session",
detail: queuedCount > 0 ? `${String(queuedCount)} queued ${queuedCount === 1 ? "message" : "messages"} will send when the backend session is ready` : "Waiting for the backend session to be ready",
at: new Date().toISOString(),
};
}
function failedPendingSessionActivity(sessionId: string, message: string, queuedCount = 0): SessionActivity {
const queuedDetail = queuedCount > 0 ? ` · ${String(queuedCount)} queued ${queuedCount === 1 ? "message" : "messages"} kept below` : "";
return {
sessionId,
phase: "error",
label: "Session creation failed",
detail: `${message}${queuedDetail}`,
at: new Date().toISOString(),
};
}
function queuedSessionMessagePreview(queued: QueuedPendingSessionSend): QueuedSessionMessage {
if (queued.type === "prompt") {
return { kind: queued.streamingBehavior === "steer" ? "steer" : "followUp", text: queuedPromptPreviewText(queued.text, queued.attachments) };
}
return { kind: "followUp", text: queued.text };
}
function queuedPromptPreviewText(text: string, attachments: PromptAttachment[] | undefined): string {
const attachmentText = queuedAttachmentSummary(attachments);
if (attachmentText === undefined) return text;
const trimmed = text.trim();
return trimmed === "" ? attachmentText : `${text}\n\n${attachmentText}`;
}
function queuedAttachmentSummary(attachments: PromptAttachment[] | undefined): string | undefined {
if (attachments === undefined || attachments.length === 0) return undefined;
const names = attachments.map((attachment) => attachment.name?.trim()).filter((name): name is string => name !== undefined && name !== "");
const count = attachments.length;
const label = `${String(count)} ${count === 1 ? "attachment" : "attachments"}`;
if (names.length === 0) return `[${label} queued]`;
const shownNames = names.slice(0, 3).join(", ");
const suffix = names.length > 3 ? `, +${String(names.length - 3)} more` : "";
return `[${label} queued: ${shownNames}${suffix}]`;
}
function uniqueSessionsById(sessions: readonly SessionInfo[]): SessionInfo[] { function uniqueSessionsById(sessions: readonly SessionInfo[]): SessionInfo[] {
const seen = new Set<string>(); const seen = new Set<string>();
const unique: SessionInfo[] = []; const unique: SessionInfo[] = [];
@@ -742,8 +1254,39 @@ function fulfilledValues<T>(results: readonly PromiseSettledResult<T>[]): T[] {
return results.filter(isFulfilled).map((result) => result.value); return results.filter(isFulfilled).map((result) => result.value);
} }
function rejectedReasons(results: readonly PromiseSettledResult<unknown>[]): string[] { function bulkFailureMessages(failures: readonly SessionBulkFailure[]): string[] {
return results.filter(isRejected).map((result) => errorMessage(result.reason)); return failures.map((failure) => `${failure.sessionId}: ${failure.error}`);
}
function settledSessionFailureMessages(sessions: readonly SessionInfo[], results: readonly PromiseSettledResult<unknown>[]): string[] {
return results.flatMap((result, index) => {
if (!isRejected(result)) return [];
const sessionId = sessions[index]?.id ?? "unknown";
return [`${sessionId}: ${errorMessage(result.reason)}`];
});
}
async function allSettledWithConcurrency<T, R>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<R>): Promise<PromiseSettledResult<R>[]> {
const indexedItems = items.map((item, index) => ({ item, index }));
const results: PromiseSettledResult<R>[] = [];
let nextIndex = 0;
async function runWorker(): Promise<void> {
while (nextIndex < indexedItems.length) {
const entry = indexedItems[nextIndex];
if (entry === undefined) return;
nextIndex += 1;
try {
results[entry.index] = { status: "fulfilled", value: await worker(entry.item) };
} catch (reason) {
results[entry.index] = { status: "rejected", reason };
}
}
}
const workerCount = Math.min(Math.max(1, concurrency), indexedItems.length);
await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
return results;
} }
function isFulfilled<T>(result: PromiseSettledResult<T>): result is PromiseFulfilledResult<T> { function isFulfilled<T>(result: PromiseSettledResult<T>): result is PromiseFulfilledResult<T> {
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { inputModeForDraft, isShellInput } from "./inputModes"; import { inputModeForDraft, inputModesEqual, isShellInput } from "./inputModes";
describe("inputModeForDraft", () => { describe("inputModeForDraft", () => {
it("detects shell input and context-excluded shell input after leading whitespace", () => { it("detects shell input and context-excluded shell input after leading whitespace", () => {
@@ -14,6 +14,13 @@ describe("inputModeForDraft", () => {
expect(inputModeForDraft("please mention/path")).toEqual({ kind: "normal" }); expect(inputModeForDraft("please mention/path")).toEqual({ kind: "normal" });
}); });
it("treats modes as equal only when kind and shell context-exclusion match", () => {
expect(inputModesEqual({ kind: "normal" }, { kind: "normal" })).toBe(true);
expect(inputModesEqual({ kind: "normal" }, { kind: "command" })).toBe(false);
expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: false })).toBe(true);
expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: true })).toBe(false);
});
it("detects file completion contexts", () => { it("detects file completion contexts", () => {
expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" }); expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" });
expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" }); expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" });
+6
View File
@@ -19,6 +19,12 @@ export function isShellInput(text: string): boolean {
return inputModeForDraft(text).kind === "shell"; return inputModeForDraft(text).kind === "shell";
} }
export function inputModesEqual(a: InputMode, b: InputMode): boolean {
if (a.kind !== b.kind) return false;
if (a.kind === "shell" && b.kind === "shell") return a.excludeFromContext === b.excludeFromContext;
return true;
}
function currentToken(draft: string): string { function currentToken(draft: string): string {
const tokenStart = Math.max(draft.lastIndexOf(" "), draft.lastIndexOf("\n")) + 1; const tokenStart = Math.max(draft.lastIndexOf(" "), draft.lastIndexOf("\n")) + 1;
return draft.slice(tokenStart); return draft.slice(tokenStart);
+24 -14
View File
@@ -1,8 +1,8 @@
import { isSessionActive } from "../../../../shared/activity"; import { isSessionActive } from "../../../../shared/activity";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities"; import { PI_WEB_CAPABILITIES, supportsPiWebCapability, type PiWebCapability } from "../../../../shared/capabilities";
import type { AppState } from "../../appState"; import type { AppState } from "../../appState";
import { isCachedNewSessionInfo } from "../../cachedNewSessions";
import { selectedMachineId } from "../../controllers/types"; import { selectedMachineId } from "../../controllers/types";
import { isArchivableSessionInfo, isTransientNewSessionInfo } from "../../sessionPersistence";
import { isWorkspaceDeletionPending } from "../../workspaceDeletion"; import { isWorkspaceDeletionPending } from "../../workspaceDeletion";
import type { PluginAction } from "../types"; import type { PluginAction } from "../types";
@@ -177,18 +177,19 @@ export function createCoreActions(): PluginAction[] {
}, },
{ {
id: "session.reload", id: "session.reload",
title: "Reload Session", title: "Reload Session from Disk",
description: "Re-read the selected session from disk to pick up entries written by another process", description: "Close and re-open the selected session from its session file. Use /reload in the prompt for Pi runtime resources.",
group: "Session", group: "Session",
enabled: hasReloadableSession, enabled: hasReloadableSession,
disabledReason: reloadSessionDisabledReason,
run: (context) => context.reloadSession(), run: (context) => context.reloadSession(),
}, },
{ {
id: "session.delete", id: "session.delete",
title: "Delete New Session", title: "Delete New Session",
description: "Delete the selected browser-cached new session", description: "Delete the selected transient new session",
group: "Session", group: "Session",
enabled: hasCachedNewSession, enabled: hasTransientNewSession,
run: (context) => context.deleteCachedNewSession(), run: (context) => context.deleteCachedNewSession(),
}, },
{ {
@@ -216,18 +217,27 @@ function hasDeletableWorkspace(context: { state: AppState }): boolean {
} }
function hasArchivableSession(context: { state: AppState }): boolean { function hasArchivableSession(context: { state: AppState }): boolean {
const session = context.state.selectedSession; return isArchivableSessionInfo(context.state.selectedSession, context.state.status);
return session !== undefined && session.archived !== true && !isCachedNewSessionInfo(session);
} }
function hasCachedNewSession(context: { state: AppState }): boolean { function hasTransientNewSession(context: { state: AppState }): boolean {
return isCachedNewSessionInfo(context.state.selectedSession); return isTransientNewSessionInfo(context.state.selectedSession, context.state.status);
} }
function hasReloadableSession(context: { state: AppState }): boolean { function hasReloadableSession(context: { state: AppState }): boolean {
const session = context.state.selectedSession; if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status)) return false;
if (session === undefined || session.archived === true || isCachedNewSessionInfo(session)) return false; if (reloadSessionDisabledReason(context) !== undefined) return false;
const runtime = context.state.machineRuntimes[selectedMachineId(context.state)];
if (runtime?.ok !== true || !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload)) return false;
return !isSessionActive(context.state.status, context.state.activity); return !isSessionActive(context.state.status, context.state.activity);
} }
function reloadSessionDisabledReason(context: { state: AppState }): string | undefined {
if (!isArchivableSessionInfo(context.state.selectedSession, context.state.status)) return undefined;
if (isSessionActive(context.state.status, context.state.activity)) return undefined;
return missingCapabilityReason(context.state, PI_WEB_CAPABILITIES.sessionsReload, "reload sessions from disk");
}
function missingCapabilityReason(state: AppState, capability: PiWebCapability, action: string): string | undefined {
const runtime = state.machineRuntimes[selectedMachineId(state)];
if (runtime?.ok === true && supportsPiWebCapability(runtime, capability)) return undefined;
return `Update and restart Pi-Web on ${state.selectedMachine?.name ?? "this machine"} to ${action}.`;
}
+45 -13
View File
@@ -170,45 +170,77 @@ describe("PluginRegistry", () => {
expect(calls).toEqual(["deleteWorkspace"]); expect(calls).toEqual(["deleteWorkspace"]);
}); });
it("offers archive only for persisted sessions and delete only for browser-cached new sessions", () => { it("offers archive only for persisted sessions and delete only for transient new sessions", () => {
const registry = new PluginRegistry(); const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin }); registry.register({ id: "core", plugin: corePlugin });
const persistedActions = registry.getActions(createContext({ selectedSession: testSession() }).context); const persistedActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }) }).context);
expect(persistedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true); expect(persistedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true);
expect(persistedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false); expect(persistedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
const unknownActions = registry.getActions(createContext({ selectedSession: testSession() }).context);
expect(unknownActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(unknownActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
const transientActions = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }) }).context);
expect(transientActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(transientActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true);
const cachedActions = registry.getActions(createContext({ selectedSession: markCachedNewSessionInfo(testSession()) }).context); const cachedActions = registry.getActions(createContext({ selectedSession: markCachedNewSessionInfo(testSession()) }).context);
expect(cachedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false); expect(cachedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(cachedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true); expect(cachedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true);
const archivedActions = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" } }).context); const archivedActions = registry.getActions(createContext({ selectedSession: { ...testSession({ persisted: true }), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" } }).context);
expect(archivedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false); expect(archivedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(archivedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false); expect(archivedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
}); });
it("enables session reload only for a writable session on a capable, idle runtime", () => { it("uses selected session status as the freshest archive/delete persistence signal", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
const statusPersisted = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }), status: testStatus({ persisted: true }) }).context);
expect(statusPersisted.find((action) => action.id === "core:session.archive")?.enabled).toBe(true);
expect(statusPersisted.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
const statusTransient = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), status: testStatus({ persisted: false }) }).context);
expect(statusTransient.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
expect(statusTransient.find((action) => action.id === "core:session.delete")?.enabled).toBe(true);
});
it("enables session disk reload only for a writable session on a capable, idle runtime", () => {
const registry = new PluginRegistry(); const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin }); registry.register({ id: "core", plugin: corePlugin });
const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } }; const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } };
const reloadable = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime }).context); const reloadable = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: reloadRuntime }).context);
expect(reloadable.find((action) => action.id === "core:session.reload")?.enabled).toBe(true); const reloadableAction = reloadable.find((action) => action.id === "core:session.reload");
expect(reloadableAction?.enabled).toBe(true);
expect(reloadableAction?.title).toBe("Reload Session from Disk");
expect(reloadableAction?.description).toContain("Use /reload in the prompt for Pi runtime resources");
const noCapability = registry.getActions(createContext({ selectedSession: testSession() }).context); const noCapability = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }) }).context);
expect(noCapability.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); const noCapabilityReload = noCapability.find((action) => action.id === "core:session.reload");
expect(noCapabilityReload?.enabled).toBe(false);
expect(noCapabilityReload?.disabledReason).toBe("Update and restart Pi-Web on this machine to reload sessions from disk.");
const archived = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" }, machineRuntimes: reloadRuntime }).context); const unknown = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime }).context);
expect(unknown.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
const transient = registry.getActions(createContext({ selectedSession: testSession({ persisted: false }), machineRuntimes: reloadRuntime }).context);
expect(transient.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
const archived = registry.getActions(createContext({ selectedSession: { ...testSession({ persisted: true }), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" }, machineRuntimes: reloadRuntime }).context);
expect(archived.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); expect(archived.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
const busy = registry.getActions(createContext({ selectedSession: testSession(), machineRuntimes: reloadRuntime, status: testStatus({ isStreaming: true }) }).context); const busy = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: reloadRuntime, status: testStatus({ persisted: true, isStreaming: true }) }).context);
expect(busy.find((action) => action.id === "core:session.reload")?.enabled).toBe(false); expect(busy.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
}); });
it("routes session reload through the runtime context", () => { it("routes session reload through the runtime context", () => {
const registry = new PluginRegistry(); const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin }); registry.register({ id: "core", plugin: corePlugin });
const { context, calls } = createContext({ selectedSession: testSession(), machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } } }); const { context, calls } = createContext({ selectedSession: testSession({ persisted: true }), machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload] } } });
const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.reload"); const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.reload");
if (action !== undefined) void action.run(); if (action !== undefined) void action.run();
@@ -216,10 +248,10 @@ describe("PluginRegistry", () => {
expect(calls).toEqual(["reloadSession"]); expect(calls).toEqual(["reloadSession"]);
}); });
it("routes browser-cached new session delete through the runtime context", () => { it("routes transient new session delete through the runtime context", () => {
const registry = new PluginRegistry(); const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin }); registry.register({ id: "core", plugin: corePlugin });
const { context, calls } = createContext({ selectedSession: markCachedNewSessionInfo(testSession()) }); const { context, calls } = createContext({ selectedSession: testSession({ persisted: false }) });
const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.delete"); const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.delete");
if (action !== undefined) void action.run(); if (action !== undefined) void action.run();
+2
View File
@@ -60,6 +60,7 @@ export class PluginRegistry {
return this.actions.filter((action) => this.isContributionActive(action.pluginId, action.machineId, selectedMachineId, action.sourcePluginId)).map((action) => { return this.actions.filter((action) => this.isContributionActive(action.pluginId, action.machineId, selectedMachineId, action.sourcePluginId)).map((action) => {
const scopedContext = pluginRuntimeContextFor(context, action.pluginId); const scopedContext = pluginRuntimeContextFor(context, action.pluginId);
const enabled = action.enabled?.(scopedContext); const enabled = action.enabled?.(scopedContext);
const disabledReason = enabled === false ? action.disabledReason?.(scopedContext) : undefined;
const qualified: QualifiedPluginAction = { const qualified: QualifiedPluginAction = {
id: action.id, id: action.id,
pluginId: action.pluginId, pluginId: action.pluginId,
@@ -72,6 +73,7 @@ export class PluginRegistry {
if (action.shortcut !== undefined) qualified.shortcut = action.shortcut; if (action.shortcut !== undefined) qualified.shortcut = action.shortcut;
if (action.group !== undefined) qualified.group = action.group; if (action.group !== undefined) qualified.group = action.group;
if (enabled !== undefined) qualified.enabled = enabled; if (enabled !== undefined) qualified.enabled = enabled;
if (disabledReason !== undefined && disabledReason !== "") qualified.disabledReason = disabledReason;
return qualified; return qualified;
}); });
} }
+2
View File
@@ -128,6 +128,8 @@ export interface PluginAction {
shortcut?: string; shortcut?: string;
group?: string; group?: string;
enabled?: (context: PluginRuntimeContext) => boolean; enabled?: (context: PluginRuntimeContext) => boolean;
/** Explain why a disabled action is visible but unavailable. */
disabledReason?: (context: PluginRuntimeContext) => string | undefined;
run: (context: PluginRuntimeContext) => void | Promise<void>; run: (context: PluginRuntimeContext) => void | Promise<void>;
} }
+48 -18
View File
@@ -1,51 +1,81 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { captureImageAttachments, READ_FAILURE_MESSAGE, UNSUPPORTED_IMAGE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture"; import { capturePromptAttachments, DEFAULT_FILE_MIME_TYPE, effectivePromptAttachmentDelivery, READ_FAILURE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture";
function file(name: string, type: string, size = 10): CapturableFile { function file(name: string, type: string, size = 10): CapturableFile {
return { name, type, size }; return { name, type, size };
} }
describe("captureImageAttachments", () => { describe("capturePromptAttachments", () => {
it("reads supported images as base64 attachments", async () => { it("reads supported images as native inline image attachments", async () => {
const result = await captureImageAttachments( const result = await capturePromptAttachments(
[file("shot.png", "image/png"), file("pic.webp", "image/webp")], [file("shot.png", "image/png"), file("pic.webp", "image/webp")],
(f) => Promise.resolve(`data-for-${f.name}`), (f) => Promise.resolve(`data-for-${f.name}`),
); );
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
expect(result.attachments).toEqual([ expect(result.attachments).toEqual([
{ name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 }, { kind: "image", name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 },
{ name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 }, { kind: "image", name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 },
]); ]);
}); });
it("derives a name from the mime type when the file is unnamed", async () => { it("captures generic files with their browser MIME type", async () => {
const result = await captureImageAttachments([file("", "image/jpeg")], () => Promise.resolve("x")); const result = await capturePromptAttachments(
expect(result.attachments[0]?.name).toBe("pasted-image.jpg"); [file("report.pdf", "application/pdf", 1234), file("vector.svg", "image/svg+xml")],
(f) => Promise.resolve(`data-for-${f.name}`),
);
expect(result.error).toBeUndefined();
expect(result.attachments).toEqual([
{ kind: "file", name: "report.pdf", mimeType: "application/pdf", data: "data-for-report.pdf", size: 1234 },
{ kind: "file", name: "vector.svg", mimeType: "image/svg+xml", data: "data-for-vector.svg", size: 10 },
]);
}); });
it("skips unsupported types and reports a single error while keeping valid ones", async () => { it("uses application/octet-stream when the browser does not provide a MIME type", async () => {
const result = await captureImageAttachments( const result = await capturePromptAttachments([file("archive", "")], () => Promise.resolve("x"));
[file("doc.pdf", "application/pdf"), file("ok.gif", "image/gif")],
expect(result.attachments[0]).toMatchObject({ kind: "file", name: "archive", mimeType: DEFAULT_FILE_MIME_TYPE });
});
it("derives fallback names for unnamed pasted attachments", async () => {
const result = await capturePromptAttachments(
[file("", "image/jpeg"), file("", "application/pdf")],
() => Promise.resolve("x"), () => Promise.resolve("x"),
); );
expect(result.error).toBe(UNSUPPORTED_IMAGE_MESSAGE); expect(result.attachments.map((attachment) => attachment.name)).toEqual(["pasted-image.jpg", "pasted-file.bin"]);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["ok.gif"]);
}); });
it("reports a read failure without dropping other attachments", async () => { it("reports a read failure without dropping other attachments", async () => {
const result = await captureImageAttachments( const result = await capturePromptAttachments(
[file("bad.png", "image/png"), file("good.png", "image/png")], [file("bad.png", "image/png"), file("good.txt", "text/plain")],
(f) => f.name === "bad.png" ? Promise.reject(new Error("boom")) : Promise.resolve("ok"), (f) => f.name === "bad.png" ? Promise.reject(new Error("boom")) : Promise.resolve("ok"),
); );
expect(result.error).toBe(READ_FAILURE_MESSAGE); expect(result.error).toBe(READ_FAILURE_MESSAGE);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.png"]); expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.txt"]);
}); });
it("returns no attachments and no error for an empty batch", async () => { it("returns no attachments and no error for an empty batch", async () => {
const result = await captureImageAttachments([], () => Promise.resolve("x")); const result = await capturePromptAttachments([], () => Promise.resolve("x"));
expect(result).toEqual({ attachments: [] }); expect(result).toEqual({ attachments: [] });
}); });
}); });
describe("effectivePromptAttachmentDelivery", () => {
it("preserves inline delivery when all pending attachments are supported images", () => {
expect(effectivePromptAttachmentDelivery("inline", [{ kind: "image", mimeType: "image/png" }])).toBe("inline");
});
it("preserves an explicit folder preference for supported images", () => {
expect(effectivePromptAttachmentDelivery("folder", [{ kind: "image", mimeType: "image/png" }])).toBe("folder");
});
it("forces folder delivery when any attachment is a generic file", () => {
expect(effectivePromptAttachmentDelivery("inline", [
{ kind: "image", mimeType: "image/png" },
{ kind: "file", mimeType: "application/pdf" },
])).toBe("folder");
});
});
+49 -12
View File
@@ -1,3 +1,4 @@
import type { PromptAttachmentDelivery } from "../../shared/apiTypes";
import { extensionForImageMimeType, isSupportedImageMimeType } from "../../shared/promptAttachments"; import { extensionForImageMimeType, isSupportedImageMimeType } from "../../shared/promptAttachments";
/** /**
@@ -11,44 +12,51 @@ export interface CapturableFile {
size: number; size: number;
} }
export interface CapturedAttachment { export type CapturedAttachment =
| {
kind: "image";
name: string; name: string;
mimeType: string; mimeType: string;
/** Base64 payload without the data: URL prefix. */ /** Base64 payload without the data: URL prefix. */
data: string; data: string;
size: number; size: number;
} }
| {
kind: "file";
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
};
export interface CaptureResult { export interface CaptureResult {
attachments: CapturedAttachment[]; attachments: CapturedAttachment[];
error?: string; error?: string;
} }
export const UNSUPPORTED_IMAGE_MESSAGE = "Only PNG, JPEG, GIF, and WebP images are supported."; export const DEFAULT_FILE_MIME_TYPE = "application/octet-stream";
export const READ_FAILURE_MESSAGE = "Failed to read an attachment."; export const READ_FAILURE_MESSAGE = "Failed to read an attachment.";
/** /**
* Validate a batch of files and read the supported images as base64. * Read a batch of browser files as prompt attachments.
* *
* Pure orchestration: the actual byte reading is injected so the side effect * Pure orchestration: the actual byte reading is injected so the side effect
* (FileReader/Blob access) stays at the component boundary and tests can supply * (FileReader/Blob access) stays at the component boundary and tests can supply
* a fake reader. Unsupported types and read failures are collected into a single * a fake reader. Supported image MIME types stay marked as native inline images;
* user-facing error while still returning every attachment that did succeed. * every other file is captured as a generic file attachment that must be saved
* into the workspace before being mentioned in the prompt.
*/ */
export async function captureImageAttachments<T extends CapturableFile>( export async function capturePromptAttachments<T extends CapturableFile>(
files: readonly T[], files: readonly T[],
readBase64: (file: T) => Promise<string>, readBase64: (file: T) => Promise<string>,
): Promise<CaptureResult> { ): Promise<CaptureResult> {
const attachments: CapturedAttachment[] = []; const attachments: CapturedAttachment[] = [];
let error: string | undefined; let error: string | undefined;
for (const file of files) { for (const file of files) {
if (!isSupportedImageMimeType(file.type)) {
error = UNSUPPORTED_IMAGE_MESSAGE;
continue;
}
try { try {
const data = await readBase64(file); const data = await readBase64(file);
attachments.push({ name: attachmentName(file), mimeType: file.type, data, size: file.size }); attachments.push(capturedAttachment(file, data));
} catch { } catch {
error = READ_FAILURE_MESSAGE; error = READ_FAILURE_MESSAGE;
} }
@@ -56,6 +64,35 @@ export async function captureImageAttachments<T extends CapturableFile>(
return { attachments, ...(error === undefined ? {} : { error }) }; return { attachments, ...(error === undefined ? {} : { error }) };
} }
export function isInlinePromptAttachment(attachment: Pick<CapturedAttachment, "kind" | "mimeType">): boolean {
return attachment.kind === "image" && isSupportedImageMimeType(attachment.mimeType);
}
export function promptAttachmentsCanUseInlineDelivery(attachments: readonly Pick<CapturedAttachment, "kind" | "mimeType">[]): boolean {
return attachments.every((attachment) => isInlinePromptAttachment(attachment));
}
export function effectivePromptAttachmentDelivery(
preferredDelivery: PromptAttachmentDelivery,
attachments: readonly Pick<CapturedAttachment, "kind" | "mimeType">[],
): PromptAttachmentDelivery {
return promptAttachmentsCanUseInlineDelivery(attachments) ? preferredDelivery : "folder";
}
function capturedAttachment(file: CapturableFile, data: string): CapturedAttachment {
if (isSupportedImageMimeType(file.type)) {
return { kind: "image", name: attachmentName(file), mimeType: file.type, data, size: file.size };
}
return { kind: "file", name: attachmentName(file), mimeType: fileMimeType(file), data, size: file.size };
}
function fileMimeType(file: CapturableFile): string {
const mimeType = file.type.trim();
return mimeType === "" ? DEFAULT_FILE_MIME_TYPE : mimeType;
}
function attachmentName(file: CapturableFile): string { function attachmentName(file: CapturableFile): string {
return file.name !== "" ? file.name : `pasted-image.${extensionForImageMimeType(file.type)}`; if (file.name !== "") return file.name;
if (isSupportedImageMimeType(file.type)) return `pasted-image.${extensionForImageMimeType(file.type)}`;
return "pasted-file.bin";
} }

Some files were not shown because too many files have changed in this diff Show More