@firstpick/pi-package-webui 0.1.7 → 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.7",
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
@@ -319,16 +319,56 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
319
319
  const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate"]);
320
320
  const NATIVE_SELECTOR_COMMANDS = new Set(["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models"]);
321
321
  const optionalFeatureInstallInProgress = new Set();
322
- const gitWorkflow = {
323
- active: false,
324
- step: "idle",
325
- busy: false,
326
- runId: 0,
327
- output: "",
328
- error: "",
329
- message: null,
330
- messageRequestedAt: 0,
331
- };
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
+
332
372
  const GIT_WORKFLOW_STEPS = ["Stage", "Message", "Commit", "Push"];
333
373
  const ACTION_FEEDBACK_REACTIONS = {
334
374
  up: { icon: "👍", label: "Good job", title: "Good job!" },
@@ -1838,6 +1878,7 @@ function setActiveTabId(tabId, { remember = false } = {}) {
1838
1878
  const nextTabId = tabId || null;
1839
1879
  if (nextTabId !== activeTabId) activeTabGeneration += 1;
1840
1880
  activeTabId = nextTabId;
1881
+ bindGitWorkflowToActiveTab();
1841
1882
  if (remember) rememberActiveTab();
1842
1883
  return activeTabContext(nextTabId);
1843
1884
  }
@@ -1914,6 +1955,7 @@ function syncTabMetadata(nextTabs = []) {
1914
1955
  tabActivities.delete(tabId);
1915
1956
  tabSeenCompletionSerials.delete(tabId);
1916
1957
  actionFeedbackByTab.delete(tabId);
1958
+ clearGitWorkflowForTab(tabId);
1917
1959
  }
1918
1960
  }
1919
1961
  }
@@ -2094,6 +2136,15 @@ function restoreStoredTabId() {
2094
2136
  }
2095
2137
  }
2096
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
+
2097
2148
  function updateDocumentTitle() {
2098
2149
  const tab = activeTab();
2099
2150
  document.title = tab ? `Pi Web UI · ${tab.title}` : "Pi Web UI";
@@ -2179,15 +2230,7 @@ function resetActiveTabUi() {
2179
2230
  cancelPendingDialogs();
2180
2231
  if (elements.nativeCommandDialog.open) closeNativeCommandDialog();
2181
2232
  if (pathPickerState) closePathPicker(null);
2182
- Object.assign(gitWorkflow, {
2183
- active: false,
2184
- step: "idle",
2185
- busy: false,
2186
- output: "",
2187
- error: "",
2188
- message: null,
2189
- messageRequestedAt: 0,
2190
- });
2233
+ bindGitWorkflowToActiveTab();
2191
2234
  resetChatOutput();
2192
2235
  elements.stateDetails.replaceChildren();
2193
2236
  elements.eventLog.replaceChildren();
@@ -2438,9 +2481,10 @@ async function refreshTabs({ selectStored = false } = {}) {
2438
2481
  syncTabMetadata(tabs);
2439
2482
  syncBlockedTabNotificationsFromTabs(tabs, previousTabs);
2440
2483
  syncAgentDoneNotificationsFromTabs(tabs, previousTabs);
2484
+ const requested = selectStored ? requestedTabIdFromUrl() : null;
2441
2485
  const stored = selectStored ? restoreStoredTabId() : null;
2442
2486
  if (!activeTabId || !tabs.some((tab) => tab.id === activeTabId)) {
2443
- 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 });
2444
2488
  }
2445
2489
  rememberServerStartCwd(tabs.find((tab) => tab.id === activeTabId)?.cwd || tabs[0]?.cwd);
2446
2490
  renderTabs();
@@ -2532,6 +2576,7 @@ async function closeTerminalTabs(tabIds, { label = "selected terminal tabs" } =
2532
2576
  for (const id of closedIds) {
2533
2577
  tabDrafts.delete(id);
2534
2578
  clearAttachments(id);
2579
+ clearGitWorkflowForTab(id);
2535
2580
  }
2536
2581
  clearOpenTerminalTabGroup(null, { force: true });
2537
2582
 
@@ -3436,6 +3481,7 @@ async function changeActiveTabCwd() {
3436
3481
  return;
3437
3482
  }
3438
3483
  const nextContext = setActiveTabId(response.data?.tab?.id || activeTabId);
3484
+ resetGitWorkflowForTab(nextContext.tabId);
3439
3485
  resetActiveTabUi();
3440
3486
  renderTabs();
3441
3487
  restoreActiveDraft();
@@ -3964,18 +4010,27 @@ function renderWidgets() {
3964
4010
  }
3965
4011
  }
3966
4012
 
3967
- function setGitWorkflow(patch) {
3968
- Object.assign(gitWorkflow, patch);
3969
- 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;
3970
4022
  }
3971
4023
 
3972
- function isCurrentGitWorkflowRun(runId) {
3973
- 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;
3974
4027
  }
3975
4028
 
3976
- function appendGitWorkflowOutput(text) {
3977
- const next = `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n" : ""}${text}`;
3978
- 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 });
3979
4034
  }
