@firstpick/pi-package-webui 0.1.6 → 0.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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
@@ -7,6 +7,11 @@ const elements = {
7
7
  newTabButton: $("#newTabButton"),
8
8
  closeAllTabsButton: $("#closeAllTabsButton"),
9
9
  statusBar: $("#statusBar"),
10
+ serverOfflinePanel: $("#serverOfflinePanel"),
11
+ serverOfflineCommand: $("#serverOfflineCommand"),
12
+ serverOfflineSlashCommand: $("#serverOfflineSlashCommand"),
13
+ copyServerCommandButton: $("#copyServerCommandButton"),
14
+ retryServerConnectionButton: $("#retryServerConnectionButton"),
10
15
  widgetArea: $("#widgetArea"),
11
16
  stickyUserPromptButton: $("#stickyUserPromptButton"),
12
17
  chat: $("#chat"),
@@ -141,6 +146,8 @@ let pathSuggestAbortController = null;
141
146
  let latestStats = null;
142
147
  let latestWorkspace = null;
143
148
  let latestNetwork = null;
149
+ let backendOffline = false;
150
+ let backendOfflineNoticeShown = false;
144
151
  let latestMessages = [];
145
152
  let transientMessages = [];
146
153
  let actionEntrySeenKeysByTab = new Map();
@@ -193,6 +200,8 @@ const CUSTOM_BACKGROUNDS_STORAGE_KEY = "pi-webui-custom-backgrounds";
193
200
  const CUSTOM_BACKGROUND_IDB_NAME = "pi-webui-custom-background";
194
201
  const CUSTOM_BACKGROUND_IDB_STORE = "backgrounds";
195
202
  const CUSTOM_BACKGROUND_LEGACY_ID = "active";
203
+ const SERVER_START_CWD_STORAGE_KEY = "pi-webui-last-server-cwd";
204
+ const DEFAULT_WEBUI_PORT = "31415";
196
205
  const CUSTOM_BACKGROUND_MAX_FILE_BYTES = 24 * 1024 * 1024;
197
206
  const OPTIONAL_FEATURES_STORAGE_KEY = "pi-webui-optional-features-disabled";
198
207
  const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
@@ -218,6 +227,7 @@ const ABORT_LONG_PRESS_MS = 700;
218
227
  const STREAM_OUTPUT_HIDE_DELAY_MS = 300;
219
228
  const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220;
220
229
  const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
230
+ const TOOL_LIVE_UPDATE_THROTTLE_MS = 80;
221
231
  const TODO_PROGRESS_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)\]\s+.+$/;
222
232
  const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)?\]?\s*.*$/;
223
233
  const CHAT_SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);
@@ -232,6 +242,8 @@ const statusEntries = new Map();
232
242
  const widgets = new Map();
233
243
  const liveToolRuns = new Map();
234
244
  const liveToolCards = new Map();
245
+ const liveToolRenderQueue = new Map();
246
+ let liveToolRenderTimer = null;
235
247
  // Optional feature detection intentionally checks loaded Pi capabilities (RPC-visible
236
248
  // commands and live widget events), not npm package folders. This keeps local dev
237
249
  // symlinks and independently installed packages working.
@@ -702,6 +714,120 @@ function registerPwaServiceWorker() {
702
714
  });
703
715
  }
704
716
 
