@firstpick/pi-package-webui 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -125,11 +125,12 @@ Environment variables:
125
125
  - Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, and abort controls.
126
126
  - Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references with live suggestions.
127
127
  - Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, `/scoped-models`, `/tools`, and `/skills`.
128
- - Model, thinking, session, workspace, theme, optional-feature, Codex usage, network, event, and notification controls in the side panel.
128
+ - Model, thinking, session, workspace, theme, optional-feature, Codex usage, network, update/restart, event, and notification controls in the side panel.
129
129
  - Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded.
130
130
  - Per-tab cwd changes, a clickable footer cwd picker, saved path fast picks, server-persisted fast picks, and restart-safe restoration of open tabs.
131
131
  - Detected app runner dropdown for the active tab cwd, including Cargo, Bun, npm/npx/pnpm, Python/uv, Go/Golang, Zig, C/C++, Docker Compose, root/dev/scripts shell scripts, and other common project runners with live output pinned at the top of the terminal. Projects can add browseable custom runners in `.pi-webui-runners.json` with a command (default `./`) plus a relative path to the file to run.
132
132
  - Browser support for Pi extension UI prompts, widgets, status updates, browser notifications when a tab needs an extension UI response and an optional side-panel toggle for agent-done notifications.
133
+ - Localhost-only Pi update checks with a top-right update notification and a confirmed **Update & restart** action that runs `pi update`, then restarts the Web UI server.
133
134
  - Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, which can ask Pi to create or update a LEARNING.
134
135
  - Mobile-friendly layout and PWA install support where the browser allows it.
135
136
 
@@ -138,6 +139,7 @@ Useful browser endpoints exposed by the local server include:
138
139
  - `GET /api/path-suggestions?tab=<tabId>&query=<path>` for `@` file/path references with live suggestions.
139
140
  - `POST /api/action-feedback?tab=<tabId>` for feedback on final assistant output and action cards.
140
141
  - `POST /api/optional-feature-install` for installing known optional companion packages from the side panel.
142
+ - `GET /api/update-status` and localhost-only `POST /api/update` for checking Pi/Web UI updates and running `pi update` followed by a Web UI server restart.
141
143
 
142
144
  For local development, run the checkout helper directly, for example:
143
145
 
@@ -191,6 +193,7 @@ This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; b
191
193
  - The side-panel **Open to network** button rebinds the server to `0.0.0.0`, shows LAN URLs when available, and toggles to "Close for network".
192
194
  - `--host 0.0.0.0` also exposes the Web UI to the local network.
193
195
  - Any connected browser client can control Pi and run Web UI bash actions as the Web UI process user.
196
+ - The Web UI update endpoint is restricted to localhost, because it runs `pi update` and restarts the server.
194
197
  - Treat Pi Web UI as a local companion, not a hardened multi-user web service.
195
198
 
196
199
  ## Troubleshooting
package/bin/pi-webui.mjs CHANGED
@@ -19,6 +19,13 @@ const webuiHelperExtensionPath = path.join(packageRoot, "webui-rpc-helper.mjs");
19
19
  const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(homedir(), ".pi", "agent");
20
20
  const OPTIONAL_FEATURE_INSTALL_ROOT_ENV = "PI_WEBUI_OPTIONAL_FEATURE_INSTALL_ROOT";
21
21
  const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