3980
4035
 
3981
4036
  function formatGitCommandResult(result) {
@@ -4078,9 +4133,11 @@ function renderGitWorkflow() {
4078
4133
  }
4079
4134
  }
4080
4135
 
4081
- async function gitWorkflowRequest(path, { method = "POST", body = {}, runId = gitWorkflow.runId } = {}) {
4082
- const response = await api(path, method === "GET" ? { method } : { method, body });
4083
- 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;
4084
4141
  if (!response.ok) {
4085
4142
  const detail = response.data ? `\n\n${formatGitCommandResult(response.data)}` : "";
4086
4143
  throw new Error(`${response.error || "Git workflow request failed"}${detail}`);
@@ -4088,27 +4145,32 @@ async function gitWorkflowRequest(path, { method = "POST", body = {}, runId = gi
4088
4145
  return response.data;
4089
4146
  }
4090
4147
 
4091
- function failGitWorkflow(error, step = gitWorkflow.step) {
4148
+ function failGitWorkflow(error, step, { tabId = activeTabId } = {}) {
4149
+ const workflow = gitWorkflowForTab(tabId);
4150
+ if (!workflow) return;
4092
4151
  const message = error?.message || String(error);
4093
4152
  setGitWorkflow({
4094
- step,
4153
+ step: step || workflow.step || "error",
4095
4154
  busy: false,
4096
4155
  error: message,
4097
- output: `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n\n" : ""}ERROR: ${message}`.slice(-60000),
4098
- });
4156
+ output: `${workflow.output || ""}${workflow.output ? "\n\n" : ""}ERROR: ${message}`.slice(-60000),
4157
+ }, { tabId });
4099
4158
  }
4100
4159
 
4101
4160
  function startGitWorkflow() {
4161
+ const tabId = activeTabId;
4162
+ if (!tabId) return;
4102
4163
  if (!isOptionalFeatureEnabled("gitWorkflow")) {
4103
- const tabContext = activeTabContext();
4164
+ const tabContext = activeTabContext(tabId);
4104
4165
  addEvent(commandUnavailableMessage("git-staged-msg"), "warn");
4105
4166
  refreshCommands(tabContext).catch((error) => {
4106
4167
  if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
4107
4168
  });
4108
4169
  return;
4109
4170
  }
4110
- if (gitWorkflow.active && !["done", "cancelled", "error"].includes(gitWorkflow.step) && !confirm("Restart the active git workflow?")) return;
4111
- 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;
4112
4174
  setGitWorkflow({
4113
4175
  active: true,
4114
4176
  step: "add",
@@ -4117,40 +4179,52 @@ function startGitWorkflow() {
4117
4179
  error: "",
4118
4180
  message: null,
4119
4181
  messageRequestedAt: 0,
4120
- });
4182
+ }, { tabId });
4121
4183
  }
4122
4184
 
4123
4185
  async function cancelGitWorkflow() {
4124
- const shouldAbortPi = gitWorkflow.step === "generating";
4125
- gitWorkflow.runId += 1;
4126
- setGitWorkflow({ step: "cancelled", busy: false, error: "", output: `${gitWorkflow.output || ""}${gitWorkflow.output ? "\n\n" : ""}Cancelled by user.` });
4127
- 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…");
4128
4194
  await Promise.allSettled([
4129
- api("/api/git-workflow/cancel", { method: "POST", body: {} }),
4130
- 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(),
4131
4197
  ]);
4132
- if (shouldAbortPi) scheduleAbortStateChecks();
4198
+ if (shouldAbortPi && isCurrentTabContext(tabContext)) scheduleAbortStateChecks();
4133
4199
  }
4134
4200
 
4135
4201
  async function runGitAdd() {
4136
- const runId = gitWorkflow.runId;
4137
- 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 });
4138
4208
  try {
4139
- const result = await gitWorkflowRequest("/api/git-workflow/add", { runId });
4209
+ const result = await gitWorkflowRequest("/api/git-workflow/add", { runId, tabId });
4140
4210
  if (!result) return;
4141
- setGitWorkflow({ step: "generate", busy: false, output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` });
4142
- scheduleRefreshFooter();
4211
+ setGitWorkflow({ step: "generate", busy: false, output: `${formatGitCommandResult(result)}\n\nStaged. Next: run /git-staged-msg.` }, { tabId });
4212
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
4143
4213
  } catch (error) {
4144
- if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "add");
4214
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "add", { tabId });
4145
4215
  }
4146
4216
  }
4147
4217
 
4148
4218
  async function runGitMessagePrompt() {
4219
+ const tabId = activeTabId;
4220
+ const tabContext = activeTabContext(tabId);
4149
4221
  if (currentState?.isStreaming) {
4150
- 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 });
4151
4223
  return;
4152
4224
  }
4153
- const runId = gitWorkflow.runId;
4225
+ const workflow = gitWorkflowForTab(tabId, { create: false });
4226
+ if (!workflow) return;
4227
+ const runId = workflow.runId;
4154
4228
  const requestedAt = Date.now();
4155
4229
  setGitWorkflow({
4156
4230
  step: "generating",
@@ -4158,32 +4232,37 @@ async function runGitMessagePrompt() {
4158
4232
  error: "",
4159
4233
  messageRequestedAt: requestedAt,
4160
4234
  output: "Sending /git-staged-msg to Pi.\n\nCancel will request Pi abort.",
4161
- });
4162
- setRunIndicatorActivity("Sending /git-staged-msg to Pi…");
4235
+ }, { tabId });
4236
+ if (isCurrentTabContext(tabContext)) setRunIndicatorActivity("Sending /git-staged-msg to Pi…");
4163
4237
  try {
4164
- await api("/api/prompt", { method: "POST", body: { message: "/git-staged-msg" } });
4165
- if (!isCurrentGitWorkflowRun(runId)) return;
4166
- appendGitWorkflowOutput("/git-staged-msg accepted. Waiting for agent_end, then the message files will be loaded.");
4167
- 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);
4168
4242
  setTimeout(() => {
4169
- if (isCurrentGitWorkflowRun(runId) && gitWorkflow.step === "generating" && !currentState?.isStreaming) {
4170
- 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 });
4171
4246
  }
4172
4247
  }, 2500);
4173
4248
  } catch (error) {
4174
- if (isCurrentGitWorkflowRun(runId)) {
4175
- clearRunIndicatorActivity();
4176
- failGitWorkflow(error, "generate");
4249
+ if (isCurrentGitWorkflowRun(runId, tabId)) {
4250
+ if (isCurrentTabContext(tabContext)) clearRunIndicatorActivity();
4251
+ failGitWorkflow(error, "generate", { tabId });
4177
4252
  }
4178
4253
  }
4179
4254
  }
4180
4255
 
4181
- 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;
4182
4259
  try {
4183
- 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 });
4184
4261
  if (!message) return;
4262
+ const currentWorkflow = gitWorkflowForTab(tabId, { create: false });
4263
+ if (!currentWorkflow) return;
4185
4264
  const newestMtime = Math.max(message.shortMtimeMs || 0, message.longMtimeMs || 0);
4186
- if (requireFresh && gitWorkflow.messageRequestedAt && newestMtime + 10000 < gitWorkflow.messageRequestedAt) {
4265
+ if (requireFresh && currentWorkflow.messageRequestedAt && newestMtime + 10000 < currentWorkflow.messageRequestedAt) {
4187
4266
  throw new Error("Generated message files have not refreshed yet.");
4188
4267
  }
4189
4268
  setGitWorkflow({
@@ -4192,40 +4271,63 @@ async function loadGitWorkflowMessage({ requireFresh = false, retries = 0, runId
4192
4271
  error: "",
4193
4272
  message,
4194
4273
  output: formatCommitMessagePreview(message),
4195
- });
4274
+ }, { tabId });
4196
4275
  } catch (error) {
4197
- if (!isCurrentGitWorkflowRun(runId)) return;
4276
+ if (!isCurrentGitWorkflowRun(expectedRunId, tabId)) return;
4198
4277
  if (retries > 0) {
4199
- setTimeout(() => loadGitWorkflowMessage({ requireFresh, retries: retries - 1, runId }), 1400);
4278
+ setTimeout(() => loadGitWorkflowMessage({ requireFresh, retries: retries - 1, runId: expectedRunId, tabId }), 1400);
4200
4279
  return;
4201
4280
  }
4202
- 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 });
4203
4283
  }
4204
4284
  }
4205
4285
 
4206
4286
  async function commitGitWorkflow(variant) {
4207
- const runId = gitWorkflow.runId;
4208
- 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 });
4209
4293
  try {
4210
- const result = await gitWorkflowRequest("/api/git-workflow/commit", { body: { variant }, runId });
4294
+ const result = await gitWorkflowRequest("/api/git-workflow/commit", { body: { variant }, runId, tabId });
4211
4295
  if (!result) return;
4212
- setGitWorkflow({ step: "push", busy: false, output: `${formatGitCommandResult(result)}\n\nCommit created. Next: git push.` });
4213
- scheduleRefreshFooter();
4296
+ setGitWorkflow({ step: "push", busy: false, output: `${formatGitCommandResult(result)}\n\nCommit created. Next: git push.` }, { tabId });
4297
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
4214
4298
  } catch (error) {
4215
- if (isCurrentGitWorkflowRun(runId)) failGitWorkflow(error, "message");
4299
+ if (isCurrentGitWorkflowRun(runId, tabId)) failGitWorkflow(error, "message", { tabId });
4216
4300
  }
4217
4301
  }
4218
4302
 
4219
4303
  async function pushGitWorkflow() {
4220
- const runId = gitWorkflow.runId;
4221
- 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 });
4222
4310
  try {
4223
- const result = await gitWorkflowRequest("/api/git-workflow/push", { runId });
4311
+ const result = await gitWorkflowRequest("/api/git-workflow/push", { runId, tabId });
4224
4312
  if (!result) return;
4225
- setGitWorkflow({ step: "done", busy: false, output: formatGitCommandResult(result) || "git push finished." });
4226
- scheduleRefreshFooter();
4313
+ setGitWorkflow({ step: "done", busy: false, output: formatGitCommandResult(result) || "git push finished." }, { tabId });
4314
+ if (isCurrentTabContext(tabContext)) scheduleRefreshFooter();
4227
4315
  } catch (error) {
4228
- 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 });
4229
4331
  }
4230
4332
  }
4231
4333
 
@@ -5486,8 +5588,39 @@ function restoreToolDetailsOpenState(root, state) {
5486
5588
  }
5487
5589
  }
5488
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
+
5489
5622
  function updateLiveToolCard(bubble, message) {
5490
- if (!bubble?.isConnected) return false;
5623
+ if (!bubble) return false;
5491
5624
  const header = bubble.querySelector(":scope > .message-header");
5492
5625
  const body = bubble.querySelector(":scope > .message-body");
5493
5626
  if (!body) return false;
@@ -5544,6 +5677,7 @@ function renderLiveToolRun(run, { scroll = true } = {}) {
5544
5677
  const existingConnected = !!(existing?.isConnected && existing.parentElement === elements.chat);
5545
5678
  const shouldFollow = scroll && (autoFollowChat || isChatNearBottom());
5546
5679
  const message = liveToolRunMessage(run);
5680
+ rememberActionEntries([{ message, messageIndex: -1, transient: true }]);
5547
5681
  if (existingConnected && updateLiveToolCard(existing, message)) {
5548
5682
  renderRunIndicator({ scroll: false });
5549
5683
  if (shouldFollow) scrollChatToBottom();
@@ -5616,7 +5750,9 @@ function jumpToStickyUserPrompt() {
5616
5750
  requestAnimationFrame(updateStickyUserPromptButton);
5617
5751
  }
5618
5752
 
5619
- 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;
5620
5756
  const role = String(message.role || "message");
5621
5757
  const safeRole = role.replace(/[^a-z0-9_-]/gi, "");
5622
5758
  const bubble = make("article", `message ${safeRole}${message.level ? ` ${message.level}` : ""}${streaming ? " streaming" : ""}${animateEntry ? " action-enter" : ""}`);
@@ -5674,9 +5810,9 @@ function appendMessage(message, { streaming = false, messageIndex = -1, transien
5674
5810
  return { bubble, body };
5675
5811
  }
5676
5812
 
5677
- 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 } = {}) {
5678
5814
  if (streaming || transient || message?.role !== "assistant") {
5679
- return appendMessage(message, { streaming, messageIndex, transient, animateEntry });
5815
+ return appendMessage(message, { streaming, messageIndex, transient, animateEntry, reusableToolCards });
5680
5816
  }
5681
5817
 
5682
5818
  let finalOutput = null;
@@ -5705,6 +5841,7 @@ function appendTranscriptMessage(message, { streaming = false, messageIndex = -1
5705
5841
  messageIndex: ["assistant", "toolExecution"].includes(transcriptMessage.role) ? messageIndex : -1,
5706
5842
  transient: false,
5707
5843
  animateEntry: animateEntry && isActionTranscriptMessage(transcriptMessage),
5844
+ reusableToolCards,
5708
5845
  });
5709
5846
  if (transcriptMessage.role === "assistant") finalOutput = created;
5710
5847
  });
@@ -5923,16 +6060,17 @@ function actionEntrySeenKeys(tabId = activeTabId) {
5923
6060
 
5924
6061
  function actionEntryKey(item) {
5925
6062
  const message = item?.message || {};
6063
+ const keyedToolExecution = message.role === "toolExecution" && message.toolCallId;
5926
6064
  return [
5927
- item?.transient ? "transient" : "message",
5928
- item?.messageIndex ?? -1,
6065
+ keyedToolExecution ? "toolExecution" : item?.transient ? "transient" : "message",
6066
+ keyedToolExecution ? "" : (item?.messageIndex ?? -1),
5929
6067
  message.role || "message",
5930
6068
  message.toolName || "",
5931
6069
  message.toolCallId || "",
5932
- message.command || "",
5933
- message.title || "",
5934
- message.timestamp || "",
5935
- 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),
5936
6074
  ].join("|");
5937
6075
  }
5938
6076
 
@@ -5974,6 +6112,7 @@ function orderedTranscriptItems() {
5974
6112
  function renderAllMessages({ preserveScroll = false } = {}) {
5975
6113
  const shouldFollow = !preserveScroll && (autoFollowChat || isChatNearBottom());
5976
6114
  const previousScrollTop = elements.chat.scrollTop;
6115
+ const reusableToolCards = captureReusableToolCards();
5977
6116
  resetChatOutput();
5978
6117
  const transcriptItems = orderedTranscriptItems();
5979
6118
  for (const item of transcriptItems) {
@@ -5981,6 +6120,7 @@ function renderAllMessages({ preserveScroll = false } = {}) {
5981
6120
  messageIndex: item.messageIndex,
5982
6121
  transient: item.transient,
5983
6122
  animateEntry: shouldAnimateActionEntry(item),
6123
+ reusableToolCards,
5984
6124
  });
5985
6125
  }
5986
6126
  rememberActionEntries(transcriptItems);
@@ -7589,6 +7729,7 @@ async function refreshAll(tabContext = activeTabContext()) {
7589
7729
  for (const result of results) {
7590
7730
  if (result.status === "rejected") addEvent(result.reason.message || String(result.reason), "error");
7591
7731
  }
7732
+ resumeGitWorkflowForActiveTab(tabContext);
7592
7733
  }
7593
7734
 
7594
7735
  async function openToNetwork() {
@@ -8026,8 +8167,12 @@ function handleEvent(event) {
8026
8167
  scheduleRefreshMessages();
8027
8168
  scheduleRefreshFooter();
8028
8169
  renderFeedbackTray();
8029
- if (gitWorkflow.active && gitWorkflow.step === "generating") {
8030
- 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
+ }
8031
8176
  }
8032
8177
  break;
8033
8178
  case "message_start":
@@ -8114,7 +8259,11 @@ function handleEvent(event) {
8114
8259
  syncRunIndicatorFromState(currentState);
8115
8260
  renderStatus();
8116
8261
  } else if (["set_model", "set_thinking_level", "new_session", "compact"].includes(event.command)) {
8117
- 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
+ }
8118
8267
  scheduleRefreshState();
8119
8268
  scheduleRefreshMessages();
8120
8269
  scheduleRefreshFooter();
@@ -8252,6 +8401,7 @@ elements.newSessionButton.addEventListener("click", async () => {
8252
8401
  const response = await api("/api/new-session", { method: "POST", body: {}, tabId: tabContext.tabId });
8253
8402
  applyResponseTab(response);
8254
8403
  forgetLastUserPrompt(tabContext.tabId);
8404
+ resetGitWorkflowForTab(tabContext.tabId);
8255
8405
  if (!isCurrentTabContext(tabContext)) return;
8256
8406
  await refreshAll(tabContext);
8257
8407
  if (isCurrentTabContext(tabContext)) focusPromptInput({ defer: true });
@@ -291,6 +291,10 @@ assert.match(app, /const TOOL_LIVE_UPDATE_THROTTLE_MS = 80/, "live tool cards sh
291
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
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
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");
294
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");
295
299
  assert.match(app, /function assistantDisplayMessages\(message\)/, "assistant history should split thinking and tool-call parts out of the final Assistant output card");
296
300
  assert.match(app, /function assistantHasToolCallAfter\(content, index\)/, "assistant text that precedes a tool call should be detectable and suppressible");