@firstpick/pi-package-webui 0.4.2 → 0.4.3

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
@@ -85,6 +85,7 @@ const elements = {
85
85
  optionsCommandPaletteButton: $("#optionsCommandPaletteButton"),
86
86
  optionsResumeButton: $("#optionsResumeButton"),
87
87
  optionsReloadButton: $("#optionsReloadButton"),
88
+ optionsRemoteButton: $("#optionsRemoteButton"),
88
89
  optionsNameButton: $("#optionsNameButton"),
89
90
  optionsCloneButton: $("#optionsCloneButton"),
90
91
  optionsSettingsButton: $("#optionsSettingsButton"),
@@ -197,6 +198,7 @@ const elements = {
197
198
  commandPaletteInput: $("#commandPaletteInput"),
198
199
  commandPaletteList: $("#commandPaletteList"),
199
200
  commandPaletteHint: $("#commandPaletteHint"),
201
+ commandPaletteCloseButton: $("#commandPaletteCloseButton"),
200
202
  editRetryDialog: $("#editRetryDialog"),
201
203
  editRetryMessage: $("#editRetryMessage"),
202
204
  editRetryText: $("#editRetryText"),
@@ -316,6 +318,8 @@ let updateStatusRefreshTimer = null;
316
318
  let updateNotificationHideTimer = null;
317
319
  let backendOfflineNoticeShown = false;
318
320
  let latestMessages = [];
321
+ let latestMessagesSessionKey = "";
322
+ const tabMessagesCache = new Map();
319
323
  let promptHistoryByTab = new Map();
320
324
  let promptHistoryNavigation = null;
321
325
  let transientMessages = [];
@@ -340,6 +344,8 @@ let toolOutputGloballyExpanded = false;
340
344
  let agentDoneNotificationPermissionRequested = false;
341
345
  let agentDoneNotificationFallbackNoted = false;
342
346
  let agentDoneNotificationKeys = new Set();
347
+ let pendingAgentDoneNotificationTimers = new Map();
348
+ let autoRetryingTabs = new Set();
343
349
  let availableModels = [];
344
350
  let availableThemes = [];
345
351
  let currentThemeName = "catppuccin-mocha";
@@ -374,7 +380,17 @@ let workspaceDashboardCollapsed = false;
374
380
  let commandPaletteIndex = 0;
375
381
  let commandPaletteItems = [];
376
382
  let activeEditRetry = null;
383
+ let activePointerActivation = null;
384
+ let pointerActivationTimeout = null;
385
+ let deferredChatFollowScroll = false;
386
+ const deferredUiRenderCallbacks = new Map();
377
387
  let abortLongPressTimer = null;
388
+ let abortLongPressTickTimer = null;
389
+ let abortLongPressResetTimer = null;
390
+ let abortLongPressStartedAt = 0;
391
+ let abortLongPressDeadlineAt = 0;
392
+ let abortLongPressSource = "long-press";
393
+ let abortLongPressReleasePending = false;
378
394
  let abortLongPressHandled = false;
379
395
  const dialogQueue = [];
380
396
  const SIDE_PANEL_STORAGE_KEY = "pi-webui-side-panel-collapsed";
@@ -412,6 +428,8 @@ const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
412
428
  const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history";
413
429
  const PROMPT_LIST_STORAGE_KEY = "pi-webui-prompt-lists";
414
430
  const WORKSPACE_DASHBOARD_STORAGE_KEY = "pi-webui-workspace-dashboard-collapsed";
431
+ const POINTER_ACTIVATION_SELECTOR = "button, a[href], input, select, textarea, summary, [role='button'], [tabindex]:not([tabindex='-1'])";
432
+ const POINTER_ACTIVATION_RENDER_DEFER_MAX_MS = 1200;
415
433
  const PROMPT_HISTORY_LIMIT_PER_TAB = 50;
416
434
  const ATTACHMENT_MAX_FILES = 12;
417
435
  const ATTACHMENT_MAX_FILE_BYTES = 64 * 1024 * 1024;
@@ -444,7 +462,9 @@ const UPDATE_STATUS_INITIAL_DELAY_MS = 1800;
444
462
  const RUN_INDICATOR_TICK_MS = 1000;
445
463
  const RUN_INDICATOR_START_GRACE_MS = 2500;
446
464
  const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
447
- const ABORT_LONG_PRESS_MS = 700;
465
+ const ABORT_LONG_PRESS_MS = 3000;
466
+ const ABORT_LONG_PRESS_TICK_MS = 100;
467
+ const ABORT_LONG_PRESS_RELEASE_GRACE_MS = 350;
448
468
  const STREAM_OUTPUT_HIDE_DELAY_MS = 300;
449
469
  const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220;
450
470
  const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
@@ -454,6 +474,7 @@ const TODO_PROGRESS_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)\]\s+
454
474
  const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)?\]?\s*.*$/;
455
475
  const CHAT_SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);
456
476
  const TAB_ACTIVITY_IDLE_RECONCILE_GRACE_MS = 1200;
477
+ const AGENT_DONE_NOTIFICATION_RETRY_GRACE_MS = 1200;
457
478
  const FOREGROUND_RECONCILE_DELAY_MS = 120;
458
479
  const TAB_GROUP_STATUS_PRIORITY = ["blocked", "done", "working", "idle"];
459
480
  const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
@@ -484,6 +505,7 @@ const optionalFeatureAvailability = {
484
505
  tuiSkillsCommand: false,
485
506
  todoProgressWidget: false,
486
507
  tuiToolsCommand: false,
508
+ remoteWebui: false,
487
509
  themeBundle: false,
488
510
  };