22
+ let piPackageJson = {};
23
+ try {
24
+ const piPackageJsonPath = require.resolve("@earendil-works/pi-coding-agent/package.json", { paths: [packageRoot] });
25
+ piPackageJson = JSON.parse(await readFile(piPackageJsonPath, "utf8"));
26
+ } catch {
27
+ piPackageJson = {};
28
+ }
22
29
  const nativeParityMatrix = JSON.parse(await readFile(path.join(packageRoot, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
23
30
  const webuiDevServer = isTruthyEnv(process.env.PI_WEBUI_DEV) || isSourceCheckout(packageRoot);
24
31
 
@@ -28,6 +35,14 @@ const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
28
35
  const WEBUI_HELPER_TIMEOUT_MS = 8 * 1000;
29
36
  const WEBUI_HELPER_COMMAND = "webui-helper";
30
37
  const WEBUI_HELPER_RESPONSE_PREFIX = "__PI_WEBUI_HELPER_RESPONSE__:";
38
+ const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
39
+ const WEBUI_PACKAGE = packageJson.name || "@firstpick/pi-package-webui";
40
+ const PI_LATEST_VERSION_URL = process.env.PI_WEBUI_PI_LATEST_VERSION_URL || "https://pi.dev/api/latest-version";
41
+ const NPM_REGISTRY_URL = (process.env.PI_WEBUI_NPM_REGISTRY_URL || "https://registry.npmjs.org").replace(/\/+$/, "");
42
+ const UPDATE_STATUS_CACHE_MS = 10 * 60 * 1000;
43
+ const UPDATE_STATUS_TIMEOUT_MS = 10 * 1000;
44
+ const PI_UPDATE_TIMEOUT_MS = 15 * 60 * 1000;
45
+ const PI_UPDATE_OUTPUT_MAX_CHARS = 120_000;
31
46
  const CODEX_USAGE_TIMEOUT_MS = 15 * 1000;
32
47
  const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
33
48
  const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
@@ -363,6 +378,51 @@ function delay(ms) {
363
378
  return new Promise((resolve) => setTimeout(resolve, ms));
364
379
  }
365
380
 
381
+ function truncateLongText(value, maxLength = 8000) {
382
+ const text = String(value || "");
383
+ if (text.length <= maxLength) return text;
384
+ return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
385
+ }
386
+
387
+ function parsePackageVersion(version) {
388
+ const match = String(version || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/);
389
+ if (!match) return undefined;
390
+ return {
391
+ major: Number.parseInt(match[1], 10),
392
+ minor: Number.parseInt(match[2], 10),
393
+ patch: Number.parseInt(match[3], 10),
394
+ prerelease: match[4],
395
+ };
396
+ }
397
+
398
+ function comparePackageVersions(leftVersion, rightVersion) {
399
+ const left = parsePackageVersion(leftVersion);
400
+ const right = parsePackageVersion(rightVersion);
401
+ if (!left || !right) return undefined;
402
+ if (left.major !== right.major) return left.major - right.major;
403
+ if (left.minor !== right.minor) return left.minor - right.minor;
404
+ if (left.patch !== right.patch) return left.patch - right.patch;
405
+ if (left.prerelease === right.prerelease) return 0;
406
+ if (!left.prerelease) return 1;
407
+ if (!right.prerelease) return -1;
408
+ return left.prerelease.localeCompare(right.prerelease);
409
+ }
410
+
411
+ function isNewerPackageVersion(candidateVersion, currentVersion) {
412
+ const comparison = comparePackageVersions(candidateVersion, currentVersion);
413
+ if (comparison !== undefined) return comparison > 0;
414
+ return String(candidateVersion || "").trim() !== String(currentVersion || "").trim();
415
+ }
416
+
417
+ async function fetchJsonWithTimeout(url, { timeoutMs = UPDATE_STATUS_TIMEOUT_MS, headers = {} } = {}) {
418
+ const response = await fetch(url, {
419
+ headers,
420
+ signal: AbortSignal.timeout(timeoutMs),
421
+ });
422
+ if (!response.ok) throw new Error(`${response.status}${response.statusText ? ` ${response.statusText}` : ""}`);
423
+ return response.json();
424
+ }
425
+
366
426
  class PiRpcProcess {
367
427
  constructor({ command, args, displayCommand, cwd }) {
368
428
  this.command = command;
@@ -3912,6 +3972,173 @@ function spawnRestartServer(restorableTabs) {
3912
3972
  return child;
3913
3973
  }
3914
3974
 
3975
+ let updateStatusCache = null;
3976
+ let updateStatusCacheAt = 0;
3977
+ let piUpdateInProgress = false;
3978
+
3979
+ function updateChecksSkippedReason() {
3980
+ if (process.env.PI_OFFLINE) return "PI_OFFLINE is set";
3981
+ if (process.env.PI_SKIP_VERSION_CHECK) return "PI_SKIP_VERSION_CHECK is set";
3982
+ return "";
3983
+ }
3984
+
3985
+ function basePackageUpdateStatus(packageName, currentVersion) {
3986
+ return {
3987
+ packageName,
3988
+ currentVersion: String(currentVersion || ""),
3989
+ latestVersion: null,
3990
+ updateAvailable: false,
3991
+ checked: false,
3992
+ skipped: false,
3993
+ skippedReason: "",
3994
+ error: "",
3995
+ };
3996
+ }
3997
+
3998
+ async function checkLatestPiReleaseStatus() {
3999
+ const status = basePackageUpdateStatus(PI_CODING_AGENT_PACKAGE, piPackageJson.version);
4000
+ const skippedReason = updateChecksSkippedReason();
4001
+ if (skippedReason) {
4002
+ status.skipped = true;
4003
+ status.skippedReason = skippedReason;
4004
+ return status;
4005
+ }
4006
+ try {
4007
+ const data = await fetchJsonWithTimeout(PI_LATEST_VERSION_URL, {
4008
+ headers: {
4009
+ "User-Agent": `pi-webui/${packageJson.version} pi/${piPackageJson.version || "unknown"}`,
4010
+ accept: "application/json",
4011
+ },
4012
+ });
4013
+ const latestVersion = typeof data.version === "string" ? data.version.trim() : "";
4014
+ if (!latestVersion) throw new Error("latest-version response did not include a version");
4015
+ status.latestVersion = latestVersion;
4016
+ status.packageName = typeof data.packageName === "string" && data.packageName.trim() ? data.packageName.trim() : PI_CODING_AGENT_PACKAGE;
4017
+ status.note = typeof data.note === "string" && data.note.trim() ? data.note.trim() : "";
4018
+ status.updateAvailable = status.currentVersion ? isNewerPackageVersion(latestVersion, status.currentVersion) : false;
4019
+ status.checked = true;
4020
+ } catch (error) {
4021
+ status.error = sanitizeError(error);
4022
+ }
4023
+ return status;
4024
+ }
4025
+
4026
+ function npmLatestPackageUrl(packageName) {
4027
+ return `${NPM_REGISTRY_URL}/${encodeURIComponent(packageName)}/latest`;
4028
+ }
4029
+
4030
+ async function checkLatestNpmPackageStatus(packageName, currentVersion) {
4031
+ const status = basePackageUpdateStatus(packageName, currentVersion);
4032
+ const skippedReason = updateChecksSkippedReason();
4033
+ if (skippedReason) {
4034
+ status.skipped = true;
4035
+ status.skippedReason = skippedReason;
4036
+ return status;
4037
+ }
4038
+ try {
4039
+ const data = await fetchJsonWithTimeout(npmLatestPackageUrl(packageName), {
4040
+ headers: {
4041
+ "User-Agent": `pi-webui/${packageJson.version}`,
4042
+ accept: "application/json",
4043
+ },
4044
+ });
4045
+ const latestVersion = typeof data.version === "string" ? data.version.trim() : "";
4046
+ if (!latestVersion) throw new Error(`${packageName} latest metadata did not include a version`);
4047
+ status.latestVersion = latestVersion;
4048
+ status.updateAvailable = status.currentVersion ? isNewerPackageVersion(latestVersion, status.currentVersion) : false;
4049
+ status.checked = true;
4050
+ } catch (error) {
4051
+ status.error = sanitizeError(error);
4052
+ }
4053
+ return status;
4054
+ }
4055
+
4056
+ function updateStatusForRequest(status, req) {
4057
+ return {
4058
+ ...status,
4059
+ canRunUpdate: isLocalAddress(req?.socket?.remoteAddress),
4060
+ updateInProgress: piUpdateInProgress,
4061
+ };
4062
+ }
4063
+
4064
+ async function getUpdateStatus({ force = false } = {}) {
4065
+ const now = Date.now();
4066
+ if (!force && updateStatusCache && now - updateStatusCacheAt < UPDATE_STATUS_CACHE_MS) return updateStatusCache;
4067
+ const [piStatus, webuiStatus] = await Promise.all([
4068
+ checkLatestPiReleaseStatus(),
4069
+ checkLatestNpmPackageStatus(WEBUI_PACKAGE, packageJson.version),
4070
+ ]);
4071
+ const updateAvailable = !!(piStatus.updateAvailable || webuiStatus.updateAvailable);
4072
+ updateStatusCache = {
4073
+ checkedAt: new Date(now).toISOString(),
4074
+ updateAvailable,
4075
+ restartRequired: true,
4076
+ command: "pi update",
4077
+ webuiDev: webuiDevServer,
4078
+ pi: piStatus,
4079
+ webui: webuiStatus,
4080
+ packages: {
4081
+ checked: false,
4082
+ note: "pi update will also update configured unpinned Pi packages.",
4083
+ },
4084
+ };
4085
+ updateStatusCacheAt = now;
4086
+ return updateStatusCache;
4087
+ }
4088
+
4089
+ async function resolvePiUpdateCommand() {
4090
+ if (options.piBinExplicit) {
4091
+ return { command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
4092
+ }
4093
+
4094
+ const pathPi = await runCommand(options.piBin, ["--version"], { timeoutMs: 3000, maxOutputLength: 4000 });
4095
+ if (pathPi.exitCode === 0 && !pathPi.timedOut && !pathPi.error) {
4096
+ return { command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
4097
+ }
4098
+
4099
+ return resolvePiCommand(["update"]);
4100
+ }
4101
+
4102
+ async function runPiUpdateAndPrepareRestart() {
4103
+ if (piUpdateInProgress) throw makeHttpError(409, "A Pi update is already running.");
4104
+ piUpdateInProgress = true;
4105
+ let restartPrepared = false;
4106
+ try {
4107
+ const restorableTabs = await restorableTabsForRestart();
4108
+ const piCommand = await resolvePiUpdateCommand();
4109
+ const command = piCommand.displayCommand || formatCommandForDisplay(piCommand.command, piCommand.args || []);
4110
+ recordEvent({ type: "webui_update_started", command, restorableTabCount: restorableTabs.length });
4111
+ const result = await runCommand(piCommand.command, piCommand.args || [], {
4112
+ cwd: process.cwd(),
4113
+ timeoutMs: PI_UPDATE_TIMEOUT_MS,
4114
+ maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS,
4115
+ });
4116
+ const ok = result.exitCode === 0 && !result.timedOut && !result.error;
4117
+ if (!ok) {
4118
+ const details = [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
4119
+ recordEvent({ type: "webui_update_failed", command, error: truncateStatusText(details || `exit code ${result.exitCode ?? "unknown"}`) });
4120
+ throw makeHttpError(500, truncateLongText(`Pi update failed: ${command}${details ? `\n${details}` : ""}`));
4121
+ }
4122
+
4123
+ updateStatusCache = null;
4124
+ updateStatusCacheAt = 0;
4125
+ const child = spawnRestartServer(restorableTabs);
4126
+ restartPrepared = true;
4127
+ recordEvent({ type: "webui_update_restarting", command, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
4128
+ return {
4129
+ message: "Pi update completed. Pi Web UI is restarting.",
4130
+ command,
4131
+ stdout: result.stdout,
4132
+ stderr: result.stderr,
4133
+ webuiPid: process.pid,
4134
+ nextWebuiPid: child.pid,
4135
+ restorableTabCount: restorableTabs.length,
4136
+ };
4137
+ } finally {
4138
+ if (!restartPrepared) piUpdateInProgress = false;
4139
+ }
4140
+ }
4141
+
3915
4142
  function rememberClosedRestorableTab(tab, state = null) {
3916
4143
  const descriptor = restorableTabDescriptor(tab, state);
3917
4144
  if (!descriptor) return;
@@ -5517,6 +5744,13 @@ const server = createServer(async (req, res) => {
5517
5744
  return;
5518
5745
  }
5519
5746
 
5747
+ if (url.pathname === "/api/update-status" && req.method === "GET") {
5748
+ const force = ["1", "true", "yes", "refresh"].includes(String(url.searchParams.get("refresh") || "").toLowerCase());
5749
+ const status = await getUpdateStatus({ force });
5750
+ sendJson(res, 200, { ok: true, data: updateStatusForRequest(status, req) });
5751
+ return;
5752
+ }
5753
+
5520
5754
  if (url.pathname === "/api/native-parity" && req.method === "GET") {
5521
5755
  sendJson(res, 200, { ok: true, data: nativeParityMatrix });
5522
5756
  return;
@@ -5577,6 +5811,14 @@ const server = createServer(async (req, res) => {
5577
5811
  return;
5578
5812
  }
5579
5813
 
5814
+ if (url.pathname === "/api/update" && req.method === "POST") {
5815
+ if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Updating Pi from the Web UI is only allowed from localhost");
5816
+ const data = await runPiUpdateAndPrepareRestart();
5817
+ sendJson(res, 200, { ok: true, data });
5818
+ setTimeout(() => shutdown("api update"), 20).unref();
5819
+ return;
5820
+ }
5821
+
5580
5822
  if (url.pathname === "/api/shutdown" && req.method === "POST") {
5581
5823
  if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Shutdown is only allowed from localhost");
5582
5824
  sendJson(res, 200, { ok: true, message: "Pi Web UI shutting down", webuiPid: process.pid });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",
package/public/app.js CHANGED
@@ -15,6 +15,12 @@ const elements = {
15
15
  serverOfflinePanel: $("#serverOfflinePanel"),
16
16
  serverRestartPanel: $("#serverRestartPanel"),
17
17
  serverRestartMessage: $("#serverRestartMessage"),
18
+ updateNotification: $("#updateNotification"),
19
+ updateNotificationTitle: $("#updateNotificationTitle"),
20
+ updateNotificationMessage: $("#updateNotificationMessage"),
21
+ updateNotificationDetail: $("#updateNotificationDetail"),
22
+ updateNotificationUpdateButton: $("#updateNotificationUpdateButton"),
23
+ updateNotificationDismissButton: $("#updateNotificationDismissButton"),
18
24
  serverOfflineCommand: $("#serverOfflineCommand"),
19
25
  serverOfflineSlashCommand: $("#serverOfflineSlashCommand"),
20
26
  copyServerCommandButton: $("#copyServerCommandButton"),
@@ -251,6 +257,10 @@ let refreshCodexUsageTimer = null;
251
257
  let codexUsageRenderTimer = null;
252
258
  let backendOffline = false;
253
259
  let serverRestartInProgress = false;
260
+ let updateRequestInProgress = false;
261
+ let latestUpdateStatus = null;
262
+ let updateStatusRefreshTimer = null;
263
+ let updateNotificationHideTimer = null;
254
264
  let backendOfflineNoticeShown = false;
255
265
  let latestMessages = [];
256
266
  let promptHistoryByTab = new Map();
@@ -311,6 +321,7 @@ const SIDE_PANEL_SECTION_STORAGE_KEY = "pi-webui-side-panel-sections-collapsed";
311
321
  const TAB_STORAGE_KEY = "pi-webui-active-tab";
312
322
  const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
313
323
  const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
324
+ const UPDATE_NOTIFICATION_DISMISS_STORAGE_KEY = "pi-webui-update-notification-dismissed";
314
325
  const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible";
315
326
  const BUSY_PROMPT_BEHAVIOR_STORAGE_KEY = "pi-webui-busy-prompt-behavior";
316
327
  const SKILL_USAGE_STORAGE_KEY = "pi-webui-skill-usage-v1";
@@ -360,6 +371,8 @@ const CHAT_PROGRAMMATIC_SCROLL_GRACE_MS = 500;
360
371
  const CHAT_USER_SCROLL_INTENT_MS = 700;
361
372
  const CODEX_USAGE_REFRESH_MS = 5 * 60 * 1000;
362
373
  const CODEX_USAGE_RENDER_TICK_MS = 30 * 1000;
374
+ const UPDATE_STATUS_REFRESH_MS = 6 * 60 * 60 * 1000;
375
+ const UPDATE_STATUS_INITIAL_DELAY_MS = 1800;
363
376
  const RUN_INDICATOR_TICK_MS = 1000;
364
377
  const RUN_INDICATOR_START_GRACE_MS = 2500;
365
378
  const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
@@ -1698,6 +1711,7 @@ function setServerRestartOverlay(active, message = "Waiting for the server to co
1698
1711
  document.body.classList.toggle("server-restarting", serverRestartInProgress);
1699
1712
  if (elements.serverRestartPanel) elements.serverRestartPanel.hidden = !serverRestartInProgress;
1700
1713
  if (elements.serverRestartMessage) elements.serverRestartMessage.textContent = message;
1714
+ if (serverRestartInProgress) hideUpdateNotification();
1701
1715
  if (serverRestartInProgress && elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = true;
1702
1716
  }
1703
1717
 
@@ -1708,6 +1722,7 @@ function setBackendOffline(offline, error) {
1708
1722
  if (elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = !showOfflinePanel;
1709
1723
  renderServerOfflinePanel();
1710
1724
  if (backendOffline) {
1725
+ hideUpdateNotification();
1711
1726
  if (!serverRestartInProgress && !backendOfflineNoticeShown) {
1712
1727
  backendOfflineNoticeShown = true;
1713
1728
  addEvent(`Pi Web UI server is offline${error?.message ? `: ${error.message}` : ""}`, "warn");
@@ -1950,6 +1965,169 @@ async function refreshWebuiVersion() {
1950
1965
  setWebuiDevServer(isWebuiDevMetadata(health));
1951
1966
  }
1952
1967
 
1968
+ function packageUpdateText(label, status = {}) {
1969
+ const current = formatWebuiVersion(status.currentVersion || "");
1970
+ const latest = formatWebuiVersion(status.latestVersion || "");
1971
+ if (current && latest) return `${label} ${current} → ${latest}`;
1972
+ if (latest) return `${label} ${latest}`;
1973
+ return label;
1974
+ }
1975
+
1976
+ function updateNotificationItems(status = latestUpdateStatus) {
1977
+ const items = [];
1978
+ if (status?.pi?.updateAvailable) items.push(packageUpdateText("Pi", status.pi));
1979
+ if (status?.webui?.updateAvailable) items.push(packageUpdateText("Web UI", status.webui));
1980
+ return items;
1981
+ }
1982
+
1983
+ function updateNotificationDismissKey(status = latestUpdateStatus) {
1984
+ const parts = [status?.pi?.latestVersion, status?.webui?.latestVersion]
1985
+ .map((value) => String(value || "").trim())
1986
+ .filter(Boolean);
1987
+ return parts.length ? parts.join("|") : "";
1988
+ }
1989
+
1990
+ function storedDismissedUpdateKey() {
1991
+ try {
1992
+ return localStorage.getItem(UPDATE_NOTIFICATION_DISMISS_STORAGE_KEY) || "";
1993
+ } catch {
1994
+ return "";
1995
+ }
1996
+ }
1997
+
1998
+ function rememberDismissedUpdateKey(key) {
1999
+ if (!key) return;
2000
+ try {
2001
+ localStorage.setItem(UPDATE_NOTIFICATION_DISMISS_STORAGE_KEY, key);
2002
+ } catch {
2003
+ // Ignore private-mode storage failures.
2004
+ }
2005
+ }
2006
+
2007
+ function hideUpdateNotification({ remember = false } = {}) {
2008
+ const panel = elements.updateNotification;
2009
+ if (!panel) return;
2010
+ clearTimeout(updateNotificationHideTimer);
2011
+ if (remember) rememberDismissedUpdateKey(updateNotificationDismissKey());
2012
+ panel.classList.remove("show");
2013
+ updateNotificationHideTimer = setTimeout(() => {
2014
+ panel.hidden = true;
2015
+ }, 360);
2016
+ }
2017
+
2018
+ function renderUpdateNotification(status = latestUpdateStatus, { force = false } = {}) {
2019
+ const panel = elements.updateNotification;
2020
+ if (!panel) return;
2021
+ latestUpdateStatus = status || latestUpdateStatus;
2022
+ const items = updateNotificationItems(latestUpdateStatus);
2023
+ const dismissKey = updateNotificationDismissKey(latestUpdateStatus);
2024
+ const shouldShow = !!latestUpdateStatus?.updateAvailable && items.length > 0 && !updateRequestInProgress;
2025
+ if (!shouldShow || (!force && dismissKey && storedDismissedUpdateKey() === dismissKey)) {
2026
+ hideUpdateNotification();
2027
+ return;
2028
+ }
2029
+
2030
+ const canRunUpdate = latestUpdateStatus.canRunUpdate !== false;
2031
+ if (elements.updateNotificationTitle) elements.updateNotificationTitle.textContent = items.length === 1 ? `${items[0]} available` : "Pi updates available";
2032
+ if (elements.updateNotificationMessage) {
2033
+ elements.updateNotificationMessage.textContent = canRunUpdate
2034
+ ? "Run pi update now, then restart this Web UI server automatically."
2035
+ : "Updates are available. Direct Web UI updates are only enabled from localhost on the host machine.";
2036
+ }
2037
+ const details = [
2038
+ items.join(" · "),
2039
+ latestUpdateStatus.webuiDev && latestUpdateStatus.webui?.updateAvailable ? "The current Web UI is a dev checkout; pi update updates installed Pi packages, not this checkout." : "",
2040
+ latestUpdateStatus.packages?.note || "",
2041
+ ].filter(Boolean).join(" ");
2042
+ if (elements.updateNotificationDetail) elements.updateNotificationDetail.textContent = details;
2043
+ if (elements.updateNotificationUpdateButton) {
2044
+ elements.updateNotificationUpdateButton.hidden = !canRunUpdate;
2045
+ elements.updateNotificationUpdateButton.disabled = updateRequestInProgress || latestUpdateStatus.updateInProgress;
2046
+ elements.updateNotificationUpdateButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update & restart";
2047
+ }
2048
+ clearTimeout(updateNotificationHideTimer);
2049
+ panel.hidden = false;
2050
+ requestAnimationFrame(() => panel.classList.add("show"));
2051
+ }
2052
+
2053
+ async function refreshUpdateStatus({ force = false, notify = true } = {}) {
2054
+ const path = force ? "/api/update-status?refresh=1" : "/api/update-status";
2055
+ const response = await api(path, { scoped: false });
2056
+ latestUpdateStatus = response.data || null;
2057
+ if (notify) renderUpdateNotification(latestUpdateStatus);
2058
+ return latestUpdateStatus;
2059
+ }
2060
+
2061
+ function scheduleUpdateStatusRefresh() {
2062
+ clearTimeout(updateStatusRefreshTimer);
2063
+ updateStatusRefreshTimer = setTimeout(() => {
2064
+ updateStatusRefreshTimer = null;
2065
+ refreshUpdateStatus({ force: true }).catch((error) => addEvent(`Pi update check failed: ${error.message || String(error)}`, "warn"));
2066
+ scheduleUpdateStatusRefresh();
2067
+ }, UPDATE_STATUS_REFRESH_MS);
2068
+ }
2069
+
2070
+ function initializeUpdateNotifications() {
2071
+ setTimeout(() => {
2072
+ refreshUpdateStatus().catch((error) => addEvent(`Pi update check failed: ${error.message || String(error)}`, "warn"));
2073
+ scheduleUpdateStatusRefresh();
2074
+ }, UPDATE_STATUS_INITIAL_DELAY_MS);
2075
+ }
2076
+
2077
+ function piUpdateConfirmationText() {
2078
+ const items = updateNotificationItems();
2079
+ const workingWarning = hasWorkingTab() ? "\n\nOne or more Pi tabs look busy or blocked. Finish or abort in-flight work before updating if you need to preserve it." : "";
2080
+ const versionText = items.length ? `\n\nDetected update: ${items.join(" · ")}.` : "";
2081
+ return `Run pi update now?${versionText}\n\nThis will run \"pi update\" on the Web UI host. After it finishes, Pi Web UI will restart itself. Browser clients will briefly disconnect, and managed Pi tabs/RPC processes will be restarted from saved session state when possible.${workingWarning}`;
2082
+ }
2083
+
2084
+ async function runPiUpdateAndRestart() {
2085
+ if (updateRequestInProgress) return;
2086
+ if (latestUpdateStatus?.canRunUpdate === false) {
2087
+ addEvent("Pi update can only be started from localhost on the Web UI host", "warn");
2088
+ renderUpdateNotification(latestUpdateStatus, { force: true });
2089
+ return;
2090
+ }
2091
+ if (!confirm(piUpdateConfirmationText())) return;
2092
+
2093
+ updateRequestInProgress = true;
2094
+ hideUpdateNotification();
2095
+ setServerActionBusy("Updating…");
2096
+ setServerActionStatus("Running pi update. The server will restart after the update completes…", "warn");
2097
+ setServerRestartOverlay(true, "Running pi update. The server will restart after the update completes…");
2098
+ try {
2099
+ await api("/api/update", { method: "POST", scoped: false });
2100
+ addEvent("Pi update completed; Pi Web UI server restart requested", "warn");
2101
+ } catch (error) {
2102
+ if (!error?.backendOffline) {
2103
+ updateRequestInProgress = false;
2104
+ setServerRestartOverlay(false);
2105
+ resetServerActionControls();
2106
+ const message = error.message || String(error);
2107
+ setServerActionStatus(message, "error");
2108
+ addEvent(message, "error");
2109
+ renderUpdateNotification(latestUpdateStatus, { force: true });
2110
+ return;
2111
+ }
2112
+ addEvent("Pi Web UI server connection dropped during update restart request", "warn");
2113
+ }
2114
+
2115
+ setBackendOffline(true, new Error("update requested from side panel"));
2116
+ const restarted = await waitForServerRestart();
2117
+ updateRequestInProgress = false;
2118
+ resetServerActionControls();
2119
+ if (restarted) {
2120
+ hideUpdateNotification({ remember: true });
2121
+ setServerActionStatus("Updated, restarted, and reconnected.", "success");
2122
+ refreshUpdateStatus({ force: true, notify: false }).catch(() => {});
2123
+ } else {
2124
+ setServerRestartOverlay(false);
2125
+ setBackendOffline(true, new Error("update restart reconnect timed out"));
2126
+ setServerActionStatus("Update completed, but the server did not reconnect automatically.", "error");
2127
+ addEvent("Pi Web UI server did not come back online after update request", "error");
2128
+ }
2129
+ }
2130
+
1953
2131
  function formatBytes(bytes) {
1954
2132
  const value = Number(bytes) || 0;
1955
2133
  if (value < 1024) return `${value} B`;
@@ -12084,9 +12262,11 @@ function updateServerActionButton() {
12084
12262
  const button = elements.runServerActionButton;
12085
12263
  if (!button) return;
12086
12264
  button.disabled = !action;
12087
- button.textContent = action === "restart" ? "Restart" : action === "stop" ? "Stop" : "Run";
12265
+ button.textContent = action === "restart" ? "Restart" : action === "update" ? "Update" : action === "stop" ? "Stop" : "Run";
12088
12266
  button.classList.toggle("danger", action === "stop");
12089
- if (action) setServerActionStatus(action === "restart" ? "Ready to restart the Web UI server." : "Ready to stop the Web UI server.", "info");
12267
+ if (action === "restart") setServerActionStatus("Ready to restart the Web UI server.", "info");
12268
+ else if (action === "update") setServerActionStatus("Ready to run pi update, then restart the Web UI server.", "info");
12269
+ else if (action === "stop") setServerActionStatus("Ready to stop the Web UI server.", "info");
12090
12270
  else setServerActionStatus();
12091
12271
  }
12092
12272
 
@@ -12192,6 +12372,7 @@ async function stopServer() {
12192
12372
  async function runSelectedServerAction() {
12193
12373
  const action = elements.serverActionSelect?.value || "";
12194
12374
  if (action === "restart") await restartServer();
12375
+ else if (action === "update") await runPiUpdateAndRestart();
12195
12376
  else if (action === "stop") await stopServer();
12196
12377
  }
12197
12378
 
@@ -13405,6 +13586,8 @@ if (elements.backgroundClearButton) {
13405
13586
  elements.openNetworkButton.addEventListener("click", openToNetwork);
13406
13587
  elements.serverActionSelect.addEventListener("change", updateServerActionButton);
13407
13588
  elements.runServerActionButton.addEventListener("click", () => runSelectedServerAction().catch((error) => addEvent(error.message || String(error), "error")));
13589
+ elements.updateNotificationUpdateButton?.addEventListener("click", () => runPiUpdateAndRestart().catch((error) => addEvent(error.message || String(error), "error")));
13590
+ elements.updateNotificationDismissButton?.addEventListener("click", () => hideUpdateNotification({ remember: true }));
13408
13591
  updateServerActionButton();
13409
13592
  elements.agentDoneNotificationsToggle.addEventListener("change", () => {
13410
13593
  setAgentDoneNotificationsEnabled(elements.agentDoneNotificationsToggle.checked, {
@@ -13752,6 +13935,7 @@ restoreSidePanelSectionState();
13752
13935
  bindSidePanelSectionToggles();
13753
13936
  restoreSidePanelState();
13754
13937
  initializeCodexUsage();
13938
+ initializeUpdateNotifications();
13755
13939
  bindMobileViewChanges();
13756
13940
  bindSidePanelOverlayViewChanges();
13757
13941
  registerPwaServiceWorker();
package/public/index.html CHANGED
@@ -12,7 +12,7 @@
12
12
  <link rel="manifest" href="/manifest.webmanifest" />
13
13
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
14
14
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
15
- <link rel="stylesheet" href="/styles.css?v=36" />
15
+ <link rel="stylesheet" href="/styles.css?v=39" />
16
16
  </head>
17
17
  <body>
18
18
  <button id="sidePanelExpandButton" class="side-panel-expand-button" type="button" aria-controls="sidePanel" aria-expanded="false" aria-label="Expand side panel" title="Expand side panel">
@@ -45,6 +45,19 @@
45
45
  </div>
46
46
  </section>
47
47
 
48
+ <section id="updateNotification" class="update-notification" aria-live="polite" hidden>
49
+ <div class="update-notification-card" role="status">
50
+ <span class="update-notification-kicker">Update available</span>
51
+ <h2 id="updateNotificationTitle">Pi update available</h2>
52
+ <p id="updateNotificationMessage">A newer Pi version is available.</p>
53
+ <p id="updateNotificationDetail" class="update-notification-detail muted"></p>
54
+ <div class="update-notification-actions">
55
+ <button id="updateNotificationUpdateButton" class="primary" type="button">Update &amp; restart</button>
56
+ <button id="updateNotificationDismissButton" type="button">Later</button>
57
+ </div>
58
+ </div>
59
+ </section>
60
+
48
61
  <main class="layout">
49
62
  <section class="chat-panel">
50
63
  <header class="terminal-tabs-shell">
@@ -84,6 +97,7 @@
84
97
  <div id="gitWorkflowActions" class="git-workflow-actions"></div>
85
98
  </section>
86
99
  <form id="composer" class="composer">
100
+ <div id="commandSuggest" class="command-suggest" role="listbox" aria-label="Command and path suggestions" hidden></div>
87
101
  <div class="composer-input-row">
88
102
  <div class="composer-context-tags">
89
103
  <button
@@ -118,7 +132,6 @@
118
132
  ><svg class="composer-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.82-2.82l8.48-8.49" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
119
133
  <input id="attachmentInput" class="attachment-input" type="file" multiple accept="image/*,video/*,audio/*,text/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/json,application/xml,.md,.markdown,.csv,.ts,.tsx,.js,.jsx,.mjs,.cjs,.py,.rs,.go,.java,.c,.cpp,.h,.hpp,.sh,.bash,.zsh,.fish,.yaml,.yml,.toml,.ini,.log" />
120
134
  </div>
121
- <div id="commandSuggest" class="command-suggest" role="listbox" aria-label="Command and path suggestions" hidden></div>
122
135
  <div id="attachmentTray" class="attachment-tray" aria-live="polite" hidden></div>
123
136
  <div class="composer-row">
124
137
  <button id="composerActionsButton" class="composer-actions-button" type="button" aria-controls="composerActionsPanel" aria-expanded="false" title="Open session, workflow, and follow-up actions">Actions</button>
@@ -348,6 +361,7 @@
348
361
  <select id="serverActionSelect" title="Server action" aria-label="Server action">
349
362
  <option value="" selected>Choose action…</option>
350
363
  <option value="restart">Restart Server</option>
364
+ <option value="update">Update Pi &amp; Restart</option>
351
365
  <option value="stop">Stop Server</option>
352
366
  </select>
353
367
  <button id="runServerActionButton" type="button" disabled>Run</button>
@@ -584,6 +598,6 @@
584
598
  </form>
585
599
  </dialog>
586
600
 
587
- <script type="module" src="/app.js?v=37"></script>
601
+ <script type="module" src="/app.js?v=38"></script>
588
602
  </body>
589
603
  </html>
package/public/styles.css CHANGED
@@ -434,6 +434,79 @@ body.server-restarting .layout {
434
434
  @keyframes server-restart-spin {
435
435
  to { transform: rotate(360deg); }
436
436
  }
437
+ .update-notification[hidden] {
438
+ display: none !important;
439
+ }
440
+ .update-notification {
441
+ position: fixed;
442
+ top: calc(env(safe-area-inset-top, 0px) + 1rem);
443
+ right: calc(env(safe-area-inset-right, 0px) + 1rem);
444
+ z-index: 64;
445
+ width: min(28rem, calc(100vw - 2rem));
446
+ opacity: 0;
447
+ transform: translateY(-1.6rem);
448
+ pointer-events: none;
449
+ transition: opacity 320ms ease, transform 1050ms cubic-bezier(0.18, 0.92, 0.24, 1);
450
+ }
451
+ .update-notification.show {
452
+ opacity: 1;
453
+ transform: translateY(0);
454
+ pointer-events: auto;
455
+ animation: update-notification-slide-down 1100ms cubic-bezier(0.18, 0.92, 0.24, 1);
456
+ }
457
+ .update-notification-card {
458
+ position: relative;
459
+ overflow: hidden;
460
+ padding: 1rem;
461
+ border: 1px solid rgba(166, 227, 161, 0.42);
462
+ border-radius: 1rem;
463
+ background:
464
+ radial-gradient(circle at 100% 0, rgba(166, 227, 161, 0.18), transparent 13rem),
465
+ linear-gradient(145deg, rgba(var(--ctp-base-rgb), 0.96), rgba(var(--ctp-crust-rgb), 0.98));
466
+ box-shadow: 0 1.1rem 3.2rem rgba(var(--ctp-crust-rgb), 0.72), 0 0 1.8rem rgba(166, 227, 161, 0.16), inset 0 1px 0 rgba(255,255,255,0.08);
467
+ backdrop-filter: blur(18px) saturate(145%);
468
+ }
469
+ .update-notification-card::before {
470
+ content: "";
471
+ position: absolute;
472
+ inset: 0 0 auto;
473
+ height: 2px;
474
+ background: linear-gradient(90deg, var(--ctp-green), var(--ctp-teal), var(--ctp-blue));
475
+ box-shadow: 0 0 1rem rgba(166, 227, 161, 0.42);
476
+ }
477
+ .update-notification-kicker {
478
+ display: inline-block;
479
+ margin-bottom: 0.45rem;
480
+ color: var(--ok);
481
+ font-size: 0.72rem;
482
+ font-weight: 900;
483
+ letter-spacing: 0.12em;
484
+ text-transform: uppercase;
485
+ }
486
+ .update-notification h2 {
487
+ margin: 0 0 0.45rem;
488
+ font-size: 1.1rem;
489
+ }
490
+ .update-notification p {
491
+ margin: 0 0 0.65rem;
492
+ color: rgba(var(--ctp-text-rgb), 0.88);
493
+ line-height: 1.45;
494
+ }
495
+ .update-notification-detail {
496
+ font-size: 0.86rem;
497
+ }
498
+ .update-notification-actions {
499
+ display: flex;
500
+ flex-wrap: wrap;
501
+ gap: 0.55rem;
502
+ justify-content: flex-end;
503
+ margin-top: 0.85rem;
504
+ }
505
+ @keyframes update-notification-slide-down {
506
+ 0% { opacity: 0; transform: translateY(-2.4rem); }
507
+ 70% { opacity: 1; transform: translateY(0.18rem); }
508
+ 100% { opacity: 1; transform: translateY(0); }
509
+ }
437
510
  .side-panel-expand-button {
438
511
  position: fixed;
439
512
  top: 1rem;
@@ -1362,39 +1435,39 @@ body.side-panel-collapsed .terminal-tabs-shell {
1362
1435
  background: rgba(var(--ctp-subtext-rgb), 0.38);
1363
1436
  box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 0 0.45rem rgba(var(--ctp-subtext-rgb), 0.10);
1364
1437
  }
1365
- .terminal-tab.activity-working .terminal-tab-activity-indicator {
1438
+ .terminal-tab.activity-working > .terminal-tab-button .terminal-tab-activity-indicator {
1366
1439
  background: var(--ctp-yellow);
1367
1440
  box-shadow: 0 0 0 1px rgba(249, 226, 175, 0.34), 0 0 0.8rem rgba(249, 226, 175, 0.52);
1368
1441
  animation: terminal-tab-working-pulse 1.15s ease-in-out infinite;
1369
1442
  }
1370
- .terminal-tab.activity-blocked .terminal-tab-activity-indicator {
1443
+ .terminal-tab.activity-blocked > .terminal-tab-button .terminal-tab-activity-indicator {
1371
1444
  background: var(--ctp-peach);
1372
1445
  box-shadow: 0 0 0 1px rgba(250, 179, 135, 0.38), 0 0 0.85rem rgba(250, 179, 135, 0.58);
1373
1446
  animation: terminal-tab-working-pulse 1.35s ease-in-out infinite;
1374
1447
  }
1375
- .terminal-tab.activity-done .terminal-tab-activity-indicator {
1448
+ .terminal-tab.activity-done > .terminal-tab-button .terminal-tab-activity-indicator {
1376
1449
  background: var(--ctp-green);
1377
1450
  box-shadow: 0 0 0 1px rgba(166, 227, 161, 0.34), 0 0 0.8rem rgba(166, 227, 161, 0.48);
1378
1451
  }
1379
- .terminal-tab.stopped .terminal-tab-activity-indicator {
1452
+ .terminal-tab.stopped > .terminal-tab-button .terminal-tab-activity-indicator {
1380
1453
  background: var(--ctp-red);
1381
1454
  box-shadow: 0 0 0 1px rgba(243, 139, 168, 0.28), 0 0 0.6rem rgba(243, 139, 168, 0.24);
1382
1455
  }
1383
- .terminal-tab-group-item.activity-working .terminal-tab-activity-indicator {
1456
+ .terminal-tab-group-item.activity-working > .terminal-tab-button .terminal-tab-activity-indicator {
1384
1457
  background: var(--ctp-yellow);
1385
1458
  box-shadow: 0 0 0 1px rgba(249, 226, 175, 0.34), 0 0 0.8rem rgba(249, 226, 175, 0.52);
1386
1459
  animation: terminal-tab-working-pulse 1.15s ease-in-out infinite;
1387
1460
  }
1388
- .terminal-tab-group-item.activity-blocked .terminal-tab-activity-indicator {
1461
+ .terminal-tab-group-item.activity-blocked > .terminal-tab-button .terminal-tab-activity-indicator {
1389
1462
  background: var(--ctp-peach);
1390
1463
  box-shadow: 0 0 0 1px rgba(250, 179, 135, 0.38), 0 0 0.85rem rgba(250, 179, 135, 0.58);
1391
1464
  animation: terminal-tab-working-pulse 1.35s ease-in-out infinite;
1392
1465
  }
1393
- .terminal-tab-group-item.activity-done .terminal-tab-activity-indicator {
1466
+ .terminal-tab-group-item.activity-done > .terminal-tab-button .terminal-tab-activity-indicator {
1394
1467
  background: var(--ctp-green);
1395
1468
  box-shadow: 0 0 0 1px rgba(166, 227, 161, 0.34), 0 0 0.8rem rgba(166, 227, 161, 0.48);
1396
1469
  }
1397
- .terminal-tab-group-item.stopped .terminal-tab-activity-indicator {
1470
+ .terminal-tab-group-item.stopped > .terminal-tab-button .terminal-tab-activity-indicator {
1398
1471
  background: var(--ctp-red);
1399
1472
  box-shadow: 0 0 0 1px rgba(243, 139, 168, 0.28), 0 0 0.6rem rgba(243, 139, 168, 0.24);
1400
1473
  }
@@ -1426,17 +1499,17 @@ body.side-panel-collapsed .terminal-tabs-shell {
1426
1499
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
1427
1500
  font-size: 0.68rem;
1428
1501
  }
1429
- .terminal-tab.active .terminal-tab-title,
1430
- .terminal-tab-group-item.active .terminal-tab-title {
1502
+ .terminal-tab.active > .terminal-tab-button .terminal-tab-title,
1503
+ .terminal-tab-group-item.active > .terminal-tab-button .terminal-tab-title {
1431
1504
  color: var(--ctp-teal);
1432
1505
  text-shadow: 0 0 0.8rem var(--glow-teal);
1433
1506
  }
1434
- .terminal-tab.activity-working .terminal-tab-meta,
1435
- .terminal-tab-group-item.activity-working .terminal-tab-meta { color: rgba(249, 226, 175, 0.82); }
1436
- .terminal-tab.activity-blocked .terminal-tab-meta,
1437
- .terminal-tab-group-item.activity-blocked .terminal-tab-meta { color: rgba(250, 179, 135, 0.88); }
1438
- .terminal-tab.activity-done .terminal-tab-meta,
1439
- .terminal-tab-group-item.activity-done .terminal-tab-meta { color: rgba(166, 227, 161, 0.82); }
1507
+ .terminal-tab.activity-working > .terminal-tab-button .terminal-tab-meta,
1508
+ .terminal-tab-group-item.activity-working > .terminal-tab-button .terminal-tab-meta { color: rgba(249, 226, 175, 0.82); }
1509
+ .terminal-tab.activity-blocked > .terminal-tab-button .terminal-tab-meta,
1510
+ .terminal-tab-group-item.activity-blocked > .terminal-tab-button .terminal-tab-meta { color: rgba(250, 179, 135, 0.88); }
1511
+ .terminal-tab.activity-done > .terminal-tab-button .terminal-tab-meta,
1512
+ .terminal-tab-group-item.activity-done > .terminal-tab-button .terminal-tab-meta { color: rgba(166, 227, 161, 0.82); }
1440
1513
  .terminal-tab-close {
1441
1514
  flex: 0 0 2.1rem;
1442
1515
  color: rgba(var(--ctp-subtext-rgb), 0.72);
@@ -3599,7 +3672,7 @@ button.composer-skill-tag:focus-visible {
3599
3672
  background: linear-gradient(180deg, rgba(137, 180, 250, 0.14), rgba(var(--ctp-crust-rgb), 0.86));
3600
3673
  }
3601
3674
  .command-suggest {
3602
- margin-top: 0.5rem;
3675
+ margin: 0 0 0.5rem;
3603
3676
  max-height: 15rem;
3604
3677
  overflow: auto;
3605
3678
  background: rgba(var(--ctp-crust-rgb), 0.94);
@@ -5491,14 +5564,21 @@ button.composer-skill-tag:focus-visible {
5491
5564
  }
5492
5565
  .terminal-tab { min-width: min(11rem, 100%); max-width: 100%; flex: 1 1 9rem; }
5493
5566
  .terminal-tab-group {
5494
- flex-wrap: wrap;
5567
+ display: grid;
5568
+ grid-template-columns: minmax(0, 1fr) auto;
5569
+ align-items: stretch;
5495
5570
  }
5496
5571
  .terminal-tab-group:hover,
5497
5572
  .terminal-tab-group:focus-within,
5498
5573
  .terminal-tab-group.menu-open {
5499
5574
  flex-basis: 100%;
5500
5575
  }
5576
+ .terminal-tab-group-button {
5577
+ width: auto;
5578
+ min-width: 0;
5579
+ }
5501
5580
  .terminal-tab-group-menu {
5581
+ grid-column: 1 / -1;
5502
5582
  position: static;
5503
5583
  flex-direction: column;
5504
5584
  flex-wrap: nowrap;
@@ -5506,7 +5586,7 @@ button.composer-skill-tag:focus-visible {
5506
5586
  max-width: none;
5507
5587
  max-height: none;
5508
5588
  min-width: 0;
5509
- margin: 0.34rem;
5589
+ margin: 0.34rem 0 0;
5510
5590
  padding-top: 0;
5511
5591
  overflow: visible;
5512
5592
  }
@@ -5780,6 +5860,7 @@ button.composer-skill-tag:focus-visible {
5780
5860
  .composer {
5781
5861
  position: sticky;
5782
5862
  bottom: 0;
5863
+ z-index: 50;
5783
5864
  padding: 0.55rem 0.55rem calc(0.55rem + env(safe-area-inset-bottom));
5784
5865
  background: linear-gradient(180deg, rgba(var(--ctp-crust-rgb), 0.92), rgba(var(--ctp-crust-rgb), 0.98));
5785
5866
  box-shadow: 0 -0.85rem 1.8rem rgba(var(--ctp-crust-rgb), 0.56), inset 0 1px 0 rgba(255,255,255,0.04);
@@ -5843,12 +5924,12 @@ button.composer-skill-tag:focus-visible {
5843
5924
  left: 0.55rem;
5844
5925
  right: 0.55rem;
5845
5926
  bottom: calc(100% + 0.42rem);
5846
- z-index: 8;
5927
+ z-index: 55;
5847
5928
  display: none;
5848
5929
  grid-template-columns: repeat(3, minmax(0, 1fr));
5849
5930
  gap: 0.45rem;
5850
5931
  max-height: min(42dvh, 22rem);
5851
- overflow: auto;
5932
+ overflow: visible;
5852
5933
  padding: 0.68rem;
5853
5934
  border: 1px solid rgba(249, 226, 175, 0.24);
5854
5935
  border-radius: 0.95rem;
@@ -5881,8 +5962,17 @@ button.composer-skill-tag:focus-visible {
5881
5962
  font-size: 0.92rem;
5882
5963
  }
5883
5964
  body:not(.pi-run-active):not(.mobile-keyboard-open) .composer-row button.primary { grid-column: span 4; }
5884
- body.pi-run-active:not(.mobile-keyboard-open) .composer-abort-button:not([hidden]) { grid-column: span 2; }
5885
- body.pi-run-active:not(.mobile-keyboard-open) .composer-row button.primary { grid-column: span 4; }
5965
+ body.pi-run-active:not(.mobile-keyboard-open) .composer-abort-button:not([hidden]) {
5966
+ order: 1;
5967
+ grid-column: span 2;
5968
+ }
5969
+ body.pi-run-active:not(.mobile-keyboard-open) .composer-row > #steerButton { order: 2; }
5970
+ body.pi-run-active:not(.mobile-keyboard-open) .composer-row > #followUpButton { order: 3; }
5971
+ body.pi-run-active:not(.mobile-keyboard-open) .composer-actions-button { order: 4; }
5972
+ body.pi-run-active:not(.mobile-keyboard-open) .composer-row button.primary {
5973
+ order: 5;
5974
+ grid-column: span 4;
5975
+ }
5886
5976
  .composer-row > #followUpButton,
5887
5977
  .composer-row > #steerButton,
5888
5978
  .composer-actions-button { grid-column: span 2; }
@@ -5896,31 +5986,49 @@ button.composer-skill-tag:focus-visible {
5896
5986
  width: 100%;
5897
5987
  margin-right: 0;
5898
5988
  }
5989
+ .composer-actions-panel > .composer-publish-menu::after {
5990
+ content: "";
5991
+ position: absolute;
5992
+ left: -0.32rem;
5993
+ right: -0.32rem;
5994
+ bottom: 100%;
5995
+ display: none;
5996
+ height: 0.8rem;
5997
+ pointer-events: auto;
5998
+ }
5999
+ .composer-actions-panel > .composer-publish-menu:hover::after,
6000
+ .composer-actions-panel > .composer-publish-menu:focus-within::after,
6001
+ .composer-actions-panel > .composer-publish-menu.open::after {
6002
+ display: block;
6003
+ }
5899
6004
  .composer-actions-panel > .composer-publish-menu.open {
5900
- display: grid;
5901
- grid-column: 1 / -1;
5902
- grid-template-columns: repeat(2, minmax(0, 1fr));
5903
- gap: 0.42rem;
6005
+ z-index: 120;
5904
6006
  }
5905
- .composer-actions-panel > .composer-publish-menu.open .composer-publish-button,
5906
- .composer-actions-panel > .composer-publish-menu .composer-publish-menu-panel {
5907
- grid-column: 1 / -1;
6007
+ .composer-actions-panel > .composer-publish-menu.open .composer-publish-button {
6008
+ min-width: 0;
5908
6009
  }
5909
6010
  .composer-actions-panel > .composer-publish-menu .composer-publish-menu-panel {
5910
- position: static;
6011
+ position: absolute;
6012
+ inset: auto auto calc(100% + 0.38rem) 0;
5911
6013
  flex-direction: column;
5912
6014
  width: 100%;
5913
6015
  min-width: 0;
5914
6016
  max-width: 100%;
5915
- max-height: none;
5916
- margin-left: 0;
6017
+ max-height: min(34dvh, 18rem);
6018
+ margin: 0;
5917
6019
  padding-bottom: 0;
5918
- overflow: visible;
6020
+ overflow: auto;
6021
+ }
6022
+ .composer-actions-panel > .composer-options-menu .composer-publish-menu-panel {
6023
+ inset-inline: auto 0;
6024
+ max-height: min(76dvh, 34rem);
5919
6025
  }
5920
6026
  .composer-actions-panel > .composer-publish-menu .composer-publish-menu-item {
5921
6027
  width: 100%;
6028
+ min-width: 0;
5922
6029
  max-width: 100%;
5923
- flex: 1 1 0;
6030
+ flex: 1 1 auto;
6031
+ white-space: normal;
5924
6032
  }
5925
6033
  .command-suggest {
5926
6034
  max-height: 35dvh;
@@ -106,6 +106,7 @@ assert.match(html, /id="stickyUserPromptButton"/, "chat should expose a fixed la
106
106
  assert.match(html, /id="feedbackTray"/, "chat should expose a queued action-feedback tray");
107
107
  assert.match(html, /id="sendFeedbackButton"/, "action feedback should be submittable after the agent finishes");
108
108
  assert.match(html, /<textarea id="promptInput"[^>]*rows="1"[^>]*enterkeyhint="enter"/, "prompt textarea should start at one row and hint that Return inserts a newline");
109
+ assert.ok(html.includes('id="commandSuggest"') && html.indexOf('id="commandSuggest"') < html.indexOf('id="promptInput"'), "slash-command and @ path suggestions should render above the prompt input");
109
110
  assert.match(html, /id="busyPromptBehaviorTag"[\s\S]*class="composer-busy-mode-tag"[\s\S]*aria-controls="busyPromptBehaviorMenu"/, "composer should expose a clickable busy prompt behavior tag on the input frame");
110
111
  assert.doesNotMatch(html, /Busy send:/i, "busy prompt behavior tag should show only the current mode label");
111
112
  assert.match(html, /id="sessionSkillTags" class="composer-skill-tags"[\s\S]*hidden/, "composer should expose a hidden-until-used skill tag strip beside the busy mode tag");
@@ -175,7 +176,8 @@ assert.match(css, /button, select, input \{ min-height: 44px; \}/, "base control
175
176
  assert.match(css, /\.composer-row button[\s\S]*?min-height:\s*44px/, "mobile composer buttons should keep 44px touch targets");
176
177
  assert.match(css, /\.composer-abort-button,\n\.composer-row button\.primary \{[\s\S]*?min-width:/, "Abort and Send should share stable bottom-row sizing");
177
178
  assert.match(css, /\.composer-abort-button\.long-pressing::after[\s\S]*?animation:\s*abort-long-press-fill 700ms linear forwards/, "Abort should expose a visible long-press progress affordance");
178
- assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-abort-button:not\(\[hidden\]\) \{ grid-column: span 2; \}/, "active mobile runs should keep Abort beside Send in the bottom controls");
179
+ assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-abort-button:not\(\[hidden\]\) \{\n\s+order:\s*1;\n\s+grid-column:\s*span 2;/, "active mobile runs should move Abort to the top row");
180
+ assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-actions-button \{ order:\s*4; \}[\s\S]*?body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-row button\.primary \{\n\s+order:\s*5;\n\s+grid-column:\s*span 4;/, "active mobile runs should keep Actions beside Send on the bottom row");
179
181
  assert.match(css, /#promptInput \{[\s\S]*?min-height:\s*calc\(1\.5em \+ 1\.8rem\)/, "prompt input should default to a compact single-line height");
180
182
  assert.match(css, /#promptInput \{[\s\S]*?overflow-y:\s*hidden/, "prompt input should be JS-resized instead of showing a scrollbar by default");
181
183
  assert.match(css, /\.composer-context-tags \{[\s\S]*?top:\s*-0\.48rem;[\s\S]*?left:\s*0\.75rem;/, "busy prompt behavior and skill tags should sit at the top-left of the input frame");
@@ -243,6 +245,7 @@ assert.match(css, /\.command-item \{[\s\S]*?width:\s*100%/, "side-panel commands
243
245
  assert.match(css, /\.toggle-control \{[\s\S]*?grid-template-columns:\s*auto minmax\(0, 1fr\)/, "side-panel notification toggle should align checkbox and label text");
244
246
  assert.match(css, /\.toggle-control:has\(input:checked\)/, "side-panel notification toggle should style the enabled state");
245
247
  assert.match(css, /\.command-item:hover,[\s\S]*?\.command-item:focus-visible/, "side-panel commands should have hover and keyboard focus affordances");
248
+ assert.match(css, /\.command-suggest \{\n\s+margin:\s*0 0 0\.5rem;[\s\S]*?max-height:\s*15rem/, "slash-command and @ path suggestions should reserve spacing below themselves above the prompt input");
246
249
  assert.match(css, /\.command-suggest-item:hover \{\n\s+box-shadow: none;\n\s+transform: none;\n\}\n\.command-suggest-item\.active \{/, "autocomplete hover should not render as the selected suggestion unless JS marks it active");
247
250
  assert.doesNotMatch(css, /\.command-suggest-item:hover,\n\.command-suggest-item\.active/, "autocomplete hover and active selection styles should stay separate");
248
251
  assert.match(css, /\.feedback-tray\[hidden\] \{ display: none; \}/, "queued action-feedback tray should hide when empty");
@@ -286,9 +289,11 @@ assert.match(css, /\.terminal-tab-group\.active \{[\s\S]*?background:[\s\S]*?var
286
289
  assert.match(css, /\.terminal-tab-group\.stopped \{[\s\S]*?opacity:\s*1/, "stopped terminal tab groups should not become transparent");
287
290
  assert.match(css, /\.terminal-tabs:has\(\.terminal-tab-group\.menu-open\)/, "open terminal tab groups should keep the tab strip usable across rerenders");
288
291
  assert.match(css, /\.terminal-tab-group\.menu-open \.terminal-tab-group-menu \{[\s\S]*?display:\s*flex/, "open terminal tab group menus should remain visible without hover");
289
- assert.match(css, /\.terminal-tab\.activity-working[\s\S]*?terminal-tab-working-pulse/, "working tab indicators should be visibly animated");
292
+ assert.match(css, /\.terminal-tab\.activity-working > \.terminal-tab-button \.terminal-tab-activity-indicator[\s\S]*?terminal-tab-working-pulse/, "working tab indicators should be visibly animated");
293
+ assert.match(css, /\.terminal-tab-group-item\.activity-working > \.terminal-tab-button \.terminal-tab-activity-indicator[\s\S]*?terminal-tab-working-pulse/, "working indicators should still animate on grouped tab menu items themselves");
290
294
  assert.match(css, /\.terminal-tab\.activity-blocked[\s\S]*?rgba\(250, 179, 135/, "blocked tab indicators should use orange styling");
291
- assert.match(css, /\.terminal-tab\.activity-blocked \.terminal-tab-activity-indicator[\s\S]*?background:\s*var\(--ctp-peach\)/, "blocked tab indicator dots should be orange");
295
+ assert.match(css, /\.terminal-tab\.activity-blocked > \.terminal-tab-button \.terminal-tab-activity-indicator[\s\S]*?background:\s*var\(--ctp-peach\)/, "blocked tab indicator dots should be orange");
296
+ assert.doesNotMatch(css, /\.terminal-tab\.activity-(?:working|blocked|done)\s+\.terminal-tab-activity-indicator/, "group status styling should not cascade into child tabs in the group menu");
292
297
  assert.match(css, /\.terminal-tab\.activity-done/, "completed unseen work should have a distinct tab style");
293
298
  assert.match(css, /\.terminal-tabs[\s\S]*?position:\s*absolute/, "expanded mobile tabs should overlay instead of consuming transcript space");
294
299
  assert.match(css, /body\.mobile-keyboard-open \.terminal-tabs-shell,[\s\S]*?body\.mobile-keyboard-open \.widget-area,[\s\S]*?body\.mobile-keyboard-open \.statusbar/, "mobile keyboard mode should hide header, widgets, and footer");
@@ -795,7 +800,18 @@ assert.match(app, /function syncLastUserPromptFromMessages\(messages = latestMes
795
800
  assert.match(app, /dataset\.compacted/, "sticky prompt should expose a compacted fallback state when its source message was summarized away");
796
801
  assert.match(app, /stickyUserPromptButton\?\.addEventListener\("click", jumpToStickyUserPrompt\)/, "last user prompt header should be clickable without breaking stale cached HTML");
797
802
  assert.match(app, /function setComposerActionsOpen\(/, "mobile composer actions panel should be JS-toggleable");
803
+ assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\) \{[\s\S]*?\.composer \{[\s\S]*?z-index:\s*50;/, "mobile composer should stack above transcript reaction controls while Actions are open");
804
+ assert.match(css, /\.composer-actions-panel \{[\s\S]*?z-index:\s*55;[\s\S]*?overflow:\s*visible;/, "mobile Actions panel should stay above message reactions and allow submenu overlays instead of clipping them into the panel layout");
805
+ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu\.open \{\n\s+z-index:\s*120;\n\s+\}/, "opened mobile Actions dropdowns should overlay neighboring controls without taking grid space");
806
+ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu::after \{[\s\S]*?bottom:\s*100%;[\s\S]*?height:\s*0\.8rem;[\s\S]*?pointer-events:\s*auto;/, "mobile Actions dropdowns should keep a hover bridge above the trigger and below the floating submenu");
807
+ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu:hover::after,[\s\S]*?\.composer-actions-panel > \.composer-publish-menu:focus-within::after,[\s\S]*?\.composer-actions-panel > \.composer-publish-menu\.open::after \{\n\s+display:\s*block;/, "mobile Actions dropdown hover bridge should activate while hovered, focused, or opened");
808
+ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu \.composer-publish-menu-panel \{[\s\S]*?position:\s*absolute;[\s\S]*?inset:\s*auto auto calc\(100% \+ 0\.38rem\) 0;[\s\S]*?max-height:\s*min\(34dvh, 18rem\);[\s\S]*?overflow:\s*auto;/, "opened mobile Actions dropdown panels should float upward over the Actions controls with their own scrollbar");
809
+ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu \.composer-publish-menu-panel \{[\s\S]*?width:\s*100%;[\s\S]*?min-width:\s*0;[\s\S]*?max-width:\s*100%;/, "mobile Actions dropdown panels should align to the width of their trigger buttons");
810
+ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu \.composer-publish-menu-item \{[\s\S]*?width:\s*100%;[\s\S]*?min-width:\s*0;[\s\S]*?white-space:\s*normal;/, "mobile Actions dropdown option buttons should not keep desktop min-widths that misalign with triggers");
811
+ assert.match(css, /\.composer-actions-panel > \.composer-options-menu \.composer-publish-menu-panel \{[\s\S]*?inset-inline:\s*auto 0;[\s\S]*?max-height:\s*min\(76dvh, 34rem\);/, "mobile Options dropdown should be tall enough to avoid scrolling for the standard option list");
798
812
  assert.match(app, /function setMobileTabsExpanded\(/, "mobile tab strip should be JS-toggleable");
813
+ assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\) \{[\s\S]*?\.terminal-tab-group \{\n\s+display:\s*grid;\n\s+grid-template-columns:\s*minmax\(0, 1fr\) auto;/, "mobile terminal tab groups should use a stable grid row for the tab and close button when expanded");
814
+ assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\) \{[\s\S]*?\.terminal-tab-group-menu \{[\s\S]*?grid-column:\s*1 \/ -1;[\s\S]*?margin:\s*0\.34rem 0 0;/, "mobile terminal tab group menus should not add horizontal margins that overflow and distort the tab card");
799
815
  assert.match(app, /let openTerminalTabGroupKey = null/, "frontend should track the open terminal tab group across tab bar rerenders");
800
816
  assert.match(app, /function updateTerminalTabGroupOpenState\(\)/, "frontend should be able to reapply open terminal tab group state after rerenders");
801
817
  assert.match(app, /classList\.toggle\("terminal-tabs-dense", tabs\.length >= 10\)/, "frontend should enable dense tab layout before tab names become unreadable");