@firstpick/pi-package-webui 0.2.1 → 0.2.2

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
@@ -161,7 +161,7 @@ This requires `/git-staged-msg` from `@firstpick/pi-prompts-git-pr`. Review the
161
161
  ## Network safety
162
162
 
163
163
  - Default bind is localhost-only: `127.0.0.1:31415`.
164
- - The side-panel **Open to network** button rebinds the server to `0.0.0.0` and shows LAN URLs when available.
164
+ - 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".
165
165
  - `--host 0.0.0.0` also exposes the Web UI to the local network.
166
166
  - Any connected browser client can control Pi and run Web UI bash actions as the Web UI process user.
167
167
  - Treat Pi Web UI as a local companion, not a hardened multi-user web service.
package/bin/pi-webui.mjs CHANGED
@@ -17,6 +17,7 @@ const packageRoot = path.resolve(__dirname, "..");
17
17
  const publicDir = path.join(packageRoot, "public");
18
18
  const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
19
19
  const nativeParityMatrix = JSON.parse(await readFile(path.join(packageRoot, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
20
+ const webuiDevServer = isTruthyEnv(process.env.PI_WEBUI_DEV) || isSourceCheckout(packageRoot);
20
21
 
21
22
  const DEFAULT_HOST = "127.0.0.1";
22
23
  const DEFAULT_PORT = 31415;
@@ -100,6 +101,15 @@ const MIME_TYPES = new Map([
100
101
  [".webmanifest", "application/manifest+json; charset=utf-8"],
101
102
  ]);
102
103
 
104
+ function isTruthyEnv(value) {
105
+ return ["1", "true", "yes", "dev"].includes(String(value || "").trim().toLowerCase());
106
+ }
107
+
108
+ function isSourceCheckout(root) {
109
+ const normalized = String(root || "").replace(/\\/g, "/");
110
+ return normalized.includes("/npm-packages/") && !normalized.includes("/node_modules/");
111
+ }
112
+
103
113
  function nativeParitySurfaces(matrix = nativeParityMatrix) {
104
114
  return Array.isArray(matrix?.surfaces) ? matrix.surfaces : [];
105
115
  }
@@ -2004,6 +2014,12 @@ if (options.version) {
2004
2014
  process.exit(0);
2005
2015
  }
2006
2016
 
2017
+ const startupDelayMs = Number.parseInt(process.env.PI_WEBUI_START_DELAY_MS || "", 10);
2018
+ delete process.env.PI_WEBUI_START_DELAY_MS;
2019
+ if (Number.isFinite(startupDelayMs) && startupDelayMs > 0) {
2020
+ await delay(Math.min(startupDelayMs, 10_000));
2021
+ }
2022
+
2007
2023
  const restoreTabs = readRestoreTabsFromEnv();
2008
2024
 
2009
2025
  function normalizedRestoreString(value, maxLength) {
@@ -2547,6 +2563,33 @@ function mergeRestorableTabDescriptors(...sources) {
2547
2563
  .slice(0, RESTORE_TAB_LIMIT);
2548
2564
  }
2549
2565
 
2566
+ async function restorableTabsForRestart() {
2567
+ const liveDescriptors = await Promise.all([...tabs.values()].map(async (tab) => {
2568
+ const state = await currentSessionState(tab).catch(() => tab.lastState || null);
2569
+ return restorableTabDescriptor(tab, state);
2570
+ }));
2571
+ return mergeRestorableTabDescriptors(liveDescriptors, closedRestorableTabs);
2572
+ }
2573
+
2574
+ function spawnRestartServer(restorableTabs) {
2575
+ const env = {
2576
+ ...process.env,
2577
+ PI_WEBUI_RESTORE_TABS: JSON.stringify(restorableTabs || []),
2578
+ PI_WEBUI_START_DELAY_MS: "1200",
2579
+ };
2580
+ if (webuiDevServer) env.PI_WEBUI_DEV = "1";
2581
+ else delete env.PI_WEBUI_DEV;
2582
+ const child = spawn(process.execPath, process.argv.slice(1), {
2583
+ cwd: process.cwd(),
2584
+ env,
2585
+ detached: true,
2586
+ stdio: "ignore",
2587
+ windowsHide: true,
2588
+ });
2589
+ child.unref();
2590
+ return child;
2591
+ }
2592
+
2550
2593
  function rememberClosedRestorableTab(tab, state = null) {
2551
2594
  const descriptor = restorableTabDescriptor(tab, state);
2552
2595
  if (!descriptor) return;
@@ -3461,6 +3504,8 @@ async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
3461
3504
  const data = {
3462
3505
  online: true,
3463
3506
  webuiVersion: packageJson.version,
3507
+ webuiDev: webuiDevServer,
3508
+ webuiMode: webuiDevServer ? "dev" : "production",
3464
3509
  webuiPid: process.pid,
3465
3510
  startedAt: serverStartedAt,
3466
3511
  cwd: options.cwd,
@@ -3537,6 +3582,8 @@ const server = createServer(async (req, res) => {
3537
3582
  sendSse(res, {
3538
3583
  type: "webui_connected",
3539
3584
  version: packageJson.version,
3585
+ webuiDev: webuiDevServer,
3586
+ webuiMode: webuiDevServer ? "dev" : "production",
3540
3587
  tabId: tab.id,
3541
3588
  tabTitle: tab.title,
3542
3589
  pid: tab.rpc.child?.pid,
@@ -3559,6 +3606,8 @@ const server = createServer(async (req, res) => {
3559
3606
  sendJson(res, 200, {
3560
3607
  ok: true,
3561
3608
  webuiVersion: status.webuiVersion,
3609
+ webuiDev: status.webuiDev,
3610
+ webuiMode: status.webuiMode,
3562
3611
  webuiPid: status.webuiPid,
3563
3612
  piPid: status.piPid,
3564
3613
  piRunning: status.piRunning,
@@ -3629,6 +3678,15 @@ const server = createServer(async (req, res) => {
3629
3678
  return;
3630
3679
  }
3631
3680
 
3681
+ if (url.pathname === "/api/restart" && req.method === "POST") {
3682
+ if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Restart is only allowed from localhost");
3683
+ const restorableTabs = await restorableTabsForRestart();
3684
+ const child = spawnRestartServer(restorableTabs);
3685
+ sendJson(res, 200, { ok: true, message: "Pi Web UI restarting", webuiPid: process.pid, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
3686
+ setTimeout(() => shutdown("api restart"), 20).unref();
3687
+ return;
3688
+ }
3689
+
3632
3690
  if (url.pathname === "/api/shutdown" && req.method === "POST") {
3633
3691
  if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Shutdown is only allowed from localhost");
3634
3692
  sendJson(res, 200, { ok: true, message: "Pi Web UI shutting down", webuiPid: process.pid });
@@ -3879,7 +3937,15 @@ server.listen(options.port, currentHost, () => {
3879
3937
 
3880
3938
  function shutdown(signal) {
3881
3939
  console.log(`\n${signal}: shutting down Pi Web UI...`);
3882
- server.close(() => process.exit(0));
3940
+ const forceCloseTimer = setTimeout(() => {
3941
+ server.closeAllConnections?.();
3942
+ }, NETWORK_REBIND_FORCE_CLOSE_MS);
3943
+ forceCloseTimer.unref?.();
3944
+ server.close(() => {
3945
+ clearTimeout(forceCloseTimer);
3946
+ process.exit(0);
3947
+ });
3948
+ server.closeIdleConnections?.();
3883
3949
  for (const tab of tabs.values()) tab.rpc.stop();
3884
3950
  setTimeout(() => process.exit(0), 4000).unref();
3885
3951
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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
  "type": "module",
package/public/app.js CHANGED
@@ -1,13 +1,16 @@
1
1
  const $ = (selector) => document.querySelector(selector);
2
2
 
3
3
  const elements = {
4
- sessionLine: $("#sessionLine"),
4
+ webuiVersionBadge: $("#webuiVersionBadge"),
5
+ webuiDevBadge: $("#webuiDevBadge"),
5
6
  tabBar: $("#tabBar"),
6
7
  terminalTabsToggleButton: $("#terminalTabsToggleButton"),
7
8
  newTabButton: $("#newTabButton"),
8
9
  closeAllTabsButton: $("#closeAllTabsButton"),
9
10
  statusBar: $("#statusBar"),
10
11
  serverOfflinePanel: $("#serverOfflinePanel"),
12
+ serverRestartPanel: $("#serverRestartPanel"),
13
+ serverRestartMessage: $("#serverRestartMessage"),
11
14
  serverOfflineCommand: $("#serverOfflineCommand"),
12
15
  serverOfflineSlashCommand: $("#serverOfflineSlashCommand"),
13
16
  copyServerCommandButton: $("#copyServerCommandButton"),
@@ -60,7 +63,9 @@ const elements = {
60
63
  backgroundStatus: $("#backgroundStatus"),
61
64
  networkStatus: $("#networkStatus"),
62
65
  openNetworkButton: $("#openNetworkButton"),
63
- stopServerButton: $("#stopServerButton"),
66
+ serverActionSelect: $("#serverActionSelect"),
67
+ runServerActionButton: $("#runServerActionButton"),
68
+ serverActionStatus: $("#serverActionStatus"),
64
69
  agentDoneNotificationsToggle: $("#agentDoneNotificationsToggle"),
65
70
  agentDoneNotificationsStatus: $("#agentDoneNotificationsStatus"),
66
71
  optionalFeaturesBox: $("#optionalFeaturesBox"),
@@ -150,12 +155,15 @@ let pathSuggestAbortController = null;
150
155
  let latestStats = null;
151
156
  let latestWorkspace = null;
152
157
  let latestNetwork = null;
158
+ let webuiVersion = "";
159
+ let webuiDevServer = false;
153
160
  let latestCodexUsage = null;
154
161
  let codexUsageError = null;
155
162
  let codexUsageLoading = false;
156
163
  let refreshCodexUsageTimer = null;
157
164
  let codexUsageRenderTimer = null;
158
165
  let backendOffline = false;
166
+ let serverRestartInProgress = false;
159
167
  let backendOfflineNoticeShown = false;
160
168
  let latestMessages = [];
161
169
  let promptHistoryByTab = new Map();
@@ -263,6 +271,7 @@ const mobileViewMedia = window.matchMedia?.(MOBILE_VIEW_QUERY) || null;
263
271
  const statusEntries = new Map();
264
272
  const widgets = new Map();
265
273
  const todoProgressWidgetExpandedByTab = new Map();
274
+ const releaseNpmOutputExpandedByTab = new Map();
266
275
  const liveToolRuns = new Map();
267
276
  const liveToolCards = new Map();
268
277
  const liveToolRenderQueue = new Map();
@@ -899,13 +908,22 @@ function renderServerOfflinePanel() {
899
908
  if (elements.serverOfflineSlashCommand) elements.serverOfflineSlashCommand.textContent = serverStartSlashCommandText();
900
909
  }
901
910
 
911
+ function setServerRestartOverlay(active, message = "Waiting for the server to come back…") {
912
+ serverRestartInProgress = !!active;
913
+ document.body.classList.toggle("server-restarting", serverRestartInProgress);
914
+ if (elements.serverRestartPanel) elements.serverRestartPanel.hidden = !serverRestartInProgress;
915
+ if (elements.serverRestartMessage) elements.serverRestartMessage.textContent = message;
916
+ if (serverRestartInProgress && elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = true;
917
+ }
918
+
902
919
  function setBackendOffline(offline, error) {
903
920
  backendOffline = !!offline;
904
- document.body.classList.toggle("server-offline", backendOffline);
905
- if (elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = !backendOffline;
921
+ const showOfflinePanel = backendOffline && !serverRestartInProgress;
922
+ document.body.classList.toggle("server-offline", showOfflinePanel);
923
+ if (elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = !showOfflinePanel;
906
924
  renderServerOfflinePanel();
907
925
  if (backendOffline) {
908
- if (!backendOfflineNoticeShown) {
926
+ if (!serverRestartInProgress && !backendOfflineNoticeShown) {
909
927
  backendOfflineNoticeShown = true;
910
928
  addEvent(`Pi Web UI server is offline${error?.message ? `: ${error.message}` : ""}`, "warn");
911
929
  }
@@ -1015,6 +1033,52 @@ async function api(path, { method = "GET", body, tabId = activeTabId, scoped = t
1015
1033
  return data;
1016
1034
  }
1017
1035
 
1036
+ function formatWebuiVersion(version) {
1037
+ const text = String(version || "").trim();
1038
+ if (!text) return "";
1039
+ return text.startsWith("v") ? text : `v${text}`;
1040
+ }
1041
+
1042
+ function isWebuiDevMetadata(data) {
1043
+ return data?.webuiDev === true || String(data?.webuiMode || "").toLowerCase() === "dev";
1044
+ }
1045
+
1046
+ function renderWebuiVersion() {
1047
+ const badge = elements.webuiVersionBadge;
1048
+ if (!badge) return;
1049
+ const label = formatWebuiVersion(webuiVersion);
1050
+ badge.hidden = !label;
1051
+ badge.textContent = label;
1052
+ if (label) badge.title = `Pi Web UI ${label}`;
1053
+ }
1054
+
1055
+ function renderWebuiDevBadge() {
1056
+ const badge = elements.webuiDevBadge;
1057
+ if (!badge) return;
1058
+ badge.hidden = !webuiDevServer;
1059
+ badge.title = "Pi Web UI dev server";
1060
+ }
1061
+
1062
+ function setWebuiVersion(version) {
1063
+ const text = String(version || "").trim();
1064
+ if (text === webuiVersion) return;
1065
+ webuiVersion = text;
1066
+ renderWebuiVersion();
1067
+ }
1068
+
1069
+ function setWebuiDevServer(dev) {
1070
+ const next = !!dev;
1071
+ if (next === webuiDevServer) return;
1072
+ webuiDevServer = next;
1073
+ renderWebuiDevBadge();
1074
+ }
1075
+
1076
+ async function refreshWebuiVersion() {
1077
+ const health = await api("/api/health", { scoped: false });
1078
+ setWebuiVersion(health.webuiVersion);
1079
+ setWebuiDevServer(isWebuiDevMetadata(health));
1080
+ }
1081
+
1018
1082
  function formatBytes(bytes) {
1019
1083
  const value = Number(bytes) || 0;
1020
1084
  if (value < 1024) return `${value} B`;
@@ -2357,7 +2421,6 @@ function resetActiveTabUi() {
2357
2421
  }
2358
2422
  elements.commandsBox.textContent = "Loading…";
2359
2423
  elements.commandsBox.classList.add("muted");
2360
- elements.sessionLine.textContent = activeTab() ? "Connecting…" : "No terminal tabs.";
2361
2424
  renderWidgets();
2362
2425
  renderGitWorkflow();
2363
2426
  renderFooter();
@@ -3899,13 +3962,6 @@ function renderStatus() {
3899
3962
  updateComposerModeButtons();
3900
3963
  const running = state?.isStreaming ? "running" : "idle";
3901
3964
  const compacting = state?.isCompacting ? " · compacting" : "";
3902
- const queue = state?.pendingMessageCount ? ` · queued ${state.pendingMessageCount}` : "";
3903
- const extra = [...statusEntries.entries()].map(([key, value]) => formatStatusEntry(key, value)).filter(Boolean).join(" · ");
3904
- const statusText = state?.isStreaming ? "Running" : "Idle";
3905
- const compactingText = state?.isCompacting ? " · Compacting" : "";
3906
- const queueText = state?.pendingMessageCount ? ` · Queue: ${state.pendingMessageCount}` : "";
3907
-
3908
- elements.sessionLine.textContent = `Status: ${statusText}${compactingText}${queueText}${extra ? ` · ${extra}` : ""} · Model: ${modelLabel(state?.model)} · Session: ${shortSessionLabel(state)}`;
3909
3965
 
3910
3966
  elements.stateDetails.replaceChildren();
3911
3967
  const details = {
@@ -4181,6 +4237,26 @@ function releaseNpmStreamHeader(label, lineCount, { live = false } = {}) {
4181
4237
  return header;
4182
4238
  }
4183
4239
 
4240
+ function renderReleaseNpmOutputDetails(key, streamHeader, terminal, controls = null) {
4241
+ const tabId = activeTabId || "default";
4242
+ const stateKey = `${tabId}:${key}`;
4243
+ const node = make("details", "release-npm-output-details");
4244
+ node.open = releaseNpmOutputExpandedByTab.get(stateKey) !== false;
4245
+ node.addEventListener("toggle", () => {
4246
+ releaseNpmOutputExpandedByTab.set(stateKey, node.open);
4247
+ if (node.open) requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
4248
+ });
4249
+
4250
+ const summary = make("summary", "release-npm-output-summary");
4251
+ summary.title = "Expand or collapse command output in the Web UI";
4252
+ const toggle = make("span", "release-npm-output-toggle", "›");
4253
+ toggle.setAttribute("aria-hidden", "true");
4254
+ summary.append(toggle, streamHeader);
4255
+ node.append(summary, terminal);
4256
+ if (controls) node.append(controls);
4257
+ return node;
4258
+ }
4259
+
4184
4260
  function renderReleaseNpmOutputWidget() {
4185
4261
  if (!isOptionalFeatureEnabled("releaseNpm")) return null;
4186
4262
  const outputLines = getWidgetLines("release-npm:output");
@@ -4216,8 +4292,9 @@ function renderReleaseNpmOutputWidget() {
4216
4292
  }
4217
4293
 
4218
4294
  const controls = make("div", "release-npm-controls", details.controls || "Controls: /release-toggle expands/collapses · /release-abort stops subprocess");
4219
- node.append(header, streamHeader, terminal, controls);
4220
- requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
4295
+ const outputDetails = renderReleaseNpmOutputDetails("release-npm:output", streamHeader, terminal, controls);
4296
+ node.append(header, outputDetails);
4297
+ requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
4221
4298
  return node;
4222
4299
  }
4223
4300
 
@@ -4246,8 +4323,9 @@ function renderReleaseNpmLogWidget() {
4246
4323
  for (const line of logLines) {
4247
4324
  appendReleaseNpmTerminalLine(terminal, line);
4248
4325
  }
4249
- node.append(header, streamHeader, terminal);
4250
- requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
4326
+ const outputDetails = renderReleaseNpmOutputDetails("release-npm:logs", streamHeader, terminal);
4327
+ node.append(header, outputDetails);
4328
+ requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
4251
4329
  return node;
4252
4330
  }
4253
4331
 
@@ -4286,8 +4364,9 @@ function renderReleaseAurOutputWidget() {
4286
4364
  }
4287
4365
 
4288
4366
  const controls = make("div", "release-npm-controls", details.controls || "Controls: /release-aur toggle expands/collapses · /release-aur abort stops subprocess");
4289
- node.append(header, streamHeader, terminal, controls);
4290
- requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
4367
+ const outputDetails = renderReleaseNpmOutputDetails("release-aur:output", streamHeader, terminal, controls);
4368
+ node.append(header, outputDetails);
4369
+ requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
4291
4370
  return node;
4292
4371
  }
4293
4372
 
@@ -4316,8 +4395,9 @@ function renderReleaseAurLogWidget() {
4316
4395
  for (const line of logLines) {
4317
4396
  appendReleaseNpmTerminalLine(terminal, line);
4318
4397
  }
4319
- node.append(header, streamHeader, terminal);
4320
- requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
4398
+ const outputDetails = renderReleaseNpmOutputDetails("release-aur:logs", streamHeader, terminal);
4399
+ node.append(header, outputDetails);
4400
+ requestAnimationFrame(() => { if (outputDetails.open) terminal.scrollTop = terminal.scrollHeight; });
4321
4401
  return node;
4322
4402
  }
4323
4403
 
@@ -8293,6 +8373,7 @@ async function refreshAll(tabContext = activeTabContext()) {
8293
8373
  refreshStats(tabContext),
8294
8374
  refreshWorkspace(tabContext),
8295
8375
  refreshNetworkStatus(),
8376
+ refreshWebuiVersion(),
8296
8377
  ]);
8297
8378
  if (!isCurrentTabContext(tabContext)) return;
8298
8379
  for (const result of results) {
@@ -8404,12 +8485,108 @@ async function closeNetworkAccess() {
8404
8485
  }
8405
8486
  }
8406
8487
 
8488
+ function setServerActionStatus(message = "", level = "info") {
8489
+ const status = elements.serverActionStatus;
8490
+ if (!status) return;
8491
+ status.textContent = message;
8492
+ status.hidden = !message;
8493
+ status.className = `server-action-status ${level} ${message ? "" : "muted"}`.trim();
8494
+ }
8495
+
8496
+ function updateServerActionButton() {
8497
+ const action = elements.serverActionSelect?.value || "";
8498
+ const button = elements.runServerActionButton;
8499
+ if (!button) return;
8500
+ button.disabled = !action;
8501
+ button.textContent = action === "restart" ? "Restart" : action === "stop" ? "Stop" : "Run";
8502
+ button.classList.toggle("danger", action === "stop");
8503
+ if (action) setServerActionStatus(action === "restart" ? "Ready to restart the Web UI server." : "Ready to stop the Web UI server.", "info");
8504
+ else setServerActionStatus();
8505
+ }
8506
+
8507
+ function setServerActionBusy(label) {
8508
+ if (elements.serverActionSelect) elements.serverActionSelect.disabled = true;
8509
+ if (elements.runServerActionButton) {
8510
+ elements.runServerActionButton.disabled = true;
8511
+ elements.runServerActionButton.textContent = label;
8512
+ }
8513
+ }
8514
+
8515
+ function resetServerActionControls() {
8516
+ if (elements.serverActionSelect) {
8517
+ elements.serverActionSelect.disabled = false;
8518
+ elements.serverActionSelect.value = "";
8519
+ }
8520
+ updateServerActionButton();
8521
+ }
8522
+
8523
+ async function waitForServerRestart() {
8524
+ for (let attempt = 0; attempt < 40; attempt++) {
8525
+ await delay(attempt === 0 ? 900 : 500);
8526
+ const message = `Restarting… reconnect attempt ${attempt + 1}/40`;
8527
+ setServerActionStatus(message, "warn");
8528
+ setServerRestartOverlay(true, message);
8529
+ try {
8530
+ await api("/api/health", { scoped: false });
8531
+ setBackendOffline(false);
8532
+ await initializeTabs();
8533
+ setServerRestartOverlay(false);
8534
+ setServerActionStatus("Server restarted and reconnected.", "success");
8535
+ addEvent("Pi Web UI server restarted", "warn");
8536
+ return true;
8537
+ } catch (error) {
8538
+ setBackendOffline(true, error);
8539
+ }
8540
+ }
8541
+ return false;
8542
+ }
8543
+
8544
+ async function restartServer() {
8545
+ if (!confirm("Restart the Pi Web UI server?\n\nThis briefly disconnects browser clients and restarts the Pi tabs managed by this Web UI.")) return;
8546
+
8547
+ setServerActionBusy("Restarting…");
8548
+ setServerActionStatus("Restart requested. Waiting for the server to come back…", "warn");
8549
+ setServerRestartOverlay(true, "Restart requested. Waiting for the server to come back…");
8550
+ try {
8551
+ await api("/api/restart", { method: "POST", scoped: false });
8552
+ addEvent("Pi Web UI server restart requested", "warn");
8553
+ } catch (error) {
8554
+ if (!error?.backendOffline) {
8555
+ const missingRestartEndpoint = error.statusCode === 404 || /not found/i.test(error.message || "");
8556
+ const message = missingRestartEndpoint
8557
+ ? "Restart is not available in the currently running server. Stop/start manually once to load the new backend."
8558
+ : error.message || String(error);
8559
+ addEvent(message, "error");
8560
+ setServerRestartOverlay(false);
8561
+ resetServerActionControls();
8562
+ setServerActionStatus(message, "error");
8563
+ return;
8564
+ }
8565
+ addEvent("Pi Web UI server connection dropped during restart request", "warn");
8566
+ }
8567
+
8568
+ setBackendOffline(true, new Error("restart requested from side panel"));
8569
+ const restarted = await waitForServerRestart();
8570
+ if (elements.serverActionSelect) {
8571
+ elements.serverActionSelect.disabled = false;
8572
+ elements.serverActionSelect.value = "";
8573
+ }
8574
+ updateServerActionButton();
8575
+ if (restarted) {
8576
+ setServerActionStatus("Server restarted and reconnected.", "success");
8577
+ } else {
8578
+ setServerRestartOverlay(false);
8579
+ setBackendOffline(true, new Error("restart reconnect timed out"));
8580
+ setServerActionStatus("Restart requested, but the server did not reconnect automatically.", "error");
8581
+ addEvent("Pi Web UI server did not come back online after restart request", "error");
8582
+ }
8583
+ }
8584
+
8407
8585
  async function stopServer() {
8408
8586
  if (!confirm("Stop the Pi Web UI server?\n\nThis disconnects all browser clients and stops the Pi tabs managed by this Web UI.")) return;
8409
8587
 
8410
- const button = elements.stopServerButton;
8411
- button.disabled = true;
8412
- button.textContent = "Stopping…";
8588
+ setServerActionBusy("Stopping…");
8589
+ setServerActionStatus("Stop requested. The Web UI will disconnect.", "warn");
8413
8590
  try {
8414
8591
  await api("/api/shutdown", { method: "POST", scoped: false });
8415
8592
  addEvent("Pi Web UI server stop requested", "warn");
@@ -8421,11 +8598,17 @@ async function stopServer() {
8421
8598
  return;
8422
8599
  }
8423
8600
  addEvent(error.message || String(error), "error");
8424
- button.disabled = false;
8425
- button.textContent = "Stop Server";
8601
+ resetServerActionControls();
8602
+ setServerActionStatus(error.message || String(error), "error");
8426
8603
  }
8427
8604
  }
8428
8605
 
8606
+ async function runSelectedServerAction() {
8607
+ const action = elements.serverActionSelect?.value || "";
8608
+ if (action === "restart") await restartServer();
8609
+ else if (action === "stop") await stopServer();
8610
+ }
8611
+
8429
8612
  function appShortcutModelLabel(model) {
8430
8613
  return model ? `${model.provider}/${model.id}` : "unknown model";
8431
8614
  }
@@ -8840,8 +9023,11 @@ function showNextDialog() {
8840
9023
  button.type = "button";
8841
9024
  if (isGuardrailDialog && /^Block$/i.test(optionLabel)) button.classList.add("guardrail-safe-action");
8842
9025
  if (isGuardrailDialog && /^Allow/i.test(optionLabel)) button.classList.add("guardrail-allow-action");
8843
- if (isReleaseDialog && /^Yes$/i.test(optionLabel)) button.classList.add("primary", "release-publish-action");
8844
- if (isReleaseDialog && /^No$/i.test(optionLabel)) button.classList.add("release-cancel-action");
9026
+ if (isReleaseDialog && /^(?:Yes|All eligible packages\b|Publish selected packages \([1-9]\d*\))/.test(optionLabel)) button.classList.add("primary", "release-publish-action");
9027
+ if (isReleaseDialog && /^Publish selected packages \(select at least one\)$/i.test(optionLabel)) button.classList.add("release-publish-disabled-action");
9028
+ if (isReleaseDialog && /^\[x\]/.test(optionLabel)) button.classList.add("release-target-option", "release-target-selected");
9029
+ if (isReleaseDialog && /^\[ \]/.test(optionLabel)) button.classList.add("release-target-option");
9030
+ if (isReleaseDialog && /^(?:No|Cancel)$/i.test(optionLabel)) button.classList.add("release-cancel-action");
8845
9031
  button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: optionLabel, tabId: request.tabId }));
8846
9032
  options.append(button);
8847
9033
  }
@@ -8889,6 +9075,8 @@ function handleEvent(event) {
8889
9075
  const tabContext = activeTabContext(event.tabId || activeTabId);
8890
9076
  switch (event.type) {
8891
9077
  case "webui_connected":
9078
+ setWebuiVersion(event.version);
9079
+ setWebuiDevServer(isWebuiDevMetadata(event));
8892
9080
  addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
8893
9081
  scheduleForegroundReconcile("event stream reconnect", 0);
8894
9082
  break;
@@ -9299,7 +9487,9 @@ if (elements.backgroundClearButton) {
9299
9487
  elements.backgroundClearButton.addEventListener("click", () => clearCustomBackground().catch((error) => addEvent(error.message || String(error), "error")));
9300
9488
  }
9301
9489
  elements.openNetworkButton.addEventListener("click", openToNetwork);
9302
- elements.stopServerButton.addEventListener("click", stopServer);
9490
+ elements.serverActionSelect.addEventListener("change", updateServerActionButton);
9491
+ elements.runServerActionButton.addEventListener("click", () => runSelectedServerAction().catch((error) => addEvent(error.message || String(error), "error")));
9492
+ updateServerActionButton();
9303
9493
  elements.agentDoneNotificationsToggle.addEventListener("change", () => {
9304
9494
  setAgentDoneNotificationsEnabled(elements.agentDoneNotificationsToggle.checked, {
9305
9495
  requestPermission: elements.agentDoneNotificationsToggle.checked,
package/public/index.html CHANGED
@@ -36,6 +36,15 @@
36
36
  </div>
37
37
  </section>
38
38
 
39
+ <section id="serverRestartPanel" class="server-restart-panel" aria-live="assertive" hidden>
40
+ <div class="server-restart-card" role="status">
41
+ <div class="server-restart-spinner" aria-hidden="true"></div>
42
+ <span class="server-restart-kicker">Restarting</span>
43
+ <h1>Restarting Pi Web UI server</h1>
44
+ <p id="serverRestartMessage">Waiting for the server to come back…</p>
45
+ </div>
46
+ </section>
47
+
39
48
  <main class="layout">
40
49
  <section class="chat-panel">
41
50
  <header class="terminal-tabs-shell">
@@ -151,8 +160,11 @@
151
160
  <div class="side-panel-header">
152
161
  <div>
153
162
  <span class="side-panel-kicker">Pi Web UI</span>
154
- <strong>Control Deck</strong>
155
- <p id="sessionLine" class="side-panel-session-line muted">Connecting…</p>
163
+ <strong class="side-panel-title">
164
+ <span>Control Deck</span>
165
+ <span id="webuiVersionBadge" class="webui-version-badge" aria-label="Pi Web UI version" hidden></span>
166
+ <span id="webuiDevBadge" class="webui-dev-badge" aria-label="Pi Web UI dev server" hidden>DEV</span>
167
+ </strong>
156
168
  </div>
157
169
  <button id="toggleSidePanelButton" class="side-panel-toggle-button" type="button" aria-controls="sidePanel" aria-expanded="true" aria-label="Collapse side panel" title="Collapse side panel">
158
170
  <span class="side-panel-button-icon" aria-hidden="true">
@@ -217,8 +229,16 @@
217
229
  <button id="openNetworkButton" type="button">Open to network</button>
218
230
  </div>
219
231
  <div class="control-field server-control-field">
220
- <label>Server</label>
221
- <button id="stopServerButton" class="danger" type="button" title="Stop the Pi Web UI server and disconnect all browser clients" aria-label="Stop the Pi Web UI server">Stop Server</button>
232
+ <label for="serverActionSelect">Server</label>
233
+ <div class="server-action-row">
234
+ <select id="serverActionSelect" title="Server action" aria-label="Server action">
235
+ <option value="" selected>Choose action…</option>
236
+ <option value="restart">Restart Server</option>
237
+ <option value="stop">Stop Server</option>
238
+ </select>
239
+ <button id="runServerActionButton" type="button" disabled>Run</button>
240
+ </div>
241
+ <div id="serverActionStatus" class="server-action-status muted" role="status" aria-live="polite"></div>
222
242
  </div>
223
243
  <div class="control-field notification-control-field">
224
244
  <span class="control-label">Notifications</span>
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = "pi-webui-pwa-v16";
1
+ const CACHE_NAME = "pi-webui-pwa-v18";
2
2
  const APP_SHELL = [
3
3
  "/",
4
4
  "/index.html",
package/public/styles.css CHANGED
@@ -375,6 +375,65 @@ body.server-offline .layout {
375
375
  filter: blur(2px);
376
376
  pointer-events: none;
377
377
  }
378
+ .server-restart-panel[hidden] {
379
+ display: none !important;
380
+ }
381
+ .server-restart-panel {
382
+ position: fixed;
383
+ inset: 1rem;
384
+ z-index: 62;
385
+ display: grid;
386
+ place-items: center;
387
+ padding: 1rem;
388
+ pointer-events: none;
389
+ }
390
+ .server-restart-card {
391
+ position: relative;
392
+ display: grid;
393
+ justify-items: center;
394
+ width: min(34rem, 100%);
395
+ pointer-events: auto;
396
+ padding: clamp(1.35rem, 4vw, 2.2rem);
397
+ text-align: center;
398
+ border: 1px solid rgba(148, 226, 213, 0.34);
399
+ border-radius: 1.2rem;
400
+ background:
401
+ radial-gradient(circle at 50% 0, rgba(148, 226, 213, 0.18), transparent 18rem),
402
+ linear-gradient(145deg, rgba(var(--ctp-base-rgb), 0.96), rgba(var(--ctp-crust-rgb), 0.98));
403
+ box-shadow: 0 1.2rem 4rem rgba(var(--ctp-crust-rgb), 0.74), 0 0 2rem rgba(148, 226, 213, 0.14), inset 0 1px 0 rgba(255,255,255,0.07);
404
+ }
405
+ .server-restart-spinner {
406
+ width: 2.8rem;
407
+ height: 2.8rem;
408
+ margin-bottom: 0.9rem;
409
+ border: 0.22rem solid rgba(148, 226, 213, 0.18);
410
+ border-top-color: var(--ctp-teal);
411
+ border-radius: 999px;
412
+ animation: server-restart-spin 900ms linear infinite;
413
+ }
414
+ .server-restart-kicker {
415
+ color: var(--ctp-teal);
416
+ font-size: 0.76rem;
417
+ font-weight: 900;
418
+ letter-spacing: 0.12em;
419
+ text-transform: uppercase;
420
+ }
421
+ .server-restart-card h1 {
422
+ margin: 0.35rem 0 0.45rem;
423
+ font-size: clamp(1.35rem, 4vw, 2rem);
424
+ }
425
+ .server-restart-card p {
426
+ margin: 0;
427
+ color: rgba(var(--ctp-subtext-rgb), 0.9);
428
+ }
429
+ body.server-restarting .layout {
430
+ opacity: 0.56;
431
+ filter: blur(1.5px);
432
+ pointer-events: none;
433
+ }
434
+ @keyframes server-restart-spin {
435
+ to { transform: rotate(360deg); }
436
+ }
378
437
  .side-panel-expand-button {
379
438
  position: fixed;
380
439
  top: 1rem;
@@ -504,13 +563,36 @@ body.side-panel-collapsed .terminal-tabs-shell {
504
563
  color: var(--ctp-text);
505
564
  letter-spacing: 0.03em;
506
565
  }
507
- .side-panel-session-line {
508
- margin: 0.34rem 0 0;
509
- max-width: 17rem;
510
- overflow: hidden;
511
- text-overflow: ellipsis;
512
- white-space: nowrap;
513
- font-size: 0.76rem;
566
+ .side-panel-header .side-panel-title {
567
+ display: flex;
568
+ align-items: center;
569
+ gap: 0.46rem;
570
+ flex-wrap: wrap;
571
+ }
572
+ .webui-version-badge,
573
+ .webui-dev-badge {
574
+ display: inline-flex;
575
+ align-items: center;
576
+ min-height: 1.18rem;
577
+ padding: 0.05rem 0.44rem;
578
+ border: 1px solid rgba(180, 190, 254, 0.24);
579
+ border-radius: 999px;
580
+ color: var(--ctp-subtext);
581
+ background: rgba(var(--ctp-surface-rgb), 0.74);
582
+ font-size: 0.68rem;
583
+ font-weight: 800;
584
+ letter-spacing: 0.04em;
585
+ line-height: 1;
586
+ }
587
+ .webui-dev-badge {
588
+ border-color: rgba(249, 226, 175, 0.38);
589
+ color: var(--ctp-yellow);
590
+ background: rgba(249, 226, 175, 0.12);
591
+ box-shadow: 0 0 0.7rem rgba(249, 226, 175, 0.1);
592
+ }
593
+ .webui-version-badge[hidden],
594
+ .webui-dev-badge[hidden] {
595
+ display: none;
514
596
  }
515
597
  .side-panel-kicker {
516
598
  display: block;
@@ -647,6 +729,35 @@ body.side-panel-collapsed .terminal-tabs-shell {
647
729
  gap: 0.42rem;
648
730
  align-items: center;
649
731
  }
732
+ .server-action-row {
733
+ display: grid;
734
+ grid-template-columns: minmax(0, 1fr) auto;
735
+ gap: 0.42rem;
736
+ align-items: center;
737
+ }
738
+ .server-action-row button {
739
+ width: auto;
740
+ min-width: 4.4rem;
741
+ }
742
+ .server-action-status {
743
+ min-height: 1.05rem;
744
+ color: rgba(var(--ctp-subtext-rgb), 0.82);
745
+ font-size: 0.72rem;
746
+ font-weight: 750;
747
+ line-height: 1.35;
748
+ }
749
+ .server-action-status.warn {
750
+ color: var(--ctp-yellow);
751
+ }
752
+ .server-action-status.error {
753
+ color: var(--ctp-red);
754
+ }
755
+ .server-action-status.success {
756
+ color: var(--ctp-green);
757
+ }
758
+ .server-action-status[hidden] {
759
+ display: none;
760
+ }
650
761
  .background-clear-button {
651
762
  width: 44px !important;
652
763
  min-width: 44px !important;
@@ -1311,6 +1422,15 @@ body.side-panel-collapsed .terminal-tabs-shell {
1311
1422
  color: rgba(var(--ctp-text-rgb), 0.78);
1312
1423
  background: linear-gradient(90deg, rgba(203, 166, 247, 0.10), rgba(137, 180, 250, 0.05), rgba(148, 226, 213, 0.08));
1313
1424
  }
1425
+ .widget-area:has(.release-npm-live-widget .release-npm-output-details[open]),
1426
+ .widget-area:has(.release-aur-live-widget .release-npm-output-details[open]) {
1427
+ flex: 0 0 min(44rem, 68dvh);
1428
+ min-height: 0;
1429
+ overflow: auto;
1430
+ overscroll-behavior: contain;
1431
+ scrollbar-gutter: stable;
1432
+ overflow-anchor: none;
1433
+ }
1314
1434
  .statusbar {
1315
1435
  position: relative;
1316
1436
  flex: 0 0 auto;
@@ -1806,6 +1926,52 @@ button.footer-meta {
1806
1926
  text-transform: none;
1807
1927
  white-space: nowrap;
1808
1928
  }
1929
+ .release-npm-output-details {
1930
+ display: grid;
1931
+ gap: 0.58rem;
1932
+ min-width: 0;
1933
+ }
1934
+ .release-npm-output-summary {
1935
+ display: grid;
1936
+ grid-template-columns: auto minmax(0, 1fr);
1937
+ align-items: center;
1938
+ gap: 0.38rem;
1939
+ min-width: 0;
1940
+ color: rgba(var(--ctp-text-rgb), 0.92);
1941
+ cursor: pointer;
1942
+ list-style: none;
1943
+ }
1944
+ .release-npm-output-summary::-webkit-details-marker { display: none; }
1945
+ .release-npm-output-summary:focus-visible {
1946
+ outline: 2px solid rgba(137, 180, 250, 0.72);
1947
+ outline-offset: 0.18rem;
1948
+ border-radius: 0.72rem;
1949
+ }
1950
+ .release-npm-output-summary:hover .release-npm-stream-header,
1951
+ .release-npm-output-summary:focus-visible .release-npm-stream-header {
1952
+ border-color: rgba(137, 180, 250, 0.54);
1953
+ box-shadow: inset 0 0 0 1px rgba(137, 180, 250, 0.08), 0 0 0.8rem rgba(137, 180, 250, 0.08);
1954
+ }
1955
+ .release-npm-output-toggle {
1956
+ display: inline-grid;
1957
+ place-items: center;
1958
+ width: 1rem;
1959
+ height: 1rem;
1960
+ flex: 0 0 auto;
1961
+ color: rgba(var(--ctp-subtext-rgb), 0.78);
1962
+ font-size: 1rem;
1963
+ font-weight: 950;
1964
+ line-height: 1;
1965
+ transition: transform 0.16s ease, color 0.16s ease;
1966
+ }
1967
+ .release-npm-output-details[open] .release-npm-output-toggle {
1968
+ color: var(--ctp-blue);
1969
+ transform: rotate(90deg);
1970
+ }
1971
+ .release-npm-output-summary .release-npm-stream-header {
1972
+ min-width: 0;
1973
+ width: 100%;
1974
+ }
1809
1975
  .release-npm-terminal {
1810
1976
  max-height: min(34rem, 42vh);
1811
1977
  min-height: 5.25rem;
@@ -1825,6 +1991,11 @@ button.footer-meta {
1825
1991
  line-height: 1.5;
1826
1992
  overscroll-behavior: contain;
1827
1993
  }
1994
+ .release-npm-live-widget .release-npm-output-details[open] .release-npm-terminal,
1995
+ .release-aur-live-widget .release-npm-output-details[open] .release-npm-terminal {
1996
+ height: clamp(15rem, 42dvh, 31rem);
1997
+ min-height: 0;
1998
+ }
1828
1999
  .release-npm-line {
1829
2000
  display: block;
1830
2001
  width: max-content;
@@ -3189,6 +3360,28 @@ summary { cursor: pointer; color: var(--warning); }
3189
3360
  text-align: center;
3190
3361
  font-weight: 800;
3191
3362
  }
3363
+ .extension-dialog.release-dialog .dialog-options button.release-publish-disabled-action {
3364
+ color: rgba(var(--ctp-subtext-rgb), 0.72);
3365
+ border-color: rgba(var(--ctp-overlay-rgb), 0.32);
3366
+ background: linear-gradient(180deg, rgba(var(--ctp-surface-rgb), 0.58), rgba(var(--ctp-crust-rgb), 0.72));
3367
+ }
3368
+ .extension-dialog.release-dialog .dialog-options button.release-target-option {
3369
+ text-align: left;
3370
+ border-color: rgba(137, 180, 250, 0.34);
3371
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
3372
+ font-size: 0.78rem;
3373
+ line-height: 1.35;
3374
+ overflow-wrap: anywhere;
3375
+ white-space: normal;
3376
+ }
3377
+ .extension-dialog.release-dialog .dialog-options button.release-target-selected {
3378
+ color: var(--ctp-green);
3379
+ border-color: rgba(166, 227, 161, 0.58);
3380
+ background:
3381
+ linear-gradient(120deg, rgba(166, 227, 161, 0.16), rgba(137, 180, 250, 0.08)),
3382
+ linear-gradient(180deg, rgba(var(--ctp-surface-rgb), 0.80), rgba(var(--ctp-crust-rgb), 0.78));
3383
+ box-shadow: 0 0 1rem rgba(166, 227, 161, 0.14);
3384
+ }
3192
3385
  .extension-dialog.release-dialog .dialog-options button.release-cancel-action {
3193
3386
  border-color: rgba(249, 226, 175, 0.38);
3194
3387
  color: var(--ctp-yellow);
package/start-webui.sh CHANGED
@@ -183,51 +183,10 @@ connect_host_for_port() {
183
183
  esac
184
184
  }
185
185
 
186
- open_url() {
186
+ print_manual_url() {
187
187
  local url="$1"
188
- local platform=""
189
- platform="$(uname -s 2>/dev/null || true)"
190
188
 
191
- case "$platform" in
192
- MINGW*|MSYS*|CYGWIN*)
193
- if command -v cmd.exe >/dev/null 2>&1; then
194
- cmd.exe /c start "" "$url" </dev/null >/dev/null 2>&1 &
195
- return 0
196
- fi
197
- if command -v powershell.exe >/dev/null 2>&1; then
198
- powershell.exe -NoProfile -Command 'Start-Process -FilePath $args[0]' "$url" </dev/null >/dev/null 2>&1 &
199
- return 0
200
- fi
201
- ;;
202
- Linux*)
203
- if grep -qi microsoft /proc/version 2>/dev/null; then
204
- if command -v wslview >/dev/null 2>&1; then
205
- wslview "$url" </dev/null >/dev/null 2>&1 &
206
- return 0
207
- fi
208
- if command -v cmd.exe >/dev/null 2>&1; then
209
- cmd.exe /c start "" "$url" </dev/null >/dev/null 2>&1 &
210
- return 0
211
- fi
212
- fi
213
- ;;
214
- esac
215
-
216
- if command -v xdg-open >/dev/null 2>&1; then
217
- xdg-open "$url" </dev/null >/dev/null 2>&1 &
218
- elif command -v open >/dev/null 2>&1; then
219
- open "$url" </dev/null >/dev/null 2>&1 &
220
- elif command -v wslview >/dev/null 2>&1; then
221
- wslview "$url" </dev/null >/dev/null 2>&1 &
222
- elif command -v cmd.exe >/dev/null 2>&1; then
223
- cmd.exe /c start "" "$url" </dev/null >/dev/null 2>&1 &
224
- elif command -v powershell.exe >/dev/null 2>&1; then
225
- powershell.exe -NoProfile -Command 'Start-Process -FilePath $args[0]' "$url" </dev/null >/dev/null 2>&1 &
226
- else
227
- echo "Could not find a browser opener. Open manually:" >&2
228
- echo " $url" >&2
229
- return 1
230
- fi
189
+ echo "Open manually: $url"
231
190
  }
232
191
 
233
192
  http_ok() {
@@ -456,8 +415,7 @@ main() {
456
415
  if [[ "$dev_mode" -eq 1 ]]; then
457
416
  echo "--dev only affects newly started servers; stop the existing server first to run this checkout."
458
417
  fi
459
- echo "Opening: $target_url"
460
- open_url "$target_url" || true
418
+ print_manual_url "$target_url"
461
419
  exit 0
462
420
  fi
463
421
 
@@ -474,10 +432,12 @@ main() {
474
432
  if [[ "$dev_mode" -eq 1 ]]; then
475
433
  local_webui_bin="$(local_pi_webui_bin)"
476
434
  webui_cmd=(node "$local_webui_bin")
435
+ export PI_WEBUI_DEV=1
477
436
  echo "Dev mode: using local Pi Web UI server: $local_webui_bin"
478
437
  else
479
438
  ensure_pi_webui
480
439
  webui_cmd=("$PI_WEBUI_COMMAND")
440
+ unset PI_WEBUI_DEV
481
441
  fi
482
442
 
483
443
  echo "Starting Pi Web UI in: $cwd"
@@ -491,7 +451,8 @@ main() {
491
451
  trap 'cleanup; exit 143' TERM
492
452
 
493
453
  if wait_until_ready "$url" "$SERVER_PID"; then
494
- open_url "$url" || true
454
+ echo "Pi Web UI is ready."
455
+ print_manual_url "$url"
495
456
  else
496
457
  ready_status="$?"
497
458
  if [[ "$ready_status" -eq 2 ]]; then
@@ -500,8 +461,8 @@ main() {
500
461
  exit $?
501
462
  fi
502
463
 
503
- echo "Server did not respond yet; opening the URL anyway." >&2
504
- open_url "$url" || true
464
+ echo "Server did not respond yet; not opening a browser automatically." >&2
465
+ print_manual_url "$url"
505
466
  fi
506
467
 
507
468
  wait "$SERVER_PID"
@@ -41,11 +41,15 @@ assert.match(html, /<link rel="apple-touch-icon" href="\/apple-touch-icon\.png"
41
41
  assert.match(html, /id="terminalTabsToggleButton"/, "mobile should expose a compact terminal-tabs toggle");
42
42
  assert.match(html, /id="closeAllTabsButton"[\s\S]*?>Close all Tabs<\/button>/, "tab header should expose a top-right close-all tabs action");
43
43
  assert.match(html, /id="sidePanelBackdrop"/, "mobile side panel needs an overlay/backdrop close target");
44
+ assert.match(html, /<strong class="side-panel-title">[\s\S]*Control Deck[\s\S]*id="webuiVersionBadge"[\s\S]*id="webuiDevBadge"/, "Control Deck title should expose Web UI version and dev badges");
45
+ assert.doesNotMatch(html, /id="sessionLine"/, "Control Deck title should not show verbose session status metadata");
44
46
  assert.match(html, /id="themeSelect"/, "side panel should expose a theme selector");
45
47
  assert.match(html, /<label for="themeSelect">Theme<\/label>/, "theme selector should be labeled in side-panel controls");
46
48
  assert.match(html, /id="backgroundInput"[^>]*type="file"[^>]*accept="image\/png,image\/jpeg,image\/webp,image\/gif"/, "side panel should expose an image picker for custom backgrounds");
47
49
  assert.match(html, /id="backgroundClearButton"[\s\S]*?>×<\/button>/, "side-panel background control should expose an X remove button");
48
- assert.match(html, /id="stopServerButton"[^>]*?>Stop Server<\/button>/, "side panel should expose a Stop Server button");
50
+ assert.match(html, /id="serverActionSelect"[\s\S]*<option value="restart">Restart Server<\/option>[\s\S]*<option value="stop">Stop Server<\/option>/, "side panel should expose restart and stop server actions in a dropdown");
51
+ assert.match(html, /id="runServerActionButton"[^>]*disabled[^>]*>Run<\/button>/, "side panel should expose a guarded button for selected server actions");
52
+ assert.match(html, /id="serverActionStatus"[^>]*aria-live="polite"/, "server actions should expose visible restart feedback");
49
53
  assert.match(html, /id="agentDoneNotificationsToggle"/, "side panel should expose an agent-done notifications toggle");
50
54
  assert.match(html, /id="agentDoneNotificationsStatus"/, "agent-done notifications toggle should expose status text");
51
55
  assert.match(html, /id="thinkingVisibilityToggle"/, "side panel should expose a thinking-output visibility toggle");
@@ -56,6 +60,7 @@ assert.match(html, /id="optionalFeaturesBox"/, "side panel should expose optiona
56
60
  assert.match(html, /id="codexUsageBox"/, "side panel should expose Codex subscription usage status");
57
61
  assert.match(html, /data-side-panel-section="codex-usage"/, "Codex usage should live in a collapsible side-panel section");
58
62
  assert.match(html, /id="serverOfflinePanel"/, "PWA/offline shell should expose a backend-offline recovery panel");
63
+ assert.match(html, /id="serverRestartPanel"[\s\S]*id="serverRestartMessage"/, "server restart should expose a loading overlay instead of the generic offline shell");
59
64
  assert.match(html, /id="copyServerCommandButton"/, "backend-offline recovery panel should expose a start-command copy button");
60
65
  assert.match(html, /id="retryServerConnectionButton"/, "backend-offline recovery panel should expose a retry button");
61
66
  assert.match(html, /data-side-panel-section="controls"/, "side panel controls should live in a collapsible section");
@@ -135,6 +140,10 @@ assert.match(css, /\.message-collapse\[open\] \+ \.tool-result-preview \{[\s\S]*
135
140
  assert.match(css, /\.run-indicator-pulse \{[\s\S]*?animation:\s*run-indicator-pulse/, "active agent run indicator should have an animated pulse");
136
141
  assert.match(css, /\.optional-features-box \{[\s\S]*?display:\s*grid/, "optional features should render as a side-panel feature list");
137
142
  assert.match(css, /\.side-panel-section-toggle \{[\s\S]*?justify-content:\s*space-between/, "side panel section toggles should align labels and chevrons");
143
+ assert.match(css, /\.server-restart-panel \{[\s\S]*?z-index:\s*62/, "server restart overlay should render above the offline shell");
144
+ assert.match(css, /@keyframes server-restart-spin/, "server restart overlay should show a loading spinner");
145
+ assert.match(css, /\.webui-version-badge,\n\.webui-dev-badge \{[\s\S]*?border-radius:\s*999px/, "Web UI version and dev indicators should render as compact title badges");
146
+ assert.match(css, /\.webui-dev-badge \{[\s\S]*?color:\s*var\(--ctp-yellow\)/, "Web UI dev indicator should have distinct warning styling");
138
147
  assert.match(css, /\.side-panel-section\.collapsed \.side-panel-section-content,\n\.side-panel-section-content\[hidden\] \{\n\s+display:\s*none;/, "collapsed side panel section content should be hidden");
139
148
  assert.match(css, /\.side-panel-section:not\(\.collapsed\) \.side-panel-section-chevron/, "expanded side panel sections should rotate the chevron");
140
149
  assert.match(css, /\.optional-feature-pill\.enabled/, "optional features should visually distinguish enabled state");
@@ -145,6 +154,10 @@ assert.match(css, /\.todo-widget-item\.partial \.todo-widget-marker/, "todo-prog
145
154
  assert.match(css, /\.todo-widget-item\.done \.todo-widget-text[\s\S]*?text-decoration:\s*line-through/, "todo-progress completed items should be visually crossed out");
146
155
  assert.match(css, /\.release-npm-widget \{[\s\S]*?border-left:\s*0\.28rem solid/, "release-npm output should stand apart from the page background");
147
156
  assert.match(css, /\.release-npm-stream-header \{[\s\S]*?text-transform:\s*uppercase/, "release-npm output should label the output stream clearly");
157
+ assert.match(css, /\.release-npm-output-summary \{[\s\S]*?cursor:\s*pointer/, "release-npm output should expose a local expand/collapse summary");
158
+ assert.match(css, /\.release-npm-output-details\[open\] \.release-npm-output-toggle/, "release-npm expanded output should rotate the summary chevron");
159
+ assert.match(css, /\.widget-area:has\(\.release-npm-live-widget \.release-npm-output-details\[open\]\)[\s\S]*?flex:\s*0 0 min\(44rem, 68dvh\)/, "live release output should reserve a stable widget slot instead of resizing the transcript while streaming");
160
+ assert.match(css, /\.release-npm-live-widget \.release-npm-output-details\[open\] \.release-npm-terminal,[\s\S]*?height:\s*clamp\(15rem, 42dvh, 31rem\)/, "live release terminals should keep a fixed viewport height while output streams");
148
161
  assert.match(css, /\.release-npm-terminal \{[\s\S]*?rgba\(3, 4, 10, 0\.98\)/, "release-npm terminal should use a high-contrast stream panel");
149
162
  assert.match(css, /\.release-aur-widget \{[\s\S]*?border-color/, "release-aur output should render as a specialized Web UI widget variant");
150
163
  assert.match(css, /\.widget-area \.widget:not\(\.todo-widget\):not\(\.release-npm-widget\)/, "mobile widget filtering should keep release workflow output visible");
@@ -239,8 +252,22 @@ assert.match(app, /Restart Web UI to load themes/, "frontend should explain when
239
252
  assert.match(app, /themeSelect\.addEventListener\("change"/, "side-panel theme selector should switch themes immediately");
240
253
  assert.match(app, /open \? "Close for network" : "Open to network"/, "network button should toggle from open to close action");
241
254
  assert.match(app, /api\("\/api\/network\/close", \{ method: "POST"/, "network close action should call the close endpoint");
242
- assert.match(app, /stopServerButton\.addEventListener\("click", stopServer\)/, "Stop Server button should be wired to the shutdown handler");
255
+ assert.match(app, /webuiVersionBadge: \$\("#webuiVersionBadge"\)/, "frontend should bind the Control Deck version badge");
256
+ assert.match(app, /webuiDevBadge: \$\("#webuiDevBadge"\)/, "frontend should bind the Control Deck dev badge");
257
+ assert.match(app, /function refreshWebuiVersion\(\)[\s\S]*api\("\/api\/health", \{ scoped: false \}\)[\s\S]*setWebuiVersion\(health\.webuiVersion\)[\s\S]*setWebuiDevServer\(isWebuiDevMetadata\(health\)\)/, "frontend should load Web UI version and dev mode from health metadata");
258
+ assert.match(app, /case "webui_connected":[\s\S]*setWebuiVersion\(event\.version\)[\s\S]*setWebuiDevServer\(isWebuiDevMetadata\(event\)\)/, "frontend should refresh Web UI version and dev mode from reconnect events");
259
+ assert.match(server, /const webuiDevServer = isTruthyEnv\(process\.env\.PI_WEBUI_DEV\) \|\| isSourceCheckout\(packageRoot\)/, "server should derive dev mode from PI_WEBUI_DEV or a source checkout");
260
+ assert.match(server, /webuiDev: webuiDevServer,[\s\S]*webuiMode: webuiDevServer \? "dev" : "production"/, "server status should expose Web UI dev mode");
261
+ assert.match(server, /type: "webui_connected",[\s\S]*webuiDev: webuiDevServer,[\s\S]*webuiMode: webuiDevServer \? "dev" : "production"/, "SSE connect event should expose Web UI dev mode");
262
+ assert.match(app, /serverActionSelect\.addEventListener\("change", updateServerActionButton\)/, "Server action dropdown should control the guarded run button");
263
+ assert.match(app, /runServerActionButton\.addEventListener\("click"[\s\S]*runSelectedServerAction/, "Server action run button should execute the selected action");
264
+ assert.match(app, /api\("\/api\/restart", \{ method: "POST", scoped: false \}\)/, "Restart Server action should call the unscoped restart endpoint");
265
+ assert.match(app, /setServerActionStatus\(message, "warn"\);\n\s+setServerRestartOverlay\(true, message\)/, "Restart Server action should show reconnect progress in the side panel and loading overlay");
266
+ assert.match(app, /const showOfflinePanel = backendOffline && !serverRestartInProgress/, "intentional restart should suppress the generic offline shell while reconnecting");
243
267
  assert.match(app, /api\("\/api\/shutdown", \{ method: "POST", scoped: false \}\)/, "Stop Server action should call the unscoped shutdown endpoint");
268
+ assert.match(server, /url\.pathname === "\/api\/restart" && req\.method === "POST"/, "server should expose restart endpoint");
269
+ assert.match(server, /PI_WEBUI_RESTORE_TABS: JSON\.stringify\(restorableTabs \|\| \[\]\)/, "server restart should preserve restorable tab metadata");
270
+ assert.match(server, /if \(webuiDevServer\) env\.PI_WEBUI_DEV = "1";/, "server restart should explicitly preserve dev mode");
244
271
  assert.match(server, /async function closeNetworkAccess\(\)/, "server should expose a local-only rebind helper for closing network access");
245
272
  assert.match(server, /url\.pathname === "\/api\/network\/close" && req\.method === "POST"/, "server should route network close requests");
246
273
  assert.match(server, /server\.closeAllConnections\?\.\(\)/, "network rebind should force-close long-lived clients so close-to-localhost can complete");
@@ -324,7 +351,10 @@ assert.match(app, /function bindGitWorkflowToActiveTab\(\) \{\n\s+gitWorkflow =
324
351
  assert.match(app, /function setGitWorkflow\(patch, \{ tabId = activeTabId \} = \{\}\)[\s\S]*if \(tabId === activeTabId\) \{[\s\S]*renderGitWorkflow\(\);/, "guided git workflow should not render inactive terminal workflows globally");
325
352
  assert.doesNotMatch(app, /gitWorkflowVisibleTabId|Workflow belongs to/, "guided git workflow should not pin or show workflows outside their owning terminal tab");
326
353
  assert.match(app, /function renderReleaseNpmOutputWidget\(\)/, "release-npm live output should use a specialized Web UI renderer");
354
+ assert.match(app, /const releaseNpmOutputExpandedByTab = new Map\(\)/, "release-npm output collapse state should be tracked per browser tab");
355
+ assert.match(app, /function renderReleaseNpmOutputDetails\(key, streamHeader, terminal, controls = null\)[\s\S]*node\.open = releaseNpmOutputExpandedByTab\.get\(stateKey\) !== false[\s\S]*release-npm-output-toggle/, "release-npm output should render as a browser-side details expander");
327
356
  assert.match(app, /releaseNpmStreamHeader\("Live output stream", outputLines\.length, \{ live: true \}\)/, "release-npm live output should expose a clear stream heading");
357
+ assert.match(app, /renderReleaseNpmOutputDetails\("release-npm:output", streamHeader, terminal, controls\)/, "release-npm live stream should be wrapped in the local expander");
328
358
  assert.match(app, /function renderReleaseAurOutputWidget\(\)/, "release-aur live output should use a specialized Web UI renderer");
329
359
  assert.match(app, /releaseNpmActionButton\("Abort", "\/release-abort", "danger"\)/, "release-npm Web UI output should expose an abort action");
330
360
  assert.match(app, /releaseNpmActionButton\("Abort", "\/release-aur abort", "danger"\)/, "release-aur Web UI output should expose an abort action");
@@ -566,7 +596,7 @@ assert.equal(manifest.start_url, "/", "PWA manifest should start at the web UI r
566
596
  assert.ok(manifest.icons?.some((icon) => icon.src === "/apple-touch-icon.png" && icon.sizes === "180x180"), "PWA manifest should include a conventional 180px apple touch icon");
567
597
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-192.png" && icon.sizes === "192x192"), "PWA manifest should include a 192px icon");
568
598
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-512.png" && icon.sizes === "512x512"), "PWA manifest should include a 512px icon");
569
- assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v16"/, "PWA service worker should define an app-shell cache");
599
+ assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v18"/, "PWA service worker should define an app-shell cache");
570
600
  assert.match(serviceWorker, /self\.addEventListener\("notificationclick"/, "PWA service worker should focus Web UI when blocked-tab notifications are clicked");
571
601
  assert.match(serviceWorker, /event\.notification\.data\?\.url/, "blocked-tab notifications should carry a URL for service-worker click handling");
572
602
  assert.match(serviceWorker, /"\/apple-touch-icon\.png"/, "PWA service worker should cache the apple touch icon");
@@ -715,6 +745,7 @@ assert.match(readme, /\.\/start-webui\.sh --dev --cwd \/path\/to\/project/, "REA
715
745
  assert.match(startScript, /--dev\)/, "start-webui.sh should accept a --dev flag");
716
746
  assert.match(startScript, /local_pi_webui_bin\(\)/, "start-webui.sh should resolve this checkout's local server entrypoint");
717
747
  assert.match(startScript, /webui_cmd=\(node "\$local_webui_bin"\)/, "start-webui.sh --dev should run the local bin with node");
748
+ assert.match(startScript, /export PI_WEBUI_DEV=1/, "start-webui.sh --dev should mark the Web UI server as dev mode");
718
749
  assert.match(startScript, /"\$\{webui_cmd\[@\]\}" --cwd "\$cwd" --host "\$host" --port "\$port" "\$\{pass_args\[@\]\}"/, "start-webui.sh should launch through the selected server command without forwarding --dev");
719
750
 
720
751
  assert.match(pkg.scripts?.test || "", /node tests\/mobile-static\.test\.mjs/, "package test script should run the mobile static harness");