@firstpick/pi-package-webui 0.1.6 → 0.1.8
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 +2 -0
- package/images/Guardrails_v0.1.7.png +0 -0
- package/images/Guided_GitWorkflow_v0.1.7.png +0 -0
- package/images/Main_Window_v0.1.7.png +0 -0
- package/images/Matrix_Theme_v0.1.7.png +0 -0
- package/package.json +3 -1
- package/public/app.js +504 -117
- package/public/index.html +14 -0
- package/public/styles.css +67 -0
- package/tests/mobile-static.test.mjs +16 -1
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Local browser companion for [Pi coding agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent).
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
5
7
|
This package provides:
|
|
6
8
|
|
|
7
9
|
- `pi-webui`: a local HTTP/SSE server that starts `pi --mode rpc`, serves the static browser UI, and proxies browser actions to Pi RPC commands.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-package-webui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"extension"
|
|
15
15
|
],
|
|
16
16
|
"pi": {
|
|
17
|
+
"image": "https://unpkg.com/@firstpick/pi-package-webui/images/Main_Window_v0.1.7.png",
|
|
17
18
|
"extensions": [
|
|
18
19
|
"./index.ts",
|
|
19
20
|
"../pi-extension-git-footer-status/index.ts",
|
|
@@ -63,6 +64,7 @@
|
|
|
63
64
|
"index.ts",
|
|
64
65
|
"bin",
|
|
65
66
|
"public",
|
|
67
|
+
"images",
|
|
66
68
|
"tests",
|
|
67
69
|
"README.md",
|
|
68
70
|
"LICENSE"
|
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.
|
|
@@ -307,16 +319,56 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
|
|
|
307
319
|
const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate"]);
|
|
308
320
|
const NATIVE_SELECTOR_COMMANDS = new Set(["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models"]);
|
|
309
321
|
const optionalFeatureInstallInProgress = new Set();
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
322
|
+
|
|
323
|
+
function createGitWorkflowState() {
|
|
324
|
+
return {
|
|
325
|
+
active: false,
|
|
326
|
+
step: "idle",
|
|
327
|
+
busy: false,
|
|
328
|
+
runId: 0,
|
|
329
|
+
output: "",
|
|
330
|
+
error: "",
|
|
331
|
+
message: null,
|
|
332
|
+
messageRequestedAt: 0,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const gitWorkflowsByTab = new Map();
|
|
337
|
+
let gitWorkflow = createGitWorkflowState();
|
|
338
|
+
|
|
339
|
+
function gitWorkflowForTab(tabId = activeTabId, { create = true } = {}) {
|
|
340
|
+
if (!tabId) return null;
|
|
341
|
+
let workflow = gitWorkflowsByTab.get(tabId);
|
|
342
|
+
if (!workflow && create) {
|
|
343
|
+
workflow = createGitWorkflowState();
|
|
344
|
+
gitWorkflowsByTab.set(tabId, workflow);
|
|
345
|
+
}
|
|
346
|
+
return workflow || null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function bindGitWorkflowToActiveTab() {
|
|
350
|
+
gitWorkflow = gitWorkflowForTab(activeTabId) || createGitWorkflowState();
|
|
351
|
+
return gitWorkflow;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function resetGitWorkflowForTab(tabId = activeTabId) {
|
|
355
|
+
if (!tabId) return;
|
|
356
|
+
gitWorkflowsByTab.set(tabId, createGitWorkflowState());
|
|
357
|
+
if (tabId === activeTabId) {
|
|
358
|
+
bindGitWorkflowToActiveTab();
|
|
359
|
+
renderGitWorkflow();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function clearGitWorkflowForTab(tabId) {
|
|
364
|
+
if (!tabId) return;
|
|
365
|
+
gitWorkflowsByTab.delete(tabId);
|
|
366
|
+
if (tabId === activeTabId) {
|
|
367
|
+
bindGitWorkflowToActiveTab();
|
|
368
|
+
renderGitWorkflow();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
320
372
|
const GIT_WORKFLOW_STEPS = ["Stage", "Message", "Commit", "Push"];
|
|
321
373
|
const ACTION_FEEDBACK_REACTIONS = {
|
|
322
374
|
up: { icon: "👍", label: "Good job", title: "Good job!" },
|
|
@@ -702,6 +754,120 @@ function registerPwaServiceWorker() {
|
|
|
702
754
|
});
|
|
703
755
|
}
|
|
704
756
|
|
|
757
|
+
function readStoredServerStartCwd() {
|
|
758
|
+
try {
|
|
759
|
+
return localStorage.getItem(SERVER_START_CWD_STORAGE_KEY) || "";
|
|
760
|
+
} catch {
|
|
761
|
+
return "";
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function rememberServerStartCwd(cwd) {
|
|
766
|
+
const value = typeof cwd === "string" ? cwd.trim() : "";
|
|
767
|
+
if (!value) return;
|
|
768
|
+
try {
|
|
769
|
+
localStorage.setItem(SERVER_START_CWD_STORAGE_KEY, value);
|
|
770
|
+
} catch {
|
|
771
|
+
// Ignore storage failures; the offline start helper can still show a generic command.
|
|
772
|
+
}
|
|
773
|
+
if (backendOffline) renderServerOfflinePanel();
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function quoteCommandArg(value) {
|
|
777
|
+
const text = String(value || ".");
|
|
778
|
+
if (!/[\s"'`$]/.test(text)) return text;
|
|
779
|
+
if (!text.includes("'")) return `'${text}'`;
|
|
780
|
+
return `"${text.replace(/(["`$])/g, "\\$1")}"`;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function currentPortArg() {
|
|
784
|
+
const port = window.location.port || "";
|
|
785
|
+
return port && port !== DEFAULT_WEBUI_PORT ? ` --port ${port}` : "";
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function serverStartCommandText() {
|
|
789
|
+
const cwd = readStoredServerStartCwd() || ".";
|
|
790
|
+
return `pi-webui --cwd ${quoteCommandArg(cwd)}${currentPortArg()}`;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function serverStartSlashCommandText() {
|
|
794
|
+
return `/webui-start${currentPortArg()}`;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function renderServerOfflinePanel() {
|
|
798
|
+
if (elements.serverOfflineCommand) elements.serverOfflineCommand.textContent = serverStartCommandText();
|
|
799
|
+
if (elements.serverOfflineSlashCommand) elements.serverOfflineSlashCommand.textContent = serverStartSlashCommandText();
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function setBackendOffline(offline, error) {
|
|
803
|
+
backendOffline = !!offline;
|
|
804
|
+
document.body.classList.toggle("server-offline", backendOffline);
|
|
805
|
+
if (elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = !backendOffline;
|
|
806
|
+
renderServerOfflinePanel();
|
|
807
|
+
if (backendOffline) {
|
|
808
|
+
if (!backendOfflineNoticeShown) {
|
|
809
|
+
backendOfflineNoticeShown = true;
|
|
810
|
+
addEvent(`Pi Web UI server is offline${error?.message ? `: ${error.message}` : ""}`, "warn");
|
|
811
|
+
}
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (backendOfflineNoticeShown) addEvent("Pi Web UI server is back online", "info");
|
|
815
|
+
backendOfflineNoticeShown = false;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async function copyText(text) {
|
|
819
|
+
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
|
820
|
+
await navigator.clipboard.writeText(text);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const textarea = document.createElement("textarea");
|
|
824
|
+
textarea.value = text;
|
|
825
|
+
textarea.setAttribute("readonly", "");
|
|
826
|
+
textarea.style.position = "fixed";
|
|
827
|
+
textarea.style.opacity = "0";
|
|
828
|
+
document.body.append(textarea);
|
|
829
|
+
textarea.select();
|
|
830
|
+
const copied = document.execCommand("copy");
|
|
831
|
+
textarea.remove();
|
|
832
|
+
if (!copied) throw new Error("Clipboard copy failed");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function copyServerStartCommand() {
|
|
836
|
+
const command = serverStartCommandText();
|
|
837
|
+
try {
|
|
838
|
+
await copyText(command);
|
|
839
|
+
addEvent("copied Pi Web UI start command", "info");
|
|
840
|
+
} catch (error) {
|
|
841
|
+
addEvent(`copy failed; manually run: ${command}`, "warn");
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function retryServerConnection() {
|
|
846
|
+
const button = elements.retryServerConnectionButton;
|
|
847
|
+
if (button) {
|
|
848
|
+
button.disabled = true;
|
|
849
|
+
button.textContent = "Retrying…";
|
|
850
|
+
}
|
|
851
|
+
try {
|
|
852
|
+
await api("/api/health", { scoped: false });
|
|
853
|
+
} catch (error) {
|
|
854
|
+
setBackendOffline(true, error);
|
|
855
|
+
addEvent("Pi Web UI server is still offline", "warn");
|
|
856
|
+
return;
|
|
857
|
+
} finally {
|
|
858
|
+
if (button) {
|
|
859
|
+
button.disabled = false;
|
|
860
|
+
button.textContent = "Retry connection";
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
try {
|
|
865
|
+
await initializeTabs();
|
|
866
|
+
} catch (error) {
|
|
867
|
+
addEvent(error.message || String(error), "error");
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
705
871
|
function scopedApiPath(path, tabId = activeTabId) {
|
|
706
872
|
if (!tabId || !path.startsWith("/api/") || path === "/api/tabs" || path.startsWith("/api/tabs?") || path.startsWith("/api/tabs/")) return path;
|
|
707
873
|
const url = new URL(path, window.location.origin);
|
|
@@ -710,12 +876,21 @@ function scopedApiPath(path, tabId = activeTabId) {
|
|
|
710
876
|
}
|
|
711
877
|
|
|
712
878
|
async function api(path, { method = "GET", body, tabId = activeTabId, scoped = true, signal } = {}) {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
879
|
+
let response;
|
|
880
|
+
try {
|
|
881
|
+
response = await fetch(scoped ? scopedApiPath(path, tabId) : path, {
|
|
882
|
+
method,
|
|
883
|
+
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
|
884
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
885
|
+
signal,
|
|
886
|
+
});
|
|
887
|
+
} catch (error) {
|
|
888
|
+
const offlineError = error instanceof Error ? error : new Error(String(error));
|
|
889
|
+
offlineError.backendOffline = true;
|
|
890
|
+
setBackendOffline(true, offlineError);
|
|
891
|
+
throw offlineError;
|
|
892
|
+
}
|
|
893
|
+
setBackendOffline(false);
|
|
719
894
|
const data = await response.json().catch(() => ({}));
|
|
720
895
|
if (!response.ok) {
|
|
721
896
|
const error = new Error(data.error || data.message || JSON.stringify(data));
|
|
@@ -1703,6 +1878,7 @@ function setActiveTabId(tabId, { remember = false } = {}) {
|
|
|
1703
1878
|
const nextTabId = tabId || null;
|
|
1704
1879
|
if (nextTabId !== activeTabId) activeTabGeneration += 1;
|
|
1705
1880
|
activeTabId = nextTabId;
|
|
1881
|
+
bindGitWorkflowToActiveTab();
|
|
1706
1882
|
if (remember) rememberActiveTab();
|
|
1707
1883
|
return activeTabContext(nextTabId);
|
|
1708
1884
|
}
|
|
@@ -1779,6 +1955,7 @@ function syncTabMetadata(nextTabs = []) {
|
|
|
1779
1955
|
tabActivities.delete(tabId);
|
|
1780
1956
|
tabSeenCompletionSerials.delete(tabId);
|
|
1781
1957
|
actionFeedbackByTab.delete(tabId);
|
|
1958
|
+
clearGitWorkflowForTab(tabId);
|
|
1782
1959
|
}
|
|
1783
1960
|
}
|
|
1784
1961
|
}
|
|
@@ -1959,6 +2136,15 @@ function restoreStoredTabId() {
|
|
|
1959
2136
|
}
|
|
1960
2137
|
}
|
|
1961
2138
|
|
|
2139
|
+
function requestedTabIdFromUrl() {
|
|
2140
|
+
try {
|
|
2141
|
+
const params = new URLSearchParams(window.location.search);
|
|
2142
|
+
return params.get("tab") || params.get("tabId") || null;
|
|
2143
|
+
} catch {
|
|
2144
|
+
return null;
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
1962
2148
|
function updateDocumentTitle() {
|
|
1963
2149
|
const tab = activeTab();
|
|
1964
2150
|
document.title = tab ? `Pi Web UI · ${tab.title}` : "Pi Web UI";
|
|
@@ -2016,6 +2202,7 @@ function cancelPendingDialogs() {
|
|
|
2016
2202
|
|
|
2017
2203
|
function resetActiveTabUi() {
|
|
2018
2204
|
clearRefreshTimers();
|
|
2205
|
+
clearLiveToolRenderQueue();
|
|
2019
2206
|
eventSource?.close();
|
|
2020
2207
|
eventSource = null;
|
|
2021
2208
|
currentState = null;
|
|
@@ -2043,15 +2230,7 @@ function resetActiveTabUi() {
|
|
|
2043
2230
|
cancelPendingDialogs();
|
|
2044
2231
|
if (elements.nativeCommandDialog.open) closeNativeCommandDialog();
|
|
2045
2232
|
if (pathPickerState) closePathPicker(null);
|
|
2046
|
-
|
|
2047
|
-
active: false,
|
|
2048
|
-
step: "idle",
|
|
2049
|
-
busy: false,
|
|
2050
|
-
output: "",
|
|
2051
|
-
error: "",
|
|
2052
|
-
message: null,
|
|
2053
|
-
messageRequestedAt: 0,
|
|
2054
|
-
});
|
|
2233
|
+
bindGitWorkflowToActiveTab();
|
|
2055
2234
|
resetChatOutput();
|
|
2056
2235
|
elements.stateDetails.replaceChildren();
|
|
2057
2236
|
elements.eventLog.replaceChildren();
|
|
@@ -2302,10 +2481,12 @@ async function refreshTabs({ selectStored = false } = {}) {
|
|
|
2302
2481
|
syncTabMetadata(tabs);
|
|
2303
2482
|
syncBlockedTabNotificationsFromTabs(tabs, previousTabs);
|
|
2304
2483
|
syncAgentDoneNotificationsFromTabs(tabs, previousTabs);
|
|
2484
|
+
const requested = selectStored ? requestedTabIdFromUrl() : null;
|
|
2305
2485
|
const stored = selectStored ? restoreStoredTabId() : null;
|
|
2306
2486
|
if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
|
|
2307
|
-
setActiveTabId((stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null, { remember: true });
|
|
2487
|
+
setActiveTabId((requested && tabs.some((tab) => tab.id === requested) ? requested : stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null, { remember: true });
|
|
2308
2488
|
}
|
|
2489
|
+
rememberServerStartCwd(tabs.find((tab) => tab.id === activeTabId)?.cwd || tabs[0]?.cwd);
|
|
2309
2490
|
renderTabs();
|
|
2310
2491
|
return tabs;
|
|
2311
2492
|
}
|
|
@@ -2395,6 +2576,7 @@ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } =
|
|
|
2395
2576
|
for (const id of closedIds) {
|
|
2396
2577
|
tabDrafts.delete(id);
|
|
2397
2578
|
clearAttachments(id);
|
|
2579
|
+
clearGitWorkflowForTab(id);
|
|
2398
2580
|
}
|
|
2399
2581
|
clearOpenTerminalTabGroup(null, { force: true });
|
|
2400
2582
|
|
|
@@ -3299,6 +3481,7 @@ async function changeActiveTabCwd() {
|
|
|
3299
3481
|
return;
|
|
3300
3482
|
}
|
|
3301
3483
|
const nextContext = setActiveTabId(response.data?.tab?.id || activeTabId);
|
|
3484
|
+
resetGitWorkflowForTab(nextContext.tabId);
|
|
3302
3485
|
resetActiveTabUi();
|
|
3303
3486
|
renderTabs();
|
|
3304
3487
|
restoreActiveDraft();
|
|
@@ -3827,18 +4010,27 @@ function renderWidgets() {
|
|
|
3827
4010
|
}
|
|
3828
4011
|
}
|
|
3829
4012
|
|
|
3830
|
-
function setGitWorkflow(patch) {
|
|
3831
|
-
|
|
3832
|
-
|
|
4013
|
+
function setGitWorkflow(patch, { tabId = activeTabId } = {}) {
|
|
4014
|
+
const workflow = gitWorkflowForTab(tabId);
|
|
4015
|
+
if (!workflow) return null;
|
|
4016
|
+
Object.assign(workflow, patch);
|
|
4017
|
+
if (tabId === activeTabId) {
|
|
4018
|
+
gitWorkflow = workflow;
|
|
4019
|
+
renderGitWorkflow();
|
|
4020
|
+
}
|
|
4021
|
+
return workflow;
|
|
3833
4022
|
}
|
|
3834
4023
|
|
|
3835
|
-
function isCurrentGitWorkflowRun(runId) {
|
|
3836
|
-
|
|
4024
|
+
function isCurrentGitWorkflowRun(runId, tabId = activeTabId) {
|
|
4025
|
+
const workflow = gitWorkflowForTab(tabId, { create: false });
|
|
4026
|
+
return !!workflow?.active && workflow.runId === runId;
|
|
3837
4027
|
}
|
|
3838
4028
|
|
|
3839
|
-
function appendGitWorkflowOutput(text) {
|
|
3840
|
-
const
|
|
3841
|
-
|
|
4029
|
+
function appendGitWorkflowOutput(text, { tabId = activeTabId } = {}) {
|
|
4030
|
+
const workflow = gitWorkflowForTab(tabId);
|
|
4031
|
+
if (!workflow) return;
|
|
4032
|
+
const next = `${workflow.output || ""}${workflow.output ? "\n" : ""}${text}`;
|
|
4033
|
+
setGitWorkflow({ output: next.slice(-60000) }, { tabId });
|
|
3842
4034
|
}
|
|
3843
4035
|
|
|
3844
4036
|
function formatGitCommandResult(result) {
|
|
@@ -3941,9 +4133,11 @@ function renderGitWorkflow() {
|
|
|
3941
4133
|
}
|
|
3942
4134
|
}
|
|
3943
4135
|
|
|
3944
|
-
async function gitWorkflowRequest(path, { method = "POST", body = {}, runId =
|
|
3945
|
-
const
|
|
3946
|
-
|
|
4136
|
+
async function gitWorkflowRequest(path, { method = "POST", body = {}, runId, tabId = activeTabId } = {}) {
|
|
4137
|
+
const workflow = gitWorkflowForTab(tabId, { create: false });
|
|
4138
|
+
const expectedRunId = runId ?? workflow?.runId;
|
|
4139
|
+
const response = await api(path, method === "GET" ? { method, tabId } : { method, body, tabId });
|
|
4140
|
+
if (expectedRunId !== undefined && !isCurrentGitWorkflowRun(expectedRunId, tabId)) return null;
|
|
3947
4141
|
if (!response.ok) {
|
|
3948
4142
|
const detail = response.data ? `\n\n${formatGitCommandResult(response.data)}` : "";
|
|
3949
4143
|
throw new Error(`${response.error || "Git workflow request failed"}${detail}`);
|
|
@@ -3951,27 +4145,32 @@ async function gitWorkflowRequest(path, { method = "POST", body = {}, runId = gi
|
|
|
3951
4145
|
return response.data;
|
|
3952
4146
|
}
|
|
3953
4147
|
|
|
3954
|
-
function failGitWorkflow(error, step =
|
|
4148
|
+
function failGitWorkflow(error, step, { tabId = activeTabId } = {}) {
|
|
4149
|
+
const workflow = gitWorkflowForTab(tabId);
|
|
4150
|
+
if (!workflow) return;
|
|
3955
4151
|
const message = error?.message || String(error);
|
|
3956
4152
|
setGitWorkflow({
|
|
3957
|
-
step,
|
|
4153
|
+
step: step || workflow.step || "error",
|
|
3958
4154
|
busy: false,
|
|
3959
4155
|
error: message,
|
|
3960
|
-
output: `${
|
|
3961
|
-
});
|
|
4156
|
+
output: `${workflow.output || ""}${workflow.output ? "\n\n" : ""}ERROR: ${message}`.slice(-60000),
|
|
4157
|
+
}, { tabId });
|
|
3962
4158
|
}
|
|
3963
4159
|
|
|
3964
4160
|
function startGitWorkflow() {
|
|
4161
|
+
const tabId = activeTabId;
|
|
4162
|
+
if (!tabId) return;
|
|
3965
4163
|
if (!isOptionalFeatureEnabled("gitWorkflow")) {
|
|
3966
|
-
const tabContext = activeTabContext();
|
|
4164
|
+
const tabContext = activeTabContext(tabId);
|
|
3967
4165
|
addEvent(commandUnavailableMessage("git-staged-msg"), "warn");
|
|
3968
4166
|
refreshCommands(tabContext).catch((error) => {
|
|
3969
4167
|
if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
|
|
3970
4168
|
});
|
|
3971
4169
|
return;
|
|
3972
4170
|
}
|
|
3973
|
-
|
|
3974
|
-
|
|
4171
|
+
const workflow = gitWorkflowForTab(tabId);
|
|
4172
|
+
if (workflow.active && !["done", "cancelled", "error"].includes(workflow.step) && !confirm("Restart the active git workflow?")) return;
|
|
4173
|
+
workflow.runId += 1;
|
|
3975
4174
|
setGitWorkflow({
|
|
3976
4175
|
active: true,
|
|
3977
4176
|
step: "add",
|
|
@@ -3980,40 +4179,52 @@ function startGitWorkflow() {
|
|
|
3980
4179
|
error: "",
|
|
3981
4180
|
message: null,
|
|
3982
4181
|
messageRequestedAt: 0,
|
|
3983
|
-
});
|
|
4182
|
+
}, { tabId });
|
|
3984
4183
|
}
|
|
3985
4184
|
|
|
3986
4185
|
async function cancelGitWorkflow() {
|
|
3987
|
-
const
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
if (
|
|
4186
|
+
const tabId = activeTabId;
|
|
4187
|
+
const tabContext = activeTabContext(tabId);
|
|
4188
|
+
const workflow = gitWorkflowForTab(tabId, { create: false });
|
|
4189
|
+
if (!workflow?.active) return;
|
|
4190
|
+
const shouldAbortPi = workflow.step === "generating";
|
|
4191
|
+
workflow.runId += 1;
|
|
4192
|
+
setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${workflow.output || ""}${workflow.output ? "\n\n" : ""}Cancelled by user.` }, { tabId });
|
|
4193
|
+
if (shouldAbortPi && isCurrentTabContext(tabContext)) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
|
|
3991
4194
|
await Promise.allSettled([
|
|
3992
|
-
api("/api/git-workflow/cancel", { method: "POST", body: {} }),
|
|
3993
|
-
shouldAbortPi ? api("/api/abort", { method: "POST", body: {} }) : Promise.resolve(),
|
|
4195
|
+
api("/api/git-workflow/cancel", { method: "POST", body: {}, tabId }),
|
|
4196
|
+
shouldAbortPi ? api("/api/abort", { method: "POST", body: {}, tabId }) : Promise.resolve(),
|
|
3994
4197
|
]);
|
|
3995
|
-
if (shouldAbortPi) scheduleAbortStateChecks();
|
|
4198
|
+
if (shouldAbortPi && isCurrentTabContext(tabContext)) scheduleAbortStateChecks();
|
|
3996
4199
|
}
|
|
3997
4200
|
|
|
3998
4201
|
async function runGitAdd() {
|
|
3999
|
-
const
|
|
4000
|
-
|
|
4202
|
+
const tabId = activeTabId;
|
|
4203
|
+
const tabContext = activeTabContext(tabId);
|
|
4204
|
+
const workflow = gitWorkflowForTab(tabId, { create: false });
|
|
4205
|
+
if (!workflow) return;
|
|
4206
|
+
const runId = workflow.runId;
|
|
4207
|
+
setGitWorkflow({ step: "add", busy: true, error: "", output: "Running git add ." }, { tabId });
|
|
4001
4208
|
try {
|
|
4002
|
-
const result = await gitWorkflowRequest("/api/git-workflow/add", { runId });
|
|
4209
|
+
const result = await gitWorkflowRequest("/api/git-workflow/add", { runId, tabId });
|
|
4003
4210
|
if (!result) return;
|
|
4004
|
-
setGitWorkflow({ step: "generate", busy: false, output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` });
|
|
4005
|
-
scheduleRefreshFooter();
|
|
4211
|
+
setGitWorkflow({ step: "generate", busy: false, output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` }, { tabId });
|
|
4212
|
+
if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
|
|
4006
4213
|
} catch (error) {
|
|
4007
|
-
if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "add");
|
|
4214
|
+
if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "add", { tabId });
|
|
4008
4215
|
}
|
|
4009
4216
|
}
|
|
4010
4217
|
|
|
4011
4218
|
async function runGitMessagePrompt() {
|
|
4219
|
+
const tabId = activeTabId;
|
|
4220
|
+
const tabContext = activeTabContext(tabId);
|
|
4012
4221
|
if (currentState?.isStreaming) {
|
|
4013
|
-
failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating a staged commit message."), "generate");
|
|
4222
|
+
failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating a staged commit message."), "generate", { tabId });
|
|
4014
4223
|
return;
|
|
4015
4224
|
}
|
|
4016
|
-
const
|
|
4225
|
+
const workflow = gitWorkflowForTab(tabId, { create: false });
|
|
4226
|
+
if (!workflow) return;
|
|
4227
|
+
const runId = workflow.runId;
|
|
4017
4228
|
const requestedAt = Date.now();
|
|
4018
4229
|
setGitWorkflow({
|
|
4019
4230
|
step: "generating",
|
|
@@ -4021,32 +4232,37 @@ async function runGitMessagePrompt() {
|
|
|
4021
4232
|
error: "",
|
|
4022
4233
|
messageRequestedAt: requestedAt,
|
|
4023
4234
|
output: "Sending /git-staged-msg to Pi.\n\nCancel will request Pi abort.",
|
|
4024
|
-
});
|
|
4025
|
-
setRunIndicatorActivity("Sending /git-staged-msg to Pi…");
|
|
4235
|
+
}, { tabId });
|
|
4236
|
+
if (isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending /git-staged-msg to Pi…");
|
|
4026
4237
|
try {
|
|
4027
|
-
await api("/api/prompt", { method: "POST", body: { message: "/git-staged-msg" } });
|
|
4028
|
-
if (!isCurrentGitWorkflowRun(runId)) return;
|
|
4029
|
-
appendGitWorkflowOutput("/git-staged-msg accepted. Waiting for agent_end, then the message files will be loaded.");
|
|
4030
|
-
scheduleRefreshState();
|
|
4238
|
+
await api("/api/prompt", { method: "POST", body: { message: "/git-staged-msg" }, tabId });
|
|
4239
|
+
if (!isCurrentGitWorkflowRun(runId, tabId)) return;
|
|
4240
|
+
appendGitWorkflowOutput("/git-staged-msg accepted. Waiting for agent_end, then the message files will be loaded.", { tabId });
|
|
4241
|
+
if (isCurrentTabContext(tabContext)) scheduleRefreshState(120, tabContext);
|
|
4031
4242
|
setTimeout(() => {
|
|
4032
|
-
|
|
4033
|
-
|
|
4243
|
+
const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
|
|
4244
|
+
if (isCurrentTabContext(tabContext) && isCurrentGitWorkflowRun(runId, tabId) && currentWorkflow?.step === "generating" && !currentState?.isStreaming) {
|
|
4245
|
+
loadGitWorkflowMessage({ requireFresh: true, retries: 1, runId, tabId });
|
|
4034
4246
|
}
|
|
4035
4247
|
}, 2500);
|
|
4036
4248
|
} catch (error) {
|
|
4037
|
-
if (isCurrentGitWorkflowRun(runId)) {
|
|
4038
|
-
clearRunIndicatorActivity();
|
|
4039
|
-
failGitWorkflow(error, "generate");
|
|
4249
|
+
if (isCurrentGitWorkflowRun(runId, tabId)) {
|
|
4250
|
+
if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
|
|
4251
|
+
failGitWorkflow(error, "generate", { tabId });
|
|
4040
4252
|
}
|
|
4041
4253
|
}
|
|
4042
4254
|
}
|
|
4043
4255
|
|
|
4044
|
-
async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId =
|
|
4256
|
+
async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId, tabId = activeTabId } = {}) {
|
|
4257
|
+
const workflow = gitWorkflowForTab(tabId, { create: false });
|
|
4258
|
+
const expectedRunId = runId ?? workflow?.runId;
|
|
4045
4259
|
try {
|
|
4046
|
-
const message = await gitWorkflowRequest("/api/git-workflow/message", { method: "GET", runId });
|
|
4260
|
+
const message = await gitWorkflowRequest("/api/git-workflow/message", { method: "GET", runId: expectedRunId, tabId });
|
|
4047
4261
|
if (!message) return;
|
|
4262
|
+
const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
|
|
4263
|
+
if (!currentWorkflow) return;
|
|
4048
4264
|
const newestMtime = Math.max(message.shortMtimeMs || 0, message.longMtimeMs || 0);
|
|
4049
|
-
if (requireFresh &&
|
|
4265
|
+
if (requireFresh && currentWorkflow.messageRequestedAt && newestMtime + 10000 < currentWorkflow.messageRequestedAt) {
|
|
4050
4266
|
throw new Error("Generated message files have not refreshed yet.");
|
|
4051
4267
|
}
|
|
4052
4268
|
setGitWorkflow({
|
|
@@ -4055,40 +4271,63 @@ async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId
|
|
|
4055
4271
|
error: "",
|
|
4056
4272
|
message,
|
|
4057
4273
|
output: formatCommitMessagePreview(message),
|
|
4058
|
-
});
|
|
4274
|
+
}, { tabId });
|
|
4059
4275
|
} catch (error) {
|
|
4060
|
-
if (!isCurrentGitWorkflowRun(
|
|
4276
|
+
if (!isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
|
|
4061
4277
|
if (retries > 0) {
|
|
4062
|
-
setTimeout(() => loadGitWorkflowMessage({ requireFresh, retries: retries - 1, runId }), 1400);
|
|
4278
|
+
setTimeout(() => loadGitWorkflowMessage({ requireFresh, retries: retries - 1, runId: expectedRunId, tabId }), 1400);
|
|
4063
4279
|
return;
|
|
4064
4280
|
}
|
|
4065
|
-
|
|
4281
|
+
const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
|
|
4282
|
+
failGitWorkflow(error, currentWorkflow?.step === "generating" ? "generate" : currentWorkflow?.step, { tabId });
|
|
4066
4283
|
}
|
|
4067
4284
|
}
|
|
4068
4285
|
|
|
4069
4286
|
async function commitGitWorkflow(variant) {
|
|
4070
|
-
const
|
|
4071
|
-
|
|
4287
|
+
const tabId = activeTabId;
|
|
4288
|
+
const tabContext = activeTabContext(tabId);
|
|
4289
|
+
const workflow = gitWorkflowForTab(tabId, { create: false });
|
|
4290
|
+
if (!workflow) return;
|
|
4291
|
+
const runId = workflow.runId;
|
|
4292
|
+
setGitWorkflow({ step: "committing", busy: true, error: "", output: `${formatCommitMessagePreview(workflow.message)}\n\nRunning native ${variant} commit…` }, { tabId });
|
|
4072
4293
|
try {
|
|
4073
|
-
const result = await gitWorkflowRequest("/api/git-workflow/commit", { body: { variant }, runId });
|
|
4294
|
+
const result = await gitWorkflowRequest("/api/git-workflow/commit", { body: { variant }, runId, tabId });
|
|
4074
4295
|
if (!result) return;
|
|
4075
|
-
setGitWorkflow({ step: "push", busy: false, output: `${formatGitCommandResult(result)}\n\nCommit created. Next: git push.` });
|
|
4076
|
-
scheduleRefreshFooter();
|
|
4296
|
+
setGitWorkflow({ step: "push", busy: false, output: `${formatGitCommandResult(result)}\n\nCommit created. Next: git push.` }, { tabId });
|
|
4297
|
+
if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
|
|
4077
4298
|
} catch (error) {
|
|
4078
|
-
if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "message");
|
|
4299
|
+
if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "message", { tabId });
|
|
4079
4300
|
}
|
|
4080
4301
|
}
|
|
4081
4302
|
|
|
4082
4303
|
async function pushGitWorkflow() {
|
|
4083
|
-
const
|
|
4084
|
-
|
|
4304
|
+
const tabId = activeTabId;
|
|
4305
|
+
const tabContext = activeTabContext(tabId);
|
|
4306
|
+
const workflow = gitWorkflowForTab(tabId, { create: false });
|
|
4307
|
+
if (!workflow) return;
|
|
4308
|
+
const runId = workflow.runId;
|
|
4309
|
+
setGitWorkflow({ step: "pushing", busy: true, error: "", output: "Running git push…" }, { tabId });
|
|
4085
4310
|
try {
|
|
4086
|
-
const result = await gitWorkflowRequest("/api/git-workflow/push", { runId });
|
|
4311
|
+
const result = await gitWorkflowRequest("/api/git-workflow/push", { runId, tabId });
|
|
4087
4312
|
if (!result) return;
|
|
4088
|
-
setGitWorkflow({ step: "done", busy: false, output: formatGitCommandResult(result) || "git push finished." });
|
|
4089
|
-
scheduleRefreshFooter();
|
|
4313
|
+
setGitWorkflow({ step: "done", busy: false, output: formatGitCommandResult(result) || "git push finished." }, { tabId });
|
|
4314
|
+
if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
|
|
4090
4315
|
} catch (error) {
|
|
4091
|
-
if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "push");
|
|
4316
|
+
if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "push", { tabId });
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
4319
|
+
|
|
4320
|
+
function resumeGitWorkflowForActiveTab(tabContext = activeTabContext()) {
|
|
4321
|
+
if (!isCurrentTabContext(tabContext)) return;
|
|
4322
|
+
bindGitWorkflowToActiveTab();
|
|
4323
|
+
renderGitWorkflow();
|
|
4324
|
+
if (gitWorkflow.active && gitWorkflow.step === "generating" && !currentState?.isStreaming) {
|
|
4325
|
+
const retryDelayMs = Math.max(0, 2500 - (Date.now() - (gitWorkflow.messageRequestedAt || 0)));
|
|
4326
|
+
if (retryDelayMs > 0) {
|
|
4327
|
+
setTimeout(() => resumeGitWorkflowForActiveTab(tabContext), retryDelayMs);
|
|
4328
|
+
return;
|
|
4329
|
+
}
|
|
4330
|
+
loadGitWorkflowMessage({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: tabContext.tabId });
|
|
4092
4331
|
}
|
|
4093
4332
|
}
|
|
4094
4333
|
|
|
@@ -4992,7 +5231,10 @@ function toolResultForCallId(toolCallId, messages = latestMessages) {
|
|
|
4992
5231
|
function cleanupLiveToolRunsForMessages(messages = latestMessages) {
|
|
4993
5232
|
const results = buildToolResultMap(messages);
|
|
4994
5233
|
for (const id of liveToolRuns.keys()) {
|
|
4995
|
-
if (results.has(id))
|
|
5234
|
+
if (results.has(id)) {
|
|
5235
|
+
liveToolRuns.delete(id);
|
|
5236
|
+
cancelQueuedLiveToolRunRender(id);
|
|
5237
|
+
}
|
|
4996
5238
|
}
|
|
4997
5239
|
}
|
|
4998
5240
|
|
|
@@ -5306,12 +5548,143 @@ function liveToolRunMessage(run) {
|
|
|
5306
5548
|
};
|
|
5307
5549
|
}
|
|
5308
5550
|
|
|
5551
|
+
function applyToolExecutionBubbleState(bubble, message) {
|
|
5552
|
+
const status = toolExecutionStatus(message);
|
|
5553
|
+
bubble.classList.remove("tool-pending", "tool-running", "tool-success", "tool-error", "error");
|
|
5554
|
+
bubble.classList.add(`tool-${status}`);
|
|
5555
|
+
if (message.isError || status === "error") bubble.classList.add("error");
|
|
5556
|
+
if (message.toolCallId) {
|
|
5557
|
+
const id = String(message.toolCallId);
|
|
5558
|
+
bubble.dataset.toolCallId = id;
|
|
5559
|
+
if (message.live) liveToolCards.set(id, bubble);
|
|
5560
|
+
}
|
|
5561
|
+
}
|
|
5562
|
+
|
|
5563
|
+
function toolDetailsStateKey(details, counts) {
|
|
5564
|
+
const classKey = Array.from(details.classList || []).sort().join(".") || "details";
|
|
5565
|
+
const summaryText = details.querySelector("summary")?.textContent || "";
|
|
5566
|
+
const summaryKey = summaryText.replace(/\s*\([^)]*\)\s*$/g, "").trim();
|
|
5567
|
+
const base = `${classKey}|${summaryKey}`;
|
|
5568
|
+
const index = counts.get(base) || 0;
|
|
5569
|
+
counts.set(base, index + 1);
|
|
5570
|
+
return `${base}|${index}`;
|
|
5571
|
+
}
|
|
5572
|
+
|
|
5573
|
+
function captureToolDetailsOpenState(root) {
|
|
5574
|
+
const state = new Set();
|
|
5575
|
+
const counts = new Map();
|
|
5576
|
+
for (const details of root.querySelectorAll("details")) {
|
|
5577
|
+
const key = toolDetailsStateKey(details, counts);
|
|
5578
|
+
if (details.open) state.add(key);
|
|
5579
|
+
}
|
|
5580
|
+
return state;
|
|
5581
|
+
}
|
|
5582
|
+
|
|
5583
|
+
function restoreToolDetailsOpenState(root, state) {
|
|
5584
|
+
if (!state?.size) return;
|
|
5585
|
+
const counts = new Map();
|
|
5586
|
+
for (const details of root.querySelectorAll("details")) {
|
|
5587
|
+
if (state.has(toolDetailsStateKey(details, counts))) details.open = true;
|
|
5588
|
+
}
|
|
5589
|
+
}
|
|
5590
|
+
|
|
5591
|
+
function captureReusableToolCards() {
|
|
5592
|
+
const cards = new Map();
|
|
5593
|
+
for (const bubble of elements.chat.querySelectorAll(".message.toolExecution[data-tool-call-id]")) {
|
|
5594
|
+
const id = bubble.dataset.toolCallId;
|
|
5595
|
+
if (id) cards.set(id, bubble);
|
|
5596
|
+
}
|
|
5597
|
+
return cards;
|
|
5598
|
+
}
|
|
5599
|
+
|
|
5600
|
+
function reuseToolExecutionBubble(reusableToolCards, message, { streaming = false, messageIndex = -1, transient = false } = {}) {
|
|
5601
|
+
if (streaming || message?.role !== "toolExecution" || !message.toolCallId || !reusableToolCards) return null;
|
|
5602
|
+
const id = String(message.toolCallId);
|
|
5603
|
+
const bubble = reusableToolCards.get(id);
|
|
5604
|
+
if (!bubble) return null;
|
|
5605
|
+
reusableToolCards.delete(id);
|
|
5606
|
+
const body = bubble.querySelector(":scope > .message-body");
|
|
5607
|
+
if (!body || !updateLiveToolCard(bubble, message)) return null;
|
|
5608
|
+
bubble.classList.remove("action-enter", "streaming", "has-action-feedback");
|
|
5609
|
+
bubble.querySelector(":scope > .action-feedback-controls")?.remove();
|
|
5610
|
+
if (!transient && messageIndex >= 0) {
|
|
5611
|
+
bubble.dataset.messageIndex = String(messageIndex);
|
|
5612
|
+
bubble.removeAttribute("data-user-prompt");
|
|
5613
|
+
} else {
|
|
5614
|
+
bubble.removeAttribute("data-message-index");
|
|
5615
|
+
bubble.removeAttribute("data-user-prompt");
|
|
5616
|
+
}
|
|
5617
|
+
if (!streaming && !transient) renderActionFeedbackControls(bubble, message, messageIndex);
|
|
5618
|
+
elements.chat.append(bubble);
|
|
5619
|
+
return { bubble, body };
|
|
5620
|
+
}
|
|
5621
|
+
|
|
5622
|
+
function updateLiveToolCard(bubble, message) {
|
|
5623
|
+
if (!bubble) return false;
|
|
5624
|
+
const header = bubble.querySelector(":scope > .message-header");
|
|
5625
|
+
const body = bubble.querySelector(":scope > .message-body");
|
|
5626
|
+
if (!body) return false;
|
|
5627
|
+
applyToolExecutionBubbleState(bubble, message);
|
|
5628
|
+
const role = header?.querySelector(".message-role");
|
|
5629
|
+
if (role) role.textContent = messageTitle(message);
|
|
5630
|
+
const timestamp = header?.querySelector(".muted");
|
|
5631
|
+
if (timestamp) timestamp.textContent = formatDate(message.timestamp);
|
|
5632
|
+
const detailsOpenState = captureToolDetailsOpenState(body);
|
|
5633
|
+
body.replaceChildren();
|
|
5634
|
+
renderToolExecution(body, message);
|
|
5635
|
+
restoreToolDetailsOpenState(body, detailsOpenState);
|
|
5636
|
+
return true;
|
|
5637
|
+
}
|
|
5638
|
+
|
|
5639
|
+
function cancelQueuedLiveToolRunRender(toolCallId = "") {
|
|
5640
|
+
if (toolCallId) liveToolRenderQueue.delete(String(toolCallId));
|
|
5641
|
+
else liveToolRenderQueue.clear();
|
|
5642
|
+
if (liveToolRenderQueue.size === 0) {
|
|
5643
|
+
clearTimeout(liveToolRenderTimer);
|
|
5644
|
+
liveToolRenderTimer = null;
|
|
5645
|
+
}
|
|
5646
|
+
}
|
|
5647
|
+
|
|
5648
|
+
function clearLiveToolRenderQueue() {
|
|
5649
|
+
cancelQueuedLiveToolRunRender();
|
|
5650
|
+
}
|
|
5651
|
+
|
|
5652
|
+
function flushLiveToolRunRenderQueue() {
|
|
5653
|
+
const entries = Array.from(liveToolRenderQueue.values());
|
|
5654
|
+
clearLiveToolRenderQueue();
|
|
5655
|
+
for (const entry of entries) renderLiveToolRun(entry.run, { scroll: entry.scroll });
|
|
5656
|
+
}
|
|
5657
|
+
|
|
5658
|
+
function scheduleLiveToolRunRender(run, { scroll = false } = {}) {
|
|
5659
|
+
if (!run?.toolCallId) return;
|
|
5660
|
+
const id = String(run.toolCallId);
|
|
5661
|
+
const existing = liveToolRenderQueue.get(id);
|
|
5662
|
+
liveToolRenderQueue.set(id, { run, scroll: !!(existing?.scroll || scroll) });
|
|
5663
|
+
if (liveToolRenderTimer) return;
|
|
5664
|
+
liveToolRenderTimer = setTimeout(() => {
|
|
5665
|
+
liveToolRenderTimer = null;
|
|
5666
|
+
const flush = () => flushLiveToolRunRenderQueue();
|
|
5667
|
+
if (typeof requestAnimationFrame === "function") requestAnimationFrame(flush);
|
|
5668
|
+
else flush();
|
|
5669
|
+
}, TOOL_LIVE_UPDATE_THROTTLE_MS);
|
|
5670
|
+
}
|
|
5671
|
+
|
|
5309
5672
|
function renderLiveToolRun(run, { scroll = true } = {}) {
|
|
5310
5673
|
if (!run?.toolCallId) return;
|
|
5311
|
-
const
|
|
5674
|
+
const id = String(run.toolCallId);
|
|
5675
|
+
cancelQueuedLiveToolRunRender(id);
|
|
5676
|
+
const existing = liveToolCards.get(id);
|
|
5677
|
+
const existingConnected = !!(existing?.isConnected && existing.parentElement === elements.chat);
|
|
5312
5678
|
const shouldFollow = scroll && (autoFollowChat || isChatNearBottom());
|
|
5313
|
-
const
|
|
5314
|
-
|
|
5679
|
+
const message = liveToolRunMessage(run);
|
|
5680
|
+
rememberActionEntries([{ message, messageIndex: -1, transient: true }]);
|
|
5681
|
+
if (existingConnected && updateLiveToolCard(existing, message)) {
|
|
5682
|
+
renderRunIndicator({ scroll: false });
|
|
5683
|
+
if (shouldFollow) scrollChatToBottom();
|
|
5684
|
+
return;
|
|
5685
|
+
}
|
|
5686
|
+
const created = appendMessage(message, { transient: true, animateEntry: !existingConnected });
|
|
5687
|
+
if (existingConnected && existing !== created.bubble) existing.replaceWith(created.bubble);
|
|
5315
5688
|
renderRunIndicator({ scroll: false });
|
|
5316
5689
|
if (shouldFollow) scrollChatToBottom();
|
|
5317
5690
|
}
|
|
@@ -5345,7 +5718,7 @@ function handleToolExecutionStart(event) {
|
|
|
5345
5718
|
function handleToolExecutionUpdate(event) {
|
|
5346
5719
|
const result = { ...(event.partialResult || {}), isError: false };
|
|
5347
5720
|
const run = upsertLiveToolRun(event, { result, isPartial: true, isError: false });
|
|
5348
|
-
if (run)
|
|
5721
|
+
if (run) scheduleLiveToolRunRender(run, { scroll: false });
|
|
5349
5722
|
}
|
|
5350
5723
|
|
|
5351
5724
|
function handleToolExecutionEnd(event) {
|
|
@@ -5377,19 +5750,13 @@ function jumpToStickyUserPrompt() {
|
|
|
5377
5750
|
requestAnimationFrame(updateStickyUserPromptButton);
|
|
5378
5751
|
}
|
|
5379
5752
|
|
|
5380
|
-
function appendMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false } = {}) {
|
|
5753
|
+
function appendMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false, reusableToolCards = null } = {}) {
|
|
5754
|
+
const reused = reuseToolExecutionBubble(reusableToolCards, message, { streaming, messageIndex, transient });
|
|
5755
|
+
if (reused) return reused;
|
|
5381
5756
|
const role = String(message.role || "message");
|
|
5382
5757
|
const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
|
|
5383
5758
|
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
|
-
}
|
|
5759
|
+
if (message.role === "toolExecution") applyToolExecutionBubbleState(bubble, message);
|
|
5393
5760
|
if (!transient && messageIndex >= 0) {
|
|
5394
5761
|
bubble.dataset.messageIndex = String(messageIndex);
|
|
5395
5762
|
if (role === "user") bubble.dataset.userPrompt = "true";
|
|
@@ -5443,9 +5810,9 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
|
|
|
5443
5810
|
return { bubble, body };
|
|
5444
5811
|
}
|
|
5445
5812
|
|
|
5446
|
-
function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false } = {}) {
|
|
5813
|
+
function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false, reusableToolCards = null } = {}) {
|
|
5447
5814
|
if (streaming || transient || message?.role !== "assistant") {
|
|
5448
|
-
return appendMessage(message, { streaming, messageIndex, transient, animateEntry });
|
|
5815
|
+
return appendMessage(message, { streaming, messageIndex, transient, animateEntry, reusableToolCards });
|
|
5449
5816
|
}
|
|
5450
5817
|
|
|
5451
5818
|
let finalOutput = null;
|
|
@@ -5474,6 +5841,7 @@ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1
|
|
|
5474
5841
|
messageIndex: ["assistant", "toolExecution"].includes(transcriptMessage.role) ? messageIndex : -1,
|
|
5475
5842
|
transient: false,
|
|
5476
5843
|
animateEntry: animateEntry && isActionTranscriptMessage(transcriptMessage),
|
|
5844
|
+
reusableToolCards,
|
|
5477
5845
|
});
|
|
5478
5846
|
if (transcriptMessage.role === "assistant") finalOutput = created;
|
|
5479
5847
|
});
|
|
@@ -5692,16 +6060,17 @@ function actionEntrySeenKeys(tabId = activeTabId) {
|
|
|
5692
6060
|
|
|
5693
6061
|
function actionEntryKey(item) {
|
|
5694
6062
|
const message = item?.message || {};
|
|
6063
|
+
const keyedToolExecution = message.role === "toolExecution" && message.toolCallId;
|
|
5695
6064
|
return [
|
|
5696
|
-
item?.transient ? "transient" : "message",
|
|
5697
|
-
item?.messageIndex ?? -1,
|
|
6065
|
+
keyedToolExecution ? "toolExecution" : item?.transient ? "transient" : "message",
|
|
6066
|
+
keyedToolExecution ? "" : (item?.messageIndex ?? -1),
|
|
5698
6067
|
message.role || "message",
|
|
5699
6068
|
message.toolName || "",
|
|
5700
6069
|
message.toolCallId || "",
|
|
5701
|
-
message.command || "",
|
|
5702
|
-
message.title || "",
|
|
5703
|
-
message.timestamp || "",
|
|
5704
|
-
textFromContent(message.content).slice(0, 240),
|
|
6070
|
+
keyedToolExecution ? "" : message.command || "",
|
|
6071
|
+
keyedToolExecution ? "" : message.title || "",
|
|
6072
|
+
keyedToolExecution ? "" : message.timestamp || "",
|
|
6073
|
+
keyedToolExecution ? "" : textFromContent(message.content).slice(0, 240),
|
|
5705
6074
|
].join("|");
|
|
5706
6075
|
}
|
|
5707
6076
|
|
|
@@ -5743,6 +6112,7 @@ function orderedTranscriptItems() {
|
|
|
5743
6112
|
function renderAllMessages({ preserveScroll = false } = {}) {
|
|
5744
6113
|
const shouldFollow = !preserveScroll && (autoFollowChat || isChatNearBottom());
|
|
5745
6114
|
const previousScrollTop = elements.chat.scrollTop;
|
|
6115
|
+
const reusableToolCards = captureReusableToolCards();
|
|
5746
6116
|
resetChatOutput();
|
|
5747
6117
|
const transcriptItems = orderedTranscriptItems();
|
|
5748
6118
|
for (const item of transcriptItems) {
|
|
@@ -5750,6 +6120,7 @@ function renderAllMessages({ preserveScroll = false } = {}) {
|
|
|
5750
6120
|
messageIndex: item.messageIndex,
|
|
5751
6121
|
transient: item.transient,
|
|
5752
6122
|
animateEntry: shouldAnimateActionEntry(item),
|
|
6123
|
+
reusableToolCards,
|
|
5753
6124
|
});
|
|
5754
6125
|
}
|
|
5755
6126
|
rememberActionEntries(transcriptItems);
|
|
@@ -6819,6 +7190,7 @@ async function refreshWorkspace(tabContext = activeTabContext()) {
|
|
|
6819
7190
|
}
|
|
6820
7191
|
if (!isCurrentTabContext(tabContext)) return;
|
|
6821
7192
|
latestWorkspace = nextWorkspace;
|
|
7193
|
+
rememberServerStartCwd(nextWorkspace?.cwd);
|
|
6822
7194
|
renderFooter();
|
|
6823
7195
|
}
|
|
6824
7196
|
|
|
@@ -7357,6 +7729,7 @@ async function refreshAll(tabContext = activeTabContext()) {
|
|
|
7357
7729
|
for (const result of results) {
|
|
7358
7730
|
if (result.status === "rejected") addEvent(result.reason.message || String(result.reason), "error");
|
|
7359
7731
|
}
|
|
7732
|
+
resumeGitWorkflowForActiveTab(tabContext);
|
|
7360
7733
|
}
|
|
7361
7734
|
|
|
7362
7735
|
async function openToNetwork() {
|
|
@@ -7794,8 +8167,12 @@ function handleEvent(event) {
|
|
|
7794
8167
|
scheduleRefreshMessages();
|
|
7795
8168
|
scheduleRefreshFooter();
|
|
7796
8169
|
renderFeedbackTray();
|
|
7797
|
-
|
|
7798
|
-
|
|
8170
|
+
{
|
|
8171
|
+
const workflowTabId = event.tabId || activeTabId;
|
|
8172
|
+
const workflow = gitWorkflowForTab(workflowTabId, { create: false });
|
|
8173
|
+
if (workflow?.active && workflow.step === "generating") {
|
|
8174
|
+
loadGitWorkflowMessage({ requireFresh: true, retries: 3, runId: workflow.runId, tabId: workflowTabId });
|
|
8175
|
+
}
|
|
7799
8176
|
}
|
|
7800
8177
|
break;
|
|
7801
8178
|
case "message_start":
|
|
@@ -7882,7 +8259,11 @@ function handleEvent(event) {
|
|
|
7882
8259
|
syncRunIndicatorFromState(currentState);
|
|
7883
8260
|
renderStatus();
|
|
7884
8261
|
} else if (["set_model", "set_thinking_level", "new_session", "compact"].includes(event.command)) {
|
|
7885
|
-
if (event.command === "new_session")
|
|
8262
|
+
if (event.command === "new_session") {
|
|
8263
|
+
const tabId = event.tabId || activeTabId;
|
|
8264
|
+
forgetLastUserPrompt(tabId);
|
|
8265
|
+
resetGitWorkflowForTab(tabId);
|
|
8266
|
+
}
|
|
7886
8267
|
scheduleRefreshState();
|
|
7887
8268
|
scheduleRefreshMessages();
|
|
7888
8269
|
scheduleRefreshFooter();
|
|
@@ -7908,10 +8289,14 @@ function connectEvents(tabContext = activeTabContext()) {
|
|
|
7908
8289
|
}
|
|
7909
8290
|
};
|
|
7910
8291
|
source.onerror = () => {
|
|
7911
|
-
if (eventSource
|
|
8292
|
+
if (eventSource !== source || !isCurrentTabContext(tabContext)) return;
|
|
8293
|
+
addEvent("event stream disconnected; browser will retry", "warn");
|
|
8294
|
+
fetch("/api/health", { cache: "no-store" }).catch((error) => setBackendOffline(true, error));
|
|
7912
8295
|
};
|
|
7913
8296
|
}
|
|
7914
8297
|
|
|
8298
|
+
elements.copyServerCommandButton?.addEventListener("click", copyServerStartCommand);
|
|
8299
|
+
elements.retryServerConnectionButton?.addEventListener("click", retryServerConnection);
|
|
7915
8300
|
elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
|
|
7916
8301
|
elements.composer.addEventListener("submit", (event) => {
|
|
7917
8302
|
event.preventDefault();
|
|
@@ -8016,6 +8401,7 @@ elements.newSessionButton.addEventListener("click", async () => {
|
|
|
8016
8401
|
const response = await api("/api/new-session", { method: "POST", body: {}, tabId: tabContext.tabId });
|
|
8017
8402
|
applyResponseTab(response);
|
|
8018
8403
|
forgetLastUserPrompt(tabContext.tabId);
|
|
8404
|
+
resetGitWorkflowForTab(tabContext.tabId);
|
|
8019
8405
|
if (!isCurrentTabContext(tabContext)) return;
|
|
8020
8406
|
await refreshAll(tabContext);
|
|
8021
8407
|
if (isCurrentTabContext(tabContext)) focusPromptInput({ defer: true });
|
|
@@ -8273,4 +8659,5 @@ bindSidePanelSectionToggles();
|
|
|
8273
8659
|
restoreSidePanelState();
|
|
8274
8660
|
bindMobileViewChanges();
|
|
8275
8661
|
registerPwaServiceWorker();
|
|
8662
|
+
renderServerOfflinePanel();
|
|
8276
8663
|
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,14 @@ 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");
|
|
294
|
+
assert.match(app, /function captureReusableToolCards\(\)[\s\S]*?\.message\.toolExecution\[data-tool-call-id\]/, "full transcript re-renders should capture existing tool cards before clearing the chat");
|
|
295
|
+
assert.match(app, /function appendMessage\(message,[\s\S]*?reusableToolCards = null[\s\S]*?reuseToolExecutionBubble\(reusableToolCards, message/, "message rendering should reuse matching tool cards instead of replacing them during refreshes");
|
|
296
|
+
assert.match(app, /function renderAllMessages\(\{ preserveScroll = false \} = \{\}\)[\s\S]*?const reusableToolCards = captureReusableToolCards\(\);[\s\S]*?appendTranscriptMessage\(item\.message,[\s\S]*?reusableToolCards,/, "transcript refreshes should pass reusable tool cards through to item rendering");
|
|
297
|
+
assert.match(app, /const keyedToolExecution = message\.role === "toolExecution" && message\.toolCallId[\s\S]*?keyedToolExecution \? "toolExecution"[\s\S]*?keyedToolExecution \? "" : message\.title[\s\S]*?keyedToolExecution \? "" : message\.timestamp/, "tool action entry identity should stay stable when live transient cards become persisted transcript cards");
|
|
283
298
|
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
299
|
assert.match(app, /function assistantDisplayMessages\(message\)/, "assistant history should split thinking and tool-call parts out of the final Assistant output card");
|
|
285
300
|
assert.match(app, /function assistantHasToolCallAfter\(content, index\)/, "assistant text that precedes a tool call should be detectable and suppressible");
|