489
511
  const OPTIONAL_FEATURES = [
@@ -536,6 +558,13 @@ const OPTIONAL_FEATURES = [
536
558
  capabilityLabel: "RPC /tools from tools extension",
537
559
  description: "Terminal-native active-tool manager alongside WebUI-native /tools toggles.",
538
560
  },
561
+ {
562
+ id: "remoteWebui",
563
+ label: "Remote WebUI",
564
+ packageName: "@firstpick/pi-package-remote-webui",
565
+ capabilityLabel: "/remote",
566
+ description: "Trusted-LAN QR helper for opening the Web UI from mobile browsers.",
567
+ },
539
568
  {
540
569
  id: "gitFooterStatus",
541
570
  label: "Git footer status",
@@ -568,6 +597,7 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
568
597
  ["safety-guard", "safetyGuard"],
569
598
  ["skills", "tuiSkillsCommand"],
570
599
  ["tools", "tuiToolsCommand"],
600
+ ["remote", "remoteWebui"],
571
601
  ["stats", "statsCommand"],
572
602
  ["git-footer-refresh", "gitFooterStatus"],
573
603
  ["todo-progress-status", "todoProgressWidget"],
@@ -826,6 +856,82 @@ function delay(ms) {
826
856
  return new Promise((resolve) => setTimeout(resolve, ms));
827
857
  }
828
858
 
859
+ function activationControlFromEvent(event) {
860
+ const target = event?.target instanceof Element ? event.target : null;
861
+ const control = target?.closest?.(POINTER_ACTIVATION_SELECTOR);
862
+ if (!control || control === document.body || control === document.documentElement) return null;
863
+ if (control.disabled || control.getAttribute("aria-disabled") === "true") return null;
864
+ return control;
865
+ }
866
+
867
+ function shouldDeferUiRenderForPointerActivation() {
868
+ return Boolean(
869
+ activePointerActivation
870
+ && performance.now() - activePointerActivation.startedAt <= POINTER_ACTIVATION_RENDER_DEFER_MAX_MS,
871
+ );
872
+ }
873
+
874
+ function deferUiRenderDuringPointerActivation(key, callback) {
875
+ if (!shouldDeferUiRenderForPointerActivation()) return false;
876
+ deferredUiRenderCallbacks.set(key, callback);
877
+ return true;
878
+ }
879
+
880
+ function deferChatFollowScrollDuringPointerActivation({ force = false } = {}) {
881
+ if (force || !shouldDeferUiRenderForPointerActivation()) return false;
882
+ deferredChatFollowScroll = true;
883
+ return true;
884
+ }
885
+
886
+ function flushDeferredUiRenders() {
887
+ const callbacks = [...deferredUiRenderCallbacks.values()];
888
+ deferredUiRenderCallbacks.clear();
889
+ const shouldScroll = deferredChatFollowScroll;
890
+ deferredChatFollowScroll = false;
891
+
892
+ for (const callback of callbacks) {
893
+ try {
894
+ callback();
895
+ } catch (error) {
896
+ console.error("deferred Web UI render failed", error);
897
+ }
898
+ }
899
+ if (shouldScroll) scrollChatToBottom();
900
+ }
901
+
902
+ function beginPointerActivation(event) {
903
+ if (event?.button !== undefined && event.button !== 0) return;
904
+ const control = activationControlFromEvent(event);
905
+ if (!control) return;
906
+ clearTimeout(pointerActivationTimeout);
907
+ const activation = { pointerId: event.pointerId, startedAt: performance.now(), control };
908
+ activePointerActivation = activation;
909
+ pointerActivationTimeout = setTimeout(() => {
910
+ if (activePointerActivation === activation) activePointerActivation = null;
911
+ pointerActivationTimeout = null;
912
+ flushDeferredUiRenders();
913
+ }, POINTER_ACTIVATION_RENDER_DEFER_MAX_MS);
914
+ }
915
+
916
+ function finishPointerActivation(event) {
917
+ if (!activePointerActivation) return;
918
+ if (event?.pointerId !== undefined && activePointerActivation.pointerId !== event.pointerId) return;
919
+ const activation = activePointerActivation;
920
+ clearTimeout(pointerActivationTimeout);
921
+ pointerActivationTimeout = null;
922
+ setTimeout(() => {
923
+ if (activePointerActivation === activation) activePointerActivation = null;
924
+ flushDeferredUiRenders();
925
+ }, 0);
926
+ }
927
+
928
+ function cancelPointerActivation() {
929
+ clearTimeout(pointerActivationTimeout);
930
+ pointerActivationTimeout = null;
931
+ activePointerActivation = null;
932
+ flushDeferredUiRenders();
933
+ }
934
+
829
935
  function isMobileView() {
830
936
  return mobileViewMedia?.matches || false;
831
937
  }
@@ -834,6 +940,36 @@ function isSidePanelOverlayView() {
834
940
  return sidePanelOverlayMedia?.matches || false;
835
941
  }
836
942
 
943
+ function mobileDropdownViewportHeight() {
944
+ return window.visualViewport?.height || window.innerHeight || document.documentElement.clientHeight || 0;
945
+ }
946
+
947
+ function mobileDropdownConfigs() {
948
+ return [
949
+ { menu: elements.publishButton?.parentElement, button: elements.publishButton, panel: elements.publishButton?.parentElement?.querySelector(".composer-publish-menu-panel") },
950
+ { menu: elements.nativeCommandMenuButton?.parentElement, button: elements.nativeCommandMenuButton, panel: elements.nativeCommandMenuButton?.parentElement?.querySelector(".composer-publish-menu-panel") },
951
+ { menu: elements.optionsMenuButton?.parentElement, button: elements.optionsMenuButton, panel: elements.optionsMenu },
952
+ { menu: elements.appRunnerMenu, button: elements.appRunnerMenuButton, panel: elements.appRunnerMenuPanel },
953
+ ];
954
+ }
955
+
956
+ function updateMobileDropdownScrollBounds() {
957
+ const viewportHeight = mobileDropdownViewportHeight();
958
+ for (const { menu, button, panel } of mobileDropdownConfigs()) {
959
+ if (!panel) continue;
960
+ panel.style.removeProperty("--mobile-dropdown-max-height");
961
+ if (!isMobileView() || !menu?.classList.contains("open") || !viewportHeight) continue;
962
+ const anchorRect = (button || menu).getBoundingClientRect();
963
+ const availableAbove = Math.floor(anchorRect.top - 8);
964
+ const boundedHeight = Math.max(72, Math.min(viewportHeight - 16, availableAbove));
965
+ panel.style.setProperty("--mobile-dropdown-max-height", `${boundedHeight}px`);
966
+ }
967
+ }
968
+
969
+ function scheduleMobileDropdownScrollBoundsUpdate() {
970
+ requestAnimationFrame(updateMobileDropdownScrollBounds);
971
+ }
972
+
837
973
  function readStoredSidePanelCollapsed() {
838
974
  try {
839
975
  const stored = localStorage.getItem(SIDE_PANEL_STORAGE_KEY);
@@ -1592,6 +1728,7 @@ function setComposerActionsOpen(open) {
1592
1728
  setOptionsMenuOpen(false);
1593
1729
  setBusyPromptBehaviorMenuOpen(false);
1594
1730
  }
1731
+ scheduleMobileDropdownScrollBoundsUpdate();
1595
1732
  }
1596
1733
 
1597
1734
  function isUserBashActive(tabId = activeTabId) {
@@ -1643,11 +1780,17 @@ function updateComposerModeButtons() {
1643
1780
  button.hidden = !runActive;
1644
1781
  button.disabled = !runActive;
1645
1782
  }
1646
- elements.abortButton.hidden = !abortAvailable;
1647
- elements.abortButton.disabled = !abortAvailable || abortRequestInFlight;
1648
- elements.abortButton.textContent = abortRequestInFlight ? "Aborting…" : "Abort";
1649
- elements.abortButton.title = abortAvailable ? "Abort the active Pi run (Esc or hold)" : "Abort is available while Pi is running";
1650
- elements.abortButton.setAttribute("aria-label", elements.abortButton.title);
1783
+ const abortHoldActive = isAbortLongPressActive();
1784
+ if (!abortAvailable && !abortHoldActive) resetAbortLongPressAffordance();
1785
+ elements.abortButton.hidden = !abortAvailable && !abortHoldActive;
1786
+ elements.abortButton.disabled = (!abortAvailable && !abortHoldActive) || abortRequestInFlight;
1787
+ if (abortHoldActive) {
1788
+ renderAbortLongPressAffordance();
1789
+ } else {
1790
+ elements.abortButton.textContent = abortRequestInFlight ? "Aborting…" : "Abort";
1791
+ elements.abortButton.title = abortAvailable ? abortButtonReadyTitle() : "Abort is available while Pi is running";
1792
+ elements.abortButton.setAttribute("aria-label", elements.abortButton.title);
1793
+ }
1651
1794
  renderBusyPromptBehaviorTag();
1652
1795
  document.body.classList.toggle("pi-run-active", runActive || abortAvailable);
1653
1796
  }
@@ -1851,6 +1994,7 @@ function updateVisualViewportVars() {
1851
1994
  syncMobileChatToBottomForInput();
1852
1995
  }
1853
1996
  updateFooterModelPickerPosition();
1997
+ updateMobileDropdownScrollBounds();
1854
1998
  }
1855
1999
 
1856
2000
  function installViewportHandlers() {
@@ -3726,11 +3870,17 @@ function syncTabMetadata(nextTabs = []) {
3726
3870
  if (!liveIds.has(tabId)) {
3727
3871
  tabActivities.delete(tabId);
3728
3872
  tabSeenCompletionSerials.delete(tabId);
3873
+ autoRetryingTabs.delete(tabId);
3874
+ suppressPendingAgentDoneNotificationsForTab(tabId, { markSeen: false });
3729
3875
  actionFeedbackByTab.delete(tabId);
3730
3876
  skillUsageByTab.delete(tabId);
3877
+ tabMessagesCache.delete(tabId);
3731
3878
  clearGitWorkflowForTab(tabId);
3732
3879
  }
3733
3880
  }
3881
+ for (const tabId of tabMessagesCache.keys()) {
3882
+ if (!liveIds.has(tabId)) tabMessagesCache.delete(tabId);
3883
+ }
3734
3884
  pruneSkillUsageForKnownTabs(liveIds);
3735
3885
  }
3736
3886
 
@@ -3894,6 +4044,18 @@ function ingestEventTabActivity(event) {
3894
4044
  if (changed) renderTabs();
3895
4045
  }
3896
4046
 
4047
+ function trackAutoRetryStateFromEvent(event) {
4048
+ const tabId = event?.tabId || activeTabId;
4049
+ if (!tabId) return;
4050
+ if (event.type === "auto_retry_start") {
4051
+ autoRetryingTabs.add(tabId);
4052
+ suppressPendingAgentDoneNotificationsForTab(tabId);
4053
+ markTabWorkingLocally(tabId);
4054
+ } else if (event.type === "auto_retry_end") {
4055
+ autoRetryingTabs.delete(tabId);
4056
+ }
4057
+ }
4058
+
3897
4059
  function rememberActiveTab() {
3898
4060
  try {
3899
4061
  if (activeTabId) localStorage.setItem(TAB_STORAGE_KEY, activeTabId);
@@ -3985,6 +4147,7 @@ function resetActiveTabUi() {
3985
4147
  latestStatsOverlayPayload = null;
3986
4148
  latestWorkspace = null;
3987
4149
  latestMessages = [];
4150
+ latestMessagesSessionKey = "";
3988
4151
  clearRunIndicatorActivity({ render: false });
3989
4152
  statusEntries.clear();
3990
4153
  widgets.clear();
@@ -4016,8 +4179,10 @@ function resetActiveTabUi() {
4016
4179
  renderAppRunnerControls();
4017
4180
  renderWidgets();
4018
4181
  renderGitWorkflow();
4019
- renderFooter();
4020
- renderFeedbackTray();
4182
+ if (!restoreCachedMessagesForActiveTab()) {
4183
+ renderFooter();
4184
+ renderFeedbackTray();
4185
+ }
4021
4186
  }
4022
4187
 
4023
4188
  function tabGroupStatusRank(state) {
@@ -4256,6 +4421,7 @@ function moveNewTabMenuFocus(delta) {
4256
4421
  }
4257
4422
 
4258
4423
  function renderTabs() {
4424
+ if (deferUiRenderDuringPointerActivation("tabs", renderTabs)) return;
4259
4425
  const active = activeTab();
4260
4426
  const activeIndicator = active ? tabIndicator(active) : null;
4261
4427
  elements.terminalTabsToggleButton.textContent = active ? `${activeIndicator.glyph} ${active.title}${tabs.length > 1 ? ` · ${tabs.length}` : ""}` : "Tabs";
@@ -4280,7 +4446,7 @@ function renderTabs() {
4280
4446
  updateDocumentTitle();
4281
4447
  renderWorkspaceDashboard();
4282
4448
  renderContextMeter();
4283
- if (elements.commandPaletteDialog?.open) renderCommandPalette();
4449
+ if (elements.commandPaletteDialog?.open) renderCommandPalette({ preserveScroll: true });
4284
4450
  syncTabPolling();
4285
4451
  }
4286
4452
 
@@ -4311,6 +4477,7 @@ async function switchTab(tabId) {
4311
4477
  footerBranchPickerOpen = false;
4312
4478
  footerBranchPickerRequestSerial += 1;
4313
4479
  saveActiveDraft();
4480
+ cacheMessagesForTab(activeTabId);
4314
4481
  const tabContext = setActiveTabId(tabId, { remember: true });
4315
4482
  resetActiveTabUi();
4316
4483
  renderTabs();
@@ -4423,6 +4590,7 @@ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } =
4423
4590
  clearAttachments(id);
4424
4591
  clearGitWorkflowForTab(id);
4425
4592
  appRunnerDataByTab.delete(id);
4593
+ tabMessagesCache.delete(id);
4426
4594
  }
4427
4595
  clearOpenTerminalTabGroup(null, { force: true });
4428
4596
 
@@ -4689,6 +4857,36 @@ function agentDoneNotificationKey(tabId, activity = {}) {
4689
4857
  return `${tabId}:${Number.isFinite(serial) && serial > 0 ? serial : "done"}`;
4690
4858
  }
4691
4859
 
4860
+ function isAutoRetryingTab(tabId) {
4861
+ return !!tabId && autoRetryingTabs.has(tabId);
4862
+ }
4863
+
4864
+ function clearPendingAgentDoneNotification(key, { markSeen = false } = {}) {
4865
+ const pending = pendingAgentDoneNotificationTimers.get(key);
4866
+ if (!pending) return false;
4867
+ clearTimeout(pending.timer);
4868
+ pendingAgentDoneNotificationTimers.delete(key);
4869
+ if (markSeen) agentDoneNotificationKeys.add(key);
4870
+ return true;
4871
+ }
4872
+
4873
+ function suppressPendingAgentDoneNotificationsForTab(tabId, { markSeen = true } = {}) {
4874
+ if (!tabId) return;
4875
+ for (const [key, pending] of pendingAgentDoneNotificationTimers) {
4876
+ if (pending.tabId === tabId) clearPendingAgentDoneNotification(key, { markSeen });
4877
+ }
4878
+ }
4879
+
4880
+ function queueAgentDoneBrowserNotification({ key, tabId, title, body }) {
4881
+ clearPendingAgentDoneNotification(key);
4882
+ const timer = setTimeout(() => {
4883
+ pendingAgentDoneNotificationTimers.delete(key);
4884
+ if (isAutoRetryingTab(tabId)) return;
4885
+ showAgentDoneBrowserNotification({ tabId, title, body });
4886
+ }, AGENT_DONE_NOTIFICATION_RETRY_GRACE_MS);
4887
+ pendingAgentDoneNotificationTimers.set(key, { tabId, timer });
4888
+ }
4889
+
4692
4890
  function notifyAgentDone(tabOrId, { activity = null, tabTitle = "" } = {}) {
4693
4891
  if (!agentDoneNotificationsEnabled) return;
4694
4892
  const tabId = typeof tabOrId === "string" ? tabOrId : tabOrId?.id || activeTabId;
@@ -4699,9 +4897,11 @@ function notifyAgentDone(tabOrId, { activity = null, tabTitle = "" } = {}) {
4699
4897
  const key = agentDoneNotificationKey(tabId, normalizedActivity);
4700
4898
  if (agentDoneNotificationKeys.has(key)) return;
4701
4899
  agentDoneNotificationKeys.add(key);
4900
+ if (isAutoRetryingTab(tabId)) return;
4702
4901
 
4703
4902
  const displayTitle = tabTitle || tab?.title || "terminal";
4704
- showAgentDoneBrowserNotification({
4903
+ queueAgentDoneBrowserNotification({
4904
+ key,
4705
4905
  tabId,
4706
4906
  title: "Pi finished work",
4707
4907
  body: `${displayTitle} finished its agent run.`,
@@ -6183,6 +6383,7 @@ async function requestManualCompaction({ triggerButton = null } = {}) {
6183
6383
  }
6184
6384
 
6185
6385
  function renderContextMeter() {
6386
+ if (deferUiRenderDuringPointerActivation("context-meter", renderContextMeter)) return;
6186
6387
  const root = elements.contextMeterBar;
6187
6388
  if (!root) return;
6188
6389
  const tab = activeTab();
@@ -6239,6 +6440,7 @@ function dashboardAction(label, handler, className = "") {
6239
6440
  }
6240
6441
 
6241
6442
  function renderWorkspaceDashboard() {
6443
+ if (deferUiRenderDuringPointerActivation("workspace-dashboard", renderWorkspaceDashboard)) return;
6242
6444
  const root = elements.workspaceDashboard;
6243
6445
  if (!root) return;
6244
6446
  const tab = activeTab();
@@ -7037,6 +7239,7 @@ async function changeActiveTabCwd() {
7037
7239
  }
7038
7240
 
7039
7241
  function renderFooter() {
7242
+ if (deferUiRenderDuringPointerActivation("footer", renderFooter)) return;
7040
7243
  const gitFooterPayload = parseGitFooterWebuiPayload();
7041
7244
  if (gitFooterPayload) {
7042
7245
  renderGitFooterPayload(footerPayloadWithLiveModel(gitFooterPayload));
@@ -7269,6 +7472,7 @@ function initializeCodexUsage() {
7269
7472
  }
7270
7473
 
7271
7474
  function renderStatus() {
7475
+ if (deferUiRenderDuringPointerActivation("status", renderStatus)) return;
7272
7476
  const state = currentState;
7273
7477
  updateComposerModeButtons();
7274
7478
  const running = state?.isStreaming ? "running" : "idle";
@@ -8710,7 +8914,22 @@ function handleStatsWebuiStatus(statusText) {
8710
8914
  if (payload.open || elements.statsOverlayDialog?.open) renderStatsOverlay();
8711
8915
  }
8712
8916
 
8917
+ function remoteWebuiWidgetLines(lines = []) {
8918
+ return (Array.isArray(lines) ? lines : [])
8919
+ .map(stripAnsi)
8920
+ .map((line) => String(line ?? ""))
8921
+ .filter((line, index, array) => line.trim() || (index > 0 && index < array.length - 1));
8922
+ }
8923
+
8924
+ function mirrorRemoteWebuiWidgetToTranscript(widgetKey, lines = [], request = {}) {
8925
+ if (widgetKey !== "pi-remote-webui" || request.replayed) return;
8926
+ const content = remoteWebuiWidgetLines(lines).join("\n").trimEnd();
8927
+ if (!content) return;
8928
+ addTransientMessage({ role: "extension", title: "/remote", content, level: "info", widgetKey });
8929
+ }
8930
+
8713
8931
  function renderWidgets() {
8932
+ if (deferUiRenderDuringPointerActivation("widgets", renderWidgets)) return;
8714
8933
  elements.widgetArea.replaceChildren();
8715
8934
  const releaseOutput = renderReleaseNpmOutputWidget();
8716
8935
  if (releaseOutput) elements.widgetArea.append(releaseOutput);
@@ -8726,8 +8945,9 @@ function renderWidgets() {
8726
8945
  for (const [key, value] of widgets) {
8727
8946
  const widgetFeatureId = optionalFeatureWidgetFeatureId(key);
8728
8947
  if (widgetFeatureId && !isOptionalFeatureEnabled(widgetFeatureId)) continue;
8729
- if (widgetFeatureId && key !== "todo-progress") continue;
8948
+ if (widgetFeatureId && optionalFeatureWidgetHasSpecializedRenderer(key)) continue;
8730
8949
  const lines = Array.isArray(value.widgetLines) ? value.widgetLines : [];
8950
+ if (key === "pi-remote-webui") continue;
8731
8951
  const specialized = key === "todo-progress" && isOptionalFeatureEnabled("todoProgressWidget") ? renderTodoProgressWidget(key, lines) : null;
8732
8952
  if (specialized) {
8733
8953
  elements.widgetArea.append(specialized);
@@ -10330,6 +10550,7 @@ function renderQueueGroup(label, items, tone) {
10330
10550
  }
10331
10551
 
10332
10552
  function renderQueue(event) {
10553
+ if (deferUiRenderDuringPointerActivation("queue", () => renderQueue(event))) return;
10333
10554
  const snapshot = normalizeQueuedMessages(event);
10334
10555
  const tabId = event?.tabId || activeTabId;
10335
10556
  if (tabId) latestQueuedMessagesByTab.set(tabId, snapshot);
@@ -11281,6 +11502,7 @@ function renderActionFeedbackControls(bubble, message, messageIndex) {
11281
11502
  }
11282
11503
 
11283
11504
  function renderFeedbackTray() {
11505
+ if (deferUiRenderDuringPointerActivation("feedback-tray", renderFeedbackTray)) return;
11284
11506
  const items = queuedActionFeedback();
11285
11507
  const hasItems = items.length > 0;
11286
11508
  elements.feedbackTray.hidden = !hasItems;
@@ -11753,6 +11975,7 @@ function findStickyUserPromptTarget(targets = userPromptTargets()) {
11753
11975
  }
11754
11976
 
11755
11977
  function updateStickyUserPromptButton() {
11978
+ if (deferUiRenderDuringPointerActivation("sticky-user-prompt", updateStickyUserPromptButton)) return;
11756
11979
  const button = elements.stickyUserPromptButton;
11757
11980
  if (!button) return;
11758
11981
  const targets = userPromptTargets();
@@ -12902,6 +13125,7 @@ function pruneDisconnectedLiveToolCards() {
12902
13125
  }
12903
13126
 
12904
13127
  function renderAllMessages({ preserveScroll = false, forceRebuild = false } = {}) {
13128
+ if (deferUiRenderDuringPointerActivation("messages", () => renderAllMessages({ preserveScroll, forceRebuild }))) return;
12905
13129
  const shouldFollow = !preserveScroll && (autoFollowChat || isChatNearBottom());
12906
13130
  const previousScrollTop = elements.chat.scrollTop;
12907
13131
  const transcriptItems = orderedTranscriptItems();
@@ -13073,6 +13297,7 @@ function scheduleChatFollowScroll() {
13073
13297
  }
13074
13298
 
13075
13299
  function scrollChatToBottom({ force = false } = {}) {
13300
+ if (deferChatFollowScrollDuringPointerActivation({ force })) return;
13076
13301
  if (force) autoFollowChat = true;
13077
13302
  if (!autoFollowChat) {
13078
13303
  updateJumpToLatestButton();
@@ -13133,6 +13358,7 @@ function setPublishMenuOpen(open) {
13133
13358
  elements.publishButton.setAttribute("aria-expanded", publishMenuOpen ? "true" : "false");
13134
13359
  elements.publishButton.classList.toggle("menu-open", publishMenuOpen);
13135
13360
  elements.publishButton.parentElement?.classList.toggle("open", publishMenuOpen);
13361
+ scheduleMobileDropdownScrollBoundsUpdate();
13136
13362
  }
13137
13363
 
13138
13364
  function setNativeCommandMenuOpen(open) {
@@ -13140,6 +13366,7 @@ function setNativeCommandMenuOpen(open) {
13140
13366
  elements.nativeCommandMenuButton.setAttribute("aria-expanded", nativeCommandMenuOpen ? "true" : "false");
13141
13367
  elements.nativeCommandMenuButton.classList.toggle("menu-open", nativeCommandMenuOpen);
13142
13368
  elements.nativeCommandMenuButton.parentElement?.classList.toggle("open", nativeCommandMenuOpen);
13369
+ scheduleMobileDropdownScrollBoundsUpdate();
13143
13370
  }
13144
13371
 
13145
13372
  function setAppRunnerMenuOpen(open) {
@@ -13147,6 +13374,7 @@ function setAppRunnerMenuOpen(open) {
13147
13374
  elements.appRunnerMenuButton?.setAttribute("aria-expanded", appRunnerMenuOpen ? "true" : "false");
13148
13375
  elements.appRunnerMenuButton?.classList.toggle("menu-open", appRunnerMenuOpen);
13149
13376
  elements.appRunnerMenuButton?.parentElement?.classList.toggle("open", appRunnerMenuOpen);
13377
+ scheduleMobileDropdownScrollBoundsUpdate();
13150
13378
  }
13151
13379
 
13152
13380
  function setOptionsMenuOpen(open) {
@@ -13154,6 +13382,7 @@ function setOptionsMenuOpen(open) {
13154
13382
  elements.optionsMenuButton.setAttribute("aria-expanded", optionsMenuOpen ? "true" : "false");
13155
13383
  elements.optionsMenuButton.classList.toggle("menu-open", optionsMenuOpen);
13156
13384
  elements.optionsMenuButton.parentElement?.classList.toggle("open", optionsMenuOpen);
13385
+ scheduleMobileDropdownScrollBoundsUpdate();
13157
13386
  }
13158
13387
 
13159
13388
  function optionalFeatureIdForCommand(name) {
@@ -13318,6 +13547,7 @@ function updateOptionalFeatureAvailability() {
13318
13547
  optionalFeatureAvailability.tuiSkillsCommand = hasLoadedRpcCommand("skills");
13319
13548
  optionalFeatureAvailability.todoProgressWidget = hasAvailableCommand("todo-progress-status") || optionalFeatureAvailability.todoProgressWidget || widgets.has("todo-progress");
13320
13549
  optionalFeatureAvailability.tuiToolsCommand = hasLoadedRpcCommand("tools");
13550
+ optionalFeatureAvailability.remoteWebui = hasAvailableCommand("remote") || optionalFeatureAvailability.remoteWebui || statusEntries.has("pi-remote-webui") || widgets.has("pi-remote-webui");
13321
13551
  optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
13322
13552
  requestGitFooterWebuiPayload();
13323
13553
  renderOptionalFeatureControls();
@@ -13342,9 +13572,14 @@ function optionalFeatureWidgetFeatureId(key) {
13342
13572
  if (key.startsWith("release-npm:")) return "releaseNpm";
13343
13573
  if (key.startsWith("release-aur:")) return "releaseAur";
13344
13574
  if (key === "todo-progress") return "todoProgressWidget";
13575
+ if (key === "pi-remote-webui") return "remoteWebui";
13345
13576
  return null;
13346
13577
  }
13347
13578
 
13579
+ function optionalFeatureWidgetHasSpecializedRenderer(key) {
13580
+ return key.startsWith("release-npm:") || key.startsWith("release-aur:");
13581
+ }
13582
+
13348
13583
  function renderOptionalFeaturePanel() {
13349
13584
  if (!elements.optionalFeaturesBox) return;
13350
13585
  elements.optionalFeaturesBox.replaceChildren();
@@ -13449,6 +13684,16 @@ function renderOptionalFeatureControls() {
13449
13684
  );
13450
13685
  }
13451
13686
 
13687
+ const hasRemoteWebuiCommand = isOptionalFeatureEnabled("remoteWebui") && hasAvailableCommand("remote");
13688
+ if (elements.optionsRemoteButton) {
13689
+ elements.optionsRemoteButton.hidden = !hasRemoteWebuiCommand;
13690
+ setOptionalControlState(
13691
+ elements.optionsRemoteButton,
13692
+ hasRemoteWebuiCommand,
13693
+ optionalFeatureUnavailableMessage("remoteWebui"),
13694
+ );
13695
+ }
13696
+
13452
13697
  renderOptionalFeaturePanel();
13453
13698
  }
13454
13699
 
@@ -14751,6 +14996,10 @@ async function refreshState(tabContext = activeTabContext()) {
14751
14996
  if (!isCurrentTabContext(tabContext)) return;
14752
14997
  const previousState = currentState;
14753
14998
  currentState = response.data || null;
14999
+ if (latestMessages.length) {
15000
+ latestMessagesSessionKey = resolveMessagesSessionKey(tabContext.tabId);
15001
+ cacheMessagesForTab(tabContext.tabId, latestMessages, latestMessagesSessionKey);
15002
+ }
14754
15003
  const shouldRefreshGitFooter = gitFooterRelevantStateChanged(previousState, currentState);
14755
15004
  syncActiveTabActivityFromState(currentState);
14756
15005
  syncRunIndicatorFromState(currentState);
@@ -14904,7 +15153,32 @@ async function refreshFooterData(tabContext = activeTabContext()) {
14904
15153
 
14905
15154
  // Session key of the last applied transcript fetch; deltas are only
14906
15155
  // attempted while the tab+session is unchanged.
14907
- let latestMessagesSessionKey = "";
15156
+ function resolveMessagesSessionKey(tabId = activeTabId) {
15157
+ if (!tabId) return "";
15158
+ const stateSessionId = tabId === activeTabId ? currentState?.sessionId : null;
15159
+ if (stateSessionId) return `${tabId}|${stateSessionId}`;
15160
+ if (latestMessagesSessionKey.startsWith(`${tabId}|`)) return latestMessagesSessionKey;
15161
+ const cached = tabMessagesCache.get(tabId);
15162
+ if (cached?.sessionKey?.startsWith(`${tabId}|`)) return cached.sessionKey;
15163
+ return `${tabId}|`;
15164
+ }
15165
+
15166
+ function cacheMessagesForTab(tabId = activeTabId, messages = latestMessages, sessionKey = latestMessagesSessionKey) {
15167
+ if (!tabId || !Array.isArray(messages)) return;
15168
+ const stateSessionKey = tabId === activeTabId && currentState?.sessionId ? `${tabId}|${currentState.sessionId}` : "";
15169
+ const resolvedSessionKey = stateSessionKey || (sessionKey?.startsWith(`${tabId}|`) ? sessionKey : resolveMessagesSessionKey(tabId));
15170
+ tabMessagesCache.set(tabId, { messages, sessionKey: resolvedSessionKey });
15171
+ }
15172
+
15173
+ function restoreCachedMessagesForActiveTab() {
15174
+ if (!activeTabId) return false;
15175
+ const cached = tabMessagesCache.get(activeTabId);
15176
+ if (!cached || !Array.isArray(cached.messages)) return false;
15177
+ latestMessages = cached.messages;
15178
+ latestMessagesSessionKey = cached.sessionKey || resolveMessagesSessionKey(activeTabId);
15179
+ renderMessages(latestMessages);
15180
+ return true;
15181
+ }
14908
15182
 
14909
15183
  function messagesLookEqual(a, b) {
14910
15184
  return !!a && !!b && a.role === b.role && String(a.timestamp || "") === String(b.timestamp || "")
@@ -14933,7 +15207,7 @@ function mergeMessagesDelta(previous, data) {
14933
15207
  async function refreshMessages(tabContext = activeTabContext()) {
14934
15208
  if (!tabContext.tabId) return;
14935
15209
  const previousMessages = latestMessages;
14936
- const sessionKey = `${tabContext.tabId}|${currentState?.sessionId || ""}`;
15210
+ const sessionKey = resolveMessagesSessionKey(tabContext.tabId);
14937
15211
  let nextMessages = null;
14938
15212
  if (previousMessages.length > 1 && sessionKey === latestMessagesSessionKey) {
14939
15213
  // Delta fetch with a one-message overlap: the last known message is
@@ -14949,6 +15223,7 @@ async function refreshMessages(tabContext = activeTabContext()) {
14949
15223
  }
14950
15224
  latestMessages = nextMessages;
14951
15225
  latestMessagesSessionKey = sessionKey;
15226
+ cacheMessagesForTab(tabContext.tabId, latestMessages, latestMessagesSessionKey);
14952
15227
  const preserveLiveStream = liveStreamRenderActive();
14953
15228
  if (!preserveLiveStream) resetStreamBubble();
14954
15229
  renderMessages(latestMessages);
@@ -14989,7 +15264,7 @@ async function refreshModels(tabContext = activeTabContext()) {
14989
15264
  syncModelSelectToState();
14990
15265
  renderFooter();
14991
15266
  renderFeedbackTray();
14992
- if (elements.commandPaletteDialog?.open) renderCommandPalette();
15267
+ if (elements.commandPaletteDialog?.open) renderCommandPalette({ preserveScroll: true });
14993
15268
  }
14994
15269
 
14995
15270
  function syncModelSelectToState() {
@@ -15466,7 +15741,7 @@ async function refreshCommands(tabContext = activeTabContext()) {
15466
15741
  availableCommands = normalizeCommands(response.data?.commands || []);
15467
15742
  updateOptionalFeatureAvailability();
15468
15743
  renderCommands();
15469
- if (elements.commandPaletteDialog?.open) renderCommandPalette();
15744
+ if (elements.commandPaletteDialog?.open) renderCommandPalette({ preserveScroll: true });
15470
15745
  }
15471
15746
 
15472
15747
  function paletteText(value) {
@@ -15563,12 +15838,14 @@ function setCommandPaletteIndex(index) {
15563
15838
  renderCommandPaletteList();
15564
15839
  }
15565
15840
 
15566
- function renderCommandPaletteList() {
15841
+ function renderCommandPaletteList({ preserveScroll = false } = {}) {
15567
15842
  const list = elements.commandPaletteList;
15568
15843
  if (!list) return;
15844
+ const scrollTop = preserveScroll ? list.scrollTop : 0;
15569
15845
  list.replaceChildren();
15570
15846
  if (!commandPaletteItems.length) {
15571
15847
  list.append(make("div", "command-palette-empty muted", "No matching actions."));
15848
+ if (preserveScroll) list.scrollTop = scrollTop;
15572
15849
  return;
15573
15850
  }
15574
15851
  commandPaletteItems.forEach((item, index) => {
@@ -15584,14 +15861,18 @@ function renderCommandPaletteList() {
15584
15861
  );
15585
15862
  list.append(button);
15586
15863
  });
15864
+ if (preserveScroll) {
15865
+ list.scrollTop = scrollTop;
15866
+ return;
15867
+ }
15587
15868
  const active = list.children[commandPaletteIndex];
15588
15869
  active?.scrollIntoView({ block: "nearest" });
15589
15870
  }
15590
15871
 
15591
- function renderCommandPalette() {
15872
+ function renderCommandPalette({ preserveScroll = false } = {}) {
15592
15873
  commandPaletteItems = filteredCommandPaletteItems();
15593
15874
  if (commandPaletteIndex >= commandPaletteItems.length) commandPaletteIndex = 0;
15594
- renderCommandPaletteList();
15875
+ renderCommandPaletteList({ preserveScroll });
15595
15876
  }
15596
15877
 
15597
15878
  function openCommandPalette(initialQuery = "") {
@@ -16167,7 +16448,7 @@ function hasQueuedDialogRequest(id) {
16167
16448
 
16168
16449
  function removeQueuedDialogRequests(ids = []) {
16169
16450
  const idSet = new Set(ids.map((id) => String(id)).filter(Boolean));
16170
- if (idSet.size === 0) return;
16451
+ if (idSet.size === 0) return false;
16171
16452
  for (let i = dialogQueue.length - 1; i >= 0; i -= 1) {
16172
16453
  if (idSet.has(String(dialogQueue[i]?.id || ""))) dialogQueue.splice(i, 1);
16173
16454
  }
@@ -16175,7 +16456,9 @@ function removeQueuedDialogRequests(ids = []) {
16175
16456
  if (elements.dialog.open) elements.dialog.close();
16176
16457
  activeDialog = null;
16177
16458
  showNextDialog();
16459
+ return true;
16178
16460
  }
16461
+ return false;
16179
16462
  }
16180
16463
 
16181
16464
  function handleExtensionUiRequest(request) {
@@ -16201,12 +16484,20 @@ function handleExtensionUiRequest(request) {
16201
16484
  renderStatus();
16202
16485
  return;
16203
16486
  }
16204
- case "setWidget":
16205
- if (Array.isArray(request.widgetLines)) widgets.set(request.widgetKey || request.id, request);
16206
- else widgets.delete(request.widgetKey || request.id);
16487
+ case "setWidget": {
16488
+ const widgetKey = request.widgetKey || request.id;
16489
+ if (widgetKey === "pi-remote-webui") {
16490
+ widgets.delete(widgetKey);
16491
+ if (Array.isArray(request.widgetLines)) mirrorRemoteWebuiWidgetToTranscript(widgetKey, request.widgetLines, request);
16492
+ } else if (Array.isArray(request.widgetLines)) {
16493
+ widgets.set(widgetKey, request);
16494
+ } else {
16495
+ widgets.delete(widgetKey);
16496
+ }
16207
16497
  updateOptionalFeatureAvailability();
16208
16498
  renderWidgets();
16209
16499
  return;
16500
+ }
16210
16501
  case "setTitle":
16211
16502
  if (request.title) document.title = request.title;
16212
16503
  return;
@@ -16239,6 +16530,7 @@ function handleExtensionUiRequest(request) {
16239
16530
  async function sendDialogResponse(payload) {
16240
16531
  const { tabId = activeTabId, ...body } = payload;
16241
16532
  const tabContext = activeTabContext(tabId);
16533
+ const responseId = String(body.id || "");
16242
16534
  try {
16243
16535
  const response = await api("/api/extension-ui-response", { method: "POST", body, tabId });
16244
16536
  if (!applyResponseTab(response) && decrementTabPendingBlockerCount(tabId)) renderTabs();
@@ -16246,6 +16538,7 @@ async function sendDialogResponse(payload) {
16246
16538
  if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
16247
16539
  } finally {
16248
16540
  if (!isCurrentTabContext(tabContext)) return;
16541
+ if (responseId && activeDialog && String(activeDialog.id || "") !== responseId) return;
16249
16542
  if (elements.dialog.open) elements.dialog.close();
16250
16543
  activeDialog = null;
16251
16544
  if (runIndicatorIsActive()) setRunIndicatorActivity("Continuing after your response…");
@@ -16336,6 +16629,7 @@ function handleInactiveTabEvent(event) {
16336
16629
 
16337
16630
  function handleEvent(event) {
16338
16631
  ingestEventTabActivity(event);
16632
+ trackAutoRetryStateFromEvent(event);
16339
16633
  trackSkillsFromEvent(event);
16340
16634
  if (!eventTargetsActiveTab(event)) {
16341
16635
  handleInactiveTabEvent(event);
@@ -16390,6 +16684,14 @@ function handleEvent(event) {
16390
16684
  removeQueuedDialogRequests(event.ids || []);
16391
16685
  addEvent(`cancelled ${event.ids?.length || 0} pending extension UI request(s)`, "warn");
16392
16686
  break;
16687
+ case "webui_extension_ui_resolved": {
16688
+ const closedActiveDialog = removeQueuedDialogRequests([event.id]);
16689
+ if (closedActiveDialog) {
16690
+ addEvent("extension UI request resolved");
16691
+ if (runIndicatorIsActive() && !activeDialog) setRunIndicatorActivity("Continuing after extension UI response…");
16692
+ }
16693
+ break;
16694
+ }
16393
16695
  case "webui_app_runner_update":
16394
16696
  setAppRunnerData(event.tabId || activeTabId, { cwd: event.cwd, activeRun: event.activeRun });
16395
16697
  renderAppRunnerControls();
@@ -16894,6 +17196,7 @@ elements.nativeToolsButton.addEventListener("click", () => runNativeCommandMenu(
16894
17196
  elements.optionsCommandPaletteButton.addEventListener("click", () => openCommandPalette());
16895
17197
  elements.optionsResumeButton.addEventListener("click", () => runNativeCommandMenu("/resume"));
16896
17198
  elements.optionsReloadButton.addEventListener("click", () => runNativeCommandMenu("/reload"));
17199
+ elements.optionsRemoteButton.addEventListener("click", () => runNativeCommandMenu("/remote"));
16897
17200
  elements.optionsNameButton.addEventListener("click", () => runNativeCommandMenu("/name"));
16898
17201
  elements.optionsCloneButton.addEventListener("click", () => runNativeCommandMenu("/clone"));
16899
17202
  elements.optionsSettingsButton.addEventListener("click", () => runNativeCommandMenu("/settings"));
@@ -16954,6 +17257,7 @@ elements.commandPaletteDialog?.addEventListener("cancel", (event) => {
16954
17257
  event.preventDefault();
16955
17258
  closeCommandPalette();
16956
17259
  });
17260
+ elements.commandPaletteCloseButton?.addEventListener("click", closeCommandPalette);
16957
17261
  elements.commandPaletteInput?.addEventListener("input", () => {
16958
17262
  commandPaletteIndex = 0;
16959
17263
  renderCommandPalette();
@@ -16986,11 +17290,104 @@ elements.editRetryCancelButton?.addEventListener("click", closeEditRetryDialog);
16986
17290
  elements.editRetryForkButton?.addEventListener("click", () => submitEditRetry({ send: false }));
16987
17291
  elements.editRetrySendButton?.addEventListener("click", () => submitEditRetry({ send: true }));
16988
17292
 
16989
- function resetAbortLongPressAffordance() {
17293
+ function abortButtonHoldSeconds() {
17294
+ return String(Math.round(ABORT_LONG_PRESS_MS / 1000));
17295
+ }
17296
+
17297
+ function abortButtonReadyTitle() {
17298
+ return `Hold Esc or the Abort button for ${abortButtonHoldSeconds()} seconds to abort the active Pi run`;
17299
+ }
17300
+
17301
+ function clearAbortLongPressResetTimer() {
17302
+ clearTimeout(abortLongPressResetTimer);
17303
+ abortLongPressResetTimer = null;
17304
+ }
17305
+
17306
+ function clearAbortLongPressCompletionTimers() {
16990
17307
  clearTimeout(abortLongPressTimer);
17308
+ clearInterval(abortLongPressTickTimer);
16991
17309
  abortLongPressTimer = null;
17310
+ abortLongPressTickTimer = null;
17311
+ }
17312
+
17313
+ function isAbortLongPressActive() {
17314
+ return abortLongPressStartedAt > 0;
17315
+ }
17316
+
17317
+ function abortLongPressRemainingMs() {
17318
+ if (!abortLongPressStartedAt || !abortLongPressDeadlineAt) return ABORT_LONG_PRESS_MS;
17319
+ return Math.max(0, abortLongPressDeadlineAt - performance.now());
17320
+ }
17321
+
17322
+ function formatAbortLongPressRemaining(ms) {
17323
+ return (Math.ceil(Math.max(0, ms) / 100) / 10).toFixed(1);
17324
+ }
17325
+
17326
+ function abortLongPressLabel() {
17327
+ const remaining = formatAbortLongPressRemaining(abortLongPressRemainingMs());
17328
+ return abortLongPressSource === "escape" ? `Hold Esc ${remaining}s` : `Hold ${remaining}s`;
17329
+ }
17330
+
17331
+ function renderAbortLongPressAffordance() {
17332
+ const label = abortLongPressLabel();
17333
+ elements.abortButton.textContent = label;
17334
+ elements.abortButton.title = `${label} more to abort the active Pi run`;
17335
+ elements.abortButton.setAttribute("aria-label", elements.abortButton.title);
17336
+ }
17337
+
17338
+ function completeAbortLongPress() {
17339
+ if (!isAbortLongPressActive()) return;
17340
+ if (abortLongPressReleasePending) return;
17341
+ const source = abortLongPressSource;
17342
+ clearAbortLongPressResetTimer();
17343
+ clearAbortLongPressCompletionTimers();
17344
+ abortLongPressHandled = true;
17345
+ if (isAbortAvailable()) abortActiveRun({ source });
17346
+ else {
17347
+ resetAbortLongPressAffordance();
17348
+ updateComposerModeButtons();
17349
+ }
17350
+ }
17351
+
17352
+ function tickAbortLongPressAffordance() {
17353
+ if (!isAbortLongPressActive()) return;
17354
+ renderAbortLongPressAffordance();
17355
+ if (abortLongPressRemainingMs() <= 0) completeAbortLongPress();
17356
+ }
17357
+
17358
+ function resumeAbortLongPressAffordance() {
17359
+ if (!isAbortLongPressActive()) return;
17360
+ clearAbortLongPressResetTimer();
17361
+ abortLongPressReleasePending = false;
17362
+ tickAbortLongPressAffordance();
17363
+ }
17364
+
17365
+ function scheduleAbortLongPressReleaseReset() {
17366
+ if (!isAbortLongPressActive()) return;
17367
+ abortLongPressReleasePending = true;
17368
+ clearAbortLongPressResetTimer();
17369
+ abortLongPressResetTimer = setTimeout(() => {
17370
+ abortLongPressResetTimer = null;
17371
+ if (!abortLongPressReleasePending) return;
17372
+ resetAbortLongPressAffordance();
17373
+ updateComposerModeButtons();
17374
+ }, ABORT_LONG_PRESS_RELEASE_GRACE_MS);
17375
+ }
17376
+
17377
+ function resetAbortLongPressAffordance() {
17378
+ clearAbortLongPressResetTimer();
17379
+ clearAbortLongPressCompletionTimers();
17380
+ abortLongPressStartedAt = 0;
17381
+ abortLongPressDeadlineAt = 0;
17382
+ abortLongPressSource = "long-press";
17383
+ abortLongPressReleasePending = false;
16992
17384
  elements.abortButton.classList.remove("long-pressing");
16993
- if (!abortRequestInFlight) elements.abortButton.textContent = "Abort";
17385
+ elements.abortButton.style.removeProperty("--abort-long-press-duration");
17386
+ if (!abortRequestInFlight) {
17387
+ elements.abortButton.textContent = "Abort";
17388
+ elements.abortButton.title = isAbortAvailable() ? abortButtonReadyTitle() : "Abort is available while Pi is running";
17389
+ elements.abortButton.setAttribute("aria-label", elements.abortButton.title);
17390
+ }
16994
17391
  }
16995
17392
 
16996
17393
  async function abortActiveRun({ source = "button" } = {}) {
@@ -17026,31 +17423,41 @@ async function abortActiveRun({ source = "button" } = {}) {
17026
17423
  }
17027
17424
  }
17028
17425
 
17029
- function startAbortLongPress(event) {
17030
- if (!isAbortAvailable() || abortRequestInFlight) return;
17031
- if (event.button !== undefined && event.button !== 0) return;
17426
+ function startAbortLongPress(event, { source = "long-press" } = {}) {
17427
+ if (!isAbortAvailable() || abortRequestInFlight) return false;
17428
+ if (source !== "escape" && event?.button !== undefined && event.button !== 0) return false;
17429
+ if (isAbortLongPressActive()) {
17430
+ resumeAbortLongPressAffordance();
17431
+ return true;
17432
+ }
17032
17433
  resetAbortLongPressAffordance();
17033
17434
  abortLongPressHandled = false;
17435
+ abortLongPressReleasePending = false;
17436
+ abortLongPressSource = source;
17437
+ abortLongPressStartedAt = performance.now();
17438
+ abortLongPressDeadlineAt = abortLongPressStartedAt + ABORT_LONG_PRESS_MS;
17439
+ elements.abortButton.style.setProperty("--abort-long-press-duration", `${ABORT_LONG_PRESS_MS}ms`);
17034
17440
  elements.abortButton.classList.add("long-pressing");
17035
- elements.abortButton.textContent = "Hold…";
17036
- abortLongPressTimer = setTimeout(() => {
17037
- abortLongPressTimer = null;
17038
- abortLongPressHandled = true;
17039
- abortActiveRun({ source: "long-press" });
17040
- }, ABORT_LONG_PRESS_MS);
17441
+ renderAbortLongPressAffordance();
17442
+ abortLongPressTickTimer = setInterval(tickAbortLongPressAffordance, ABORT_LONG_PRESS_TICK_MS);
17443
+ abortLongPressTimer = setTimeout(tickAbortLongPressAffordance, ABORT_LONG_PRESS_MS + 10);
17444
+ return true;
17041
17445
  }
17042
17446
 
17043
17447
  elements.abortButton.addEventListener("pointerdown", startAbortLongPress);
17044
17448
  for (const eventName of ["pointerup", "pointerleave", "pointercancel", "blur"]) {
17045
17449
  elements.abortButton.addEventListener(eventName, resetAbortLongPressAffordance);
17046
17450
  }
17451
+ elements.abortButton.addEventListener("keydown", (event) => {
17452
+ if (event.key !== " " && event.key !== "Enter") return;
17453
+ if (startAbortLongPress(event)) event.preventDefault();
17454
+ });
17455
+ elements.abortButton.addEventListener("keyup", (event) => {
17456
+ if (event.key === " " || event.key === "Enter") resetAbortLongPressAffordance();
17457
+ });
17047
17458
  elements.abortButton.addEventListener("click", (event) => {
17048
- if (abortLongPressHandled) {
17049
- event.preventDefault();
17050
- abortLongPressHandled = false;
17051
- return;
17052
- }
17053
- abortActiveRun({ source: "button" });
17459
+ event.preventDefault();
17460
+ if (abortLongPressHandled) abortLongPressHandled = false;
17054
17461
  });
17055
17462
  elements.newSessionButton.addEventListener("click", async () => {
17056
17463
  setComposerActionsOpen(false);
@@ -17166,6 +17573,10 @@ elements.chat.addEventListener("scroll", () => {
17166
17573
  markTabOutputSeen();
17167
17574
  updateStickyUserPromptButton();
17168
17575
  }, { passive: true });
17576
+ document.addEventListener("pointerdown", beginPointerActivation, { capture: true, passive: true });
17577
+ document.addEventListener("pointerup", finishPointerActivation, { capture: true, passive: true });
17578
+ document.addEventListener("pointercancel", cancelPointerActivation, { capture: true, passive: true });
17579
+ window.addEventListener("blur", cancelPointerActivation, { passive: true });
17169
17580
  document.addEventListener("pointerdown", (event) => {
17170
17581
  if (openTerminalTabGroupKey && !event.target?.closest?.(".terminal-tab-group")) {
17171
17582
  clearOpenTerminalTabGroup(openTerminalTabGroupKey);
@@ -17386,12 +17797,14 @@ window.addEventListener("keydown", (event) => {
17386
17797
  window.addEventListener("keydown", handleNativeAppShortcut, { capture: true });
17387
17798
  document.addEventListener("visibilitychange", () => {
17388
17799
  if (document.visibilityState === "visible") scheduleForegroundReconcile("visibility resume", 0);
17800
+ else resetAbortLongPressAffordance();
17389
17801
  });
17390
17802
  window.addEventListener("pageshow", () => scheduleForegroundReconcile("page show", 0));
17391
17803
  window.addEventListener("focus", () => scheduleForegroundReconcile("window focus"));
17392
17804
  window.addEventListener("online", () => scheduleForegroundReconcile("network online", 0));
17393
17805
  window.addEventListener("keydown", (event) => {
17394
17806
  if (event.key !== "Escape") return;
17807
+ if (event.defaultPrevented) return;
17395
17808
  if (elements.dialog?.open || elements.pathPickerDialog?.open || elements.gitChangesDialog?.open || elements.commandPaletteDialog?.open || elements.editRetryDialog?.open) return;
17396
17809
  if (publishMenuOpen) {
17397
17810
  setPublishMenuOpen(false);
@@ -17437,6 +17850,20 @@ window.addEventListener("keydown", (event) => {
17437
17850
  hideCommandSuggestions();
17438
17851
  return;
17439
17852
  }
17853
+ if (isSidePanelOverlayView() && !document.body.classList.contains("side-panel-collapsed")) {
17854
+ setSidePanelCollapsed(true);
17855
+ return;
17856
+ }
17857
+ if (isAbortAvailable()) {
17858
+ event.preventDefault();
17859
+ if (abortLongPressSource === "escape" && isAbortLongPressActive()) resumeAbortLongPressAffordance();
17860
+ else if (!event.repeat) startAbortLongPress(event, { source: "escape" });
17861
+ return;
17862
+ }
17863
+ if (event.repeat) {
17864
+ event.preventDefault();
17865
+ return;
17866
+ }
17440
17867
  if (document.activeElement === elements.promptInput && !elements.promptInput.value.trim() && doubleEscapeAction !== "none") {
17441
17868
  const now = Date.now();
17442
17869
  if (now - lastEmptyPromptEscapeTime < 500) {
@@ -17447,14 +17874,13 @@ window.addEventListener("keydown", (event) => {
17447
17874
  }
17448
17875
  lastEmptyPromptEscapeTime = now;
17449
17876
  }
17450
- if (isSidePanelOverlayView() && !document.body.classList.contains("side-panel-collapsed")) {
17451
- setSidePanelCollapsed(true);
17452
- return;
17453
- }
17454
- if (isAbortAvailable()) {
17455
- event.preventDefault();
17456
- abortActiveRun({ source: "escape" });
17457
- }
17877
+ });
17878
+ window.addEventListener("keyup", (event) => {
17879
+ if (event.key === "Escape" && abortLongPressSource === "escape") scheduleAbortLongPressReleaseReset();
17880
+ }, { capture: true });
17881
+ window.addEventListener("blur", () => {
17882
+ if (abortLongPressSource === "escape") scheduleAbortLongPressReleaseReset();
17883
+ else resetAbortLongPressAffordance();
17458
17884
  });
17459
17885
 
17460
17886
  elements.gitChangesRefreshButton?.addEventListener("click", refreshGitChangesDialog);