@firstpick/pi-package-webui 0.1.0 → 0.1.1
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 +121 -43
- package/bin/pi-webui.mjs +433 -40
- package/index.ts +150 -18
- package/package.json +1 -1
- package/public/app.js +556 -19
- package/public/favicon.svg +8 -0
- package/public/index.html +28 -0
- package/public/styles.css +317 -0
package/public/app.js
CHANGED
|
@@ -2,6 +2,8 @@ const $ = (selector) => document.querySelector(selector);
|
|
|
2
2
|
|
|
3
3
|
const elements = {
|
|
4
4
|
sessionLine: $("#sessionLine"),
|
|
5
|
+
tabBar: $("#tabBar"),
|
|
6
|
+
newTabButton: $("#newTabButton"),
|
|
5
7
|
statusBar: $("#statusBar"),
|
|
6
8
|
widgetArea: $("#widgetArea"),
|
|
7
9
|
chat: $("#chat"),
|
|
@@ -26,6 +28,8 @@ const elements = {
|
|
|
26
28
|
setModelButton: $("#setModelButton"),
|
|
27
29
|
thinkingSelect: $("#thinkingSelect"),
|
|
28
30
|
setThinkingButton: $("#setThinkingButton"),
|
|
31
|
+
networkStatus: $("#networkStatus"),
|
|
32
|
+
openNetworkButton: $("#openNetworkButton"),
|
|
29
33
|
toggleSidePanelButton: $("#toggleSidePanelButton"),
|
|
30
34
|
sidePanelExpandButton: $("#sidePanelExpandButton"),
|
|
31
35
|
sidePanel: $("#sidePanel"),
|
|
@@ -38,9 +42,22 @@ const elements = {
|
|
|
38
42
|
dialogMessage: $("#dialogMessage"),
|
|
39
43
|
dialogBody: $("#dialogBody"),
|
|
40
44
|
dialogActions: $("#dialogActions"),
|
|
45
|
+
pathPickerDialog: $("#pathPickerDialog"),
|
|
46
|
+
pathPickerTitle: $("#pathPickerTitle"),
|
|
47
|
+
pathPickerCurrent: $("#pathPickerCurrent"),
|
|
48
|
+
pathPickerAddFastPickButton: $("#pathPickerAddFastPickButton"),
|
|
49
|
+
pathPickerFastPicks: $("#pathPickerFastPicks"),
|
|
50
|
+
pathPickerRoots: $("#pathPickerRoots"),
|
|
51
|
+
pathPickerList: $("#pathPickerList"),
|
|
52
|
+
pathPickerError: $("#pathPickerError"),
|
|
53
|
+
pathPickerCancelButton: $("#pathPickerCancelButton"),
|
|
54
|
+
pathPickerChooseButton: $("#pathPickerChooseButton"),
|
|
41
55
|
};
|
|
42
56
|
|
|
43
57
|
let currentState = null;
|
|
58
|
+
let tabs = [];
|
|
59
|
+
let activeTabId = null;
|
|
60
|
+
let tabDrafts = new Map();
|
|
44
61
|
let streamBubble = null;
|
|
45
62
|
let streamText = null;
|
|
46
63
|
let streamThinking = null;
|
|
@@ -50,17 +67,21 @@ let refreshStateTimer = null;
|
|
|
50
67
|
let refreshFooterTimer = null;
|
|
51
68
|
let eventSource = null;
|
|
52
69
|
let activeDialog = null;
|
|
70
|
+
let pathPickerState = null;
|
|
53
71
|
let availableCommands = [];
|
|
54
72
|
let commandSuggestions = [];
|
|
55
73
|
let commandSuggestIndex = 0;
|
|
56
74
|
let latestStats = null;
|
|
57
75
|
let latestWorkspace = null;
|
|
76
|
+
let latestNetwork = null;
|
|
58
77
|
let latestMessages = [];
|
|
59
78
|
let currentRunStartedAt = null;
|
|
60
79
|
let currentRunStreamChars = 0;
|
|
61
80
|
let latestTokPerSecond = null;
|
|
62
81
|
const dialogQueue = [];
|
|
63
82
|
const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
|
|
83
|
+
const TAB_STORAGE_KEY = "pi-webui-active-tab";
|
|
84
|
+
const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
|
|
64
85
|
const statusEntries = new Map();
|
|
65
86
|
const widgets = new Map();
|
|
66
87
|
const gitWorkflow = {
|
|
@@ -92,6 +113,10 @@ function make(tag, className, text) {
|
|
|
92
113
|
return node;
|
|
93
114
|
}
|
|
94
115
|
|
|
116
|
+
function delay(ms) {
|
|
117
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
118
|
+
}
|
|
119
|
+
|
|
95
120
|
function setSidePanelCollapsed(collapsed) {
|
|
96
121
|
document.body.classList.toggle("side-panel-collapsed", collapsed);
|
|
97
122
|
elements.toggleSidePanelButton.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
|
@@ -113,8 +138,15 @@ function restoreSidePanelState() {
|
|
|
113
138
|
}
|
|
114
139
|
}
|
|
115
140
|
|
|
116
|
-
|
|
117
|
-
|
|
141
|
+
function scopedApiPath(path, tabId = activeTabId) {
|
|
142
|
+
if (!tabId || !path.startsWith("/api/") || path === "/api/tabs" || path.startsWith("/api/tabs?") || path.startsWith("/api/tabs/")) return path;
|
|
143
|
+
const url = new URL(path, window.location.origin);
|
|
144
|
+
url.searchParams.set("tab", tabId);
|
|
145
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function api(path, { method = "GET", body, tabId = activeTabId, scoped = true } = {}) {
|
|
149
|
+
const response = await fetch(scoped ? scopedApiPath(path, tabId) : path, {
|
|
118
150
|
method,
|
|
119
151
|
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
|
120
152
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
@@ -126,6 +158,218 @@ async function api(path, { method = "GET", body } = {}) {
|
|
|
126
158
|
return data;
|
|
127
159
|
}
|
|
128
160
|
|
|
161
|
+
function activeTab() {
|
|
162
|
+
return tabs.find((tab) => tab.id === activeTabId) || null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function rememberActiveTab() {
|
|
166
|
+
try {
|
|
167
|
+
if (activeTabId) localStorage.setItem(TAB_STORAGE_KEY, activeTabId);
|
|
168
|
+
} catch {
|
|
169
|
+
// Ignore storage failures; tabs still work for this page load.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function restoreStoredTabId() {
|
|
174
|
+
try {
|
|
175
|
+
return localStorage.getItem(TAB_STORAGE_KEY) || null;
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function updateDocumentTitle() {
|
|
182
|
+
const tab = activeTab();
|
|
183
|
+
document.title = tab ? `Pi Web UI · ${tab.title}` : "Pi Web UI";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function saveActiveDraft() {
|
|
187
|
+
if (activeTabId) tabDrafts.set(activeTabId, elements.promptInput.value || "");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function restoreActiveDraft() {
|
|
191
|
+
elements.promptInput.value = activeTabId ? tabDrafts.get(activeTabId) || "" : "";
|
|
192
|
+
renderCommandSuggestions();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function clearRefreshTimers() {
|
|
196
|
+
clearTimeout(refreshMessagesTimer);
|
|
197
|
+
clearTimeout(refreshStateTimer);
|
|
198
|
+
clearTimeout(refreshFooterTimer);
|
|
199
|
+
refreshMessagesTimer = null;
|
|
200
|
+
refreshStateTimer = null;
|
|
201
|
+
refreshFooterTimer = null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function cancelPendingDialogs() {
|
|
205
|
+
const pending = activeDialog ? [activeDialog] : [];
|
|
206
|
+
pending.push(...dialogQueue.splice(0));
|
|
207
|
+
for (const request of pending) {
|
|
208
|
+
if (!request?.id || !["select", "confirm", "input", "editor"].includes(request.method)) continue;
|
|
209
|
+
api("/api/extension-ui-response", {
|
|
210
|
+
method: "POST",
|
|
211
|
+
body: { type: "extension_ui_response", id: request.id, cancelled: true },
|
|
212
|
+
tabId: request.tabId || activeTabId,
|
|
213
|
+
}).catch((error) => console.warn("failed to cancel stale extension dialog", error));
|
|
214
|
+
}
|
|
215
|
+
activeDialog = null;
|
|
216
|
+
if (elements.dialog.open) elements.dialog.close();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function resetActiveTabUi() {
|
|
220
|
+
clearRefreshTimers();
|
|
221
|
+
eventSource?.close();
|
|
222
|
+
eventSource = null;
|
|
223
|
+
currentState = null;
|
|
224
|
+
latestStats = null;
|
|
225
|
+
latestWorkspace = null;
|
|
226
|
+
latestMessages = [];
|
|
227
|
+
currentRunStartedAt = null;
|
|
228
|
+
currentRunStreamChars = 0;
|
|
229
|
+
latestTokPerSecond = null;
|
|
230
|
+
statusEntries.clear();
|
|
231
|
+
widgets.clear();
|
|
232
|
+
availableCommands = [];
|
|
233
|
+
commandSuggestions = [];
|
|
234
|
+
commandSuggestIndex = 0;
|
|
235
|
+
resetStreamBubble();
|
|
236
|
+
hideCommandSuggestions();
|
|
237
|
+
cancelPendingDialogs();
|
|
238
|
+
Object.assign(gitWorkflow, {
|
|
239
|
+
active: false,
|
|
240
|
+
step: "idle",
|
|
241
|
+
busy: false,
|
|
242
|
+
output: "",
|
|
243
|
+
error: "",
|
|
244
|
+
message: null,
|
|
245
|
+
messageRequestedAt: 0,
|
|
246
|
+
});
|
|
247
|
+
elements.chat.replaceChildren();
|
|
248
|
+
elements.stateDetails.replaceChildren();
|
|
249
|
+
elements.eventLog.replaceChildren();
|
|
250
|
+
elements.queueBox.textContent = "No queued messages.";
|
|
251
|
+
elements.queueBox.classList.add("muted");
|
|
252
|
+
elements.commandsBox.textContent = "Loading…";
|
|
253
|
+
elements.commandsBox.classList.add("muted");
|
|
254
|
+
elements.sessionLine.textContent = activeTab() ? "Connecting…" : "No terminal tabs.";
|
|
255
|
+
renderWidgets();
|
|
256
|
+
renderGitWorkflow();
|
|
257
|
+
renderFooter();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function renderTabs() {
|
|
261
|
+
elements.tabBar.replaceChildren();
|
|
262
|
+
for (const tab of tabs) {
|
|
263
|
+
const isActive = tab.id === activeTabId;
|
|
264
|
+
const wrapper = make("div", `terminal-tab${isActive ? " active" : ""}${tab.running ? "" : " stopped"}`);
|
|
265
|
+
const button = make("button", "terminal-tab-button");
|
|
266
|
+
button.type = "button";
|
|
267
|
+
button.setAttribute("role", "tab");
|
|
268
|
+
button.setAttribute("aria-selected", isActive ? "true" : "false");
|
|
269
|
+
button.title = `${tab.title}${tab.running ? ` · pid ${tab.pid || "starting"}` : " · stopped"}`;
|
|
270
|
+
button.append(
|
|
271
|
+
make("span", "terminal-tab-title", tab.title),
|
|
272
|
+
make("span", "terminal-tab-meta", tab.running ? `pid ${tab.pid || "…"}` : "stopped"),
|
|
273
|
+
);
|
|
274
|
+
button.addEventListener("click", () => switchTab(tab.id));
|
|
275
|
+
wrapper.append(button);
|
|
276
|
+
|
|
277
|
+
if (tabs.length > 1) {
|
|
278
|
+
const close = make("button", "terminal-tab-close", "×");
|
|
279
|
+
close.type = "button";
|
|
280
|
+
close.title = `Close ${tab.title}`;
|
|
281
|
+
close.setAttribute("aria-label", `Close ${tab.title}`);
|
|
282
|
+
close.addEventListener("click", (event) => {
|
|
283
|
+
event.stopPropagation();
|
|
284
|
+
closeTerminalTab(tab.id);
|
|
285
|
+
});
|
|
286
|
+
wrapper.append(close);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
elements.tabBar.append(wrapper);
|
|
290
|
+
}
|
|
291
|
+
updateDocumentTitle();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function refreshTabs({ selectStored = false } = {}) {
|
|
295
|
+
const response = await api("/api/tabs", { scoped: false });
|
|
296
|
+
tabs = response.data?.tabs || [];
|
|
297
|
+
const stored = selectStored ? restoreStoredTabId() : null;
|
|
298
|
+
if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
|
|
299
|
+
activeTabId = (stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null;
|
|
300
|
+
rememberActiveTab();
|
|
301
|
+
}
|
|
302
|
+
renderTabs();
|
|
303
|
+
return tabs;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function switchTab(tabId) {
|
|
307
|
+
if (!tabId || tabId === activeTabId || !tabs.some((tab) => tab.id === tabId)) return;
|
|
308
|
+
saveActiveDraft();
|
|
309
|
+
activeTabId = tabId;
|
|
310
|
+
rememberActiveTab();
|
|
311
|
+
resetActiveTabUi();
|
|
312
|
+
renderTabs();
|
|
313
|
+
restoreActiveDraft();
|
|
314
|
+
connectEvents();
|
|
315
|
+
await refreshAll();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function createTerminalTab() {
|
|
319
|
+
elements.newTabButton.disabled = true;
|
|
320
|
+
try {
|
|
321
|
+
const response = await api("/api/tabs", { method: "POST", body: { cwd: activeTab()?.cwd }, scoped: false });
|
|
322
|
+
tabs = response.data?.tabs || tabs;
|
|
323
|
+
const tab = response.data?.tab;
|
|
324
|
+
renderTabs();
|
|
325
|
+
if (tab?.id) {
|
|
326
|
+
await switchTab(tab.id);
|
|
327
|
+
addEvent(`created isolated terminal ${tab.title}`, "info");
|
|
328
|
+
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
addEvent(error.message, "error");
|
|
331
|
+
} finally {
|
|
332
|
+
elements.newTabButton.disabled = false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function closeTerminalTab(tabId) {
|
|
337
|
+
const tab = tabs.find((item) => item.id === tabId);
|
|
338
|
+
if (!tab || tabs.length <= 1) return;
|
|
339
|
+
if (!confirm(`Close ${tab.title}? This terminates its isolated Pi process.`)) return;
|
|
340
|
+
|
|
341
|
+
const wasActive = tabId === activeTabId;
|
|
342
|
+
const fallbackTabId = tabs.find((item) => item.id !== tabId)?.id || null;
|
|
343
|
+
try {
|
|
344
|
+
if (wasActive) eventSource?.close();
|
|
345
|
+
const response = await api(`/api/tabs/${encodeURIComponent(tabId)}`, { method: "DELETE", scoped: false });
|
|
346
|
+
tabs = response.data?.tabs || tabs.filter((item) => item.id !== tabId);
|
|
347
|
+
tabDrafts.delete(tabId);
|
|
348
|
+
if (wasActive) {
|
|
349
|
+
activeTabId = (fallbackTabId && tabs.some((item) => item.id === fallbackTabId) ? fallbackTabId : tabs[0]?.id) || null;
|
|
350
|
+
rememberActiveTab();
|
|
351
|
+
resetActiveTabUi();
|
|
352
|
+
renderTabs();
|
|
353
|
+
restoreActiveDraft();
|
|
354
|
+
connectEvents();
|
|
355
|
+
if (activeTabId) await refreshAll();
|
|
356
|
+
} else {
|
|
357
|
+
renderTabs();
|
|
358
|
+
}
|
|
359
|
+
} catch (error) {
|
|
360
|
+
addEvent(error.message, "error");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function initializeTabs() {
|
|
365
|
+
await refreshTabs({ selectStored: true });
|
|
366
|
+
resetActiveTabUi();
|
|
367
|
+
renderTabs();
|
|
368
|
+
restoreActiveDraft();
|
|
369
|
+
connectEvents();
|
|
370
|
+
if (activeTabId) await refreshAll();
|
|
371
|
+
}
|
|
372
|
+
|
|
129
373
|
function addEvent(message, level = "info") {
|
|
130
374
|
const line = make("div", `event ${level}`.trim());
|
|
131
375
|
const time = new Date().toLocaleTimeString();
|
|
@@ -226,13 +470,215 @@ function footerMetric(icon, label, value, tone = "") {
|
|
|
226
470
|
return node;
|
|
227
471
|
}
|
|
228
472
|
|
|
229
|
-
function footerMeta(label, value, className = "") {
|
|
230
|
-
const
|
|
473
|
+
function footerMeta(label, value, className = "", options = {}) {
|
|
474
|
+
const isAction = typeof options.onClick === "function";
|
|
475
|
+
const node = make(isAction ? "button" : "span", `footer-meta ${className}${isAction ? " footer-meta-action" : ""}`.trim());
|
|
476
|
+
if (isAction) {
|
|
477
|
+
node.type = "button";
|
|
478
|
+
node.addEventListener("click", options.onClick);
|
|
479
|
+
}
|
|
231
480
|
node.append(make("span", "footer-meta-label", label), make("span", "footer-meta-value", value));
|
|
232
|
-
node.title = `${label}: ${value}`;
|
|
481
|
+
node.title = options.title || `${label}: ${value}`;
|
|
233
482
|
return node;
|
|
234
483
|
}
|
|
235
484
|
|
|
485
|
+
function pathPickerButton(label, title, onClick, className = "") {
|
|
486
|
+
const button = make("button", className, label);
|
|
487
|
+
button.type = "button";
|
|
488
|
+
button.title = title || label;
|
|
489
|
+
button.addEventListener("click", onClick);
|
|
490
|
+
return button;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function setPathPickerError(message) {
|
|
494
|
+
elements.pathPickerError.textContent = message || "";
|
|
495
|
+
elements.pathPickerError.hidden = !message;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function normalizeFastPicks(value) {
|
|
499
|
+
const items = Array.isArray(value) ? value : [];
|
|
500
|
+
const seen = new Set();
|
|
501
|
+
const picks = [];
|
|
502
|
+
for (const item of items) {
|
|
503
|
+
const cwd = typeof item === "string" ? item : String(item?.cwd || "");
|
|
504
|
+
if (!cwd || seen.has(cwd)) continue;
|
|
505
|
+
seen.add(cwd);
|
|
506
|
+
picks.push({ cwd, displayCwd: String(item?.displayCwd || cwd) });
|
|
507
|
+
}
|
|
508
|
+
return picks.slice(0, 30);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function loadFastPicks() {
|
|
512
|
+
try {
|
|
513
|
+
return normalizeFastPicks(JSON.parse(localStorage.getItem(PATH_FAST_PICKS_STORAGE_KEY) || "[]"));
|
|
514
|
+
} catch {
|
|
515
|
+
return [];
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function saveFastPicks(picks) {
|
|
520
|
+
try {
|
|
521
|
+
localStorage.setItem(PATH_FAST_PICKS_STORAGE_KEY, JSON.stringify(normalizeFastPicks(picks)));
|
|
522
|
+
} catch (error) {
|
|
523
|
+
addEvent(`failed to save path fast picks: ${error.message}`, "error");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function fastPickLabel(pick) {
|
|
528
|
+
const cwd = String(pick.cwd || pick.displayCwd || "");
|
|
529
|
+
const trimmed = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
530
|
+
if (!trimmed) return cwd || "directory";
|
|
531
|
+
return trimmed.split("/").filter(Boolean).pop() || trimmed;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function currentFastPick() {
|
|
535
|
+
if (!pathPickerState?.cwd) return null;
|
|
536
|
+
return { cwd: pathPickerState.cwd, displayCwd: elements.pathPickerCurrent.textContent || pathPickerState.cwd };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function updateAddFastPickButton() {
|
|
540
|
+
const pick = currentFastPick();
|
|
541
|
+
const exists = !!pick && loadFastPicks().some((item) => item.cwd === pick.cwd);
|
|
542
|
+
elements.pathPickerAddFastPickButton.disabled = !pick || exists;
|
|
543
|
+
elements.pathPickerAddFastPickButton.textContent = exists ? "Fast pick added" : "Add fast pick";
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function renderFastPicks() {
|
|
547
|
+
const picks = loadFastPicks();
|
|
548
|
+
elements.pathPickerFastPicks.replaceChildren();
|
|
549
|
+
if (picks.length === 0) {
|
|
550
|
+
elements.pathPickerFastPicks.append(make("div", "path-picker-fast-picks-empty muted", "No fast picks yet."));
|
|
551
|
+
updateAddFastPickButton();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
for (const pick of picks) {
|
|
556
|
+
const item = make("span", "path-picker-fast-pick");
|
|
557
|
+
const jump = pathPickerButton(fastPickLabel(pick), pick.cwd, () => loadPathPickerDirectory(pick.cwd), "path-picker-fast-pick-button");
|
|
558
|
+
const remove = pathPickerButton("×", `Remove fast pick ${pick.cwd}`, () => {
|
|
559
|
+
saveFastPicks(loadFastPicks().filter((item) => item.cwd !== pick.cwd));
|
|
560
|
+
renderFastPicks();
|
|
561
|
+
}, "path-picker-fast-pick-remove");
|
|
562
|
+
item.append(jump, remove);
|
|
563
|
+
elements.pathPickerFastPicks.append(item);
|
|
564
|
+
}
|
|
565
|
+
updateAddFastPickButton();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function addCurrentFastPick() {
|
|
569
|
+
const pick = currentFastPick();
|
|
570
|
+
if (!pick) return;
|
|
571
|
+
const picks = loadFastPicks().filter((item) => item.cwd !== pick.cwd);
|
|
572
|
+
picks.unshift(pick);
|
|
573
|
+
saveFastPicks(picks);
|
|
574
|
+
renderFastPicks();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function renderPathPicker(data) {
|
|
578
|
+
if (!pathPickerState) return;
|
|
579
|
+
pathPickerState.cwd = data.cwd;
|
|
580
|
+
elements.pathPickerCurrent.textContent = data.displayCwd || data.cwd;
|
|
581
|
+
elements.pathPickerCurrent.title = data.cwd;
|
|
582
|
+
elements.pathPickerChooseButton.disabled = false;
|
|
583
|
+
elements.pathPickerChooseButton.textContent = "Use this directory";
|
|
584
|
+
setPathPickerError(data.truncated ? "Showing the first 500 directories." : "");
|
|
585
|
+
renderFastPicks();
|
|
586
|
+
|
|
587
|
+
elements.pathPickerRoots.replaceChildren();
|
|
588
|
+
if (data.parent) {
|
|
589
|
+
elements.pathPickerRoots.append(pathPickerButton("↑ Parent", data.parent, () => loadPathPickerDirectory(data.parent), "path-picker-root-button"));
|
|
590
|
+
}
|
|
591
|
+
for (const root of data.roots || []) {
|
|
592
|
+
elements.pathPickerRoots.append(pathPickerButton(root.label, root.cwd, () => loadPathPickerDirectory(root.cwd), "path-picker-root-button"));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
elements.pathPickerList.replaceChildren();
|
|
596
|
+
if (!data.directories?.length) {
|
|
597
|
+
elements.pathPickerList.append(make("div", "path-picker-empty muted", "No subdirectories."));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
for (const directory of data.directories) {
|
|
602
|
+
const button = pathPickerButton(`${directory.name}/`, directory.cwd, () => loadPathPickerDirectory(directory.cwd), `path-picker-directory${directory.hidden ? " hidden-directory" : ""}`);
|
|
603
|
+
button.setAttribute("role", "option");
|
|
604
|
+
elements.pathPickerList.append(button);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function loadPathPickerDirectory(cwd) {
|
|
609
|
+
if (!pathPickerState) return;
|
|
610
|
+
const requestId = ++pathPickerState.requestId;
|
|
611
|
+
elements.pathPickerAddFastPickButton.disabled = true;
|
|
612
|
+
elements.pathPickerChooseButton.disabled = true;
|
|
613
|
+
elements.pathPickerCurrent.textContent = "Loading…";
|
|
614
|
+
setPathPickerError("");
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
const query = cwd ? `?path=${encodeURIComponent(cwd)}` : "";
|
|
618
|
+
const response = await api(`/api/directories${query}`);
|
|
619
|
+
if (!pathPickerState || pathPickerState.requestId !== requestId) return;
|
|
620
|
+
renderPathPicker(response.data || {});
|
|
621
|
+
} catch (error) {
|
|
622
|
+
if (!pathPickerState || pathPickerState.requestId !== requestId) return;
|
|
623
|
+
elements.pathPickerChooseButton.disabled = false;
|
|
624
|
+
elements.pathPickerCurrent.textContent = pathPickerState.cwd || "Unable to load directory";
|
|
625
|
+
setPathPickerError(error.message);
|
|
626
|
+
updateAddFastPickButton();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function closePathPicker(cwd) {
|
|
631
|
+
const state = pathPickerState;
|
|
632
|
+
if (!state) return;
|
|
633
|
+
pathPickerState = null;
|
|
634
|
+
if (elements.pathPickerDialog.open) elements.pathPickerDialog.close();
|
|
635
|
+
state.resolve(cwd || null);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function pickCwd(tab, initialCwd) {
|
|
639
|
+
if (pathPickerState) return Promise.resolve(null);
|
|
640
|
+
|
|
641
|
+
return new Promise((resolve) => {
|
|
642
|
+
pathPickerState = { tabId: tab.id, cwd: initialCwd, requestId: 0, resolve };
|
|
643
|
+
elements.pathPickerTitle.textContent = `Choose CWD for ${tab.title}`;
|
|
644
|
+
elements.pathPickerCurrent.textContent = "Loading…";
|
|
645
|
+
elements.pathPickerFastPicks.replaceChildren();
|
|
646
|
+
elements.pathPickerRoots.replaceChildren();
|
|
647
|
+
elements.pathPickerList.replaceChildren();
|
|
648
|
+
setPathPickerError("");
|
|
649
|
+
elements.pathPickerAddFastPickButton.disabled = true;
|
|
650
|
+
elements.pathPickerChooseButton.disabled = true;
|
|
651
|
+
elements.pathPickerDialog.showModal();
|
|
652
|
+
loadPathPickerDirectory(initialCwd);
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function changeActiveTabCwd() {
|
|
657
|
+
const tab = activeTab();
|
|
658
|
+
if (!tab) return;
|
|
659
|
+
|
|
660
|
+
const currentCwd = latestWorkspace?.cwd || tab.cwd || "";
|
|
661
|
+
const cwd = await pickCwd(tab, currentCwd);
|
|
662
|
+
if (!cwd || cwd === currentCwd) return;
|
|
663
|
+
if (!window.confirm(`Restart ${tab.title} in:\n${cwd}\n\nCurrent in-flight work in this tab will be stopped.`)) return;
|
|
664
|
+
|
|
665
|
+
saveActiveDraft();
|
|
666
|
+
try {
|
|
667
|
+
const response = await api(`/api/tabs/${encodeURIComponent(tab.id)}`, { method: "PATCH", body: { cwd }, scoped: false });
|
|
668
|
+
tabs = response.data?.tabs || tabs;
|
|
669
|
+
activeTabId = response.data?.tab?.id || activeTabId;
|
|
670
|
+
resetActiveTabUi();
|
|
671
|
+
renderTabs();
|
|
672
|
+
restoreActiveDraft();
|
|
673
|
+
connectEvents();
|
|
674
|
+
await refreshAll();
|
|
675
|
+
const changedCwd = response.data?.tab?.cwd || cwd;
|
|
676
|
+
addEvent(response.data?.changed === false ? `cwd unchanged: ${changedCwd}` : `changed ${tab.title} cwd to ${changedCwd}`, "info");
|
|
677
|
+
} catch (error) {
|
|
678
|
+
addEvent(error.message, "error");
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
236
682
|
function renderFooter() {
|
|
237
683
|
const stats = latestStats;
|
|
238
684
|
const tokens = stats?.tokens || {};
|
|
@@ -246,10 +692,11 @@ function renderFooter() {
|
|
|
246
692
|
? `${contextUsage.percent !== null && contextUsage.percent !== undefined ? `${Number(contextUsage.percent).toFixed(1)}% / ` : ""}${formatTokenCount(contextUsage.contextWindow)}`
|
|
247
693
|
: "?";
|
|
248
694
|
|
|
695
|
+
const tab = activeTab();
|
|
249
696
|
const git = latestWorkspace?.git;
|
|
250
697
|
const branchLabel = git?.isRepo ? git.branch || "detached" : "no repo";
|
|
251
698
|
const changeLabel = git?.isRepo ? `✎ ${git.changed ?? 0} ◌ ${git.untracked ?? 0}` : "no git";
|
|
252
|
-
const workspaceLabel = latestWorkspace?.displayCwd || "loading…";
|
|
699
|
+
const workspaceLabel = latestWorkspace?.displayCwd || (tab?.cwd ? normalizeDisplayPath(tab.cwd) : "loading…");
|
|
253
700
|
const runtime = latestWorkspace?.uptimeMs ? formatDuration(latestWorkspace.uptimeMs) : "--";
|
|
254
701
|
const modelLine = `${shortModelLabel(currentState?.model)} · ${currentState?.thinkingLevel || "?"}`;
|
|
255
702
|
|
|
@@ -266,7 +713,10 @@ function renderFooter() {
|
|
|
266
713
|
|
|
267
714
|
const row2 = make("div", "footer-line footer-line-meta");
|
|
268
715
|
row2.append(
|
|
269
|
-
footerMeta("cwd", workspaceLabel, "footer-workspace"
|
|
716
|
+
footerMeta("cwd", workspaceLabel, "footer-workspace", tab ? {
|
|
717
|
+
onClick: changeActiveTabCwd,
|
|
718
|
+
title: `Change cwd for ${tab.title}: ${workspaceLabel}`,
|
|
719
|
+
} : {}),
|
|
270
720
|
footerMeta("git", branchLabel, "footer-branch"),
|
|
271
721
|
footerMeta("changes", changeLabel, "footer-changes"),
|
|
272
722
|
footerMeta("runtime", `⏱ ${runtime} · Agent`, "footer-runtime"),
|
|
@@ -803,6 +1253,31 @@ async function refreshWorkspace() {
|
|
|
803
1253
|
renderFooter();
|
|
804
1254
|
}
|
|
805
1255
|
|
|
1256
|
+
function renderNetworkStatus() {
|
|
1257
|
+
const network = latestNetwork;
|
|
1258
|
+
const open = !!network?.open;
|
|
1259
|
+
const opening = !!network?.opening;
|
|
1260
|
+
const url = network?.networkUrls?.[0] || (open ? network?.localUrl : "");
|
|
1261
|
+
elements.networkStatus.className = `network-status ${opening ? "opening" : open ? "open" : "closed"}`;
|
|
1262
|
+
elements.networkStatus.textContent = opening ? "Opening to local network…" : open ? `Open to LAN${url ? ` · ${url}` : ""}` : "Closed · local only";
|
|
1263
|
+
elements.networkStatus.title = open
|
|
1264
|
+
? `Reachable on local network${(network?.networkUrls || []).length ? `:\n${network.networkUrls.join("\n")}` : " (no LAN address detected)"}`
|
|
1265
|
+
: "Only reachable from this machine";
|
|
1266
|
+
elements.openNetworkButton.disabled = opening || open;
|
|
1267
|
+
elements.openNetworkButton.textContent = opening ? "Opening…" : open ? "Open to network" : "Open to network";
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
async function refreshNetworkStatus() {
|
|
1271
|
+
try {
|
|
1272
|
+
const response = await api("/api/network", { scoped: false });
|
|
1273
|
+
latestNetwork = response.data || null;
|
|
1274
|
+
} catch {
|
|
1275
|
+
const health = await api("/api/health", { scoped: false });
|
|
1276
|
+
latestNetwork = health.network || { open: false, opening: false, localUrl: window.location.origin };
|
|
1277
|
+
}
|
|
1278
|
+
renderNetworkStatus();
|
|
1279
|
+
}
|
|
1280
|
+
|
|
806
1281
|
async function refreshFooterData() {
|
|
807
1282
|
await Promise.allSettled([refreshStats(), refreshWorkspace()]);
|
|
808
1283
|
}
|
|
@@ -999,12 +1474,42 @@ async function refreshCommands() {
|
|
|
999
1474
|
}
|
|
1000
1475
|
|
|
1001
1476
|
async function refreshAll() {
|
|
1002
|
-
const results = await Promise.allSettled([refreshState(), refreshMessages(), refreshModels(), refreshCommands(), refreshStats(), refreshWorkspace()]);
|
|
1477
|
+
const results = await Promise.allSettled([refreshState(), refreshMessages(), refreshModels(), refreshCommands(), refreshStats(), refreshWorkspace(), refreshNetworkStatus()]);
|
|
1003
1478
|
for (const result of results) {
|
|
1004
1479
|
if (result.status === "rejected") addEvent(result.reason.message || String(result.reason), "error");
|
|
1005
1480
|
}
|
|
1006
1481
|
}
|
|
1007
1482
|
|
|
1483
|
+
async function openToNetwork() {
|
|
1484
|
+
if (latestNetwork?.open) return;
|
|
1485
|
+
if (!confirm("Open Pi Web UI to your local network?\n\nThe Web UI has no authentication and can control Pi/tools. Only do this on a trusted LAN.")) return;
|
|
1486
|
+
|
|
1487
|
+
elements.openNetworkButton.disabled = true;
|
|
1488
|
+
elements.openNetworkButton.textContent = "Opening…";
|
|
1489
|
+
try {
|
|
1490
|
+
await api("/api/network/open", { method: "POST", body: {}, scoped: false });
|
|
1491
|
+
addEvent("opening webui to local network", "warn");
|
|
1492
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
1493
|
+
await delay(350);
|
|
1494
|
+
try {
|
|
1495
|
+
await refreshNetworkStatus();
|
|
1496
|
+
if (latestNetwork?.open) {
|
|
1497
|
+
const url = latestNetwork.networkUrls?.[0];
|
|
1498
|
+
addEvent(`webui open to local network${url ? `: ${url}` : ""}`, "warn");
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
} catch {
|
|
1502
|
+
// The listener briefly drops while rebinding; retry.
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
await refreshNetworkStatus();
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
addEvent(error.message, "error");
|
|
1508
|
+
} finally {
|
|
1509
|
+
renderNetworkStatus();
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1008
1513
|
async function sendPrompt(kind = "prompt") {
|
|
1009
1514
|
const message = elements.promptInput.value.trim();
|
|
1010
1515
|
if (!message) return;
|
|
@@ -1028,6 +1533,7 @@ async function sendPrompt(kind = "prompt") {
|
|
|
1028
1533
|
}
|
|
1029
1534
|
|
|
1030
1535
|
function handleExtensionUiRequest(request) {
|
|
1536
|
+
request.tabId ||= activeTabId;
|
|
1031
1537
|
switch (request.method) {
|
|
1032
1538
|
case "notify":
|
|
1033
1539
|
addEvent(request.message || "notification", request.notifyType === "error" ? "error" : request.notifyType === "warning" ? "warn" : "info");
|
|
@@ -1063,8 +1569,9 @@ function handleExtensionUiRequest(request) {
|
|
|
1063
1569
|
}
|
|
1064
1570
|
|
|
1065
1571
|
async function sendDialogResponse(payload) {
|
|
1572
|
+
const { tabId = activeTabId, ...body } = payload;
|
|
1066
1573
|
try {
|
|
1067
|
-
await api("/api/extension-ui-response", { method: "POST", body
|
|
1574
|
+
await api("/api/extension-ui-response", { method: "POST", body, tabId });
|
|
1068
1575
|
} catch (error) {
|
|
1069
1576
|
addEvent(error.message, "error");
|
|
1070
1577
|
} finally {
|
|
@@ -1092,36 +1599,36 @@ function showNextDialog() {
|
|
|
1092
1599
|
elements.dialogBody.replaceChildren();
|
|
1093
1600
|
elements.dialogActions.replaceChildren();
|
|
1094
1601
|
|
|
1095
|
-
const cancel = () => sendDialogResponse({ type: "extension_ui_response", id: request.id, cancelled: true });
|
|
1602
|
+
const cancel = () => sendDialogResponse({ type: "extension_ui_response", id: request.id, cancelled: true, tabId: request.tabId });
|
|
1096
1603
|
|
|
1097
1604
|
if (request.method === "select") {
|
|
1098
1605
|
const options = make("div", "dialog-options");
|
|
1099
1606
|
for (const option of request.options || []) {
|
|
1100
1607
|
const button = make("button", undefined, String(option));
|
|
1101
1608
|
button.type = "button";
|
|
1102
|
-
button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: String(option) }));
|
|
1609
|
+
button.addEventListener("click", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: String(option), tabId: request.tabId }));
|
|
1103
1610
|
options.append(button);
|
|
1104
1611
|
}
|
|
1105
1612
|
elements.dialogBody.append(options);
|
|
1106
1613
|
addDialogButton("Cancel", cancel);
|
|
1107
1614
|
} else if (request.method === "confirm") {
|
|
1108
1615
|
addDialogButton("Cancel", cancel);
|
|
1109
|
-
addDialogButton("No", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, confirmed: false }));
|
|
1110
|
-
addDialogButton("Yes", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, confirmed: true }), "primary");
|
|
1616
|
+
addDialogButton("No", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, confirmed: false, tabId: request.tabId }));
|
|
1617
|
+
addDialogButton("Yes", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, confirmed: true, tabId: request.tabId }), "primary");
|
|
1111
1618
|
} else if (request.method === "input") {
|
|
1112
1619
|
const input = make("input", "dialog-input");
|
|
1113
1620
|
input.value = request.prefill || "";
|
|
1114
1621
|
input.placeholder = request.placeholder || "";
|
|
1115
1622
|
elements.dialogBody.append(input);
|
|
1116
1623
|
addDialogButton("Cancel", cancel);
|
|
1117
|
-
addDialogButton("Submit", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: input.value }), "primary");
|
|
1624
|
+
addDialogButton("Submit", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: input.value, tabId: request.tabId }), "primary");
|
|
1118
1625
|
setTimeout(() => input.focus(), 0);
|
|
1119
1626
|
} else if (request.method === "editor") {
|
|
1120
1627
|
const textarea = make("textarea", "dialog-editor");
|
|
1121
1628
|
textarea.value = request.prefill || "";
|
|
1122
1629
|
elements.dialogBody.append(textarea);
|
|
1123
1630
|
addDialogButton("Cancel", cancel);
|
|
1124
|
-
addDialogButton("Submit", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: textarea.value }), "primary");
|
|
1631
|
+
addDialogButton("Submit", () => sendDialogResponse({ type: "extension_ui_response", id: request.id, value: textarea.value, tabId: request.tabId }), "primary");
|
|
1125
1632
|
setTimeout(() => textarea.focus(), 0);
|
|
1126
1633
|
}
|
|
1127
1634
|
|
|
@@ -1131,16 +1638,33 @@ function showNextDialog() {
|
|
|
1131
1638
|
function handleEvent(event) {
|
|
1132
1639
|
switch (event.type) {
|
|
1133
1640
|
case "webui_connected":
|
|
1134
|
-
addEvent(`connected to
|
|
1641
|
+
addEvent(`connected to ${event.tabTitle || "terminal"} for ${event.cwd}`);
|
|
1642
|
+
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
1135
1643
|
break;
|
|
1136
1644
|
case "pi_process_start":
|
|
1137
1645
|
addEvent(`started pi rpc pid ${event.pid}`);
|
|
1646
|
+
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
1647
|
+
break;
|
|
1648
|
+
case "webui_tab_restarting":
|
|
1649
|
+
addEvent(`restarting ${event.tabTitle || "terminal"} in ${event.cwd}`);
|
|
1650
|
+
break;
|
|
1651
|
+
case "webui_cwd_changed":
|
|
1652
|
+
addEvent(`${event.tabTitle || "terminal"} cwd changed to ${event.cwd}`);
|
|
1653
|
+
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
1654
|
+
scheduleRefreshFooter();
|
|
1655
|
+
break;
|
|
1656
|
+
case "webui_network_rebinding":
|
|
1657
|
+
addEvent(`webui network listener rebinding on ${event.host}:${event.port}; event stream will reconnect`, "warn");
|
|
1658
|
+
latestNetwork = { ...(latestNetwork || {}), opening: true };
|
|
1659
|
+
renderNetworkStatus();
|
|
1138
1660
|
break;
|
|
1139
1661
|
case "pi_process_exit":
|
|
1140
1662
|
addEvent(`pi rpc exited (${event.code ?? event.signal ?? "unknown"})`, "error");
|
|
1663
|
+
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
1141
1664
|
break;
|
|
1142
1665
|
case "pi_process_error":
|
|
1143
1666
|
addEvent(event.error || "pi rpc process error", "error");
|
|
1667
|
+
refreshTabs().catch((error) => addEvent(error.message, "error"));
|
|
1144
1668
|
break;
|
|
1145
1669
|
case "pi_stderr":
|
|
1146
1670
|
addEvent(event.text.trim(), "warn");
|
|
@@ -1216,7 +1740,8 @@ function handleEvent(event) {
|
|
|
1216
1740
|
|
|
1217
1741
|
function connectEvents() {
|
|
1218
1742
|
eventSource?.close();
|
|
1219
|
-
|
|
1743
|
+
if (!activeTabId) return;
|
|
1744
|
+
eventSource = new EventSource(`/api/events?tab=${encodeURIComponent(activeTabId)}`);
|
|
1220
1745
|
eventSource.onmessage = (message) => {
|
|
1221
1746
|
try {
|
|
1222
1747
|
handleEvent(JSON.parse(message.data));
|
|
@@ -1233,6 +1758,7 @@ elements.composer.addEventListener("submit", (event) => {
|
|
|
1233
1758
|
});
|
|
1234
1759
|
elements.steerButton.addEventListener("click", () => sendPrompt("steer"));
|
|
1235
1760
|
elements.followUpButton.addEventListener("click", () => sendPrompt("follow-up"));
|
|
1761
|
+
elements.newTabButton.addEventListener("click", createTerminalTab);
|
|
1236
1762
|
elements.gitWorkflowButton.addEventListener("click", startGitWorkflow);
|
|
1237
1763
|
elements.gitWorkflowCancelButton.addEventListener("click", cancelGitWorkflow);
|
|
1238
1764
|
elements.abortButton.addEventListener("click", async () => {
|
|
@@ -1285,6 +1811,7 @@ elements.setThinkingButton.addEventListener("click", async () => {
|
|
|
1285
1811
|
addEvent(error.message, "error");
|
|
1286
1812
|
}
|
|
1287
1813
|
});
|
|
1814
|
+
elements.openNetworkButton.addEventListener("click", openToNetwork);
|
|
1288
1815
|
elements.toggleSidePanelButton.addEventListener("click", () => {
|
|
1289
1816
|
setSidePanelCollapsed(true);
|
|
1290
1817
|
});
|
|
@@ -1292,6 +1819,17 @@ elements.sidePanelExpandButton.addEventListener("click", () => {
|
|
|
1292
1819
|
setSidePanelCollapsed(false);
|
|
1293
1820
|
});
|
|
1294
1821
|
|
|
1822
|
+
elements.pathPickerAddFastPickButton.addEventListener("click", addCurrentFastPick);
|
|
1823
|
+
elements.pathPickerCancelButton.addEventListener("click", () => closePathPicker(null));
|
|
1824
|
+
elements.pathPickerChooseButton.addEventListener("click", () => closePathPicker(pathPickerState?.cwd || null));
|
|
1825
|
+
elements.pathPickerDialog.addEventListener("cancel", (event) => {
|
|
1826
|
+
event.preventDefault();
|
|
1827
|
+
closePathPicker(null);
|
|
1828
|
+
});
|
|
1829
|
+
elements.pathPickerDialog.addEventListener("close", () => {
|
|
1830
|
+
if (pathPickerState) closePathPicker(null);
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1295
1833
|
elements.promptInput.addEventListener("keydown", (event) => {
|
|
1296
1834
|
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
|
1297
1835
|
event.preventDefault();
|
|
@@ -1336,5 +1874,4 @@ elements.promptInput.addEventListener("blur", () => {
|
|
|
1336
1874
|
});
|
|
1337
1875
|
|
|
1338
1876
|
restoreSidePanelState();
|
|
1339
|
-
|
|
1340
|
-
refreshAll();
|
|
1877
|
+
initializeTabs().catch((error) => addEvent(error.message, "error"));
|