pi-studio 0.10.1 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -674,6 +674,18 @@
674
674
  let replTmuxAvailable = null;
675
675
  let replSessions = [];
676
676
  let replActiveSessionName = "";
677
+ // Studio's selection as this page last adopted it, with its `shown`, and this page's
678
+ // own choices: while one is unanswered, other replies don't change what the page
679
+ // shows (Sol, Astra).
680
+ let replServerSelection = { sessionName: "", runtime: "" };
681
+ let replShownBasis = null;
682
+ let replChoiceRevision = 0;
683
+ let replChoiceAnswered = 0;
684
+ // The session as chosen, kept with the unanswered choice rather than read from the list.
685
+ let replChoicePin = null;
686
+ let replReconcileSent = "";
687
+ // The session lifetime the transcript belongs to.
688
+ let replTranscriptOwner = "";
677
689
  let replRuntime = (() => {
678
690
  try {
679
691
  return (window.localStorage && window.localStorage.getItem("piStudio.replRuntime")) || "python";
@@ -701,6 +713,9 @@
701
713
  const replQuickDrafts = new Map();
702
714
  let replQuickPending = null;
703
715
  let replQuickFocusRequested = false;
716
+ // After a Quick send from the box, the cursor goes back to it (Oliver, 19 Oct):
717
+ // { requestId, settled }, cleared when restored or when anything else is chosen.
718
+ let replQuickRefocus = null;
704
719
  let replSendMode = (() => {
705
720
  try {
706
721
  const stored = window.localStorage && window.localStorage.getItem("piStudio.replSendMode");
@@ -721,6 +736,9 @@
721
736
  if (!entry || typeof entry !== "object") return null;
722
737
  const hasCompatibleOrigin = entry.origin === "pi-repl" || entry.origin === "pi-studio";
723
738
  const sharedSynced = entry.sharedSynced === true || (entry.sharedSynced !== false && hasCompatibleOrigin);
739
+ // Whether the output was cleaned where it was captured is separate from whether
740
+ // it reached the shared record: a failed record write leaves clean output.
741
+ const outputClean = sharedSynced || entry.outputClean === true;
724
742
  const normalized = {
725
743
  id: typeof entry.id === "string" && entry.id ? entry.id : ("repl-journal-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8)),
726
744
  requestId: typeof entry.requestId === "string" ? entry.requestId : "",
@@ -731,11 +749,14 @@
731
749
  runtime: typeof entry.runtime === "string" ? entry.runtime : "python",
732
750
  origin: hasCompatibleOrigin ? entry.origin : "",
733
751
  legacyLocal: !sharedSynced,
752
+ outputClean,
734
753
  label: typeof entry.label === "string" ? entry.label : "REPL send",
735
754
  mode: typeof entry.mode === "string" ? entry.mode : "raw",
736
755
  prose: typeof entry.prose === "string" ? entry.prose : "",
737
756
  code: typeof entry.code === "string" ? entry.code : "",
738
- output: typeof entry.output === "string" ? stripStudioReplSubmissionEcho(entry.output) : "",
757
+ // Studio's server and pi-repl record clean output; only this browser's
758
+ // older local entries can still hold a loader echo.
759
+ output: typeof entry.output === "string" ? (outputClean ? entry.output : stripStudioReplSubmissionEcho(entry.output)) : "",
739
760
  beforeTranscript: "",
740
761
  status: typeof entry.status === "string" ? entry.status : "sent",
741
762
  skippedChunks: Math.max(0, Math.floor(Number(entry.skippedChunks) || 0)),
@@ -767,6 +788,7 @@
767
788
  runtime: entry.runtime,
768
789
  origin: entry.origin,
769
790
  sharedSynced: !entry.legacyLocal,
791
+ outputClean: entry.outputClean === true,
770
792
  label: entry.label,
771
793
  mode: entry.mode,
772
794
  prose: entry.prose,
@@ -786,7 +808,8 @@
786
808
  let changed = false;
787
809
  const next = [...replJournalEntries];
788
810
  for (const rawEntry of entries) {
789
- const entry = normalizeReplJournalEntry(rawEntry);
811
+ // These come from Studio's server, which has already cleaned their output.
812
+ const entry = normalizeReplJournalEntry(rawEntry && typeof rawEntry === "object" ? { ...rawEntry, outputClean: true } : rawEntry);
790
813
  if (!entry) continue;
791
814
  const existingIndex = next.findIndex((candidate) => (
792
815
  (entry.requestId && candidate.requestId === entry.requestId)
@@ -1488,7 +1511,7 @@
1488
1511
  if (!sessionName) return null;
1489
1512
  return {
1490
1513
  sessionName,
1491
- target: typeof session.target === "string" ? session.target : (sessionName + ":0.0"),
1514
+ target: typeof session.target === "string" ? session.target : "",
1492
1515
  tmuxSessionId: typeof session.tmuxSessionId === "string" ? session.tmuxSessionId : "",
1493
1516
  tmuxSessionCreatedAt: typeof session.tmuxSessionCreatedAt === "number" && Number.isFinite(session.tmuxSessionCreatedAt)
1494
1517
  ? session.tmuxSessionCreatedAt
@@ -1670,9 +1693,48 @@
1670
1693
  if (submitted && replQuickDrafts.get(pending.key) === pending.text) {
1671
1694
  replQuickDrafts.delete(pending.key);
1672
1695
  }
1696
+ // The cursor goes back after the redraw, to the box then on screen (Sol, Astra).
1697
+ if (replQuickRefocus && replQuickRefocus.requestId === requestId) {
1698
+ replQuickRefocus.settled = true;
1699
+ renderReplViewIfActive({ force: true });
1700
+ }
1673
1701
  return true;
1674
1702
  }
1675
1703
 
1704
+ // Runs straight after a redraw. Nothing chosen elsewhere since the send (that clears
1705
+ // replQuickRefocus), and focus still on the page or in the box: the box gets it.
1706
+ function applyReplQuickRefocus() {
1707
+ const pending = replQuickRefocus;
1708
+ if (!pending || !pending.settled) return;
1709
+ if (rightView !== "repl" || !critiqueViewEl) {
1710
+ replQuickRefocus = null;
1711
+ return;
1712
+ }
1713
+ const activeEl = document.activeElement;
1714
+ if (activeEl instanceof Element && activeEl !== document.body && !activeEl.closest(".repl-quick-composer")) {
1715
+ replQuickRefocus = null;
1716
+ return;
1717
+ }
1718
+ const composer = critiqueViewEl.querySelector("[data-repl-quick-draft]");
1719
+ // Another send can keep the box disabled; the next redraw tries again.
1720
+ if (!(composer instanceof HTMLTextAreaElement) || composer.disabled) return;
1721
+ replQuickRefocus = null;
1722
+ try {
1723
+ composer.focus({ preventScroll: true });
1724
+ } catch {
1725
+ try { composer.focus(); } catch {}
1726
+ }
1727
+ }
1728
+
1729
+ // A click or focus outside the box, while the send runs or before the cursor is back,
1730
+ // means something else was chosen, so the box doesn't take focus back.
1731
+ function cancelReplQuickRefocusOutside(event) {
1732
+ if (!replQuickRefocus) return;
1733
+ const target = event && event.target;
1734
+ if (target instanceof Element && target.closest(".repl-quick-composer")) return;
1735
+ replQuickRefocus = null;
1736
+ }
1737
+
1676
1738
  function reconcileReplQuickPending() {
1677
1739
  if (!replQuickPending) return false;
1678
1740
  const entry = replJournalEntries.find((candidate) => candidate.requestId === replQuickPending.requestId);
@@ -1729,14 +1791,20 @@
1729
1791
  function setReplSessions(sessions) {
1730
1792
  const previous = serializeReplSessionsForCompare(replSessions);
1731
1793
  const previousActive = replActiveSessionName;
1732
- replSessions = Array.isArray(sessions)
1733
- ? sessions.map(normalizeReplSession).filter(Boolean)
1734
- : [];
1735
- if (replActiveSessionName && !replSessions.some((session) => session.sessionName === replActiveSessionName)) {
1736
- setActiveReplSession("");
1737
- }
1738
- if (!getActiveReplSessionForCurrentRuntime()) {
1739
- selectReplSessionForRuntime(replRuntime, replActiveSessionName);
1794
+ // While a choice is unanswered, the chosen session keeps the lifetime it was chosen
1795
+ // with: no list, even one after a list that left it out, can swap in a replacement
1796
+ // with the same name (Astra, Sol).
1797
+ const chosen = isReplChoicePending() ? replChoicePin : null;
1798
+ replSessions = (Array.isArray(sessions) ? sessions.map(normalizeReplSession).filter(Boolean) : [])
1799
+ .map((session) => (chosen && session.sessionName === chosen.sessionName ? chosen : session));
1800
+ // The list may change while a choice is unanswered, but the selection waits for its reply.
1801
+ if (!isReplChoicePending()) {
1802
+ if (replActiveSessionName && !replSessions.some((session) => session.sessionName === replActiveSessionName)) {
1803
+ setActiveReplSession("");
1804
+ }
1805
+ if (!getActiveReplSessionForCurrentRuntime()) {
1806
+ selectReplSessionForRuntime(replRuntime, replActiveSessionName);
1807
+ }
1740
1808
  }
1741
1809
  return previous !== serializeReplSessionsForCompare(replSessions) || previousActive !== replActiveSessionName;
1742
1810
  }
@@ -1789,7 +1857,7 @@
1789
1857
  "The right pane is mirroring an active tmux-backed REPL session.",
1790
1858
  "If the user refers to the active REPL, send code to this session rather than inventing a separate one.",
1791
1859
  "Session name: " + session.sessionName,
1792
- "tmux target: " + (session.target || (session.sessionName + ":0.0")),
1860
+ "tmux target: " + (session.target || session.sessionName),
1793
1861
  "runtime: " + runtime,
1794
1862
  "Use the studio_repl_send tool for code execution in this REPL. Pass sessionName when targeting this exact session.",
1795
1863
  "Do not improvise raw tmux paste commands for multiline code; Studio handles runtime-specific safe submission.",
@@ -1804,21 +1872,94 @@
1804
1872
  }
1805
1873
 
1806
1874
  function setActiveReplSession(sessionName) {
1807
- const name = String(sessionName || "").trim();
1808
- if (!name) {
1809
- if (replActiveSessionName) {
1810
- replTranscript = "";
1811
- replCapturedAt = 0;
1812
- }
1813
- replActiveSessionName = "";
1814
- return;
1875
+ replActiveSessionName = String(sessionName || "").trim();
1876
+ retireReplViewIfTargetChanged();
1877
+ }
1878
+
1879
+ // The view belongs to one session lifetime, or to none. When the displayed target
1880
+ // changes, including to or from none or to a replacement with the same name, its
1881
+ // transcript, timestamp and live entry go with it (Sol).
1882
+ function retireReplViewIfTargetChanged() {
1883
+ const owner = getReplSessionLifetimeKey(getActiveReplSession());
1884
+ if (owner === replTranscriptOwner) return false;
1885
+ replTranscript = "";
1886
+ replCapturedAt = 0;
1887
+ activeReplJournalEntryId = "";
1888
+ replTranscriptOwner = owner;
1889
+ return true;
1890
+ }
1891
+
1892
+ function getReplSessionLifetimeKey(session) {
1893
+ return session ? [session.sessionName, session.tmuxSessionId || "", Number(session.tmuxSessionCreatedAt) || 0].join("\u001f") : "";
1894
+ }
1895
+
1896
+ function upsertReplSession(value) {
1897
+ const session = normalizeReplSession(value);
1898
+ if (!session) return false;
1899
+ const index = replSessions.findIndex((candidate) => candidate.sessionName === session.sessionName);
1900
+ if (index < 0) {
1901
+ replSessions = [...replSessions, session];
1902
+ return true;
1815
1903
  }
1816
- if (replActiveSessionName && replActiveSessionName !== name) {
1817
- replTranscript = "";
1818
- replCapturedAt = 0;
1819
- activeReplJournalEntryId = "";
1904
+ if (serializeReplSessionsForCompare([replSessions[index]]) === serializeReplSessionsForCompare([session])) return false;
1905
+ replSessions = replSessions.map((candidate, candidateIndex) => candidateIndex === index ? session : candidate);
1906
+ return true;
1907
+ }
1908
+
1909
+ function isReplChoicePending() {
1910
+ return replChoiceAnswered < replChoiceRevision;
1911
+ }
1912
+
1913
+ // Only the reply to this page's latest choice ends the wait.
1914
+ function noteReplChoiceAnswer(message) {
1915
+ if (typeof message.choiceRevision === "number" && message.choiceRevision > replChoiceAnswered) {
1916
+ replChoiceAnswered = Math.min(message.choiceRevision, replChoiceRevision);
1917
+ }
1918
+ if (!isReplChoicePending()) replChoicePin = null;
1919
+ }
1920
+
1921
+ // Adopts Studio's selection from a message that states it. A session brings its
1922
+ // runtime; none comes for a runtime; with no preference yet, the page keeps its
1923
+ // runtime and picks within it (Sol, Astra).
1924
+ function adoptReplSelection(message) {
1925
+ const sessionName = typeof message.activeSessionName === "string" ? message.activeSessionName.trim() : "";
1926
+ const runtime = getKnownReplRuntime(message.activeRuntime);
1927
+ replServerSelection = { sessionName, runtime };
1928
+ if (typeof message.shown === "number") replShownBasis = message.shown;
1929
+ if (runtime && runtime !== replRuntime) setReplRuntime(runtime);
1930
+ if (sessionName) {
1931
+ setActiveReplSessionForCurrentRuntime(sessionName);
1932
+ } else {
1933
+ selectReplSessionForRuntime(replRuntime, runtime ? "" : replActiveSessionName);
1820
1934
  }
1821
- replActiveSessionName = name;
1935
+ }
1936
+
1937
+ // Reports a selection the page made itself, against the selection it last adopted.
1938
+ // Studio applies it only if nothing has changed since; it isn't a choice (Sol, Astra).
1939
+ function reconcileReplSelection() {
1940
+ if (replShownBasis === null || isReplChoicePending() || wsState === "Disconnected") return false;
1941
+ const session = getActiveReplSessionForCurrentRuntime();
1942
+ const matches = session
1943
+ ? session.sessionName === replServerSelection.sessionName
1944
+ : !replServerSelection.sessionName && replServerSelection.runtime === replRuntime;
1945
+ if (matches) return false;
1946
+ const target = session
1947
+ ? { sessionName: session.sessionName, tmuxSessionId: session.tmuxSessionId, tmuxSessionCreatedAt: session.tmuxSessionCreatedAt }
1948
+ : { runtime: replRuntime };
1949
+ const key = replShownBasis + "\u001f" + JSON.stringify(target);
1950
+ if (key === replReconcileSent) return false;
1951
+ replReconcileSent = key;
1952
+ return sendMessage({ type: "repl_reconcile_request", basis: replShownBasis, ...target });
1953
+ }
1954
+
1955
+ // A new connection has no choice outstanding and no basis until it adopts one.
1956
+ function resetReplSelectionBasis() {
1957
+ replChoiceAnswered = replChoiceRevision;
1958
+ replChoicePin = null;
1959
+ replShownBasis = null;
1960
+ replReconcileSent = "";
1961
+ replServerSelection = { sessionName: "", runtime: "" };
1962
+ if (rightView === "repl") requestReplList();
1822
1963
  }
1823
1964
 
1824
1965
  function trimReplTranscript(text) {
@@ -1832,6 +1973,7 @@
1832
1973
  return sendMessage({ type: "repl_list_request" });
1833
1974
  }
1834
1975
 
1976
+ // A poll: the displayed session as it is now, or the list when there is none.
1835
1977
  function requestReplCapture() {
1836
1978
  if (wsState === "Disconnected") return false;
1837
1979
  if (replActiveSessionName) {
@@ -1840,6 +1982,25 @@
1840
1982
  return sendMessage({ type: "repl_list_request" });
1841
1983
  }
1842
1984
 
1985
+ // A choice on the Session or Runtime selector, which Studio counts. Until its reply
1986
+ // arrives, other replies don't change what the page shows.
1987
+ function requestReplChoice() {
1988
+ if (wsState === "Disconnected") return false;
1989
+ const choiceRevision = replChoiceRevision + 1;
1990
+ const sent = sendMessage({
1991
+ type: "repl_capture_request",
1992
+ choice: true,
1993
+ choiceRevision,
1994
+ runtime: replRuntime,
1995
+ ...(replActiveSessionName ? { sessionName: replActiveSessionName } : {}),
1996
+ });
1997
+ if (sent) {
1998
+ replChoiceRevision = choiceRevision;
1999
+ replChoicePin = getActiveReplSession();
2000
+ }
2001
+ return sent;
2002
+ }
2003
+
1843
2004
  function isReplControlFocused() {
1844
2005
  const activeEl = document.activeElement;
1845
2006
  return activeEl instanceof Element && Boolean(activeEl.closest(".repl-controls, .repl-quick-composer"));
@@ -1898,6 +2059,7 @@
1898
2059
  traceRenderRaf = window.requestAnimationFrame(() => {
1899
2060
  traceRenderRaf = null;
1900
2061
  refreshResponseUi();
2062
+ applyReplQuickRefocus();
1901
2063
  if (replQuickFocusRequested) focusReplQuickComposer();
1902
2064
  });
1903
2065
  }
@@ -1905,6 +2067,8 @@
1905
2067
  function startReplPolling() {
1906
2068
  if (rightView !== "repl") return;
1907
2069
  if (replPollTimer !== null) return;
2070
+ // Polls only observe, so the view starts from Studio's current selection.
2071
+ if (replActiveSessionName) requestReplList();
1908
2072
  requestReplCapture();
1909
2073
  replPollTimer = window.setInterval(() => {
1910
2074
  if (rightView !== "repl") {
@@ -2182,6 +2346,7 @@
2182
2346
  runtime: details.runtime || getActiveReplRuntime(),
2183
2347
  origin: details.origin === "pi-repl" ? "pi-repl" : "pi-studio",
2184
2348
  legacyLocal: !sharedSynced,
2349
+ outputClean: sharedSynced || details.outputClean === true,
2185
2350
  label: details.label || "REPL send",
2186
2351
  mode: details.mode || replSendMode,
2187
2352
  prose: String(details.prose || ""),
@@ -2259,7 +2424,7 @@
2259
2424
  return sent;
2260
2425
  }
2261
2426
 
2262
- function recordReplToolSend(message) {
2427
+ function recordReplToolSend(message, selects) {
2263
2428
  const requestId = typeof message.toolCallId === "string" && message.toolCallId.trim()
2264
2429
  ? "tool:" + message.toolCallId.trim()
2265
2430
  : (typeof message.requestId === "string" && message.requestId.trim() ? message.requestId.trim() : "");
@@ -2267,20 +2432,23 @@
2267
2432
  if (!code.trim()) return false;
2268
2433
  const runtime = normalizeReplRuntime(message.runtime || getActiveReplRuntime());
2269
2434
  const sessionName = typeof message.sessionName === "string" ? message.sessionName : replActiveSessionName;
2270
- const output = cleanReplCapturedOutput(String(message.output || ""), { code, runtime });
2435
+ // The server has already cleaned this send's output; cleaning it again could drop real lines.
2436
+ const output = trimReplJournalOutput(String(message.output || "").trimEnd());
2271
2437
  const details = {
2272
2438
  requestId,
2273
2439
  sessionName,
2274
2440
  runtime,
2275
2441
  origin: message.origin === "pi-repl" ? "pi-repl" : "pi-studio",
2276
2442
  sharedSynced: message.sharedSynced === true,
2443
+ outputClean: true,
2277
2444
  label: typeof message.label === "string" && message.label.trim() ? message.label.trim() : "Pi",
2278
2445
  mode: "agent",
2279
2446
  code,
2280
2447
  output,
2281
2448
  status: output.trim() ? "captured" : (message.timedOut ? "timeout" : "sent"),
2282
2449
  };
2283
- activeReplJournalEntryId = "";
2450
+ // A result that doesn't select leaves this page's own entry tracking alone (Astra).
2451
+ if (selects !== false) activeReplJournalEntryId = "";
2284
2452
  if (requestId) {
2285
2453
  const existingIndex = replJournalEntries.findIndex((entry) => entry.requestId === requestId);
2286
2454
  if (existingIndex >= 0) {
@@ -2375,7 +2543,8 @@
2375
2543
  if (entryIndex < 0) return false;
2376
2544
  const entry = replJournalEntries[entryIndex];
2377
2545
  if (entry.sessionName && sessionName && entry.sessionName !== sessionName) return false;
2378
- if (!entry.legacyLocal && (entry.status === "captured" || entry.status === "timeout" || entry.status === "error")) {
2546
+ // Finished output from Studio's server stands, whether or not the shared record took it.
2547
+ if ((entry.outputClean || !entry.legacyLocal) && (entry.status === "captured" || entry.status === "timeout" || entry.status === "error")) {
2379
2548
  activeReplJournalEntryId = "";
2380
2549
  return false;
2381
2550
  }
@@ -2650,6 +2819,8 @@
2650
2819
  });
2651
2820
  if (!requestId) return false;
2652
2821
  replQuickPending = { requestId, key, text };
2822
+ const activeEl = document.activeElement;
2823
+ replQuickRefocus = activeEl instanceof Element && activeEl.closest(".repl-quick-composer") ? { requestId, settled: false } : null;
2653
2824
  renderReplViewIfActive({ force: true });
2654
2825
  return true;
2655
2826
  }
@@ -11699,13 +11870,13 @@
11699
11870
  selectReplSessionForRuntime(replRuntime, previousActive);
11700
11871
  replError = "";
11701
11872
  replMessage = "";
11702
- if (replActiveSessionName) {
11703
- requestReplCapture();
11704
- } else {
11873
+ if (!replActiveSessionName) {
11705
11874
  replTranscript = "";
11706
11875
  replCapturedAt = 0;
11707
11876
  syncActionButtons();
11708
11877
  }
11878
+ // Counted even with no session, so nothing that finishes later takes it back.
11879
+ requestReplChoice();
11709
11880
  renderReplViewIfActive({ force: true });
11710
11881
  return;
11711
11882
  }
@@ -11727,7 +11898,7 @@
11727
11898
  replError = "";
11728
11899
  replMessage = "";
11729
11900
  replFollow = true;
11730
- requestReplCapture();
11901
+ requestReplChoice();
11731
11902
  renderReplViewIfActive({ force: true });
11732
11903
  }
11733
11904
  }
@@ -11743,6 +11914,8 @@
11743
11914
  critiqueViewEl.addEventListener("click", handleStudioQuartoPreviewClick);
11744
11915
  critiqueViewEl.addEventListener("click", (event) => { void handleSideQuestionClick(event); });
11745
11916
  critiqueViewEl.addEventListener("focusin", handleReplPaneFocusIn);
11917
+ document.addEventListener("focusin", cancelReplQuickRefocusOutside, true);
11918
+ document.addEventListener("pointerdown", cancelReplQuickRefocusOutside, true);
11746
11919
  critiqueViewEl.addEventListener("input", handleSideQuestionInput);
11747
11920
  critiqueViewEl.addEventListener("input", handleReplPaneInput);
11748
11921
  critiqueViewEl.addEventListener("keydown", handleTracePaneKeydown);
@@ -17226,6 +17399,13 @@
17226
17399
  },
17227
17400
  replace: bufferId => retireStudioBufferSourceView(bufferId),
17228
17401
  bind: () => bindSelectedStudioBuffer({ focusEditor: false }),
17402
+ opened: bufferId => {
17403
+ const buffer = bufferRecoveryClient?.snapshot()?.buffers.find(b => b.id === bufferId);
17404
+ const path = String(buffer?.sourceState.path || "");
17405
+ const name = path.split(/^(?:[A-Za-z]:\\|\\\\)/.test(path) ? /[\\/]/ : "/").pop();
17406
+ const where = !bufferSwitchingEnabled ? "" : buffer?.role === "document" ? " in the Document" : " in the Prompt";
17407
+ return name ? "Opened " + name + where + "." : "";
17408
+ },
17229
17409
  reuse: async request => {
17230
17410
  const result = await reuseHostedDocumentOwner(request.path, { sourcePath: request.sourcePath, resourceDir: request.resourceDir });
17231
17411
  if (!result.handled) setStatus("The file owner is no longer available. Current work was kept; retry opening explicitly.", "warning");
@@ -28461,25 +28641,30 @@
28461
28641
  const responseRequestId = typeof message.requestId === "string" ? message.requestId : "";
28462
28642
  const settlesPendingRequest = Boolean(responseRequestId && responseRequestId === replPendingRequestId);
28463
28643
  const previousActiveLifetimeKey = getReplQuickDraftKey(getActiveReplSession());
28644
+ const previousRuntime = replRuntime;
28645
+ // Every repl_state states Studio's selection; while this page's own choice is
28646
+ // unanswered, only that choice's reply changes what the page shows.
28647
+ noteReplChoiceAnswer(message);
28648
+ const present = !isReplChoicePending();
28464
28649
  replTmuxAvailable = typeof message.tmuxAvailable === "boolean" ? message.tmuxAvailable : replTmuxAvailable;
28465
28650
  const sessionsChanged = setReplSessions(message.sessions);
28466
- if (typeof message.activeSessionName === "string" && message.activeSessionName.trim()) {
28467
- setActiveReplSessionForCurrentRuntime(message.activeSessionName);
28468
- }
28651
+ if (present) adoptReplSelection(message);
28469
28652
  const journalChanged = mergeReplJournalEntries(message.journalEntries);
28470
28653
  reconcileReplQuickPending();
28471
28654
  maybeImportLegacyReplJournalEntries(replActiveSessionName);
28472
- if (typeof message.transcript === "string") replTranscript = trimReplTranscript(message.transcript);
28473
- if (typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
28655
+ if (present && typeof message.transcript === "string") replTranscript = trimReplTranscript(message.transcript);
28656
+ if (present && typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
28474
28657
  replError = typeof message.replError === "string" ? message.replError : (typeof message.captureError === "string" ? message.captureError : "");
28475
28658
  replMessage = typeof message.replMessage === "string" ? message.replMessage : "";
28476
28659
  if (settlesPendingRequest && replPendingRequestId === responseRequestId) replPendingRequestId = "";
28477
28660
  replBusy = Boolean(replPendingRequestId);
28661
+ if (present) reconcileReplSelection();
28478
28662
  const activeSessionIdentityChanged = Boolean(previousActiveLifetimeKey)
28479
28663
  && previousActiveLifetimeKey !== getReplQuickDraftKey(getActiveReplSession());
28480
28664
  const controlsChanged = wasBusy
28481
28665
  || sessionsChanged
28482
28666
  || previousTmuxAvailable !== replTmuxAvailable
28667
+ || previousRuntime !== replRuntime
28483
28668
  || previousActiveSessionName !== replActiveSessionName;
28484
28669
  if (controlsChanged) syncActionButtons();
28485
28670
  const viewChanged = controlsChanged
@@ -28494,14 +28679,28 @@
28494
28679
  }
28495
28680
 
28496
28681
  if (message.type === "repl_tool_send") {
28497
- if (typeof message.sessionName === "string" && message.sessionName.trim()) {
28498
- setActiveReplSession(message.sessionName);
28682
+ // The agent's send selects only if nothing was chosen since Studio admitted it,
28683
+ // and not while this page's own choice is unanswered (Sol, Astra).
28684
+ const selects = message.select !== false && !isReplChoicePending();
28685
+ const previousRuntime = replRuntime;
28686
+ const previousActiveSessionName = replActiveSessionName;
28687
+ if (selects) {
28688
+ if (message.session) upsertReplSession(message.session);
28689
+ adoptReplSelection({
28690
+ activeSessionName: typeof message.activeSessionName === "string" ? message.activeSessionName : message.sessionName,
28691
+ activeRuntime: typeof message.activeRuntime === "string" ? message.activeRuntime : message.runtime,
28692
+ shown: message.shown,
28693
+ });
28499
28694
  }
28500
- const changed = recordReplToolSend(message);
28695
+ const changed = recordReplToolSend(message, selects);
28501
28696
  const journalChanged = mergeReplJournalEntries(message.journalEntries);
28502
- if (typeof message.transcript === "string") replTranscript = trimReplTranscript(message.transcript);
28503
- if (typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
28504
- if (changed || journalChanged) renderReplViewIfActive({ force: true });
28697
+ if (selects && message.sessionName === replActiveSessionName) {
28698
+ if (typeof message.transcript === "string") replTranscript = trimReplTranscript(message.transcript);
28699
+ if (typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
28700
+ }
28701
+ if (selects) reconcileReplSelection();
28702
+ if (previousRuntime !== replRuntime || previousActiveSessionName !== replActiveSessionName) syncActionButtons();
28703
+ if (changed || journalChanged || selects) renderReplViewIfActive({ force: true });
28505
28704
  updateReferenceBadge();
28506
28705
  return;
28507
28706
  }
@@ -28516,41 +28715,55 @@
28516
28715
  const responseRequestId = typeof message.requestId === "string" ? message.requestId : "";
28517
28716
  const settlesPendingRequest = Boolean(responseRequestId && responseRequestId === replPendingRequestId);
28518
28717
  const previousActiveLifetimeKey = getReplQuickDraftKey(getActiveReplSession());
28718
+ const previousRuntime = replRuntime;
28519
28719
  let sessionsChanged = false;
28520
- if (message.session) {
28521
- const session = normalizeReplSession(message.session);
28522
- if (session) {
28523
- const existingIndex = replSessions.findIndex((candidate) => candidate.sessionName === session.sessionName);
28524
- if (existingIndex < 0) {
28525
- replSessions = [...replSessions, session];
28526
- sessionsChanged = true;
28527
- } else if (serializeReplSessionsForCompare([replSessions[existingIndex]]) !== serializeReplSessionsForCompare([session])) {
28528
- replSessions = replSessions.map((candidate, index) => index === existingIndex ? session : candidate);
28529
- sessionsChanged = true;
28720
+ let applies = false;
28721
+ if (message.observation === true) {
28722
+ // A poll's reply shows one session as it is now. It applies only to the exact
28723
+ // session this page displays, checked before anything changes (Sol, Astra).
28724
+ const observed = normalizeReplSession(message.session);
28725
+ const displayed = getActiveReplSession();
28726
+ const observedName = observed ? observed.sessionName : (typeof message.sessionName === "string" ? message.sessionName : "");
28727
+ if (!isReplChoicePending() && displayed && observedName === displayed.sessionName) {
28728
+ if (!observed) {
28729
+ // Its capture failed; the list says what Studio selects now.
28730
+ applies = true;
28731
+ requestReplList();
28732
+ } else if (getReplSessionLifetimeKey(observed) === getReplSessionLifetimeKey(displayed)) {
28733
+ applies = true;
28734
+ sessionsChanged = upsertReplSession(observed);
28735
+ } else {
28736
+ requestReplList();
28530
28737
  }
28531
28738
  }
28532
- }
28533
- if (typeof message.activeSessionName === "string" && message.activeSessionName.trim()) {
28534
- setActiveReplSessionForCurrentRuntime(message.activeSessionName);
28739
+ } else {
28740
+ // A choice's reply, or a result that selected: it states Studio's selection.
28741
+ noteReplChoiceAnswer(message);
28742
+ applies = !isReplChoicePending();
28743
+ if (applies && message.session) sessionsChanged = upsertReplSession(message.session);
28744
+ if (applies) adoptReplSelection(message);
28745
+ applies = applies && Boolean(message.session) && message.session.sessionName === replActiveSessionName;
28535
28746
  }
28536
28747
  let journalChanged = mergeReplJournalEntries(message.journalEntries);
28537
28748
  reconcileReplQuickPending();
28538
28749
  maybeImportLegacyReplJournalEntries(replActiveSessionName);
28539
- if (typeof message.transcript === "string") {
28750
+ if (applies && typeof message.transcript === "string") {
28540
28751
  replTranscript = trimReplTranscript(message.transcript);
28541
- journalChanged = updateActiveReplJournalEntryFromTranscript(
28542
- typeof message.activeSessionName === "string" && message.activeSessionName.trim() ? message.activeSessionName : replActiveSessionName,
28543
- replTranscript
28544
- ) || journalChanged;
28752
+ journalChanged = updateActiveReplJournalEntryFromTranscript(replActiveSessionName, replTranscript) || journalChanged;
28753
+ } else if (applies && typeof message.captureError === "string") {
28754
+ replTranscript = "";
28755
+ }
28756
+ if (applies && typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
28757
+ if (applies || message.observation !== true) {
28758
+ replError = typeof message.replError === "string" ? message.replError : (typeof message.captureError === "string" ? message.captureError : "");
28545
28759
  }
28546
- if (typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
28547
- replError = typeof message.replError === "string" ? message.replError : "";
28548
28760
  if (typeof message.replMessage === "string") replMessage = message.replMessage;
28549
28761
  if (settlesPendingRequest && replPendingRequestId === responseRequestId) replPendingRequestId = "";
28550
28762
  replBusy = Boolean(replPendingRequestId);
28763
+ if (message.observation !== true && !isReplChoicePending()) reconcileReplSelection();
28551
28764
  const activeSessionIdentityChanged = Boolean(previousActiveLifetimeKey)
28552
28765
  && previousActiveLifetimeKey !== getReplQuickDraftKey(getActiveReplSession());
28553
- const controlsChanged = wasBusy || sessionsChanged || previousActiveSessionName !== replActiveSessionName;
28766
+ const controlsChanged = wasBusy || sessionsChanged || previousRuntime !== replRuntime || previousActiveSessionName !== replActiveSessionName;
28554
28767
  if (controlsChanged) syncActionButtons();
28555
28768
  const viewChanged = controlsChanged
28556
28769
  || previousTranscript !== replTranscript
@@ -29375,6 +29588,7 @@
29375
29588
  setWsState("Ready");
29376
29589
  setStatus(wasReconnect ? "Reconnected. Syncing…" : "Connected. Syncing…");
29377
29590
  sendMessage({ type: "hello" });
29591
+ resetReplSelectionBasis();
29378
29592
  if (isWatchedFilePreview && watchedFilePreviewState.path) {
29379
29593
  watchedFilePreviewState.generation = 0;
29380
29594
  sendMessage({
@@ -32159,10 +32373,13 @@
32159
32373
  if (restored.restored) {
32160
32374
  const snapshot = client.snapshot();
32161
32375
  const selected = snapshot.buffers.find((entry) => entry.id === snapshot.selectedBufferId);
32162
- applyPersistedWorkspaceState(restored.editor, {
32376
+ const applied = applyPersistedWorkspaceState(restored.editor, {
32163
32377
  verifiedBuffer: documentHostingEnabled,
32164
32378
  metadataAssociations: selected && selected.metadata,
32165
32379
  });
32380
+ // A fresh dual-mode launch is registered on the server first; only an
32381
+ // applied copy kept by this tab (a reload) is reported as restored.
32382
+ workspaceRestoredFromBrowser = applied && restored.fromBrowser === true;
32166
32383
  if (sourceState.path) fileBackedBaselineText = restored.baselineText;
32167
32384
  if (selected) {
32168
32385
  sourceTextEl.setSelectionRange(selected.view.selectionStart, selected.view.selectionEnd, selected.view.selectionDirection);
@@ -32216,7 +32433,7 @@
32216
32433
  if (documentHostingEnabled) syncActionButtons();
32217
32434
  persistWorkspaceStateNow({ skipServer: serverWorkspaceRecovery.status === "unavailable" });
32218
32435
  if (workspaceRestoredFromBrowser) {
32219
- setStatus(bufferSwitchingEnabled ? "Restored Prompt and Document in this browser tab. Both texts were kept." : "Restored editor workspace from this browser tab. Use Reset editor to discard it.", "success");
32436
+ setStatus(bufferSwitchingEnabled ? "Restored the Prompt and Document." : "Restored editor workspace from this browser tab. Use Reset editor to discard it.", "success");
32220
32437
  }
32221
32438
  connect();
32222
32439
  if (documentHostingEnabled && bufferRecoveryClient) { await bufferRecoveryClient.settled(); await setupDocumentHosting(); }