@firstpick/pi-package-webui 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Local browser companion for [Pi coding agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent).
4
4
 
5
+ ![Pi Web UI main window showing multi-tab chat, controls, theme picker, and local status](https://unpkg.com/@firstpick/pi-package-webui/images/Main_Window_v0.1.7.png)
6
+
5
7
  This package provides:
6
8
 
7
9
  - `pi-webui`: a local HTTP/SSE server that starts `pi --mode rpc`, serves the static browser UI, and proxies browser actions to Pi RPC commands.
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,6 +14,7 @@
14
14
  "extension"
15
15
  ],
16
16
  "pi": {
17
+ "image": "https://unpkg.com/@firstpick/pi-package-webui/images/Main_Window_v0.1.7.png",
17
18
  "extensions": [
18
19
  "./index.ts",
19
20
  "../pi-extension-git-footer-status/index.ts",
@@ -63,6 +64,7 @@
63
64
  "index.ts",
64
65
  "bin",
65
66
  "public",
67
+ "images",
66
68
  "tests",
67
69
  "README.md",
68
70
  "LICENSE"
package/public/app.js CHANGED
@@ -7,6 +7,11 @@ const elements = {
7
7
  newTabButton: $("#newTabButton"),
8
8
  closeAllTabsButton: $("#closeAllTabsButton"),
9
9
  statusBar: $("#statusBar"),
10
+ serverOfflinePanel: $("#serverOfflinePanel"),
11
+ serverOfflineCommand: $("#serverOfflineCommand"),
12
+ serverOfflineSlashCommand: $("#serverOfflineSlashCommand"),
13
+ copyServerCommandButton: $("#copyServerCommandButton"),
14
+ retryServerConnectionButton: $("#retryServerConnectionButton"),
10
15
  widgetArea: $("#widgetArea"),
11
16
  stickyUserPromptButton: $("#stickyUserPromptButton"),
12
17
  chat: $("#chat"),
@@ -141,6 +146,8 @@ let pathSuggestAbortController = null;
141
146
  let latestStats = null;
142
147
  let latestWorkspace = null;
143
148
  let latestNetwork = null;
149
+ let backendOffline = false;
150
+ let backendOfflineNoticeShown = false;
144
151
  let latestMessages = [];
145
152
  let transientMessages = [];
146
153
  let actionEntrySeenKeysByTab = new Map();
@@ -193,6 +200,8 @@ const CUSTOM_BACKGROUNDS_STORAGE_KEY = "pi-webui-custom-backgrounds";
193
200
  const CUSTOM_BACKGROUND_IDB_NAME = "pi-webui-custom-background";
194
201
  const CUSTOM_BACKGROUND_IDB_STORE = "backgrounds";
195
202
  const CUSTOM_BACKGROUND_LEGACY_ID = "active";
203
+ const SERVER_START_CWD_STORAGE_KEY = "pi-webui-last-server-cwd";
204
+ const DEFAULT_WEBUI_PORT = "31415";
196
205
  const CUSTOM_BACKGROUND_MAX_FILE_BYTES = 24 * 1024 * 1024;
197
206
  const OPTIONAL_FEATURES_STORAGE_KEY = "pi-webui-optional-features-disabled";
198
207
  const LAST_USER_PROMPT_STORAGE_KEY = "pi-webui-last-user-prompts";
@@ -218,6 +227,7 @@ const ABORT_LONG_PRESS_MS = 700;
218
227
  const STREAM_OUTPUT_HIDE_DELAY_MS = 300;
219
228
  const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220;
220
229
  const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
230
+ const TOOL_LIVE_UPDATE_THROTTLE_MS = 80;
221
231
  const TODO_PROGRESS_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)\]\s+.+$/;
222
232
  const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)?\]?\s*.*$/;
223
233
  const CHAT_SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);
@@ -232,6 +242,8 @@ const statusEntries = new Map();
232
242
  const widgets = new Map();
233
243
  const liveToolRuns = new Map();
234
244
  const liveToolCards = new Map();
245
+ const liveToolRenderQueue = new Map();
246
+ let liveToolRenderTimer = null;
235
247
  // Optional feature detection intentionally checks loaded Pi capabilities (RPC-visible
236
248
  // commands and live widget events), not npm package folders. This keeps local dev
237
249
  // symlinks and independently installed packages working.
@@ -307,16 +319,56 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
307
319
  const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate"]);
308
320
  const NATIVE_SELECTOR_COMMANDS = new Set(["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models"]);
309
321
  const optionalFeatureInstallInProgress = new Set();
310
- const gitWorkflow = {
311
- active: false,
312
- step: "idle",
313
- busy: false,
314
- runId: 0,
315
- output: "",
316
- error: "",
317
- message: null,
318
- messageRequestedAt: 0,
319
- };
322
+
323
+ function createGitWorkflowState() {
324
+ return {
325
+ active: false,
326
+ step: "idle",
327
+ busy: false,
328
+ runId: 0,
329
+ output: "",
330
+ error: "",
331
+ message: null,
332
+ messageRequestedAt: 0,
333
+ };
334
+ }
335
+
336
+ const gitWorkflowsByTab = new Map();
337
+ let gitWorkflow = createGitWorkflowState();
338
+
339
+ function gitWorkflowForTab(tabId = activeTabId, { create = true } = {}) {
340
+ if (!tabId) return null;
341
+ let workflow = gitWorkflowsByTab.get(tabId);
342
+ if (!workflow && create) {
343
+ workflow = createGitWorkflowState();
344
+ gitWorkflowsByTab.set(tabId, workflow);
345
+ }
346
+ return workflow || null;
347
+ }
348
+
349
+ function bindGitWorkflowToActiveTab() {
350
+ gitWorkflow = gitWorkflowForTab(activeTabId) || createGitWorkflowState();
351
+ return gitWorkflow;
352
+ }
353
+
354
+ function resetGitWorkflowForTab(tabId = activeTabId) {
355
+ if (!tabId) return;
356
+ gitWorkflowsByTab.set(tabId, createGitWorkflowState());
357
+ if (tabId === activeTabId) {
358
+ bindGitWorkflowToActiveTab();
359
+ renderGitWorkflow();
360
+ }
361
+ }
362
+
363
+ function clearGitWorkflowForTab(tabId) {
364
+ if (!tabId) return;
365
+ gitWorkflowsByTab.delete(tabId);
366
+ if (tabId === activeTabId) {
367
+ bindGitWorkflowToActiveTab();
368
+ renderGitWorkflow();
369
+ }
370
+ }
371
+
320
372
  const GIT_WORKFLOW_STEPS = ["Stage", "Message", "Commit", "Push"];
321
373
  const ACTION_FEEDBACK_REACTIONS = {
322
374
  up: { icon: "👍", label: "Good job", title: "Good job!" },
@@ -702,6 +754,120 @@ function registerPwaServiceWorker() {
702
754
  });
703
755
  }
704
756
 