717
+ function readStoredServerStartCwd() {
718
+ try {
719
+ return localStorage.getItem(SERVER_START_CWD_STORAGE_KEY) || "";
720
+ } catch {
721
+ return "";
722
+ }
723
+ }
724
+
725
+ function rememberServerStartCwd(cwd) {
726
+ const value = typeof cwd === "string" ? cwd.trim() : "";
727
+ if (!value) return;
728
+ try {
729
+ localStorage.setItem(SERVER_START_CWD_STORAGE_KEY, value);
730
+ } catch {
731
+ // Ignore storage failures; the offline start helper can still show a generic command.
732
+ }
733
+ if (backendOffline) renderServerOfflinePanel();
734
+ }
735
+
736
+ function quoteCommandArg(value) {
737
+ const text = String(value || ".");
738
+ if (!/[\s"'`$]/.test(text)) return text;
739
+ if (!text.includes("'")) return `'${text}'`;
740
+ return `"${text.replace(/(["`$])/g, "\\$1")}"`;
741
+ }
742
+
743
+ function currentPortArg() {
744
+ const port = window.location.port || "";
745
+ return port && port !== DEFAULT_WEBUI_PORT ? ` --port ${port}` : "";
746
+ }
747
+
748
+ function serverStartCommandText() {
749
+ const cwd = readStoredServerStartCwd() || ".";
750
+ return `pi-webui --cwd ${quoteCommandArg(cwd)}${currentPortArg()}`;
751
+ }
752
+
753
+ function serverStartSlashCommandText() {
754
+ return `/webui-start${currentPortArg()}`;
755
+ }
756
+
757
+ function renderServerOfflinePanel() {
758
+ if (elements.serverOfflineCommand) elements.serverOfflineCommand.textContent = serverStartCommandText();
759
+ if (elements.serverOfflineSlashCommand) elements.serverOfflineSlashCommand.textContent = serverStartSlashCommandText();
760
+ }
761
+
762
+ function setBackendOffline(offline, error) {
763
+ backendOffline = !!offline;
764
+ document.body.classList.toggle("server-offline", backendOffline);
765
+ if (elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = !backendOffline;
766
+ renderServerOfflinePanel();
767
+ if (backendOffline) {
768
+ if (!backendOfflineNoticeShown) {
769
+ backendOfflineNoticeShown = true;
770
+ addEvent(`Pi Web UI server is offline${error?.message ? `: ${error.message}` : ""}`, "warn");
771
+ }
772
+ return;
773
+ }
774
+ if (backendOfflineNoticeShown) addEvent("Pi Web UI server is back online", "info");
775
+ backendOfflineNoticeShown = false;
776
+ }
777
+
778
+ async function copyText(text) {
779
+ if (navigator.clipboard?.writeText && window.isSecureContext) {
780
+ await navigator.clipboard.writeText(text);
781
+ return;
782
+ }
783
+ const textarea = document.createElement("textarea");
784
+ textarea.value = text;
785
+ textarea.setAttribute("readonly", "");
786
+ textarea.style.position = "fixed";
787
+ textarea.style.opacity = "0";
788
+ document.body.append(textarea);
789
+ textarea.select();
790
+ const copied = document.execCommand("copy");
791
+ textarea.remove();
792
+ if (!copied) throw new Error("Clipboard copy failed");
793
+ }
794
+
795
+ async function copyServerStartCommand() {
796
+ const command = serverStartCommandText();
797
+ try {
798
+ await copyText(command);
799
+ addEvent("copied Pi Web UI start command", "info");
800
+ } catch (error) {
801
+ addEvent(`copy failed; manually run: ${command}`, "warn");
802
+ }
803
+ }
804
+
805
+ async function retryServerConnection() {
806
+ const button = elements.retryServerConnectionButton;
807
+ if (button) {
808
+ button.disabled = true;
809
+ button.textContent = "Retrying…";
810
+ }
811
+ try {
812
+ await api("/api/health", { scoped: false });
813
+ } catch (error) {
814
+ setBackendOffline(true, error);
815
+ addEvent("Pi Web UI server is still offline", "warn");
816
+ return;
817
+ } finally {
818
+ if (button) {
819
+ button.disabled = false;
820
+ button.textContent = "Retry connection";
821
+ }
822
+ }
823
+
824
+ try {
825
+ await initializeTabs();
826
+ } catch (error) {
827
+ addEvent(error.message || String(error), "error");
828
+ }
829
+ }
830
+
705
831
  function scopedApiPath(path, tabId = activeTabId) {
706
832
  if (!tabId || !path.startsWith("/api/") || path === "/api/tabs" || path.startsWith("/api/tabs?") || path.startsWith("/api/tabs/")) return path;
707
833
  const url = new URL(path, window.location.origin);
@@ -710,12 +836,21 @@ function scopedApiPath(path, tabId = activeTabId) {
710
836
  }
711
837
 
712
838
  async function api(path, { method = "GET", body, tabId = activeTabId, scoped = true, signal } = {}) {
713
- const response = await fetch(scoped ? scopedApiPath(path, tabId) : path, {
714
- method,
715
- headers: body === undefined ? undefined : { "content-type": "application/json" },
716
- body: body === undefined ? undefined : JSON.stringify(body),
717
- signal,
718
- });
839
+ let response;
840
+ try {
841
+ response = await fetch(scoped ? scopedApiPath(path, tabId) : path, {
842
+ method,
843
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
844
+ body: body === undefined ? undefined : JSON.stringify(body),
845
+ signal,
846
+ });
847
+ } catch (error) {
848
+ const offlineError = error instanceof Error ? error : new Error(String(error));
849
+ offlineError.backendOffline = true;
850
+ setBackendOffline(true, offlineError);
851
+ throw offlineError;
852
+ }
853
+ setBackendOffline(false);
719
854
  const data = await response.json().catch(() => ({}));
720
855
  if (!response.ok) {
721
856
  const error = new Error(data.error || data.message || JSON.stringify(data));
@@ -2016,6 +2151,7 @@ function cancelPendingDialogs() {
2016
2151
 
2017
2152
  function resetActiveTabUi() {
2018
2153
  clearRefreshTimers();
2154
+ clearLiveToolRenderQueue();
2019
2155
  eventSource?.close();
2020
2156
  eventSource = null;
2021
2157
  currentState = null;
@@ -2306,6 +2442,7 @@ async function refreshTabs({ selectStored = false } = {}) {
2306
2442
  if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
2307
2443
  setActiveTabId((stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null, { remember: true });
2308
2444
  }
2445
+ rememberServerStartCwd(tabs.find((tab) => tab.id === activeTabId)?.cwd || tabs[0]?.cwd);
2309
2446
  renderTabs();
2310
2447
  return tabs;
2311
2448
  }
@@ -4992,7 +5129,10 @@ function toolResultForCallId(toolCallId, messages = latestMessages) {
4992
5129
  function cleanupLiveToolRunsForMessages(messages = latestMessages) {
4993
5130
  const results = buildToolResultMap(messages);
4994
5131
  for (const id of liveToolRuns.keys()) {
4995
- if (results.has(id)) liveToolRuns.delete(id);
5132
+ if (results.has(id)) {
5133
+ liveToolRuns.delete(id);
5134
+ cancelQueuedLiveToolRunRender(id);
5135
+ }
4996
5136
  }
4997
5137
  }
4998
5138
 
@@ -5306,12 +5446,111 @@ function liveToolRunMessage(run) {
5306
5446
  };
5307
5447
  }
5308
5448
 
5449
+ function applyToolExecutionBubbleState(bubble, message) {
5450
+ const status = toolExecutionStatus(message);
5451
+ bubble.classList.remove("tool-pending", "tool-running", "tool-success", "tool-error", "error");
5452
+ bubble.classList.add(`tool-${status}`);
5453
+ if (message.isError || status === "error") bubble.classList.add("error");
5454
+ if (message.toolCallId) {
5455
+ const id = String(message.toolCallId);
5456
+ bubble.dataset.toolCallId = id;
5457
+ if (message.live) liveToolCards.set(id, bubble);
5458
+ }
5459
+ }
5460
+
5461
+ function toolDetailsStateKey(details, counts) {
5462
+ const classKey = Array.from(details.classList || []).sort().join(".") || "details";
5463
+ const summaryText = details.querySelector("summary")?.textContent || "";
5464
+ const summaryKey = summaryText.replace(/\s*\([^)]*\)\s*$/g, "").trim();
5465
+ const base = `${classKey}|${summaryKey}`;
5466
+ const index = counts.get(base) || 0;
5467
+ counts.set(base, index + 1);
5468
+ return `${base}|${index}`;
5469
+ }
5470
+
5471
+ function captureToolDetailsOpenState(root) {
5472
+ const state = new Set();
5473
+ const counts = new Map();
5474
+ for (const details of root.querySelectorAll("details")) {
5475
+ const key = toolDetailsStateKey(details, counts);
5476
+ if (details.open) state.add(key);
5477
+ }
5478
+ return state;
5479
+ }
5480
+
5481
+ function restoreToolDetailsOpenState(root, state) {
5482
+ if (!state?.size) return;
5483
+ const counts = new Map();
5484
+ for (const details of root.querySelectorAll("details")) {
5485
+ if (state.has(toolDetailsStateKey(details, counts))) details.open = true;
5486
+ }
5487
+ }
5488
+
5489
+ function updateLiveToolCard(bubble, message) {
5490
+ if (!bubble?.isConnected) return false;
5491
+ const header = bubble.querySelector(":scope > .message-header");
5492
+ const body = bubble.querySelector(":scope > .message-body");
5493
+ if (!body) return false;
5494
+ applyToolExecutionBubbleState(bubble, message);
5495
+ const role = header?.querySelector(".message-role");
5496
+ if (role) role.textContent = messageTitle(message);
5497
+ const timestamp = header?.querySelector(".muted");
5498
+ if (timestamp) timestamp.textContent = formatDate(message.timestamp);
5499
+ const detailsOpenState = captureToolDetailsOpenState(body);
5500
+ body.replaceChildren();
5501
+ renderToolExecution(body, message);
5502
+ restoreToolDetailsOpenState(body, detailsOpenState);
5503
+ return true;
5504
+ }
5505
+
5506
+ function cancelQueuedLiveToolRunRender(toolCallId = "") {
5507
+ if (toolCallId) liveToolRenderQueue.delete(String(toolCallId));
5508
+ else liveToolRenderQueue.clear();
5509
+ if (liveToolRenderQueue.size === 0) {
5510
+ clearTimeout(liveToolRenderTimer);
5511
+ liveToolRenderTimer = null;
5512
+ }
5513
+ }
5514
+
5515
+ function clearLiveToolRenderQueue() {
5516
+ cancelQueuedLiveToolRunRender();
5517
+ }
5518
+
5519
+ function flushLiveToolRunRenderQueue() {
5520
+ const entries = Array.from(liveToolRenderQueue.values());
5521
+ clearLiveToolRenderQueue();
5522
+ for (const entry of entries) renderLiveToolRun(entry.run, { scroll: entry.scroll });
5523
+ }
5524
+
5525
+ function scheduleLiveToolRunRender(run, { scroll = false } = {}) {
5526
+ if (!run?.toolCallId) return;
5527
+ const id = String(run.toolCallId);
5528
+ const existing = liveToolRenderQueue.get(id);
5529
+ liveToolRenderQueue.set(id, { run, scroll: !!(existing?.scroll || scroll) });
5530
+ if (liveToolRenderTimer) return;
5531
+ liveToolRenderTimer = setTimeout(() => {
5532
+ liveToolRenderTimer = null;
5533
+ const flush = () => flushLiveToolRunRenderQueue();
5534
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(flush);
5535
+ else flush();
5536
+ }, TOOL_LIVE_UPDATE_THROTTLE_MS);
5537
+ }
5538
+
5309
5539
  function renderLiveToolRun(run, { scroll = true } = {}) {
5310
5540
  if (!run?.toolCallId) return;
5311
- const existing = liveToolCards.get(run.toolCallId);
5541
+ const id = String(run.toolCallId);
5542
+ cancelQueuedLiveToolRunRender(id);
5543
+ const existing = liveToolCards.get(id);
5544
+ const existingConnected = !!(existing?.isConnected && existing.parentElement === elements.chat);
5312
5545
  const shouldFollow = scroll && (autoFollowChat || isChatNearBottom());
5313
- const created = appendMessage(liveToolRunMessage(run), { transient: true, animateEntry: !existing });
5314
- if (existing?.isConnected && existing !== created.bubble) existing.replaceWith(created.bubble);
5546
+ const message = liveToolRunMessage(run);
5547
+ if (existingConnected && updateLiveToolCard(existing, message)) {
5548
+ renderRunIndicator({ scroll: false });
5549
+ if (shouldFollow) scrollChatToBottom();
5550
+ return;
5551
+ }
5552
+ const created = appendMessage(message, { transient: true, animateEntry: !existingConnected });
5553
+ if (existingConnected && existing !== created.bubble) existing.replaceWith(created.bubble);
5315
5554
  renderRunIndicator({ scroll: false });
5316
5555
  if (shouldFollow) scrollChatToBottom();
5317
5556
  }
@@ -5345,7 +5584,7 @@ function handleToolExecutionStart(event) {
5345
5584
  function handleToolExecutionUpdate(event) {
5346
5585
  const result = { ...(event.partialResult || {}), isError: false };
5347
5586
  const run = upsertLiveToolRun(event, { result, isPartial: true, isError: false });
5348
- if (run) renderLiveToolRun(run, { scroll: false });
5587
+ if (run) scheduleLiveToolRunRender(run, { scroll: false });
5349
5588
  }
5350
5589
 
5351
5590
  function handleToolExecutionEnd(event) {
@@ -5381,15 +5620,7 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
5381
5620
  const role = String(message.role || "message");
5382
5621
  const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
5383
5622
  const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}${animateEntry ? " action-enter" : ""}`);
5384
- if (message.role === "toolExecution") {
5385
- const status = toolExecutionStatus(message);
5386
- bubble.classList.add(`tool-${status}`);
5387
- if (message.isError || status === "error") bubble.classList.add("error");
5388
- if (message.toolCallId) {
5389
- bubble.dataset.toolCallId = String(message.toolCallId);
5390
- if (message.live) liveToolCards.set(String(message.toolCallId), bubble);
5391
- }
5392
- }
5623
+ if (message.role === "toolExecution") applyToolExecutionBubbleState(bubble, message);
5393
5624
  if (!transient && messageIndex >= 0) {
5394
5625
  bubble.dataset.messageIndex = String(messageIndex);
5395
5626
  if (role === "user") bubble.dataset.userPrompt = "true";
@@ -6819,6 +7050,7 @@ async function refreshWorkspace(tabContext = activeTabContext()) {
6819
7050
  }
6820
7051
  if (!isCurrentTabContext(tabContext)) return;
6821
7052
  latestWorkspace = nextWorkspace;
7053
+ rememberServerStartCwd(nextWorkspace?.cwd);
6822
7054
  renderFooter();
6823
7055
  }
6824
7056
 
@@ -7908,10 +8140,14 @@ function connectEvents(tabContext = activeTabContext()) {
7908
8140
  }
7909
8141
  };
7910
8142
  source.onerror = () => {
7911
- if (eventSource === source && isCurrentTabContext(tabContext)) addEvent("event stream disconnected; browser will retry", "warn");
8143
+ if (eventSource !== source || !isCurrentTabContext(tabContext)) return;
8144
+ addEvent("event stream disconnected; browser will retry", "warn");
8145
+ fetch("/api/health", { cache: "no-store" }).catch((error) => setBackendOffline(true, error));
7912
8146
  };
7913
8147
  }
7914
8148
 
8149
+ elements.copyServerCommandButton?.addEventListener("click", copyServerStartCommand);
8150
+ elements.retryServerConnectionButton?.addEventListener("click", retryServerConnection);
7915
8151
  elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
7916
8152
  elements.composer.addEventListener("submit", (event) => {
7917
8153
  event.preventDefault();
@@ -8273,4 +8509,5 @@ bindSidePanelSectionToggles();
8273
8509
  restoreSidePanelState();
8274
8510
  bindMobileViewChanges();
8275
8511
  registerPwaServiceWorker();
8512
+ renderServerOfflinePanel();
8276
8513
  initializeTabs().catch((error) => addEvent(error.message, "error"));
package/public/index.html CHANGED
@@ -22,6 +22,20 @@
22
22
  <span class="side-panel-button-chevron" aria-hidden="true">‹</span>
23
23
  </button>
24
24
 
25
+ <section id="serverOfflinePanel" class="server-offline-panel" aria-live="assertive" hidden>
26
+ <div class="server-offline-card" role="status">
27
+ <span class="server-offline-kicker">Backend unavailable</span>
28
+ <h1>Pi Web UI server is offline</h1>
29
+ <p>Run this on the machine that hosts Pi Web UI, then retry the connection.</p>
30
+ <code id="serverOfflineCommand" class="server-offline-command">pi-webui --cwd .</code>
31
+ <div class="server-offline-actions">
32
+ <button id="copyServerCommandButton" class="primary" type="button">Copy start command</button>
33
+ <button id="retryServerConnectionButton" type="button">Retry connection</button>
34
+ </div>
35
+ <p class="server-offline-note muted">From an existing Pi terminal you can also run <code id="serverOfflineSlashCommand">/webui-start</code>.</p>
36
+ </div>
37
+ </section>
38
+
25
39
  <main class="layout">
26
40
  <section class="chat-panel">
27
41
  <header class="terminal-tabs-shell">
package/public/styles.css CHANGED
@@ -309,6 +309,72 @@ body.side-panel-collapsed .side-panel {
309
309
  .side-panel-backdrop {
310
310
  display: none;
311
311
  }
312
+ .server-offline-panel[hidden] {
313
+ display: none !important;
314
+ }
315
+ .server-offline-panel {
316
+ position: fixed;
317
+ inset: 1rem;
318
+ z-index: 60;
319
+ display: grid;
320
+ place-items: center;
321
+ padding: 1rem;
322
+ pointer-events: none;
323
+ }
324
+ .server-offline-card {
325
+ position: relative;
326
+ width: min(44rem, 100%);
327
+ pointer-events: auto;
328
+ padding: clamp(1.2rem, 4vw, 2rem);
329
+ border: 1px solid rgba(249, 226, 175, 0.38);
330
+ border-radius: 1.2rem;
331
+ background:
332
+ radial-gradient(circle at 12% 0, rgba(249, 226, 175, 0.16), transparent 18rem),
333
+ linear-gradient(145deg, rgba(var(--ctp-base-rgb), 0.96), rgba(var(--ctp-crust-rgb), 0.98));
334
+ box-shadow: 0 1.2rem 4rem rgba(var(--ctp-crust-rgb), 0.74), 0 0 2rem rgba(249, 226, 175, 0.14), inset 0 1px 0 rgba(255,255,255,0.07);
335
+ }
336
+ .server-offline-kicker {
337
+ display: inline-block;
338
+ margin-bottom: 0.6rem;
339
+ color: var(--warning);
340
+ font-size: 0.76rem;
341
+ font-weight: 800;
342
+ letter-spacing: 0.12em;
343
+ text-transform: uppercase;
344
+ }
345
+ .server-offline-card h1 {
346
+ margin: 0 0 0.55rem;
347
+ font-size: clamp(1.35rem, 4vw, 2.1rem);
348
+ }
349
+ .server-offline-card p {
350
+ margin: 0 0 1rem;
351
+ }
352
+ .server-offline-command {
353
+ display: block;
354
+ margin: 0.9rem 0 1rem;
355
+ padding: 0.9rem 1rem;
356
+ overflow-x: auto;
357
+ white-space: pre;
358
+ border: 1px solid rgba(148, 226, 213, 0.26);
359
+ border-radius: 0.85rem;
360
+ color: var(--ctp-teal);
361
+ background: rgba(var(--ctp-crust-rgb), 0.72);
362
+ box-shadow: inset 0 0 1.2rem rgba(var(--ctp-crust-rgb), 0.46);
363
+ }
364
+ .server-offline-actions {
365
+ display: flex;
366
+ flex-wrap: wrap;
367
+ gap: 0.7rem;
368
+ }
369
+ .server-offline-note {
370
+ margin: 1rem 0 0;
371
+ font-size: 0.86rem;
372
+ }
373
+ body.server-offline .layout {
374
+ opacity: 0.42;
375
+ filter: blur(2px);
376
+ pointer-events: none;
377
+ }
312
378
  .side-panel-expand-button {
313
379
  position: fixed;
314
380
  top: 1rem;
@@ -1922,6 +1988,7 @@ button.footer-meta {
1922
1988
  .message.toolExecution {
1923
1989
  border-color: rgba(249, 226, 175, 0.34);
1924
1990
  background: linear-gradient(145deg, rgba(249, 226, 175, 0.12), rgba(var(--ctp-surface-rgb), 0.58));
1991
+ transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
1925
1992
  }
1926
1993
  .message.toolExecution .message-role {
1927
1994
  color: var(--ctp-yellow);
@@ -51,6 +51,9 @@ assert.match(html, /id="thinkingVisibilityStatus"/, "thinking-output visibility
51
51
  assert.match(html, /id="nativeCommandDialog"/, "native slash selector UI should have a dedicated dialog");
52
52
  assert.match(html, /id="nativeCommandSearch"[^>]*type="search"/, "native slash selector dialog should expose a filter box");
53
53
  assert.match(html, /id="optionalFeaturesBox"/, "side panel should expose optional feature status and controls");
54
+ assert.match(html, /id="serverOfflinePanel"/, "PWA/offline shell should expose a backend-offline recovery panel");
55
+ assert.match(html, /id="copyServerCommandButton"/, "backend-offline recovery panel should expose a start-command copy button");
56
+ assert.match(html, /id="retryServerConnectionButton"/, "backend-offline recovery panel should expose a retry button");
54
57
  assert.match(html, /data-side-panel-section="controls"/, "side panel controls should live in a collapsible section");
55
58
  assert.match(html, /data-side-panel-section="commands"/, "side panel commands should live in a collapsible section");
56
59
  assert.match(html, /class="side-panel-section-toggle"[^>]*aria-controls="sidePanelSectionControls"/, "side panel section toggles should target their content panels");
@@ -158,6 +161,7 @@ assert.match(css, /\.terminal-tab\.activity-done/, "completed unseen work should
158
161
  assert.match(css, /\.terminal-tabs[\s\S]*?position:\s*absolute/, "expanded mobile tabs should overlay instead of consuming transcript space");
159
162
  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");
160
163
  assert.match(css, /body\.mobile-keyboard-open \.composer-actions-button,[\s\S]*?body\.mobile-keyboard-open \.composer-actions-panel/, "mobile keyboard mode should hide the secondary actions sheet while keeping active-run controls available");
164
+ assert.match(css, /\.server-offline-panel/, "PWA/offline shell should style a backend-offline recovery panel");
161
165
  assert.match(css, /body:not\(\.pi-run-active\):not\(\.mobile-keyboard-open\) \.composer-row button\.primary \{ grid-column: span 4; \}/, "idle mobile composer should keep Actions and Send on one compact row");
162
166
  assert.match(css, /button\[hidden\] \{ display: none !important; \}/, "hidden bottom-row controls should not occupy layout space");
163
167
  assert.match(css, /\.footer-details-toggle \{ display: none; \}/, "footer details toggle should be hidden outside mobile CSS");
@@ -223,6 +227,10 @@ assert.match(app, /async function initializeTabs\(\)[\s\S]*?restoreActiveDraft\(
223
227
  assert.match(app, /resizePromptInput\(\);\nfocusPromptInput\(\{ defer: true \}\);\nupdateComposerModeButtons\(\);/, "startup should request prompt focus before waiting for tab state refreshes");
224
228
  assert.match(app, /elements\.promptInput\.addEventListener\("focus", \(\) => \{\n\s+syncMobileChatToBottomForInput\(\);/, "focusing mobile input should scroll output to bottom");
225
229
  assert.match(app, /navigator\.serviceWorker\.register\("\/service-worker\.js"\)/, "PWA service worker should be registered by the app");
230
+ assert.match(app, /function serverStartCommandText\(\)[\s\S]*pi-webui --cwd/, "PWA/offline shell should build a pi-webui --cwd recovery command");
231
+ assert.match(app, /Pi Web UI server is offline/, "PWA/offline shell should clearly report backend-down state");
232
+ assert.match(app, /navigator\.clipboard\.writeText\(text\)/, "backend-offline recovery panel should copy the start command when possible");
233
+ assert.match(app, /retryServerConnectionButton.*retryServerConnection/s, "backend-offline recovery panel should wire a retry action");
226
234
  assert.match(app, /function isChatNearBottom\(/, "chat should detect whether the user is reading above the bottom");
227
235
  assert.match(app, /function scheduleChatFollowScroll\(/, "chat auto-follow should retry after layout settles during fast streaming");
228
236
  assert.match(app, /function setChatScrollTopInstant\(top\)[\s\S]*?scrollBehavior = "auto"/, "chat auto-follow should bypass smooth scrolling while chasing fast output");
@@ -279,7 +287,10 @@ assert.match(app, /function toolResultPreviewText\(message, lineLimit = 10\)/, "
279
287
  assert.match(app, /function renderToolExecution\(parent, message\)[\s\S]*?WEBUI_TOOL_RENDERERS/, "paired tool cards should use the browser-side built-in tool renderer registry");
280
288
  assert.match(app, /appendToolRawDetails\(parent, tool\)/, "paired tool cards should keep a safe raw-data expander for debugging renderer mismatches");
281
289
  assert.match(app, /function toolStateMeta\(tool\)/, "tool cards should expose consistent status and elapsed metadata across built-in renderers");
282
- assert.match(app, /function handleToolExecutionUpdate\(event\)[\s\S]*?event\.partialResult/, "live tool_execution_update events should update transcript-visible tool cards");
290
+ assert.match(app, /const TOOL_LIVE_UPDATE_THROTTLE_MS = 80/, "live tool cards should coalesce rapid partial updates before re-rendering");
291
+ assert.match(app, /function updateLiveToolCard\(bubble, message\)[\s\S]*?body\.replaceChildren\(\);[\s\S]*?renderToolExecution\(body, message\);/, "live tool card updates should re-render the existing card body in place");
292
+ assert.match(app, /function scheduleLiveToolRunRender\(run[\s\S]*?liveToolRenderQueue\.set[\s\S]*?TOOL_LIVE_UPDATE_THROTTLE_MS/, "live tool update events should be queued and throttled for smoother browser output");
293
+ assert.match(app, /function handleToolExecutionUpdate\(event\)[\s\S]*?event\.partialResult[\s\S]*?scheduleLiveToolRunRender\(run, \{ scroll: false \}\)/, "live tool_execution_update events should update transcript-visible tool cards without replacing them per event");
283
294
  assert.match(app, /appendText\(preview, toolResultPreviewText\(message, 10\), "code-block tool-result-preview-text"\)/, "collapsed tool results should render the first ten preview lines by default");
284
295
  assert.match(app, /function assistantDisplayMessages\(message\)/, "assistant history should split thinking and tool-call parts out of the final Assistant output card");
285
296
  assert.match(app, /function assistantHasToolCallAfter\(content, index\)/, "assistant text that precedes a tool call should be detectable and suppressible");