@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 +1 -1
- package/public/app.js +258 -21
- package/public/index.html +14 -0
- package/public/styles.css +67 -0
- package/tests/mobile-static.test.mjs +12 -1
package/package.json
CHANGED
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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))
|
|
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
|
|
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
|
|
5314
|
-
if (
|
|
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)
|
|
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
|
|
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, /
|
|
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");
|