757
+ function readStoredServerStartCwd() {
758
+ try {
759
+ return localStorage.getItem(SERVER_START_CWD_STORAGE_KEY) || "";
760
+ } catch {
761
+ return "";
762
+ }
763
+ }
764
+
765
+ function rememberServerStartCwd(cwd) {
766
+ const value = typeof cwd === "string" ? cwd.trim() : "";
767
+ if (!value) return;
768
+ try {
769
+ localStorage.setItem(SERVER_START_CWD_STORAGE_KEY, value);
770
+ } catch {
771
+ // Ignore storage failures; the offline start helper can still show a generic command.
772
+ }
773
+ if (backendOffline) renderServerOfflinePanel();
774
+ }
775
+
776
+ function quoteCommandArg(value) {
777
+ const text = String(value || ".");
778
+ if (!/[\s"'`$]/.test(text)) return text;
779
+ if (!text.includes("'")) return `'${text}'`;
780
+ return `"${text.replace(/(["`$])/g, "\\$1")}"`;
781
+ }
782
+
783
+ function currentPortArg() {
784
+ const port = window.location.port || "";
785
+ return port && port !== DEFAULT_WEBUI_PORT ? ` --port ${port}` : "";
786
+ }
787
+
788
+ function serverStartCommandText() {
789
+ const cwd = readStoredServerStartCwd() || ".";
790
+ return `pi-webui --cwd ${quoteCommandArg(cwd)}${currentPortArg()}`;
791
+ }
792
+
793
+ function serverStartSlashCommandText() {
794
+ return `/webui-start${currentPortArg()}`;
795
+ }
796
+
797
+ function renderServerOfflinePanel() {
798
+ if (elements.serverOfflineCommand) elements.serverOfflineCommand.textContent = serverStartCommandText();
799
+ if (elements.serverOfflineSlashCommand) elements.serverOfflineSlashCommand.textContent = serverStartSlashCommandText();
800
+ }
801
+
802
+ function setBackendOffline(offline, error) {
803
+ backendOffline = !!offline;
804
+ document.body.classList.toggle("server-offline", backendOffline);
805
+ if (elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = !backendOffline;
806
+ renderServerOfflinePanel();
807
+ if (backendOffline) {
808
+ if (!backendOfflineNoticeShown) {
809
+ backendOfflineNoticeShown = true;
810
+ addEvent(`Pi Web UI server is offline${error?.message ? `: ${error.message}` : ""}`, "warn");
811
+ }
812
+ return;
813
+ }
814
+ if (backendOfflineNoticeShown) addEvent("Pi Web UI server is back online", "info");
815
+ backendOfflineNoticeShown = false;
816
+ }
817
+
818
+ async function copyText(text) {
819
+ if (navigator.clipboard?.writeText && window.isSecureContext) {
820
+ await navigator.clipboard.writeText(text);
821
+ return;
822
+ }
823
+ const textarea = document.createElement("textarea");
824
+ textarea.value = text;
825
+ textarea.setAttribute("readonly", "");
826
+ textarea.style.position = "fixed";
827
+ textarea.style.opacity = "0";
828
+ document.body.append(textarea);
829
+ textarea.select();
830
+ const copied = document.execCommand("copy");
831
+ textarea.remove();
832
+ if (!copied) throw new Error("Clipboard copy failed");
833
+ }
834
+
835
+ async function copyServerStartCommand() {
836
+ const command = serverStartCommandText();
837
+ try {
838
+ await copyText(command);
839
+ addEvent("copied Pi Web UI start command", "info");
840
+ } catch (error) {
841
+ addEvent(`copy failed; manually run: ${command}`, "warn");
842
+ }
843
+ }
844
+
845
+ async function retryServerConnection() {
846
+ const button = elements.retryServerConnectionButton;
847
+ if (button) {
848
+ button.disabled = true;
849
+ button.textContent = "Retrying…";
850
+ }
851
+ try {
852
+ await api("/api/health", { scoped: false });
853
+ } catch (error) {
854
+ setBackendOffline(true, error);
855
+ addEvent("Pi Web UI server is still offline", "warn");
856
+ return;
857
+ } finally {
858
+ if (button) {
859
+ button.disabled = false;
860
+ button.textContent = "Retry connection";
861
+ }
862
+ }
863
+
864
+ try {
865
+ await initializeTabs();
866
+ } catch (error) {
867
+ addEvent(error.message || String(error), "error");
868
+ }
869
+ }
870
+
705
871
  function scopedApiPath(path, tabId = activeTabId) {
706
872
  if (!tabId || !path.startsWith("/api/") || path === "/api/tabs" || path.startsWith("/api/tabs?") || path.startsWith("/api/tabs/")) return path;
707
873
  const url = new URL(path, window.location.origin);
@@ -710,12 +876,21 @@ function scopedApiPath(path, tabId = activeTabId) {
710
876
  }
711
877
 
712
878
  async function api(path, { method = "GET", body, tabId = activeTabId, scoped = true, signal } = {}) {
713
- const response = await fetch(scoped ? scopedApiPath(path, tabId) : path, {
714
- method,
715
- headers: body === undefined ? undefined : { "content-type": "application/json" },
716
- body: body === undefined ? undefined : JSON.stringify(body),
717
- signal,
718
- });
879
+ let response;
880
+ try {
881
+ response = await fetch(scoped ? scopedApiPath(path, tabId) : path, {
882
+ method,
883
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
884
+ body: body === undefined ? undefined : JSON.stringify(body),
885
+ signal,
886
+ });
887
+ } catch (error) {
888
+ const offlineError = error instanceof Error ? error : new Error(String(error));
889
+ offlineError.backendOffline = true;
890
+ setBackendOffline(true, offlineError);
891
+ throw offlineError;
892
+ }
893
+ setBackendOffline(false);
719
894
  const data = await response.json().catch(() => ({}));
720
895
  if (!response.ok) {
721
896
  const error = new Error(data.error || data.message || JSON.stringify(data));
@@ -1703,6 +1878,7 @@ function setActiveTabId(tabId, { remember = false } = {}) {
1703
1878
  const nextTabId = tabId || null;
1704
1879
  if (nextTabId !== activeTabId) activeTabGeneration += 1;
1705
1880
  activeTabId = nextTabId;
1881
+ bindGitWorkflowToActiveTab();
1706
1882
  if (remember) rememberActiveTab();
1707
1883
  return activeTabContext(nextTabId);
1708
1884
  }
@@ -1779,6 +1955,7 @@ function syncTabMetadata(nextTabs = []) {
1779
1955
  tabActivities.delete(tabId);
1780
1956
  tabSeenCompletionSerials.delete(tabId);
1781
1957
  actionFeedbackByTab.delete(tabId);
1958
+ clearGitWorkflowForTab(tabId);
1782
1959
  }
1783
1960
  }
1784
1961
  }
@@ -1959,6 +2136,15 @@ function restoreStoredTabId() {
1959
2136
  }
1960
2137
  }
1961
2138
 
2139
+ function requestedTabIdFromUrl() {
2140
+ try {
2141
+ const params = new URLSearchParams(window.location.search);
2142
+ return params.get("tab") || params.get("tabId") || null;
2143
+ } catch {
2144
+ return null;
2145
+ }
2146
+ }
2147
+
1962
2148
  function updateDocumentTitle() {
1963
2149
  const tab = activeTab();
1964
2150
  document.title = tab ? `Pi Web UI · ${tab.title}` : "Pi Web UI";
@@ -2016,6 +2202,7 @@ function cancelPendingDialogs() {
2016
2202
 
2017
2203
  function resetActiveTabUi() {
2018
2204
  clearRefreshTimers();
2205
+ clearLiveToolRenderQueue();
2019
2206
  eventSource?.close();
2020
2207
  eventSource = null;
2021
2208
  currentState = null;
@@ -2043,15 +2230,7 @@ function resetActiveTabUi() {
2043
2230
  cancelPendingDialogs();
2044
2231
  if (elements.nativeCommandDialog.open) closeNativeCommandDialog();
2045
2232
  if (pathPickerState) closePathPicker(null);
2046
- Object.assign(gitWorkflow, {
2047
- active: false,
2048
- step: "idle",
2049
- busy: false,
2050
- output: "",
2051
- error: "",
2052
- message: null,
2053
- messageRequestedAt: 0,
2054
- });
2233
+ bindGitWorkflowToActiveTab();
2055
2234
  resetChatOutput();
2056
2235
  elements.stateDetails.replaceChildren();
2057
2236
  elements.eventLog.replaceChildren();
@@ -2302,10 +2481,12 @@ async function refreshTabs({ selectStored = false } = {}) {
2302
2481
  syncTabMetadata(tabs);
2303
2482
  syncBlockedTabNotificationsFromTabs(tabs, previousTabs);
2304
2483
  syncAgentDoneNotificationsFromTabs(tabs, previousTabs);
2484
+ const requested = selectStored ? requestedTabIdFromUrl() : null;
2305
2485
  const stored = selectStored ? restoreStoredTabId() : null;
2306
2486
  if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
2307
- setActiveTabId((stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null, { remember: true });
2487
+ setActiveTabId((requested && tabs.some((tab) => tab.id === requested) ? requested : stored && tabs.some((tab) => tab.id === stored) ? stored : tabs[0]?.id) || null, { remember: true });
2308
2488
  }
2489
+ rememberServerStartCwd(tabs.find((tab) => tab.id === activeTabId)?.cwd || tabs[0]?.cwd);
2309
2490
  renderTabs();
2310
2491
  return tabs;
2311
2492
  }
@@ -2395,6 +2576,7 @@ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } =
2395
2576
  for (const id of closedIds) {
2396
2577
  tabDrafts.delete(id);
2397
2578
  clearAttachments(id);
2579
+ clearGitWorkflowForTab(id);
2398
2580
  }
2399
2581
  clearOpenTerminalTabGroup(null, { force: true });
2400
2582
 
@@ -3299,6 +3481,7 @@ async function changeActiveTabCwd() {
3299
3481
  return;
3300
3482
  }
3301
3483
  const nextContext = setActiveTabId(response.data?.tab?.id || activeTabId);
3484
+ resetGitWorkflowForTab(nextContext.tabId);
3302
3485
  resetActiveTabUi();
3303
3486
  renderTabs();
3304
3487
  restoreActiveDraft();
@@ -3827,18 +4010,27 @@ function renderWidgets() {
3827
4010
  }
3828
4011
  }
3829
4012
 
3830
- function setGitWorkflow(patch) {
3831
- Object.assign(gitWorkflow, patch);
3832
- renderGitWorkflow();
4013
+ function setGitWorkflow(patch, { tabId = activeTabId } = {}) {
4014
+ const workflow = gitWorkflowForTab(tabId);
4015
+ if (!workflow) return null;
4016
+ Object.assign(workflow, patch);
4017
+ if (tabId === activeTabId) {
4018
+ gitWorkflow = workflow;
4019
+ renderGitWorkflow();
4020
+ }
4021
+ return workflow;
3833
4022
  }
3834
4023
 
3835
- function isCurrentGitWorkflowRun(runId) {
3836
- return gitWorkflow.active && gitWorkflow.runId === runId;
4024
+ function isCurrentGitWorkflowRun(runId, tabId = activeTabId) {
4025
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4026
+ return !!workflow?.active && workflow.runId === runId;
3837
4027
  }
3838
4028
 
3839
- function appendGitWorkflowOutput(text) {
3840
- const next = `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n" : ""}${text}`;
3841
- setGitWorkflow({ output: next.slice(-60000) });
4029
+ function appendGitWorkflowOutput(text, { tabId = activeTabId } = {}) {
4030
+ const workflow = gitWorkflowForTab(tabId);
4031
+ if (!workflow) return;
4032
+ const next = `${workflow.output || ""}${workflow.output ? "\n" : ""}${text}`;
4033
+ setGitWorkflow({ output: next.slice(-60000) }, { tabId });
3842
4034
  }
3843
4035
 
3844
4036
  function formatGitCommandResult(result) {
@@ -3941,9 +4133,11 @@ function renderGitWorkflow() {
3941
4133
  }
3942
4134
  }
3943
4135
 
3944
- async function gitWorkflowRequest(path, { method = "POST", body = {}, runId = gitWorkflow.runId } = {}) {
3945
- const response = await api(path, method === "GET" ? { method } : { method, body });
3946
- if (!isCurrentGitWorkflowRun(runId)) return null;
4136
+ async function gitWorkflowRequest(path, { method = "POST", body = {}, runId, tabId = activeTabId } = {}) {
4137
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4138
+ const expectedRunId = runId ?? workflow?.runId;
4139
+ const response = await api(path, method === "GET" ? { method, tabId } : { method, body, tabId });
4140
+ if (expectedRunId !== undefined && !isCurrentGitWorkflowRun(expectedRunId, tabId)) return null;
3947
4141
  if (!response.ok) {
3948
4142
  const detail = response.data ? `\n\n${formatGitCommandResult(response.data)}` : "";
3949
4143
  throw new Error(`${response.error || "Git workflow request failed"}${detail}`);
@@ -3951,27 +4145,32 @@ async function gitWorkflowRequest(path, { method = "POST", body = {}, runId = gi
3951
4145
  return response.data;
3952
4146
  }
3953
4147
 
3954
- function failGitWorkflow(error, step = gitWorkflow.step) {
4148
+ function failGitWorkflow(error, step, { tabId = activeTabId } = {}) {
4149
+ const workflow = gitWorkflowForTab(tabId);
4150
+ if (!workflow) return;
3955
4151
  const message = error?.message || String(error);
3956
4152
  setGitWorkflow({
3957
- step,
4153
+ step: step || workflow.step || "error",
3958
4154
  busy: false,
3959
4155
  error: message,
3960
- output: `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n\n" : ""}ERROR: ${message}`.slice(-60000),
3961
- });
4156
+ output: `${workflow.output || ""}${workflow.output ? "\n\n" : ""}ERROR: ${message}`.slice(-60000),
4157
+ }, { tabId });
3962
4158
  }
3963
4159
 
3964
4160
  function startGitWorkflow() {
4161
+ const tabId = activeTabId;
4162
+ if (!tabId) return;
3965
4163
  if (!isOptionalFeatureEnabled("gitWorkflow")) {
3966
- const tabContext = activeTabContext();
4164
+ const tabContext = activeTabContext(tabId);
3967
4165
  addEvent(commandUnavailableMessage("git-staged-msg"), "warn");
3968
4166
  refreshCommands(tabContext).catch((error) => {
3969
4167
  if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
3970
4168
  });
3971
4169
  return;
3972
4170
  }
3973
- if (gitWorkflow.active && !["done", "cancelled", "error"].includes(gitWorkflow.step) && !confirm("Restart the active git workflow?")) return;
3974
- gitWorkflow.runId += 1;
4171
+ const workflow = gitWorkflowForTab(tabId);
4172
+ if (workflow.active && !["done", "cancelled", "error"].includes(workflow.step) && !confirm("Restart the active git workflow?")) return;
4173
+ workflow.runId += 1;
3975
4174
  setGitWorkflow({
3976
4175
  active: true,
3977
4176
  step: "add",
@@ -3980,40 +4179,52 @@ function startGitWorkflow() {
3980
4179
  error: "",
3981
4180
  message: null,
3982
4181
  messageRequestedAt: 0,
3983
- });
4182
+ }, { tabId });
3984
4183
  }
3985
4184
 
3986
4185
  async function cancelGitWorkflow() {
3987
- const shouldAbortPi = gitWorkflow.step === "generating";
3988
- gitWorkflow.runId += 1;
3989
- setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n\n" : ""}Cancelled by user.` });
3990
- if (shouldAbortPi) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
4186
+ const tabId = activeTabId;
4187
+ const tabContext = activeTabContext(tabId);
4188
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4189
+ if (!workflow?.active) return;
4190
+ const shouldAbortPi = workflow.step === "generating";
4191
+ workflow.runId += 1;
4192
+ setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${workflow.output || ""}${workflow.output ? "\n\n" : ""}Cancelled by user.` }, { tabId });
4193
+ if (shouldAbortPi && isCurrentTabContext(tabContext)) setRunIndicatorActivity("Abort requested; checking whether Pi stopped…");
3991
4194
  await Promise.allSettled([
3992
- api("/api/git-workflow/cancel", { method: "POST", body: {} }),
3993
- shouldAbortPi ? api("/api/abort", { method: "POST", body: {} }) : Promise.resolve(),
4195
+ api("/api/git-workflow/cancel", { method: "POST", body: {}, tabId }),
4196
+ shouldAbortPi ? api("/api/abort", { method: "POST", body: {}, tabId }) : Promise.resolve(),
3994
4197
  ]);
3995
- if (shouldAbortPi) scheduleAbortStateChecks();
4198
+ if (shouldAbortPi && isCurrentTabContext(tabContext)) scheduleAbortStateChecks();
3996
4199
  }
3997
4200
 
3998
4201
  async function runGitAdd() {
3999
- const runId = gitWorkflow.runId;
4000
- setGitWorkflow({ step: "add", busy: true, error: "", output: "Running git add ." });
4202
+ const tabId = activeTabId;
4203
+ const tabContext = activeTabContext(tabId);
4204
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4205
+ if (!workflow) return;
4206
+ const runId = workflow.runId;
4207
+ setGitWorkflow({ step: "add", busy: true, error: "", output: "Running git add ." }, { tabId });
4001
4208
  try {
4002
- const result = await gitWorkflowRequest("/api/git-workflow/add", { runId });
4209
+ const result = await gitWorkflowRequest("/api/git-workflow/add", { runId, tabId });
4003
4210
  if (!result) return;
4004
- setGitWorkflow({ step: "generate", busy: false, output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` });
4005
- scheduleRefreshFooter();
4211
+ setGitWorkflow({ step: "generate", busy: false, output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` }, { tabId });
4212
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
4006
4213
  } catch (error) {
4007
- if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "add");
4214
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "add", { tabId });
4008
4215
  }
4009
4216
  }
4010
4217
 
4011
4218
  async function runGitMessagePrompt() {
4219
+ const tabId = activeTabId;
4220
+ const tabContext = activeTabContext(tabId);
4012
4221
  if (currentState?.isStreaming) {
4013
- failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating a staged commit message."), "generate");
4222
+ failGitWorkflow(new Error("Pi is currently running. Wait for it to finish or abort before generating a staged commit message."), "generate", { tabId });
4014
4223
  return;
4015
4224
  }
4016
- const runId = gitWorkflow.runId;
4225
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4226
+ if (!workflow) return;
4227
+ const runId = workflow.runId;
4017
4228
  const requestedAt = Date.now();
4018
4229
  setGitWorkflow({
4019
4230
  step: "generating",
@@ -4021,32 +4232,37 @@ async function runGitMessagePrompt() {
4021
4232
  error: "",
4022
4233
  messageRequestedAt: requestedAt,
4023
4234
  output: "Sending /git-staged-msg to Pi.\n\nCancel will request Pi abort.",
4024
- });
4025
- setRunIndicatorActivity("Sending /git-staged-msg to Pi…");
4235
+ }, { tabId });
4236
+ if (isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending /git-staged-msg to Pi…");
4026
4237
  try {
4027
- await api("/api/prompt", { method: "POST", body: { message: "/git-staged-msg" } });
4028
- if (!isCurrentGitWorkflowRun(runId)) return;
4029
- appendGitWorkflowOutput("/git-staged-msg accepted. Waiting for agent_end, then the message files will be loaded.");
4030
- scheduleRefreshState();
4238
+ await api("/api/prompt", { method: "POST", body: { message: "/git-staged-msg" }, tabId });
4239
+ if (!isCurrentGitWorkflowRun(runId, tabId)) return;
4240
+ appendGitWorkflowOutput("/git-staged-msg accepted. Waiting for agent_end, then the message files will be loaded.", { tabId });
4241
+ if (isCurrentTabContext(tabContext)) scheduleRefreshState(120, tabContext);
4031
4242
  setTimeout(() => {
4032
- if (isCurrentGitWorkflowRun(runId) && gitWorkflow.step === "generating" && !currentState?.isStreaming) {
4033
- loadGitWorkflowMessage({ requireFresh: true, retries: 1, runId });
4243
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
4244
+ if (isCurrentTabContext(tabContext) && isCurrentGitWorkflowRun(runId, tabId) && currentWorkflow?.step === "generating" && !currentState?.isStreaming) {
4245
+ loadGitWorkflowMessage({ requireFresh: true, retries: 1, runId, tabId });
4034
4246
  }
4035
4247
  }, 2500);
4036
4248
  } catch (error) {
4037
- if (isCurrentGitWorkflowRun(runId)) {
4038
- clearRunIndicatorActivity();
4039
- failGitWorkflow(error, "generate");
4249
+ if (isCurrentGitWorkflowRun(runId, tabId)) {
4250
+ if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
4251
+ failGitWorkflow(error, "generate", { tabId });
4040
4252
  }
4041
4253
  }
4042
4254
  }
4043
4255
 
4044
- async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId = gitWorkflow.runId } = {}) {
4256
+ async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId, tabId = activeTabId } = {}) {
4257
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4258
+ const expectedRunId = runId ?? workflow?.runId;
4045
4259
  try {
4046
- const message = await gitWorkflowRequest("/api/git-workflow/message", { method: "GET", runId });
4260
+ const message = await gitWorkflowRequest("/api/git-workflow/message", { method: "GET", runId: expectedRunId, tabId });
4047
4261
  if (!message) return;
4262
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
4263
+ if (!currentWorkflow) return;
4048
4264
  const newestMtime = Math.max(message.shortMtimeMs || 0, message.longMtimeMs || 0);
4049
- if (requireFresh && gitWorkflow.messageRequestedAt && newestMtime + 10000 < gitWorkflow.messageRequestedAt) {
4265
+ if (requireFresh && currentWorkflow.messageRequestedAt && newestMtime + 10000 < currentWorkflow.messageRequestedAt) {
4050
4266
  throw new Error("Generated message files have not refreshed yet.");
4051
4267
  }
4052
4268
  setGitWorkflow({
@@ -4055,40 +4271,63 @@ async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId
4055
4271
  error: "",
4056
4272
  message,
4057
4273
  output: formatCommitMessagePreview(message),
4058
- });
4274
+ }, { tabId });
4059
4275
  } catch (error) {
4060
- if (!isCurrentGitWorkflowRun(runId)) return;
4276
+ if (!isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
4061
4277
  if (retries > 0) {
4062
- setTimeout(() => loadGitWorkflowMessage({ requireFresh, retries: retries - 1, runId }), 1400);
4278
+ setTimeout(() => loadGitWorkflowMessage({ requireFresh, retries: retries - 1, runId: expectedRunId, tabId }), 1400);
4063
4279
  return;
4064
4280
  }
4065
- failGitWorkflow(error, gitWorkflow.step === "generating" ? "generate" : gitWorkflow.step);
4281
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
4282
+ failGitWorkflow(error, currentWorkflow?.step === "generating" ? "generate" : currentWorkflow?.step, { tabId });
4066
4283
  }
4067
4284
  }
4068
4285
 
4069
4286
  async function commitGitWorkflow(variant) {
4070
- const runId = gitWorkflow.runId;
4071
- setGitWorkflow({ step: "committing", busy: true, error: "", output: `${formatCommitMessagePreview(gitWorkflow.message)}\n\nRunning native ${variant} commit…` });
4287
+ const tabId = activeTabId;
4288
+ const tabContext = activeTabContext(tabId);
4289
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4290
+ if (!workflow) return;
4291
+ const runId = workflow.runId;
4292
+ setGitWorkflow({ step: "committing", busy: true, error: "", output: `${formatCommitMessagePreview(workflow.message)}\n\nRunning native ${variant} commit…` }, { tabId });
4072
4293
  try {
4073
- const result = await gitWorkflowRequest("/api/git-workflow/commit", { body: { variant }, runId });
4294
+ const result = await gitWorkflowRequest("/api/git-workflow/commit", { body: { variant }, runId, tabId });
4074
4295
  if (!result) return;
4075
- setGitWorkflow({ step: "push", busy: false, output: `${formatGitCommandResult(result)}\n\nCommit created. Next: git push.` });
4076
- scheduleRefreshFooter();
4296
+ setGitWorkflow({ step: "push", busy: false, output: `${formatGitCommandResult(result)}\n\nCommit created. Next: git push.` }, { tabId });
4297
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
4077
4298
  } catch (error) {
4078
- if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "message");
4299
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "message", { tabId });
4079
4300
  }
4080
4301
  }
4081
4302
 
4082
4303
  async function pushGitWorkflow() {
4083
- const runId = gitWorkflow.runId;
4084
- setGitWorkflow({ step: "pushing", busy: true, error: "", output: "Running git push…" });
4304
+ const tabId = activeTabId;
4305
+ const tabContext = activeTabContext(tabId);
4306
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4307
+ if (!workflow) return;
4308
+ const runId = workflow.runId;
4309
+ setGitWorkflow({ step: "pushing", busy: true, error: "", output: "Running git push…" }, { tabId });
4085
4310
  try {
4086
- const result = await gitWorkflowRequest("/api/git-workflow/push", { runId });
4311
+ const result = await gitWorkflowRequest("/api/git-workflow/push", { runId, tabId });
4087
4312
  if (!result) return;
4088
- setGitWorkflow({ step: "done", busy: false, output: formatGitCommandResult(result) || "git push finished." });
4089
- scheduleRefreshFooter();
4313
+ setGitWorkflow({ step: "done", busy: false, output: formatGitCommandResult(result) || "git push finished." }, { tabId });
4314
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
4090
4315
  } catch (error) {
4091
- if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "push");
4316
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "push", { tabId });
4317
+ }
4318
+ }
4319
+
4320
+ function resumeGitWorkflowForActiveTab(tabContext = activeTabContext()) {
4321
+ if (!isCurrentTabContext(tabContext)) return;
4322
+ bindGitWorkflowToActiveTab();
4323
+ renderGitWorkflow();
4324
+ if (gitWorkflow.active && gitWorkflow.step === "generating" && !currentState?.isStreaming) {
4325
+ const retryDelayMs = Math.max(0, 2500 - (Date.now() - (gitWorkflow.messageRequestedAt || 0)));
4326
+ if (retryDelayMs > 0) {
4327
+ setTimeout(() => resumeGitWorkflowForActiveTab(tabContext), retryDelayMs);
4328
+ return;
4329
+ }
4330
+ loadGitWorkflowMessage({ requireFresh: true, retries: 3, runId: gitWorkflow.runId, tabId: tabContext.tabId });
4092
4331
  }
4093
4332
  }
4094
4333
 
@@ -4992,7 +5231,10 @@ function toolResultForCallId(toolCallId, messages = latestMessages) {
4992
5231
  function cleanupLiveToolRunsForMessages(messages = latestMessages) {
4993
5232
  const results = buildToolResultMap(messages);
4994
5233
  for (const id of liveToolRuns.keys()) {
4995
- if (results.has(id)) liveToolRuns.delete(id);
5234
+ if (results.has(id)) {
5235
+ liveToolRuns.delete(id);
5236
+ cancelQueuedLiveToolRunRender(id);
5237
+ }
4996
5238
  }
4997
5239
  }
4998
5240
 
@@ -5306,12 +5548,143 @@ function liveToolRunMessage(run) {
5306
5548
  };
5307
5549
  }
5308
5550
 
5551
+ function applyToolExecutionBubbleState(bubble, message) {
5552
+ const status = toolExecutionStatus(message);
5553
+ bubble.classList.remove("tool-pending", "tool-running", "tool-success", "tool-error", "error");
5554
+ bubble.classList.add(`tool-${status}`);
5555
+ if (message.isError || status === "error") bubble.classList.add("error");
5556
+ if (message.toolCallId) {
5557
+ const id = String(message.toolCallId);
5558
+ bubble.dataset.toolCallId = id;
5559
+ if (message.live) liveToolCards.set(id, bubble);
5560
+ }
5561
+ }
5562
+
5563
+ function toolDetailsStateKey(details, counts) {
5564
+ const classKey = Array.from(details.classList || []).sort().join(".") || "details";
5565
+ const summaryText = details.querySelector("summary")?.textContent || "";
5566
+ const summaryKey = summaryText.replace(/\s*\([^)]*\)\s*$/g, "").trim();
5567
+ const base = `${classKey}|${summaryKey}`;
5568
+ const index = counts.get(base) || 0;
5569
+ counts.set(base, index + 1);
5570
+ return `${base}|${index}`;
5571
+ }
5572
+
5573
+ function captureToolDetailsOpenState(root) {
5574
+ const state = new Set();
5575
+ const counts = new Map();
5576
+ for (const details of root.querySelectorAll("details")) {
5577
+ const key = toolDetailsStateKey(details, counts);
5578
+ if (details.open) state.add(key);
5579
+ }
5580
+ return state;
5581
+ }
5582
+
5583
+ function restoreToolDetailsOpenState(root, state) {
5584
+ if (!state?.size) return;
5585
+ const counts = new Map();
5586
+ for (const details of root.querySelectorAll("details")) {
5587
+ if (state.has(toolDetailsStateKey(details, counts))) details.open = true;
5588
+ }
5589
+ }
5590
+
5591
+ function captureReusableToolCards() {
5592
+ const cards = new Map();
5593
+ for (const bubble of elements.chat.querySelectorAll(".message.toolExecution[data-tool-call-id]")) {
5594
+ const id = bubble.dataset.toolCallId;
5595
+ if (id) cards.set(id, bubble);
5596
+ }
5597
+ return cards;
5598
+ }
5599
+
5600
+ function reuseToolExecutionBubble(reusableToolCards, message, { streaming = false, messageIndex = -1, transient = false } = {}) {
5601
+ if (streaming || message?.role !== "toolExecution" || !message.toolCallId || !reusableToolCards) return null;
5602
+ const id = String(message.toolCallId);
5603
+ const bubble = reusableToolCards.get(id);
5604
+ if (!bubble) return null;
5605
+ reusableToolCards.delete(id);
5606
+ const body = bubble.querySelector(":scope > .message-body");
5607
+ if (!body || !updateLiveToolCard(bubble, message)) return null;
5608
+ bubble.classList.remove("action-enter", "streaming", "has-action-feedback");
5609
+ bubble.querySelector(":scope > .action-feedback-controls")?.remove();
5610
+ if (!transient && messageIndex >= 0) {
5611
+ bubble.dataset.messageIndex = String(messageIndex);
5612
+ bubble.removeAttribute("data-user-prompt");
5613
+ } else {
5614
+ bubble.removeAttribute("data-message-index");
5615
+ bubble.removeAttribute("data-user-prompt");
5616
+ }
5617
+ if (!streaming && !transient) renderActionFeedbackControls(bubble, message, messageIndex);
5618
+ elements.chat.append(bubble);
5619
+ return { bubble, body };
5620
+ }
5621
+
5622
+ function updateLiveToolCard(bubble, message) {
5623
+ if (!bubble) return false;
5624
+ const header = bubble.querySelector(":scope > .message-header");
5625
+ const body = bubble.querySelector(":scope > .message-body");
5626
+ if (!body) return false;
5627
+ applyToolExecutionBubbleState(bubble, message);
5628
+ const role = header?.querySelector(".message-role");
5629
+ if (role) role.textContent = messageTitle(message);
5630
+ const timestamp = header?.querySelector(".muted");
5631
+ if (timestamp) timestamp.textContent = formatDate(message.timestamp);
5632
+ const detailsOpenState = captureToolDetailsOpenState(body);
5633
+ body.replaceChildren();
5634
+ renderToolExecution(body, message);
5635
+ restoreToolDetailsOpenState(body, detailsOpenState);
5636
+ return true;
5637
+ }
5638
+
5639
+ function cancelQueuedLiveToolRunRender(toolCallId = "") {
5640
+ if (toolCallId) liveToolRenderQueue.delete(String(toolCallId));
5641
+ else liveToolRenderQueue.clear();
5642
+ if (liveToolRenderQueue.size === 0) {
5643
+ clearTimeout(liveToolRenderTimer);
5644
+ liveToolRenderTimer = null;
5645
+ }
5646
+ }
5647
+
5648
+ function clearLiveToolRenderQueue() {
5649
+ cancelQueuedLiveToolRunRender();
5650
+ }
5651
+
5652
+ function flushLiveToolRunRenderQueue() {
5653
+ const entries = Array.from(liveToolRenderQueue.values());
5654
+ clearLiveToolRenderQueue();
5655
+ for (const entry of entries) renderLiveToolRun(entry.run, { scroll: entry.scroll });
5656
+ }
5657
+
5658
+ function scheduleLiveToolRunRender(run, { scroll = false } = {}) {
5659
+ if (!run?.toolCallId) return;
5660
+ const id = String(run.toolCallId);
5661
+ const existing = liveToolRenderQueue.get(id);
5662
+ liveToolRenderQueue.set(id, { run, scroll: !!(existing?.scroll || scroll) });
5663
+ if (liveToolRenderTimer) return;
5664
+ liveToolRenderTimer = setTimeout(() => {
5665
+ liveToolRenderTimer = null;
5666
+ const flush = () => flushLiveToolRunRenderQueue();
5667
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(flush);
5668
+ else flush();
5669
+ }, TOOL_LIVE_UPDATE_THROTTLE_MS);
5670
+ }
5671
+
5309
5672
  function renderLiveToolRun(run, { scroll = true } = {}) {
5310
5673
  if (!run?.toolCallId) return;
5311
- const existing = liveToolCards.get(run.toolCallId);
5674
+ const id = String(run.toolCallId);
5675
+ cancelQueuedLiveToolRunRender(id);
5676
+ const existing = liveToolCards.get(id);
5677
+ const existingConnected = !!(existing?.isConnected && existing.parentElement === elements.chat);
5312
5678
  const shouldFollow = scroll && (autoFollowChat || isChatNearBottom());
5313
- const created = appendMessage(liveToolRunMessage(run), { transient: true, animateEntry: !existing });
5314
- if (existing?.isConnected && existing !== created.bubble) existing.replaceWith(created.bubble);
5679
+ const message = liveToolRunMessage(run);
5680
+ rememberActionEntries([{ message, messageIndex: -1, transient: true }]);
5681
+ if (existingConnected && updateLiveToolCard(existing, message)) {
5682
+ renderRunIndicator({ scroll: false });
5683
+ if (shouldFollow) scrollChatToBottom();
5684
+ return;
5685
+ }
5686
+ const created = appendMessage(message, { transient: true, animateEntry: !existingConnected });
5687
+ if (existingConnected && existing !== created.bubble) existing.replaceWith(created.bubble);
5315
5688
  renderRunIndicator({ scroll: false });
5316
5689
  if (shouldFollow) scrollChatToBottom();
5317
5690
  }
@@ -5345,7 +5718,7 @@ function handleToolExecutionStart(event) {
5345
5718
  function handleToolExecutionUpdate(event) {
5346
5719
  const result = { ...(event.partialResult || {}), isError: false };
5347
5720
  const run = upsertLiveToolRun(event, { result, isPartial: true, isError: false });
5348
- if (run) renderLiveToolRun(run, { scroll: false });
5721
+ if (run) scheduleLiveToolRunRender(run, { scroll: false });
5349
5722
  }
5350
5723
 
5351
5724
  function handleToolExecutionEnd(event) {
@@ -5377,19 +5750,13 @@ function jumpToStickyUserPrompt() {
5377
5750
  requestAnimationFrame(updateStickyUserPromptButton);
5378
5751
  }
5379
5752
 
5380
- function appendMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false } = {}) {
5753
+ function appendMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false, reusableToolCards = null } = {}) {
5754
+ const reused = reuseToolExecutionBubble(reusableToolCards, message, { streaming, messageIndex, transient });
5755
+ if (reused) return reused;
5381
5756
  const role = String(message.role || "message");
5382
5757
  const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
5383
5758
  const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}${animateEntry ? " action-enter" : ""}`);
5384
- if (message.role === "toolExecution") {
5385
- const status = toolExecutionStatus(message);
5386
- bubble.classList.add(`tool-${status}`);
5387
- if (message.isError || status === "error") bubble.classList.add("error");
5388
- if (message.toolCallId) {
5389
- bubble.dataset.toolCallId = String(message.toolCallId);
5390
- if (message.live) liveToolCards.set(String(message.toolCallId), bubble);
5391
- }
5392
- }
5759
+ if (message.role === "toolExecution") applyToolExecutionBubbleState(bubble, message);
5393
5760
  if (!transient && messageIndex >= 0) {
5394
5761
  bubble.dataset.messageIndex = String(messageIndex);
5395
5762
  if (role === "user") bubble.dataset.userPrompt = "true";
@@ -5443,9 +5810,9 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
5443
5810
  return { bubble, body };
5444
5811
  }
5445
5812
 
5446
- function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false } = {}) {
5813
+ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1, transient = false, animateEntry = false, reusableToolCards = null } = {}) {
5447
5814
  if (streaming || transient || message?.role !== "assistant") {
5448
- return appendMessage(message, { streaming, messageIndex, transient, animateEntry });
5815
+ return appendMessage(message, { streaming, messageIndex, transient, animateEntry, reusableToolCards });
5449
5816
  }
5450
5817
 
5451
5818
  let finalOutput = null;
@@ -5474,6 +5841,7 @@ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1
5474
5841
  messageIndex: ["assistant", "toolExecution"].includes(transcriptMessage.role) ? messageIndex : -1,
5475
5842
  transient: false,
5476
5843
  animateEntry: animateEntry && isActionTranscriptMessage(transcriptMessage),
5844
+ reusableToolCards,
5477
5845
  });
5478
5846
  if (transcriptMessage.role === "assistant") finalOutput = created;
5479
5847
  });
@@ -5692,16 +6060,17 @@ function actionEntrySeenKeys(tabId = activeTabId) {
5692
6060
 
5693
6061
  function actionEntryKey(item) {
5694
6062
  const message = item?.message || {};
6063
+ const keyedToolExecution = message.role === "toolExecution" && message.toolCallId;
5695
6064
  return [
5696
- item?.transient ? "transient" : "message",
5697
- item?.messageIndex ?? -1,
6065
+ keyedToolExecution ? "toolExecution" : item?.transient ? "transient" : "message",
6066
+ keyedToolExecution ? "" : (item?.messageIndex ?? -1),
5698
6067
  message.role || "message",
5699
6068
  message.toolName || "",
5700
6069
  message.toolCallId || "",
5701
- message.command || "",
5702
- message.title || "",
5703
- message.timestamp || "",
5704
- textFromContent(message.content).slice(0, 240),
6070
+ keyedToolExecution ? "" : message.command || "",
6071
+ keyedToolExecution ? "" : message.title || "",
6072
+ keyedToolExecution ? "" : message.timestamp || "",
6073
+ keyedToolExecution ? "" : textFromContent(message.content).slice(0, 240),
5705
6074
  ].join("|");
5706
6075
  }
5707
6076
 
@@ -5743,6 +6112,7 @@ function orderedTranscriptItems() {
5743
6112
  function renderAllMessages({ preserveScroll = false } = {}) {
5744
6113
  const shouldFollow = !preserveScroll && (autoFollowChat || isChatNearBottom());
5745
6114
  const previousScrollTop = elements.chat.scrollTop;
6115
+ const reusableToolCards = captureReusableToolCards();
5746
6116
  resetChatOutput();
5747
6117
  const transcriptItems = orderedTranscriptItems();
5748
6118
  for (const item of transcriptItems) {
@@ -5750,6 +6120,7 @@ function renderAllMessages({ preserveScroll = false } = {}) {
5750
6120
  messageIndex: item.messageIndex,
5751
6121
  transient: item.transient,
5752
6122
  animateEntry: shouldAnimateActionEntry(item),
6123
+ reusableToolCards,
5753
6124
  });
5754
6125
  }
5755
6126
  rememberActionEntries(transcriptItems);
@@ -6819,6 +7190,7 @@ async function refreshWorkspace(tabContext = activeTabContext()) {
6819
7190
  }
6820
7191
  if (!isCurrentTabContext(tabContext)) return;
6821
7192
  latestWorkspace = nextWorkspace;
7193
+ rememberServerStartCwd(nextWorkspace?.cwd);
6822
7194
  renderFooter();
6823
7195
  }
6824
7196
 
@@ -7357,6 +7729,7 @@ async function refreshAll(tabContext = activeTabContext()) {
7357
7729
  for (const result of results) {
7358
7730
  if (result.status === "rejected") addEvent(result.reason.message || String(result.reason), "error");
7359
7731
  }
7732
+ resumeGitWorkflowForActiveTab(tabContext);
7360
7733
  }
7361
7734
 
7362
7735
  async function openToNetwork() {
@@ -7794,8 +8167,12 @@ function handleEvent(event) {
7794
8167
  scheduleRefreshMessages();
7795
8168
  scheduleRefreshFooter();
7796
8169
  renderFeedbackTray();
7797
- if (gitWorkflow.active && gitWorkflow.step === "generating") {
7798
- loadGitWorkflowMessage({ requireFresh: true, retries: 3 });
8170
+ {
8171
+ const workflowTabId = event.tabId || activeTabId;
8172
+ const workflow = gitWorkflowForTab(workflowTabId, { create: false });
8173
+ if (workflow?.active && workflow.step === "generating") {
8174
+ loadGitWorkflowMessage({ requireFresh: true, retries: 3, runId: workflow.runId, tabId: workflowTabId });
8175
+ }
7799
8176
  }
7800
8177
  break;
7801
8178
  case "message_start":
@@ -7882,7 +8259,11 @@ function handleEvent(event) {
7882
8259
  syncRunIndicatorFromState(currentState);
7883
8260
  renderStatus();
7884
8261
  } else if (["set_model", "set_thinking_level", "new_session", "compact"].includes(event.command)) {
7885
- if (event.command === "new_session") forgetLastUserPrompt(event.tabId || activeTabId);
8262
+ if (event.command === "new_session") {
8263
+ const tabId = event.tabId || activeTabId;
8264
+ forgetLastUserPrompt(tabId);
8265
+ resetGitWorkflowForTab(tabId);
8266
+ }
7886
8267
  scheduleRefreshState();
7887
8268
  scheduleRefreshMessages();
7888
8269
  scheduleRefreshFooter();
@@ -7908,10 +8289,14 @@ function connectEvents(tabContext = activeTabContext()) {
7908
8289
  }
7909
8290
  };
7910
8291
  source.onerror = () => {
7911
- if (eventSource === source && isCurrentTabContext(tabContext)) addEvent("event stream disconnected; browser will retry", "warn");
8292
+ if (eventSource !== source || !isCurrentTabContext(tabContext)) return;
8293
+ addEvent("event stream disconnected; browser will retry", "warn");
8294
+ fetch("/api/health", { cache: "no-store" }).catch((error) => setBackendOffline(true, error));
7912
8295
  };
7913
8296
  }
7914
8297
 
8298
+ elements.copyServerCommandButton?.addEventListener("click", copyServerStartCommand);
8299
+ elements.retryServerConnectionButton?.addEventListener("click", retryServerConnection);
7915
8300
  elements.sendFeedbackButton.addEventListener("click", () => submitQueuedActionFeedback());
7916
8301
  elements.composer.addEventListener("submit", (event) => {
7917
8302
  event.preventDefault();
@@ -8016,6 +8401,7 @@ elements.newSessionButton.addEventListener("click", async () => {
8016
8401
  const response = await api("/api/new-session", { method: "POST", body: {}, tabId: tabContext.tabId });
8017
8402
  applyResponseTab(response);
8018
8403
  forgetLastUserPrompt(tabContext.tabId);
8404
+ resetGitWorkflowForTab(tabContext.tabId);
8019
8405
  if (!isCurrentTabContext(tabContext)) return;
8020
8406
  await refreshAll(tabContext);
8021
8407
  if (isCurrentTabContext(tabContext)) focusPromptInput({ defer: true });
@@ -8273,4 +8659,5 @@ bindSidePanelSectionToggles();
8273
8659
  restoreSidePanelState();
8274
8660
  bindMobileViewChanges();
8275
8661
  registerPwaServiceWorker();
8662
+ renderServerOfflinePanel();
8276
8663
  initializeTabs().catch((error) => addEvent(error.message, "error"));
package/public/index.html CHANGED
@@ -22,6 +22,20 @@
22
22
  <span class="side-panel-button-chevron" aria-hidden="true">‹</span>
23
23
  </button>
24
24
 
25
+ <section id="serverOfflinePanel" class="server-offline-panel" aria-live="assertive" hidden>
26
+ <div class="server-offline-card" role="status">
27
+ <span class="server-offline-kicker">Backend unavailable</span>
28
+ <h1>Pi Web UI server is offline</h1>
29
+ <p>Run this on the machine that hosts Pi Web UI, then retry the connection.</p>
30
+ <code id="serverOfflineCommand" class="server-offline-command">pi-webui --cwd .</code>
31
+ <div class="server-offline-actions">
32
+ <button id="copyServerCommandButton" class="primary" type="button">Copy start command</button>
33
+ <button id="retryServerConnectionButton" type="button">Retry connection</button>
34
+ </div>
35
+ <p class="server-offline-note muted">From an existing Pi terminal you can also run <code id="serverOfflineSlashCommand">/webui-start</code>.</p>
36
+ </div>
37
+ </section>
38
+
25
39
  <main class="layout">
26
40
  <section class="chat-panel">
27
41
  <header class="terminal-tabs-shell">
package/public/styles.css CHANGED
@@ -309,6 +309,72 @@ body.side-panel-collapsed .side-panel {
309
309
  .side-panel-backdrop {
310
310
  display: none;
311
311
  }
312
+ .server-offline-panel[hidden] {
313
+ display: none !important;
314
+ }
315
+ .server-offline-panel {
316
+ position: fixed;
317
+ inset: 1rem;
318
+ z-index: 60;
319
+ display: grid;
320
+ place-items: center;
321
+ padding: 1rem;
322
+ pointer-events: none;
323
+ }
324
+ .server-offline-card {
325
+ position: relative;
326
+ width: min(44rem, 100%);
327
+ pointer-events: auto;
328
+ padding: clamp(1.2rem, 4vw, 2rem);
329
+ border: 1px solid rgba(249, 226, 175, 0.38);
330
+ border-radius: 1.2rem;
331
+ background:
332
+ radial-gradient(circle at 12% 0, rgba(249, 226, 175, 0.16), transparent 18rem),
333
+ linear-gradient(145deg, rgba(var(--ctp-base-rgb), 0.96), rgba(var(--ctp-crust-rgb), 0.98));
334
+ box-shadow: 0 1.2rem 4rem rgba(var(--ctp-crust-rgb), 0.74), 0 0 2rem rgba(249, 226, 175, 0.14), inset 0 1px 0 rgba(255,255,255,0.07);
335
+ }
336
+ .server-offline-kicker {
337
+ display: inline-block;
338
+ margin-bottom: 0.6rem;
339
+ color: var(--warning);
340
+ font-size: 0.76rem;
341
+ font-weight: 800;
342
+ letter-spacing: 0.12em;
343
+ text-transform: uppercase;
344
+ }
345
+ .server-offline-card h1 {
346
+ margin: 0 0 0.55rem;
347
+ font-size: clamp(1.35rem, 4vw, 2.1rem);
348
+ }
349
+ .server-offline-card p {
350
+ margin: 0 0 1rem;
351
+ }
352
+ .server-offline-command {
353
+ display: block;
354
+ margin: 0.9rem 0 1rem;
355
+ padding: 0.9rem 1rem;
356
+ overflow-x: auto;
357
+ white-space: pre;
358
+ border: 1px solid rgba(148, 226, 213, 0.26);
359
+ border-radius: 0.85rem;
360
+ color: var(--ctp-teal);
361
+ background: rgba(var(--ctp-crust-rgb), 0.72);
362
+ box-shadow: inset 0 0 1.2rem rgba(var(--ctp-crust-rgb), 0.46);
363
+ }
364
+ .server-offline-actions {
365
+ display: flex;
366
+ flex-wrap: wrap;
367
+ gap: 0.7rem;
368
+ }
369
+ .server-offline-note {
370
+ margin: 1rem 0 0;
371
+ font-size: 0.86rem;
372
+ }
373
+ body.server-offline .layout {
374
+ opacity: 0.42;
375
+ filter: blur(2px);
376
+ pointer-events: none;
377
+ }
312
378
  .side-panel-expand-button {
313
379
  position: fixed;
314
380
  top: 1rem;
@@ -1922,6 +1988,7 @@ button.footer-meta {
1922
1988
  .message.toolExecution {
1923
1989
  border-color: rgba(249, 226, 175, 0.34);
1924
1990
  background: linear-gradient(145deg, rgba(249, 226, 175, 0.12), rgba(var(--ctp-surface-rgb), 0.58));
1991
+ transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
1925
1992
  }
1926
1993
  .message.toolExecution .message-role {
1927
1994
  color: var(--ctp-yellow);
@@ -51,6 +51,9 @@ assert.match(html, /id="thinkingVisibilityStatus"/, "thinking-output visibility
51
51
  assert.match(html, /id="nativeCommandDialog"/, "native slash selector UI should have a dedicated dialog");
52
52
  assert.match(html, /id="nativeCommandSearch"[^>]*type="search"/, "native slash selector dialog should expose a filter box");
53
53
  assert.match(html, /id="optionalFeaturesBox"/, "side panel should expose optional feature status and controls");
54
+ assert.match(html, /id="serverOfflinePanel"/, "PWA/offline shell should expose a backend-offline recovery panel");
55
+ assert.match(html, /id="copyServerCommandButton"/, "backend-offline recovery panel should expose a start-command copy button");
56
+ assert.match(html, /id="retryServerConnectionButton"/, "backend-offline recovery panel should expose a retry button");
54
57
  assert.match(html, /data-side-panel-section="controls"/, "side panel controls should live in a collapsible section");
55
58
  assert.match(html, /data-side-panel-section="commands"/, "side panel commands should live in a collapsible section");
56
59
  assert.match(html, /class="side-panel-section-toggle"[^>]*aria-controls="sidePanelSectionControls"/, "side panel section toggles should target their content panels");
@@ -158,6 +161,7 @@ assert.match(css, /\.terminal-tab\.activity-done/, "completed unseen work should
158
161
  assert.match(css, /\.terminal-tabs[\s\S]*?position:\s*absolute/, "expanded mobile tabs should overlay instead of consuming transcript space");
159
162
  assert.match(css, /body\.mobile-keyboard-open \.terminal-tabs-shell,[\s\S]*?body\.mobile-keyboard-open \.widget-area,[\s\S]*?body\.mobile-keyboard-open \.statusbar/, "mobile keyboard mode should hide header, widgets, and footer");
160
163
  assert.match(css, /body\.mobile-keyboard-open \.composer-actions-button,[\s\S]*?body\.mobile-keyboard-open \.composer-actions-panel/, "mobile keyboard mode should hide the secondary actions sheet while keeping active-run controls available");
164
+ assert.match(css, /\.server-offline-panel/, "PWA/offline shell should style a backend-offline recovery panel");
161
165
  assert.match(css, /body:not\(\.pi-run-active\):not\(\.mobile-keyboard-open\) \.composer-row button\.primary \{ grid-column: span 4; \}/, "idle mobile composer should keep Actions and Send on one compact row");
162
166
  assert.match(css, /button\[hidden\] \{ display: none !important; \}/, "hidden bottom-row controls should not occupy layout space");
163
167
  assert.match(css, /\.footer-details-toggle \{ display: none; \}/, "footer details toggle should be hidden outside mobile CSS");
@@ -223,6 +227,10 @@ assert.match(app, /async function initializeTabs\(\)[\s\S]*?restoreActiveDraft\(
223
227
  assert.match(app, /resizePromptInput\(\);\nfocusPromptInput\(\{ defer: true \}\);\nupdateComposerModeButtons\(\);/, "startup should request prompt focus before waiting for tab state refreshes");
224
228
  assert.match(app, /elements\.promptInput\.addEventListener\("focus", \(\) => \{\n\s+syncMobileChatToBottomForInput\(\);/, "focusing mobile input should scroll output to bottom");
225
229
  assert.match(app, /navigator\.serviceWorker\.register\("\/service-worker\.js"\)/, "PWA service worker should be registered by the app");
230
+ assert.match(app, /function serverStartCommandText\(\)[\s\S]*pi-webui --cwd/, "PWA/offline shell should build a pi-webui --cwd recovery command");
231
+ assert.match(app, /Pi Web UI server is offline/, "PWA/offline shell should clearly report backend-down state");
232
+ assert.match(app, /navigator\.clipboard\.writeText\(text\)/, "backend-offline recovery panel should copy the start command when possible");
233
+ assert.match(app, /retryServerConnectionButton.*retryServerConnection/s, "backend-offline recovery panel should wire a retry action");
226
234
  assert.match(app, /function isChatNearBottom\(/, "chat should detect whether the user is reading above the bottom");
227
235
  assert.match(app, /function scheduleChatFollowScroll\(/, "chat auto-follow should retry after layout settles during fast streaming");
228
236
  assert.match(app, /function setChatScrollTopInstant\(top\)[\s\S]*?scrollBehavior = "auto"/, "chat auto-follow should bypass smooth scrolling while chasing fast output");
@@ -279,7 +287,14 @@ assert.match(app, /function toolResultPreviewText\(message, lineLimit = 10\)/, "
279
287
  assert.match(app, /function renderToolExecution\(parent, message\)[\s\S]*?WEBUI_TOOL_RENDERERS/, "paired tool cards should use the browser-side built-in tool renderer registry");
280
288
  assert.match(app, /appendToolRawDetails\(parent, tool\)/, "paired tool cards should keep a safe raw-data expander for debugging renderer mismatches");
281
289
  assert.match(app, /function toolStateMeta\(tool\)/, "tool cards should expose consistent status and elapsed metadata across built-in renderers");
282
- assert.match(app, /function handleToolExecutionUpdate\(event\)[\s\S]*?event\.partialResult/, "live tool_execution_update events should update transcript-visible tool cards");
290
+ assert.match(app, /const TOOL_LIVE_UPDATE_THROTTLE_MS = 80/, "live tool cards should coalesce rapid partial updates before re-rendering");
291
+ assert.match(app, /function updateLiveToolCard\(bubble, message\)[\s\S]*?body\.replaceChildren\(\);[\s\S]*?renderToolExecution\(body, message\);/, "live tool card updates should re-render the existing card body in place");
292
+ assert.match(app, /function scheduleLiveToolRunRender\(run[\s\S]*?liveToolRenderQueue\.set[\s\S]*?TOOL_LIVE_UPDATE_THROTTLE_MS/, "live tool update events should be queued and throttled for smoother browser output");
293
+ assert.match(app, /function handleToolExecutionUpdate\(event\)[\s\S]*?event\.partialResult[\s\S]*?scheduleLiveToolRunRender\(run, \{ scroll: false \}\)/, "live tool_execution_update events should update transcript-visible tool cards without replacing them per event");
294
+ assert.match(app, /function captureReusableToolCards\(\)[\s\S]*?\.message\.toolExecution\[data-tool-call-id\]/, "full transcript re-renders should capture existing tool cards before clearing the chat");
295
+ assert.match(app, /function appendMessage\(message,[\s\S]*?reusableToolCards = null[\s\S]*?reuseToolExecutionBubble\(reusableToolCards, message/, "message rendering should reuse matching tool cards instead of replacing them during refreshes");
296
+ assert.match(app, /function renderAllMessages\(\{ preserveScroll = false \} = \{\}\)[\s\S]*?const reusableToolCards = captureReusableToolCards\(\);[\s\S]*?appendTranscriptMessage\(item\.message,[\s\S]*?reusableToolCards,/, "transcript refreshes should pass reusable tool cards through to item rendering");
297
+ assert.match(app, /const keyedToolExecution = message\.role === "toolExecution" && message\.toolCallId[\s\S]*?keyedToolExecution \? "toolExecution"[\s\S]*?keyedToolExecution \? "" : message\.title[\s\S]*?keyedToolExecution \? "" : message\.timestamp/, "tool action entry identity should stay stable when live transient cards become persisted transcript cards");
283
298
  assert.match(app, /appendText\(preview, toolResultPreviewText\(message, 10\), "code-block tool-result-preview-text"\)/, "collapsed tool results should render the first ten preview lines by default");
284
299
  assert.match(app, /function assistantDisplayMessages\(message\)/, "assistant history should split thinking and tool-call parts out of the final Assistant output card");
285
300
  assert.match(app, /function assistantHasToolCallAfter\(content, index\)/, "assistant text that precedes a tool call should be detectable and suppressible");