@firstpick/pi-package-webui 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -15,6 +15,12 @@ const elements = {
15
15
  serverOfflinePanel: $("#serverOfflinePanel"),
16
16
  serverRestartPanel: $("#serverRestartPanel"),
17
17
  serverRestartMessage: $("#serverRestartMessage"),
18
+ updateNotification: $("#updateNotification"),
19
+ updateNotificationTitle: $("#updateNotificationTitle"),
20
+ updateNotificationMessage: $("#updateNotificationMessage"),
21
+ updateNotificationDetail: $("#updateNotificationDetail"),
22
+ updateNotificationUpdateButton: $("#updateNotificationUpdateButton"),
23
+ updateNotificationDismissButton: $("#updateNotificationDismissButton"),
18
24
  serverOfflineCommand: $("#serverOfflineCommand"),
19
25
  serverOfflineSlashCommand: $("#serverOfflineSlashCommand"),
20
26
  copyServerCommandButton: $("#copyServerCommandButton"),
@@ -87,6 +93,13 @@ const elements = {
87
93
  gitPrStatus: $("#gitPrStatus"),
88
94
  gitPrCancelButton: $("#gitPrCancelButton"),
89
95
  gitPrCreateButton: $("#gitPrCreateButton"),
96
+ gitChangesDialog: $("#gitChangesDialog"),
97
+ gitChangesTitle: $("#gitChangesTitle"),
98
+ gitChangesSubtitle: $("#gitChangesSubtitle"),
99
+ gitChangesStatus: $("#gitChangesStatus"),
100
+ gitChangesBody: $("#gitChangesBody"),
101
+ gitChangesRefreshButton: $("#gitChangesRefreshButton"),
102
+ gitChangesCloseButton: $("#gitChangesCloseButton"),
90
103
  modelSelect: $("#modelSelect"),
91
104
  setModelButton: $("#setModelButton"),
92
105
  thinkingSelect: $("#thinkingSelect"),
@@ -213,6 +226,9 @@ let foregroundReconcileTimer = null;
213
226
  let eventSource = null;
214
227
  let activeDialog = null;
215
228
  let activeGitPrDialogResolve = null;
229
+ let gitChangesState = { loading: false, error: "", data: null, tabId: null };
230
+ let gitChangesRequestSerial = 0;
231
+ const gitChangesUntrackedContentRequests = new Set();
216
232
  let nativeCommandTabId = null;
217
233
  let pathPickerState = null;
218
234
  let firstTerminalCwdPromptShown = false;
@@ -251,6 +267,10 @@ let refreshCodexUsageTimer = null;
251
267
  let codexUsageRenderTimer = null;
252
268
  let backendOffline = false;
253
269
  let serverRestartInProgress = false;
270
+ let updateRequestInProgress = false;
271
+ let latestUpdateStatus = null;
272
+ let updateStatusRefreshTimer = null;
273
+ let updateNotificationHideTimer = null;
254
274
  let backendOfflineNoticeShown = false;
255
275
  let latestMessages = [];
256
276
  let promptHistoryByTab = new Map();
@@ -295,6 +315,9 @@ let chatUserScrollIntentUntil = 0;
295
315
  let mobileFooterExpanded = false;
296
316
  let footerModelPickerOpen = false;
297
317
  let footerThinkingPickerOpen = false;
318
+ let footerBranchPickerOpen = false;
319
+ let footerBranchPickerState = { loading: false, error: "", branches: [], current: "", root: "", switching: "", tabId: null };
320
+ let footerBranchPickerRequestSerial = 0;
298
321
  let publishMenuOpen = false;
299
322
  let maxVisualViewportHeight = 0;
300
323
  let abortRequestInFlight = false;
@@ -311,6 +334,7 @@ const SIDE_PANEL_SECTION_STORAGE_KEY = "pi-webui-side-panel-sections-collapsed";
311
334
  const TAB_STORAGE_KEY = "pi-webui-active-tab";
312
335
  const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
313
336
  const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
337
+ const UPDATE_NOTIFICATION_DISMISS_STORAGE_KEY = "pi-webui-update-notification-dismissed";
314
338
  const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible";
315
339
  const BUSY_PROMPT_BEHAVIOR_STORAGE_KEY = "pi-webui-busy-prompt-behavior";
316
340
  const SKILL_USAGE_STORAGE_KEY = "pi-webui-skill-usage-v1";
@@ -330,6 +354,7 @@ const GIT_FOOTER_WEBUI_STATUS_KEY = "git-footer-webui";
330
354
  const GIT_FOOTER_WEBUI_PAYLOAD_TYPE = "firstpick.git-footer-status.footer";
331
355
  const GIT_FOOTER_WEBUI_PAYLOAD_VERSION = 1;
332
356
  const GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY = "pi-webui-git-footer-webui-payload-cache";
357
+ const GIT_CHANGES_RENDER_ROW_LIMIT = 4000;
333
358
  const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
334
359
  const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history";
335
360
  const PROMPT_LIST_STORAGE_KEY = "pi-webui-prompt-lists";
@@ -360,6 +385,8 @@ const CHAT_PROGRAMMATIC_SCROLL_GRACE_MS = 500;
360
385
  const CHAT_USER_SCROLL_INTENT_MS = 700;
361
386
  const CODEX_USAGE_REFRESH_MS = 5 * 60 * 1000;
362
387
  const CODEX_USAGE_RENDER_TICK_MS = 30 * 1000;
388
+ const UPDATE_STATUS_REFRESH_MS = 6 * 60 * 60 * 1000;
389
+ const UPDATE_STATUS_INITIAL_DELAY_MS = 1800;
363
390
  const RUN_INDICATOR_TICK_MS = 1000;
364
391
  const RUN_INDICATOR_START_GRACE_MS = 2500;
365
392
  const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
@@ -1467,10 +1494,11 @@ function updateComposerModeButtons() {
1467
1494
  }
1468
1495
 
1469
1496
  function isFooterPickerOpen() {
1470
- return footerModelPickerOpen || footerThinkingPickerOpen;
1497
+ return footerModelPickerOpen || footerThinkingPickerOpen || footerBranchPickerOpen;
1471
1498
  }
1472
1499
 
1473
1500
  function footerActivePickerTarget() {
1501
+ if (footerBranchPickerOpen) return elements.statusBar.querySelector(".footer-branch.footer-meta-action");
1474
1502
  if (footerThinkingPickerOpen) return elements.statusBar.querySelector(".footer-thinking.footer-meta-action");
1475
1503
  if (footerModelPickerOpen) return elements.statusBar.querySelector(".footer-model.footer-meta-action, .footer-tui-model");
1476
1504
  return null;
@@ -1521,6 +1549,7 @@ function setMobileFooterExpanded(expanded) {
1521
1549
  if (mobileFooterExpanded && isFooterPickerOpen()) {
1522
1550
  footerModelPickerOpen = false;
1523
1551
  footerThinkingPickerOpen = false;
1552
+ footerBranchPickerOpen = false;
1524
1553
  document.body.classList.remove("footer-model-picker-open");
1525
1554
  elements.statusBar.querySelectorAll(".footer-model-picker").forEach((node) => node.remove());
1526
1555
  }
@@ -1624,6 +1653,7 @@ function updateVisualViewportVars() {
1624
1653
  setMobileTabsExpanded(false);
1625
1654
  setMobileFooterExpanded(false);
1626
1655
  setFooterModelPickerOpen(false);
1656
+ setFooterBranchPickerOpen(false);
1627
1657
  syncMobileChatToBottomForInput();
1628
1658
  }
1629
1659
  updateFooterModelPickerPosition();
@@ -1698,6 +1728,7 @@ function setServerRestartOverlay(active, message = "Waiting for the server to co
1698
1728
  document.body.classList.toggle("server-restarting", serverRestartInProgress);
1699
1729
  if (elements.serverRestartPanel) elements.serverRestartPanel.hidden = !serverRestartInProgress;
1700
1730
  if (elements.serverRestartMessage) elements.serverRestartMessage.textContent = message;
1731
+ if (serverRestartInProgress) hideUpdateNotification();
1701
1732
  if (serverRestartInProgress && elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = true;
1702
1733
  }
1703
1734
 
@@ -1708,6 +1739,7 @@ function setBackendOffline(offline, error) {
1708
1739
  if (elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = !showOfflinePanel;
1709
1740
  renderServerOfflinePanel();
1710
1741
  if (backendOffline) {
1742
+ hideUpdateNotification();
1711
1743
  if (!serverRestartInProgress && !backendOfflineNoticeShown) {
1712
1744
  backendOfflineNoticeShown = true;
1713
1745
  addEvent(`Pi Web UI server is offline${error?.message ? `: ${error.message}` : ""}`, "warn");
@@ -1950,6 +1982,169 @@ async function refreshWebuiVersion() {
1950
1982
  setWebuiDevServer(isWebuiDevMetadata(health));
1951
1983
  }
1952
1984
 
1985
+ function packageUpdateText(label, status = {}) {
1986
+ const current = formatWebuiVersion(status.currentVersion || "");
1987
+ const latest = formatWebuiVersion(status.latestVersion || "");
1988
+ if (current && latest) return `${label} ${current} → ${latest}`;
1989
+ if (latest) return `${label} ${latest}`;
1990
+ return label;
1991
+ }
1992
+
1993
+ function updateNotificationItems(status = latestUpdateStatus) {
1994
+ const items = [];
1995
+ if (status?.pi?.updateAvailable) items.push(packageUpdateText("Pi", status.pi));
1996
+ if (status?.webui?.updateAvailable) items.push(packageUpdateText("Web UI", status.webui));
1997
+ return items;
1998
+ }
1999
+
2000
+ function updateNotificationDismissKey(status = latestUpdateStatus) {
2001
+ const parts = [status?.pi?.latestVersion, status?.webui?.latestVersion]
2002
+ .map((value) => String(value || "").trim())
2003
+ .filter(Boolean);
2004
+ return parts.length ? parts.join("|") : "";
2005
+ }
2006
+
2007
+ function storedDismissedUpdateKey() {
2008
+ try {
2009
+ return localStorage.getItem(UPDATE_NOTIFICATION_DISMISS_STORAGE_KEY) || "";
2010
+ } catch {
2011
+ return "";
2012
+ }
2013
+ }
2014
+
2015
+ function rememberDismissedUpdateKey(key) {
2016
+ if (!key) return;
2017
+ try {
2018
+ localStorage.setItem(UPDATE_NOTIFICATION_DISMISS_STORAGE_KEY, key);
2019
+ } catch {
2020
+ // Ignore private-mode storage failures.
2021
+ }
2022
+ }
2023
+
2024
+ function hideUpdateNotification({ remember = false } = {}) {
2025
+ const panel = elements.updateNotification;
2026
+ if (!panel) return;
2027
+ clearTimeout(updateNotificationHideTimer);
2028
+ if (remember) rememberDismissedUpdateKey(updateNotificationDismissKey());
2029
+ panel.classList.remove("show");
2030
+ updateNotificationHideTimer = setTimeout(() => {
2031
+ panel.hidden = true;
2032
+ }, 360);
2033
+ }
2034
+
2035
+ function renderUpdateNotification(status = latestUpdateStatus, { force = false } = {}) {
2036
+ const panel = elements.updateNotification;
2037
+ if (!panel) return;
2038
+ latestUpdateStatus = status || latestUpdateStatus;
2039
+ const items = updateNotificationItems(latestUpdateStatus);
2040
+ const dismissKey = updateNotificationDismissKey(latestUpdateStatus);
2041
+ const shouldShow = !!latestUpdateStatus?.updateAvailable && items.length > 0 && !updateRequestInProgress;
2042
+ if (!shouldShow || (!force && dismissKey && storedDismissedUpdateKey() === dismissKey)) {
2043
+ hideUpdateNotification();
2044
+ return;
2045
+ }
2046
+
2047
+ const canRunUpdate = latestUpdateStatus.canRunUpdate !== false;
2048
+ if (elements.updateNotificationTitle) elements.updateNotificationTitle.textContent = items.length === 1 ? `${items[0]} available` : "Pi updates available";
2049
+ if (elements.updateNotificationMessage) {
2050
+ elements.updateNotificationMessage.textContent = canRunUpdate
2051
+ ? "Run pi update now, then restart this Web UI server automatically."
2052
+ : "Updates are available. Direct Web UI updates are only enabled from localhost on the host machine.";
2053
+ }
2054
+ const details = [
2055
+ items.join(" · "),
2056
+ latestUpdateStatus.webuiDev && latestUpdateStatus.webui?.updateAvailable ? "The current Web UI is a dev checkout; pi update updates installed Pi packages, not this checkout." : "",
2057
+ latestUpdateStatus.packages?.note || "",
2058
+ ].filter(Boolean).join(" ");
2059
+ if (elements.updateNotificationDetail) elements.updateNotificationDetail.textContent = details;
2060
+ if (elements.updateNotificationUpdateButton) {
2061
+ elements.updateNotificationUpdateButton.hidden = !canRunUpdate;
2062
+ elements.updateNotificationUpdateButton.disabled = updateRequestInProgress || latestUpdateStatus.updateInProgress;
2063
+ elements.updateNotificationUpdateButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update & restart";
2064
+ }
2065
+ clearTimeout(updateNotificationHideTimer);
2066
+ panel.hidden = false;
2067
+ requestAnimationFrame(() => panel.classList.add("show"));
2068
+ }
2069
+
2070
+ async function refreshUpdateStatus({ force = false, notify = true } = {}) {
2071
+ const path = force ? "/api/update-status?refresh=1" : "/api/update-status";
2072
+ const response = await api(path, { scoped: false });
2073
+ latestUpdateStatus = response.data || null;
2074
+ if (notify) renderUpdateNotification(latestUpdateStatus);
2075
+ return latestUpdateStatus;
2076
+ }
2077
+
2078
+ function scheduleUpdateStatusRefresh() {
2079
+ clearTimeout(updateStatusRefreshTimer);
2080
+ updateStatusRefreshTimer = setTimeout(() => {
2081
+ updateStatusRefreshTimer = null;
2082
+ refreshUpdateStatus({ force: true }).catch((error) => addEvent(`Pi update check failed: ${error.message || String(error)}`, "warn"));
2083
+ scheduleUpdateStatusRefresh();
2084
+ }, UPDATE_STATUS_REFRESH_MS);
2085
+ }
2086
+
2087
+ function initializeUpdateNotifications() {
2088
+ setTimeout(() => {
2089
+ refreshUpdateStatus().catch((error) => addEvent(`Pi update check failed: ${error.message || String(error)}`, "warn"));
2090
+ scheduleUpdateStatusRefresh();
2091
+ }, UPDATE_STATUS_INITIAL_DELAY_MS);
2092
+ }
2093
+
2094
+ function piUpdateConfirmationText() {
2095
+ const items = updateNotificationItems();
2096
+ const workingWarning = hasWorkingTab() ? "\n\nOne or more Pi tabs look busy or blocked. Finish or abort in-flight work before updating if you need to preserve it." : "";
2097
+ const versionText = items.length ? `\n\nDetected update: ${items.join(" · ")}.` : "";
2098
+ return `Run pi update now?${versionText}\n\nThis will run \"pi update\" on the Web UI host. After it finishes, Pi Web UI will restart itself. Browser clients will briefly disconnect, and managed Pi tabs/RPC processes will be restarted from saved session state when possible.${workingWarning}`;
2099
+ }
2100
+
2101
+ async function runPiUpdateAndRestart() {
2102
+ if (updateRequestInProgress) return;
2103
+ if (latestUpdateStatus?.canRunUpdate === false) {
2104
+ addEvent("Pi update can only be started from localhost on the Web UI host", "warn");
2105
+ renderUpdateNotification(latestUpdateStatus, { force: true });
2106
+ return;
2107
+ }
2108
+ if (!confirm(piUpdateConfirmationText())) return;
2109
+
2110
+ updateRequestInProgress = true;
2111
+ hideUpdateNotification();
2112
+ setServerActionBusy("Updating…");
2113
+ setServerActionStatus("Running pi update. The server will restart after the update completes…", "warn");
2114
+ setServerRestartOverlay(true, "Running pi update. The server will restart after the update completes…");
2115
+ try {
2116
+ await api("/api/update", { method: "POST", scoped: false });
2117
+ addEvent("Pi update completed; Pi Web UI server restart requested", "warn");
2118
+ } catch (error) {
2119
+ if (!error?.backendOffline) {
2120
+ updateRequestInProgress = false;
2121
+ setServerRestartOverlay(false);
2122
+ resetServerActionControls();
2123
+ const message = error.message || String(error);
2124
+ setServerActionStatus(message, "error");
2125
+ addEvent(message, "error");
2126
+ renderUpdateNotification(latestUpdateStatus, { force: true });
2127
+ return;
2128
+ }
2129
+ addEvent("Pi Web UI server connection dropped during update restart request", "warn");
2130
+ }
2131
+
2132
+ setBackendOffline(true, new Error("update requested from side panel"));
2133
+ const restarted = await waitForServerRestart();
2134
+ updateRequestInProgress = false;
2135
+ resetServerActionControls();
2136
+ if (restarted) {
2137
+ hideUpdateNotification({ remember: true });
2138
+ setServerActionStatus("Updated, restarted, and reconnected.", "success");
2139
+ refreshUpdateStatus({ force: true, notify: false }).catch(() => {});
2140
+ } else {
2141
+ setServerRestartOverlay(false);
2142
+ setBackendOffline(true, new Error("update restart reconnect timed out"));
2143
+ setServerActionStatus("Update completed, but the server did not reconnect automatically.", "error");
2144
+ addEvent("Pi Web UI server did not come back online after update request", "error");
2145
+ }
2146
+ }
2147
+
1953
2148
  function formatBytes(bytes) {
1954
2149
  const value = Number(bytes) || 0;
1955
2150
  if (value < 1024) return `${value} B`;
@@ -3398,7 +3593,7 @@ function restoreActiveDraft() {
3398
3593
 
3399
3594
  function focusPromptInput({ defer = false } = {}) {
3400
3595
  const focus = () => {
3401
- if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.nativeCommandDialog.open || elements.appRunnerInfoDialog?.open || elements.promptListDialog?.open || elements.attachmentTextDialog?.open || elements.skillEditorDialog?.open || document.visibilityState === "hidden") return;
3596
+ if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.gitChangesDialog?.open || elements.nativeCommandDialog.open || elements.appRunnerInfoDialog?.open || elements.promptListDialog?.open || elements.attachmentTextDialog?.open || elements.skillEditorDialog?.open || document.visibilityState === "hidden") return;
3402
3597
  try {
3403
3598
  elements.promptInput.focus({ preventScroll: true });
3404
3599
  } catch {
@@ -3764,6 +3959,8 @@ async function switchTab(tabId) {
3764
3959
  setMobileTabsExpanded(false);
3765
3960
  footerModelPickerOpen = false;
3766
3961
  footerThinkingPickerOpen = false;
3962
+ footerBranchPickerOpen = false;
3963
+ footerBranchPickerRequestSerial += 1;
3767
3964
  saveActiveDraft();
3768
3965
  const tabContext = setActiveTabId(tabId, { remember: true });
3769
3966
  resetActiveTabUi();
@@ -4870,6 +5067,12 @@ function renderGitFooterPayloadMeta(chip, tab) {
4870
5067
  if (chip.key === "cwd" && tab) {
4871
5068
  options.onClick = changeActiveTabCwd;
4872
5069
  action = `Click to change the working directory for ${tab.title}.`;
5070
+ } else if (chip.key === "git" && chip.value !== "no repo") {
5071
+ options.onClick = () => setFooterBranchPickerOpen(!footerBranchPickerOpen);
5072
+ action = "Click to switch to another local branch.";
5073
+ } else if (chip.key === "changes") {
5074
+ options.onClick = openGitChangesDialog;
5075
+ action = "Click to view the current git diff.";
4873
5076
  } else if (chip.key === "model") {
4874
5077
  options.onClick = () => setFooterModelPickerOpen(!footerModelPickerOpen);
4875
5078
  action = "Click to choose another model.";
@@ -4880,6 +5083,10 @@ function renderGitFooterPayloadMeta(chip, tab) {
4880
5083
  options.title = gitFooterPayloadTooltip(chip, { action });
4881
5084
  options.tooltipAlign = gitFooterTooltipAlign(chip);
4882
5085
  const node = footerMeta(chip.label, chip.value, footerMetaClassForPayload(chip), options);
5086
+ if (chip.key === "git" && options.onClick) {
5087
+ node.setAttribute("aria-haspopup", "listbox");
5088
+ node.setAttribute("aria-expanded", footerBranchPickerOpen ? "true" : "false");
5089
+ }
4883
5090
  return chip.contextUsage ? applyFooterContextUsage(node, chip.contextUsage) : node;
4884
5091
  }
4885
5092
 
@@ -4905,10 +5112,450 @@ function renderGitFooterPayload(payload) {
4905
5112
  elements.statusBar.append(row1, row2);
4906
5113
  if (footerModelPickerOpen) elements.statusBar.append(renderFooterModelPicker());
4907
5114
  if (footerThinkingPickerOpen) elements.statusBar.append(renderFooterThinkingPicker());
5115
+ if (footerBranchPickerOpen) elements.statusBar.append(renderFooterBranchPicker());
4908
5116
  setMobileFooterExpanded(mobileFooterExpanded);
4909
5117
  updateFooterModelPickerPosition();
4910
5118
  }
4911
5119
 
5120
+ function cleanGitDiffPath(value = "") {
5121
+ let text = String(value || "").trim();
5122
+ if (!text || text === "/dev/null") return "";
5123
+ if ((text.startsWith("a/") || text.startsWith("b/")) && text.length > 2) text = text.slice(2);
5124
+ return text;
5125
+ }
5126
+
5127
+ function gitDiffPathFromHeader(line) {
5128
+ const match = String(line || "").match(/^diff --git\s+(.+?)\s+(.+)$/);
5129
+ return cleanGitDiffPath(match?.[2] || match?.[1] || "");
5130
+ }
5131
+
5132
+ function parseGitUnifiedDiff(diffText = "") {
5133
+ const normalized = String(diffText || "").replace(/\r\n?/g, "\n");
5134
+ const lines = normalized.split("\n");
5135
+ const files = [];
5136
+ let file = null;
5137
+ let hunk = null;
5138
+ let oldLineNumber = 0;
5139
+ let newLineNumber = 0;
5140
+ let deleteBuffer = [];
5141
+ let addBuffer = [];
5142
+
5143
+ const flushChangeRows = () => {
5144
+ if (!hunk || (!deleteBuffer.length && !addBuffer.length)) return;
5145
+ if (file) {
5146
+ file.deletions += deleteBuffer.length;
5147
+ file.additions += addBuffer.length;
5148
+ }
5149
+ const rowCount = Math.max(deleteBuffer.length, addBuffer.length);
5150
+ for (let i = 0; i < rowCount; i++) {
5151
+ const left = deleteBuffer[i] || null;
5152
+ const right = addBuffer[i] || null;
5153
+ hunk.rows.push({
5154
+ type: left && right ? "changed" : left ? "removed" : "added",
5155
+ oldNumber: left?.number ?? "",
5156
+ newNumber: right?.number ?? "",
5157
+ left: left?.text ?? "",
5158
+ right: right?.text ?? "",
5159
+ });
5160
+ }
5161
+ deleteBuffer = [];
5162
+ addBuffer = [];
5163
+ };
5164
+
5165
+ const finishFile = () => {
5166
+ flushChangeRows();
5167
+ if (!file) return;
5168
+ file.path = file.newPath || file.oldPath || file.headerPath || "diff";
5169
+ files.push(file);
5170
+ file = null;
5171
+ hunk = null;
5172
+ };
5173
+
5174
+ for (let index = 0; index < lines.length; index++) {
5175
+ const line = lines[index] || "";
5176
+ if (index === lines.length - 1 && !line && normalized.endsWith("\n")) continue;
5177
+
5178
+ if (line.startsWith("diff --git ")) {
5179
+ finishFile();
5180
+ file = { path: "", oldPath: "", newPath: "", headerPath: gitDiffPathFromHeader(line), headers: [line], hunks: [], additions: 0, deletions: 0 };
5181
+ continue;
5182
+ }
5183
+
5184
+ if (!file) {
5185
+ if (!line.trim()) continue;
5186
+ file = { path: "diff", oldPath: "", newPath: "", headerPath: "diff", headers: [], hunks: [], additions: 0, deletions: 0 };
5187
+ }
5188
+
5189
+ if (line.startsWith("@@ ")) {
5190
+ flushChangeRows();
5191
+ const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
5192
+ oldLineNumber = Number.parseInt(match?.[1] || "0", 10) || 0;
5193
+ newLineNumber = Number.parseInt(match?.[2] || "0", 10) || 0;
5194
+ hunk = { header: line, rows: [] };
5195
+ file.hunks.push(hunk);
5196
+ continue;
5197
+ }
5198
+
5199
+ if (!hunk) {
5200
+ file.headers.push(line);
5201
+ if (line.startsWith("--- ")) file.oldPath = cleanGitDiffPath(line.slice(4));
5202
+ if (line.startsWith("+++ ")) file.newPath = cleanGitDiffPath(line.slice(4));
5203
+ continue;
5204
+ }
5205
+
5206
+ if (line.startsWith("-")) {
5207
+ deleteBuffer.push({ number: oldLineNumber, text: line.slice(1) });
5208
+ oldLineNumber += 1;
5209
+ continue;
5210
+ }
5211
+ if (line.startsWith("+")) {
5212
+ addBuffer.push({ number: newLineNumber, text: line.slice(1) });
5213
+ newLineNumber += 1;
5214
+ continue;
5215
+ }
5216
+
5217
+ flushChangeRows();
5218
+ if (line.startsWith(" ")) {
5219
+ const text = line.slice(1);
5220
+ hunk.rows.push({ type: "context", oldNumber: oldLineNumber, newNumber: newLineNumber, left: text, right: text });
5221
+ oldLineNumber += 1;
5222
+ newLineNumber += 1;
5223
+ } else if (line.startsWith("\\")) {
5224
+ hunk.rows.push({ type: "meta", oldNumber: "", newNumber: "", left: line, right: line });
5225
+ } else {
5226
+ hunk.rows.push({ type: "meta", oldNumber: "", newNumber: "", left: line, right: line });
5227
+ }
5228
+ }
5229
+
5230
+ finishFile();
5231
+ return files;
5232
+ }
5233
+
5234
+ function gitChangesChip(label, value, className = "") {
5235
+ const chip = make("div", `git-changes-chip ${className}`.trim());
5236
+ chip.append(make("span", "git-changes-chip-label", label), make("span", "git-changes-chip-value", String(value ?? "—")));
5237
+ return chip;
5238
+ }
5239
+
5240
+ function renderGitChangesOverview(data) {
5241
+ const summary = data?.summary || {};
5242
+ const untrackedCount = Array.isArray(data?.untracked) ? data.untracked.length : Number(summary.untracked || 0);
5243
+ const overview = make("div", "git-changes-overview");
5244
+ overview.append(
5245
+ gitChangesChip("repo", data?.root || "—", "wide"),
5246
+ gitChangesChip("branch", data?.branch || "detached"),
5247
+ gitChangesChip("staged", summary.staged || 0, "success"),
5248
+ gitChangesChip("modified", summary.unstaged || 0, "warning"),
5249
+ gitChangesChip("untracked", untrackedCount, "muted"),
5250
+ gitChangesChip("conflicts", summary.conflicted || 0, (summary.conflicted || 0) > 0 ? "danger" : "muted"),
5251
+ );
5252
+ return overview;
5253
+ }
5254
+
5255
+ function renderGitDiffRow(row) {
5256
+ const node = make("div", `git-diff-row ${row.type || "context"}`.trim());
5257
+ node.append(
5258
+ make("span", "git-diff-line-number old", row.oldNumber === "" ? "" : String(row.oldNumber)),
5259
+ make("code", "git-diff-line old", row.left ?? ""),
5260
+ make("span", "git-diff-line-number new", row.newNumber === "" ? "" : String(row.newNumber)),
5261
+ make("code", "git-diff-line new", row.right ?? ""),
5262
+ );
5263
+ return node;
5264
+ }
5265
+
5266
+ function renderGitDiffGrid(file) {
5267
+ const grid = make("div", "git-diff-grid");
5268
+ const rowLimit = file.renderRowLimit ?? GIT_CHANGES_RENDER_ROW_LIMIT;
5269
+ let renderedRows = 0;
5270
+ let truncated = false;
5271
+ for (const hunk of file.hunks || []) {
5272
+ if (renderedRows >= rowLimit) {
5273
+ truncated = true;
5274
+ break;
5275
+ }
5276
+ grid.append(renderGitDiffRow({ type: "hunk", oldNumber: "", newNumber: "", left: hunk.header, right: hunk.header }));
5277
+ renderedRows += 1;
5278
+ for (const row of hunk.rows || []) {
5279
+ if (renderedRows >= rowLimit) {
5280
+ truncated = true;
5281
+ break;
5282
+ }
5283
+ grid.append(renderGitDiffRow(row));
5284
+ renderedRows += 1;
5285
+ }
5286
+ if (truncated) break;
5287
+ }
5288
+ if (truncated) {
5289
+ grid.append(renderGitDiffRow({ type: "meta", oldNumber: "", newNumber: "", left: `Diff preview truncated after ${rowLimit} rows.`, right: "Use git diff in the terminal for the full output." }));
5290
+ }
5291
+ return grid;
5292
+ }
5293
+
5294
+ function renderGitDiffFile(file) {
5295
+ const details = make("details", `git-diff-file ${file.className || ""}`.trim());
5296
+ details.open = true;
5297
+ details.dataset.gitDiffFile = file.path || "diff";
5298
+ const summary = make("summary", "git-diff-file-summary");
5299
+ summary.append(
5300
+ make("span", "git-diff-file-name", file.path || "diff"),
5301
+ make("span", "git-diff-file-stats", file.statsText || `+${file.additions || 0} −${file.deletions || 0}`),
5302
+ );
5303
+ details.append(summary);
5304
+ if (file.hunks?.length) {
5305
+ details.append(renderGitDiffGrid(file));
5306
+ } else {
5307
+ details.append(make("pre", "git-diff-raw", (file.headers || []).join("\n") || "No textual diff for this file."));
5308
+ }
5309
+ return details;
5310
+ }
5311
+
5312
+ function renderGitDiffSection(section, files) {
5313
+ const key = String(section?.key || "diff").replace(/[^a-z0-9_-]/gi, "-");
5314
+ const wrapper = make("section", `git-diff-section git-diff-section-${key}`);
5315
+ const header = make("div", "git-diff-section-heading");
5316
+ header.append(
5317
+ make("div", "git-diff-section-title", section?.label || "Git diff"),
5318
+ make("div", "git-diff-section-meta", `${files.length} file${files.length === 1 ? "" : "s"} · ${section?.command || "git diff"}`),
5319
+ );
5320
+ wrapper.append(header, ...files.map(renderGitDiffFile));
5321
+ return wrapper;
5322
+ }
5323
+
5324
+ function normalizeGitUntrackedEntry(value) {
5325
+ if (typeof value === "string") return { path: value, size: 0, binary: false, content: "", contentMissing: true };
5326
+ if (!value || typeof value !== "object") return null;
5327
+ const path = String(value.path || "").trim();
5328
+ if (!path) return null;
5329
+ const hasContent = Object.prototype.hasOwnProperty.call(value, "content");
5330
+ const binary = value.binary === true;
5331
+ const error = value.error ? String(value.error) : "";
5332
+ return {
5333
+ path,
5334
+ size: Number(value.size || 0) || 0,
5335
+ binary,
5336
+ content: hasContent && typeof value.content === "string" ? value.content : "",
5337
+ contentMissing: !hasContent && !binary && !error,
5338
+ error,
5339
+ };
5340
+ }
5341
+
5342
+ function gitUntrackedEntries(untracked) {
5343
+ return Array.isArray(untracked) ? untracked.map(normalizeGitUntrackedEntry).filter(Boolean) : [];
5344
+ }
5345
+
5346
+ function gitUntrackedContentLines(content = "") {
5347
+ const normalized = String(content || "").replace(/\r\n?/g, "\n");
5348
+ if (!normalized) return [];
5349
+ const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
5350
+ return withoutFinalNewline ? withoutFinalNewline.split("\n") : [""];
5351
+ }
5352
+
5353
+ function gitUntrackedEntryToDiffFile(entry) {
5354
+ const lines = gitUntrackedContentLines(entry.content);
5355
+ return {
5356
+ path: entry.path,
5357
+ className: "git-untracked-full-file",
5358
+ additions: lines.length,
5359
+ deletions: 0,
5360
+ statsText: `${entry.binary ? "binary" : `+${lines.length}`} · ${formatBytes(entry.size)}`,
5361
+ renderRowLimit: Number.POSITIVE_INFINITY,
5362
+ headers: lines.length ? [] : ["Empty untracked file."],
5363
+ hunks: lines.length ? [{
5364
+ header: `@@ -0,0 +1,${lines.length} @@`,
5365
+ rows: lines.map((line, index) => ({ type: "added", oldNumber: "", newNumber: index + 1, left: "", right: line })),
5366
+ }] : [],
5367
+ };
5368
+ }
5369
+
5370
+ function renderGitUntrackedRawFile(entry) {
5371
+ const details = make("details", "git-diff-file git-untracked-full-file");
5372
+ details.open = true;
5373
+ details.dataset.gitDiffFile = entry.path;
5374
+ const summary = make("summary", "git-diff-file-summary");
5375
+ summary.append(
5376
+ make("span", "git-diff-file-name", entry.path),
5377
+ make("span", "git-diff-file-stats", entry.error ? "unreadable" : `binary · ${formatBytes(entry.size)}`),
5378
+ );
5379
+ details.append(summary, make("pre", "git-diff-raw", entry.error || "Binary untracked file; text preview unavailable."));
5380
+ return details;
5381
+ }
5382
+
5383
+ function renderGitUntrackedLoadingFile(entry) {
5384
+ const details = make("details", "git-diff-file git-untracked-full-file git-untracked-loading-file");
5385
+ details.open = true;
5386
+ details.dataset.gitDiffFile = entry.path;
5387
+ const summary = make("summary", "git-diff-file-summary");
5388
+ summary.append(make("span", "git-diff-file-name", entry.path), make("span", "git-diff-file-stats", "loading content"));
5389
+ details.append(summary, make("pre", "git-diff-raw", "Loading complete untracked file content…"));
5390
+ return details;
5391
+ }
5392
+
5393
+ function renderGitUntrackedFile(entry) {
5394
+ if (entry.contentMissing) return renderGitUntrackedLoadingFile(entry);
5395
+ if (entry.error || entry.binary) return renderGitUntrackedRawFile(entry);
5396
+ return renderGitDiffFile(gitUntrackedEntryToDiffFile(entry));
5397
+ }
5398
+
5399
+ function replaceGitUntrackedEntry(entry, tabId = gitChangesState.tabId) {
5400
+ const data = gitChangesState.data;
5401
+ if (!data || tabId !== gitChangesState.tabId) return;
5402
+ const entries = gitUntrackedEntries(data.untracked);
5403
+ const nextEntries = entries.map((item) => item.path === entry.path ? normalizeGitUntrackedEntry(entry) : item);
5404
+ gitChangesState = { ...gitChangesState, data: { ...data, untracked: nextEntries } };
5405
+ renderGitChangesDialog();
5406
+ }
5407
+
5408
+ async function loadMissingGitUntrackedContent(entry, tabId = gitChangesState.tabId) {
5409
+ const key = `${tabId || ""}\u0000${entry.path}`;
5410
+ if (!entry.contentMissing || gitChangesUntrackedContentRequests.has(key)) return;
5411
+ gitChangesUntrackedContentRequests.add(key);
5412
+ try {
5413
+ const response = await api(`/api/git-changes/untracked-file?path=${encodeURIComponent(entry.path)}`, { tabId });
5414
+ if (!response.ok) throw new Error(response.error || "Failed to load untracked file content");
5415
+ replaceGitUntrackedEntry(response.data, tabId);
5416
+ } catch (error) {
5417
+ replaceGitUntrackedEntry({ ...entry, contentMissing: false, error: error.message || String(error) }, tabId);
5418
+ } finally {
5419
+ gitChangesUntrackedContentRequests.delete(key);
5420
+ }
5421
+ }
5422
+
5423
+ function renderGitUntrackedSection(untracked) {
5424
+ const entries = gitUntrackedEntries(untracked);
5425
+ const wrapper = make("section", "git-diff-section git-diff-section-untracked");
5426
+ const header = make("div", "git-diff-section-heading");
5427
+ header.append(
5428
+ make("div", "git-diff-section-title", "Untracked"),
5429
+ make("div", "git-diff-section-meta", `${entries.length} file${entries.length === 1 ? "" : "s"} · complete file contents`),
5430
+ );
5431
+ wrapper.append(header, ...entries.map(renderGitUntrackedFile));
5432
+ for (const entry of entries) {
5433
+ if (entry.contentMissing) queueMicrotask(() => loadMissingGitUntrackedContent(entry));
5434
+ }
5435
+ return wrapper;
5436
+ }
5437
+
5438
+ function renderGitCurrentFileHeader() {
5439
+ const header = make("div", "git-current-file-header");
5440
+ header.append(make("span", "git-current-file-label", "Current file"), make("span", "git-current-file-name", "—"));
5441
+ return header;
5442
+ }
5443
+
5444
+ function updateGitChangesCurrentFileHeader() {
5445
+ const body = elements.gitChangesBody;
5446
+ const header = body?.querySelector(".git-current-file-header");
5447
+ const name = header?.querySelector(".git-current-file-name");
5448
+ if (!body || !header || !name) return;
5449
+ const files = Array.from(body.querySelectorAll(".git-diff-file[data-git-diff-file]"));
5450
+ if (!files.length) {
5451
+ name.textContent = "—";
5452
+ return;
5453
+ }
5454
+ const bodyRect = body.getBoundingClientRect();
5455
+ const headerRect = header.getBoundingClientRect();
5456
+ const markerY = Math.min(bodyRect.bottom - 1, Math.max(bodyRect.top, headerRect.bottom + 8));
5457
+ let current = files[0];
5458
+ for (const file of files) {
5459
+ const rect = file.getBoundingClientRect();
5460
+ if (rect.top <= markerY && rect.bottom > markerY) {
5461
+ current = file;
5462
+ break;
5463
+ }
5464
+ if (rect.top <= markerY) current = file;
5465
+ else break;
5466
+ }
5467
+ name.textContent = current?.dataset.gitDiffFile || "—";
5468
+ }
5469
+
5470
+ function gitChangesGeneratedLabel(data) {
5471
+ const timestamp = Date.parse(data?.generatedAt || "");
5472
+ if (!Number.isFinite(timestamp)) return "";
5473
+ return `Updated ${new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`;
5474
+ }
5475
+
5476
+ function renderGitChangesDialog() {
5477
+ if (!elements.gitChangesDialog || !elements.gitChangesBody) return;
5478
+ const { loading, error, data } = gitChangesState;
5479
+ if (elements.gitChangesTitle) elements.gitChangesTitle.textContent = "Uncommitted Changes";
5480
+ if (elements.gitChangesSubtitle) elements.gitChangesSubtitle.textContent = data?.root ? `${data.branch || "detached"} · ${data.root}` : "Current tab git diff";
5481
+ if (elements.gitChangesRefreshButton) {
5482
+ elements.gitChangesRefreshButton.disabled = loading;
5483
+ elements.gitChangesRefreshButton.textContent = loading ? "Refreshing…" : "Refresh";
5484
+ }
5485
+ if (elements.gitChangesStatus) {
5486
+ elements.gitChangesStatus.className = `git-changes-status ${error ? "error" : "muted"}`;
5487
+ elements.gitChangesStatus.textContent = error || (loading ? "Loading git diff…" : data ? gitChangesGeneratedLabel(data) : "");
5488
+ elements.gitChangesStatus.hidden = !elements.gitChangesStatus.textContent;
5489
+ }
5490
+
5491
+ const body = elements.gitChangesBody;
5492
+ body.replaceChildren();
5493
+ if (loading && !data) {
5494
+ body.append(make("div", "git-changes-empty", "Loading git diff…"));
5495
+ return;
5496
+ }
5497
+ if (error) {
5498
+ body.append(make("div", "git-changes-empty error", error));
5499
+ return;
5500
+ }
5501
+ if (!data) {
5502
+ body.append(make("div", "git-changes-empty", "Open from the footer CHANGES chip to load the current git diff."));
5503
+ return;
5504
+ }
5505
+
5506
+ body.append(renderGitChangesOverview(data));
5507
+ const parsedSections = (Array.isArray(data.sections) ? data.sections : [])
5508
+ .map((section) => ({ section, files: parseGitUnifiedDiff(section.diff || "") }))
5509
+ .filter((entry) => entry.files.length > 0);
5510
+ const untracked = gitUntrackedEntries(data.untracked);
5511
+ const hasVisibleFiles = parsedSections.length > 0 || untracked.length > 0;
5512
+ if (hasVisibleFiles) body.append(renderGitCurrentFileHeader());
5513
+ for (const entry of parsedSections) body.append(renderGitDiffSection(entry.section, entry.files));
5514
+ if (untracked.length) body.append(renderGitUntrackedSection(untracked));
5515
+ if (!hasVisibleFiles) body.append(make("div", "git-changes-empty success", "Working tree is clean. No staged or unstaged diff."));
5516
+ if (hasVisibleFiles) requestAnimationFrame(updateGitChangesCurrentFileHeader);
5517
+ }
5518
+
5519
+ async function loadGitChangesDialog(tabContext = activeTabContext()) {
5520
+ const requestSerial = ++gitChangesRequestSerial;
5521
+ gitChangesUntrackedContentRequests.clear();
5522
+ gitChangesState = { ...gitChangesState, loading: true, error: "", tabId: tabContext.tabId || activeTabId };
5523
+ renderGitChangesDialog();
5524
+ try {
5525
+ const response = await api("/api/git-changes", { tabId: tabContext.tabId });
5526
+ if (requestSerial !== gitChangesRequestSerial) return;
5527
+ if (!response.ok) throw new Error(response.error || "Failed to load git changes");
5528
+ gitChangesState = { loading: false, error: "", data: response.data || null, tabId: tabContext.tabId || activeTabId };
5529
+ } catch (error) {
5530
+ if (requestSerial !== gitChangesRequestSerial) return;
5531
+ gitChangesState = { ...gitChangesState, loading: false, error: error.message || String(error) };
5532
+ }
5533
+ renderGitChangesDialog();
5534
+ }
5535
+
5536
+ function openGitChangesDialog() {
5537
+ if (!elements.gitChangesDialog) return;
5538
+ hideFooterTooltip();
5539
+ const tabContext = activeTabContext();
5540
+ const tabId = tabContext.tabId || activeTabId;
5541
+ gitChangesState = { loading: true, error: "", data: gitChangesState.tabId === tabId ? gitChangesState.data : null, tabId };
5542
+ renderGitChangesDialog();
5543
+ if (!elements.gitChangesDialog.open) elements.gitChangesDialog.showModal();
5544
+ loadGitChangesDialog(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
5545
+ }
5546
+
5547
+ function refreshGitChangesDialog() {
5548
+ const tabContext = { tabId: gitChangesState.tabId || activeTabId };
5549
+ loadGitChangesDialog(tabContext).catch((error) => addEvent(error.message || String(error), "error"));
5550
+ }
5551
+
5552
+ function closeGitChangesDialog() {
5553
+ gitChangesRequestSerial += 1;
5554
+ gitChangesUntrackedContentRequests.clear();
5555
+ gitChangesState = { ...gitChangesState, loading: false };
5556
+ if (elements.gitChangesDialog?.open) elements.gitChangesDialog.close();
5557
+ }
5558
+
4912
5559
  function gitFooterFallbackMessage() {
4913
5560
  if (isOptionalFeatureDisabled("gitFooterStatus")) return "";
4914
5561
  const tabContext = activeTabContext();
@@ -4940,13 +5587,18 @@ function renderMinimalFooter() {
4940
5587
  }));
4941
5588
  if (footerModelPickerOpen) elements.statusBar.append(renderFooterModelPicker());
4942
5589
  if (footerThinkingPickerOpen) elements.statusBar.append(renderFooterThinkingPicker());
5590
+ if (footerBranchPickerOpen) elements.statusBar.append(renderFooterBranchPicker());
4943
5591
  setMobileFooterExpanded(false);
4944
5592
  updateFooterModelPickerPosition();
4945
5593
  }
4946
5594
 
4947
5595
  function setFooterModelPickerOpen(open) {
4948
5596
  footerModelPickerOpen = !!open;
4949
- if (footerModelPickerOpen) footerThinkingPickerOpen = false;
5597
+ if (footerModelPickerOpen) {
5598
+ footerThinkingPickerOpen = false;
5599
+ footerBranchPickerOpen = false;
5600
+ footerBranchPickerRequestSerial += 1;
5601
+ }
4950
5602
  if (footerModelPickerOpen && isMobileView()) {
4951
5603
  mobileFooterExpanded = false;
4952
5604
  document.body.classList.remove("footer-details-expanded");
@@ -4960,7 +5612,11 @@ function setFooterModelPickerOpen(open) {
4960
5612
 
4961
5613
  function setFooterThinkingPickerOpen(open) {
4962
5614
  footerThinkingPickerOpen = !!open;
4963
- if (footerThinkingPickerOpen) footerModelPickerOpen = false;
5615
+ if (footerThinkingPickerOpen) {
5616
+ footerModelPickerOpen = false;
5617
+ footerBranchPickerOpen = false;
5618
+ footerBranchPickerRequestSerial += 1;
5619
+ }
4964
5620
  if (footerThinkingPickerOpen && isMobileView()) {
4965
5621
  mobileFooterExpanded = false;
4966
5622
  document.body.classList.remove("footer-details-expanded");
@@ -4972,6 +5628,239 @@ function setFooterThinkingPickerOpen(open) {
4972
5628
  updateFooterModelPickerPosition();
4973
5629
  }
4974
5630
 
5631
+ function normalizeFooterGitBranches(data = {}) {
5632
+ const current = cleanStatusText(data.current || "");
5633
+ const seen = new Set();
5634
+ const branches = [];
5635
+ for (const item of Array.isArray(data.branches) ? data.branches : []) {
5636
+ const name = cleanStatusText(typeof item === "string" ? item : item?.name);
5637
+ if (!name || seen.has(name)) continue;
5638
+ seen.add(name);
5639
+ branches.push({ name, current: Boolean(item?.current) || (!!current && name === current) });
5640
+ }
5641
+ return {
5642
+ root: cleanFooterPayloadText(data.root, "", 4000),
5643
+ current,
5644
+ branches,
5645
+ };
5646
+ }
5647
+
5648
+ function applyOptimisticGitFooterBranch(branch, tabContext = activeTabContext()) {
5649
+ const nextBranch = cleanStatusText(branch);
5650
+ if (!nextBranch) return;
5651
+ const raw = statusEntries.get(GIT_FOOTER_WEBUI_STATUS_KEY) || readCachedGitFooterWebuiPayloadRaw();
5652
+ const payload = parseGitFooterWebuiPayloadRaw(raw);
5653
+ if (!payload) return;
5654
+ const nextPayload = {
5655
+ type: GIT_FOOTER_WEBUI_PAYLOAD_TYPE,
5656
+ version: GIT_FOOTER_WEBUI_PAYLOAD_VERSION,
5657
+ generatedAt: Date.now(),
5658
+ main: payload.main,
5659
+ meta: payload.meta.map((chip) => chip.key === "git" ? { ...chip, value: nextBranch, title: `git branch: ${nextBranch}` } : chip),
5660
+ };
5661
+ const nextRaw = JSON.stringify(nextPayload);
5662
+ statusEntries.set(GIT_FOOTER_WEBUI_STATUS_KEY, nextRaw);
5663
+ cacheGitFooterWebuiPayload(nextRaw, tabContext.tabId);
5664
+ }
5665
+
5666
+ async function loadFooterBranchPicker(tabContext = activeTabContext()) {
5667
+ const requestSerial = ++footerBranchPickerRequestSerial;
5668
+ const tabId = tabContext.tabId || activeTabId;
5669
+ footerBranchPickerState = {
5670
+ loading: true,
5671
+ error: "",
5672
+ branches: footerBranchPickerState.tabId === tabId ? footerBranchPickerState.branches : [],
5673
+ current: footerBranchPickerState.tabId === tabId ? footerBranchPickerState.current : "",
5674
+ root: footerBranchPickerState.tabId === tabId ? footerBranchPickerState.root : "",
5675
+ switching: "",
5676
+ tabId,
5677
+ };
5678
+ if (isCurrentTabContext(tabContext)) {
5679
+ renderFooter();
5680
+ updateFooterModelPickerPosition();
5681
+ }
5682
+ try {
5683
+ const response = await api("/api/git-branches", { tabId });
5684
+ if (requestSerial !== footerBranchPickerRequestSerial || !footerBranchPickerOpen || !isCurrentTabContext(tabContext)) return;
5685
+ if (!response.ok) throw new Error(response.error || "Failed to load git branches");
5686
+ footerBranchPickerState = { loading: false, error: "", switching: "", tabId, ...normalizeFooterGitBranches(response.data || {}) };
5687
+ } catch (error) {
5688
+ if (requestSerial !== footerBranchPickerRequestSerial || !footerBranchPickerOpen || !isCurrentTabContext(tabContext)) return;
5689
+ footerBranchPickerState = { ...footerBranchPickerState, loading: false, switching: "", error: error.message || String(error) };
5690
+ }
5691
+ if (isCurrentTabContext(tabContext)) {
5692
+ renderFooter();
5693
+ updateFooterModelPickerPosition();
5694
+ }
5695
+ }
5696
+
5697
+ function setFooterBranchPickerOpen(open) {
5698
+ footerBranchPickerOpen = !!open;
5699
+ if (footerBranchPickerOpen) {
5700
+ footerModelPickerOpen = false;
5701
+ footerThinkingPickerOpen = false;
5702
+ if (isMobileView()) {
5703
+ mobileFooterExpanded = false;
5704
+ document.body.classList.remove("footer-details-expanded");
5705
+ setComposerActionsOpen(false);
5706
+ setMobileTabsExpanded(false);
5707
+ }
5708
+ loadFooterBranchPicker(activeTabContext()).catch((error) => addEvent(error.message || String(error), "error"));
5709
+ } else {
5710
+ footerBranchPickerRequestSerial += 1;
5711
+ }
5712
+ document.body.classList.toggle("footer-model-picker-open", isFooterPickerOpen());
5713
+ renderFooter();
5714
+ updateFooterModelPickerPosition();
5715
+ }
5716
+
5717
+ function pathLooksInside(parentPath, childPath) {
5718
+ const normalizePath = (value) => String(value || "").replace(/\\+/g, "/").replace(/\/+$/, "");
5719
+ const parent = normalizePath(parentPath);
5720
+ const child = normalizePath(childPath);
5721
+ return !!parent && !!child && (child === parent || child.startsWith(`${parent}/`));
5722
+ }
5723
+
5724
+ function footerBranchActiveAgentTabs(tabContext = activeTabContext()) {
5725
+ const active = activeTab();
5726
+ const activeCwd = latestWorkspace?.cwd || active?.cwd || "";
5727
+ const root = footerBranchPickerState.root || "";
5728
+ return tabs.filter((tab) => {
5729
+ const sameWorktree = root ? pathLooksInside(root, tab.cwd) : !!activeCwd && tab.cwd === activeCwd;
5730
+ if (!sameWorktree) return false;
5731
+ if (tab.id === tabContext.tabId) return currentState?.isStreaming || currentState?.isCompacting || tabHasActiveAgent(tab);
5732
+ return tabHasActiveAgent(tab);
5733
+ });
5734
+ }
5735
+
5736
+ function footerBranchAgentWarningLines(tabContext = activeTabContext()) {
5737
+ const busyTabs = footerBranchActiveAgentTabs(tabContext);
5738
+ if (!busyTabs.length) return [];
5739
+ const list = busyTabs.slice(0, 4).map((tab) => `- ${tab.title || tab.id}`).join("\n");
5740
+ const extra = busyTabs.length > 4 ? `\n- … +${busyTabs.length - 4} more` : "";
5741
+ return [
5742
+ "",
5743
+ `WARNING: ${busyTabs.length === 1 ? "An agent is" : "Agents are"} still running or waiting for input in this Git working tree:`,
5744
+ `${list}${extra}`,
5745
+ "Switching branches can change files underneath the running agent.",
5746
+ ];
5747
+ }
5748
+
5749
+ function confirmFooterGitBranchAction(branch, { create = false, requireConfirm = false, tabContext = activeTabContext() } = {}) {
5750
+ const branchName = cleanStatusText(branch);
5751
+ const warningLines = footerBranchAgentWarningLines(tabContext);
5752
+ if (!requireConfirm && warningLines.length === 0) return true;
5753
+ const action = create ? "Create and switch to new git branch" : "Switch git branch";
5754
+ const message = [
5755
+ `${action}: ${branchName}?`,
5756
+ "",
5757
+ `Repository: ${footerBranchPickerState.root || currentGitFooterCacheCwd(tabContext.tabId) || "current tab"}`,
5758
+ ...warningLines,
5759
+ "",
5760
+ "Continue?",
5761
+ ].join("\n");
5762
+ return window.confirm(message);
5763
+ }
5764
+
5765
+ function promptFooterGitBranchName() {
5766
+ const value = window.prompt("New git branch name:", "");
5767
+ if (value === null) return "";
5768
+ return cleanStatusText(value);
5769
+ }
5770
+
5771
+ async function createFooterGitBranch() {
5772
+ const branchName = promptFooterGitBranchName();
5773
+ if (!branchName) return;
5774
+ const tabContext = activeTabContext();
5775
+ if (!confirmFooterGitBranchAction(branchName, { create: true, requireConfirm: true, tabContext })) return;
5776
+ await applyFooterGitBranch(branchName, { create: true, tabContext, skipConfirm: true });
5777
+ }
5778
+
5779
+ async function applyFooterGitBranch(branch, { create = false, tabContext = activeTabContext(), skipConfirm = false } = {}) {
5780
+ const branchName = cleanStatusText(branch);
5781
+ if (!branchName) return;
5782
+ const tabId = tabContext.tabId || activeTabId;
5783
+ if (!skipConfirm && !confirmFooterGitBranchAction(branchName, { create, tabContext })) return;
5784
+ try {
5785
+ footerBranchPickerState = { ...footerBranchPickerState, loading: true, error: "", switching: branchName, tabId };
5786
+ renderFooter();
5787
+ const response = await api("/api/git-branch", { method: "POST", body: { branch: branchName, create }, tabId });
5788
+ if (!isCurrentTabContext(tabContext)) return;
5789
+ if (!response.ok) throw new Error(response.error || `Failed to ${create ? "create and switch to" : "switch to"} ${branchName}`);
5790
+ const switchedBranch = cleanStatusText(response.data?.branch || branchName);
5791
+ footerBranchPickerOpen = false;
5792
+ footerBranchPickerRequestSerial += 1;
5793
+ footerBranchPickerState = { ...footerBranchPickerState, loading: false, switching: "", current: switchedBranch };
5794
+ applyOptimisticGitFooterBranch(switchedBranch, tabContext);
5795
+ addEvent(response.data?.created ? `Created and switched to git branch ${switchedBranch}.` : response.data?.switched === false ? `Already on git branch ${switchedBranch}.` : `Switched git branch to ${switchedBranch}.`, "info");
5796
+ requestGitFooterWebuiPayload(tabContext, { force: true });
5797
+ } catch (error) {
5798
+ if (isCurrentTabContext(tabContext)) {
5799
+ footerBranchPickerState = { ...footerBranchPickerState, loading: false, switching: "", error: error.message || String(error) };
5800
+ addEvent(error.message || String(error), "error");
5801
+ }
5802
+ } finally {
5803
+ if (isCurrentTabContext(tabContext)) {
5804
+ document.body.classList.toggle("footer-model-picker-open", isFooterPickerOpen());
5805
+ renderFooter();
5806
+ updateFooterModelPickerPosition();
5807
+ }
5808
+ }
5809
+ }
5810
+
5811
+ function renderFooterBranchPicker() {
5812
+ const picker = make("div", "footer-model-picker footer-branch-picker");
5813
+ picker.setAttribute("role", "listbox");
5814
+ picker.setAttribute("aria-label", "Git branches");
5815
+ const state = footerBranchPickerState;
5816
+ const current = state.current || "detached";
5817
+ picker.append(make("div", "footer-model-picker-title", "Git branches"));
5818
+ picker.append(make("div", "footer-model-picker-source", `${state.loading ? "Refreshing" : "Current"}: ${state.switching || current}${state.root ? ` · ${state.root}` : ""}`));
5819
+
5820
+ if (state.error) {
5821
+ const error = make("div", "footer-model-picker-empty error");
5822
+ error.append(make("strong", undefined, "Cannot load branches."), make("span", undefined, ` ${state.error}`));
5823
+ picker.append(error);
5824
+ return picker;
5825
+ }
5826
+ if (state.loading && state.branches.length === 0) {
5827
+ picker.append(make("div", "footer-model-picker-empty muted", "Loading local branches…"));
5828
+ return picker;
5829
+ }
5830
+
5831
+ const hasOtherBranches = state.branches.some((branch) => !branch.current && branch.name !== state.current);
5832
+ if (!state.loading && !hasOtherBranches) {
5833
+ const empty = make("div", "footer-model-picker-empty muted");
5834
+ empty.append(make("strong", undefined, "No other local branches available."), make("span", undefined, " Create a branch from the current HEAD to continue."));
5835
+ const createButton = make("button", "footer-model-option footer-branch-create-option");
5836
+ createButton.type = "button";
5837
+ createButton.append(
5838
+ make("span", "footer-model-option-main", "Create new branch"),
5839
+ make("span", "footer-model-option-name", "prompts for a name, confirms, then runs git switch -c"),
5840
+ );
5841
+ createButton.addEventListener("click", () => createFooterGitBranch().catch((error) => addEvent(error.message || String(error), "error")));
5842
+ picker.append(empty, createButton);
5843
+ }
5844
+
5845
+ for (const branch of state.branches) {
5846
+ const selected = branch.current || (!!state.current && branch.name === state.current);
5847
+ const disabled = selected || state.loading || !!state.switching;
5848
+ const button = make("button", `footer-model-option footer-branch-option${selected ? " active" : ""}`);
5849
+ button.type = "button";
5850
+ button.disabled = disabled;
5851
+ button.setAttribute("role", "option");
5852
+ button.setAttribute("aria-selected", selected ? "true" : "false");
5853
+ button.title = selected ? `Current branch: ${branch.name}` : `git switch ${branch.name}`;
5854
+ button.append(
5855
+ make("span", "footer-model-option-main", branch.name),
5856
+ make("span", "footer-model-option-name", selected ? "current branch" : state.switching === branch.name ? "switching…" : "switch to this branch"),
5857
+ );
5858
+ if (!disabled) button.addEventListener("click", () => applyFooterGitBranch(branch.name));
5859
+ picker.append(button);
5860
+ }
5861
+ return picker;
5862
+ }
5863
+
4975
5864
  async function applyFooterModel(model) {
4976
5865
  if (!model?.provider || !model?.id) return;
4977
5866
  const tabContext = activeTabContext();
@@ -12084,9 +12973,11 @@ function updateServerActionButton() {
12084
12973
  const button = elements.runServerActionButton;
12085
12974
  if (!button) return;
12086
12975
  button.disabled = !action;
12087
- button.textContent = action === "restart" ? "Restart" : action === "stop" ? "Stop" : "Run";
12976
+ button.textContent = action === "restart" ? "Restart" : action === "update" ? "Update" : action === "stop" ? "Stop" : "Run";
12088
12977
  button.classList.toggle("danger", action === "stop");
12089
- if (action) setServerActionStatus(action === "restart" ? "Ready to restart the Web UI server." : "Ready to stop the Web UI server.", "info");
12978
+ if (action === "restart") setServerActionStatus("Ready to restart the Web UI server.", "info");
12979
+ else if (action === "update") setServerActionStatus("Ready to run pi update, then restart the Web UI server.", "info");
12980
+ else if (action === "stop") setServerActionStatus("Ready to stop the Web UI server.", "info");
12090
12981
  else setServerActionStatus();
12091
12982
  }
12092
12983
 
@@ -12192,6 +13083,7 @@ async function stopServer() {
12192
13083
  async function runSelectedServerAction() {
12193
13084
  const action = elements.serverActionSelect?.value || "";
12194
13085
  if (action === "restart") await restartServer();
13086
+ else if (action === "update") await runPiUpdateAndRestart();
12195
13087
  else if (action === "stop") await stopServer();
12196
13088
  }
12197
13089
 
@@ -13405,6 +14297,8 @@ if (elements.backgroundClearButton) {
13405
14297
  elements.openNetworkButton.addEventListener("click", openToNetwork);
13406
14298
  elements.serverActionSelect.addEventListener("change", updateServerActionButton);
13407
14299
  elements.runServerActionButton.addEventListener("click", () => runSelectedServerAction().catch((error) => addEvent(error.message || String(error), "error")));
14300
+ elements.updateNotificationUpdateButton?.addEventListener("click", () => runPiUpdateAndRestart().catch((error) => addEvent(error.message || String(error), "error")));
14301
+ elements.updateNotificationDismissButton?.addEventListener("click", () => hideUpdateNotification({ remember: true }));
13408
14302
  updateServerActionButton();
13409
14303
  elements.agentDoneNotificationsToggle.addEventListener("change", () => {
13410
14304
  setAgentDoneNotificationsEnabled(elements.agentDoneNotificationsToggle.checked, {
@@ -13478,6 +14372,7 @@ document.addEventListener("pointerdown", (event) => {
13478
14372
  if (isFooterPickerOpen() && !elements.statusBar.contains(event.target)) {
13479
14373
  setFooterModelPickerOpen(false);
13480
14374
  setFooterThinkingPickerOpen(false);
14375
+ setFooterBranchPickerOpen(false);
13481
14376
  }
13482
14377
  }, { passive: true });
13483
14378
  document.addEventListener("pointermove", (event) => {
@@ -13495,7 +14390,7 @@ function isTextEntryTarget(target) {
13495
14390
 
13496
14391
  function shouldHandleNativeAppShortcut(event) {
13497
14392
  if (event.defaultPrevented) return false;
13498
- if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.nativeCommandDialog?.open || elements.appRunnerInfoDialog?.open) return false;
14393
+ if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.gitChangesDialog?.open || elements.nativeCommandDialog?.open || elements.appRunnerInfoDialog?.open) return false;
13499
14394
  return event.target === elements.promptInput || !isTextEntryTarget(event.target);
13500
14395
  }
13501
14396
 
@@ -13554,7 +14449,7 @@ window.addEventListener("focus", () => scheduleForegroundReconcile("window focus
13554
14449
  window.addEventListener("online", () => scheduleForegroundReconcile("network online", 0));
13555
14450
  window.addEventListener("keydown", (event) => {
13556
14451
  if (event.key !== "Escape") return;
13557
- if (elements.dialog?.open || elements.pathPickerDialog?.open) return;
14452
+ if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.gitChangesDialog?.open) return;
13558
14453
  if (publishMenuOpen) {
13559
14454
  setPublishMenuOpen(false);
13560
14455
  return;
@@ -13592,6 +14487,7 @@ window.addEventListener("keydown", (event) => {
13592
14487
  if (isFooterPickerOpen()) {
13593
14488
  setFooterModelPickerOpen(false);
13594
14489
  setFooterThinkingPickerOpen(false);
14490
+ setFooterBranchPickerOpen(false);
13595
14491
  return;
13596
14492
  }
13597
14493
  if (!elements.commandSuggest.hidden) {
@@ -13618,6 +14514,18 @@ window.addEventListener("keydown", (event) => {
13618
14514
  }
13619
14515
  });
13620
14516
 
14517
+ elements.gitChangesRefreshButton?.addEventListener("click", refreshGitChangesDialog);
14518
+ elements.gitChangesCloseButton?.addEventListener("click", closeGitChangesDialog);
14519
+ elements.gitChangesBody?.addEventListener("scroll", updateGitChangesCurrentFileHeader, { passive: true });
14520
+ elements.gitChangesDialog?.addEventListener("cancel", (event) => {
14521
+ event.preventDefault();
14522
+ closeGitChangesDialog();
14523
+ });
14524
+ elements.gitChangesDialog?.addEventListener("close", () => {
14525
+ gitChangesRequestSerial += 1;
14526
+ gitChangesState = { ...gitChangesState, loading: false };
14527
+ });
14528
+
13621
14529
  elements.refreshCodexUsageButton?.addEventListener("click", () => {
13622
14530
  refreshCodexUsage({ forceAuthRefresh: true }).finally(() => scheduleRefreshCodexUsage());
13623
14531
  });
@@ -13752,6 +14660,7 @@ restoreSidePanelSectionState();
13752
14660
  bindSidePanelSectionToggles();
13753
14661
  restoreSidePanelState();
13754
14662
  initializeCodexUsage();
14663
+ initializeUpdateNotifications();
13755
14664
  bindMobileViewChanges();
13756
14665
  bindSidePanelOverlayViewChanges();
13757
14666
  registerPwaServiceWorker();