pi-studio 0.10.0 → 0.10.2

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";
@@ -721,6 +733,9 @@
721
733
  if (!entry || typeof entry !== "object") return null;
722
734
  const hasCompatibleOrigin = entry.origin === "pi-repl" || entry.origin === "pi-studio";
723
735
  const sharedSynced = entry.sharedSynced === true || (entry.sharedSynced !== false && hasCompatibleOrigin);
736
+ // Whether the output was cleaned where it was captured is separate from whether
737
+ // it reached the shared record: a failed record write leaves clean output.
738
+ const outputClean = sharedSynced || entry.outputClean === true;
724
739
  const normalized = {
725
740
  id: typeof entry.id === "string" && entry.id ? entry.id : ("repl-journal-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8)),
726
741
  requestId: typeof entry.requestId === "string" ? entry.requestId : "",
@@ -731,11 +746,14 @@
731
746
  runtime: typeof entry.runtime === "string" ? entry.runtime : "python",
732
747
  origin: hasCompatibleOrigin ? entry.origin : "",
733
748
  legacyLocal: !sharedSynced,
749
+ outputClean,
734
750
  label: typeof entry.label === "string" ? entry.label : "REPL send",
735
751
  mode: typeof entry.mode === "string" ? entry.mode : "raw",
736
752
  prose: typeof entry.prose === "string" ? entry.prose : "",
737
753
  code: typeof entry.code === "string" ? entry.code : "",
738
- output: typeof entry.output === "string" ? stripStudioReplSubmissionEcho(entry.output) : "",
754
+ // Studio's server and pi-repl record clean output; only this browser's
755
+ // older local entries can still hold a loader echo.
756
+ output: typeof entry.output === "string" ? (outputClean ? entry.output : stripStudioReplSubmissionEcho(entry.output)) : "",
739
757
  beforeTranscript: "",
740
758
  status: typeof entry.status === "string" ? entry.status : "sent",
741
759
  skippedChunks: Math.max(0, Math.floor(Number(entry.skippedChunks) || 0)),
@@ -767,6 +785,7 @@
767
785
  runtime: entry.runtime,
768
786
  origin: entry.origin,
769
787
  sharedSynced: !entry.legacyLocal,
788
+ outputClean: entry.outputClean === true,
770
789
  label: entry.label,
771
790
  mode: entry.mode,
772
791
  prose: entry.prose,
@@ -786,7 +805,8 @@
786
805
  let changed = false;
787
806
  const next = [...replJournalEntries];
788
807
  for (const rawEntry of entries) {
789
- const entry = normalizeReplJournalEntry(rawEntry);
808
+ // These come from Studio's server, which has already cleaned their output.
809
+ const entry = normalizeReplJournalEntry(rawEntry && typeof rawEntry === "object" ? { ...rawEntry, outputClean: true } : rawEntry);
790
810
  if (!entry) continue;
791
811
  const existingIndex = next.findIndex((candidate) => (
792
812
  (entry.requestId && candidate.requestId === entry.requestId)
@@ -920,6 +940,9 @@
920
940
  const seconds = Math.max(0, Math.floor((Date.now() - run.startedAt) / 1000));
921
941
  return "Running " + Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0");
922
942
  }
943
+ // Oliver, 14 Oct: a Run waiting for Pi to start says so briefly here, so Queue steering fits
944
+ // beside it and the toolbar doesn't wrap and jump back. The footer keeps the detail.
945
+ if (uiBusy && pendingKind === "direct") return "Starting…";
923
946
  return uiBusy ? getStudioBusyStatus(pendingKind) : "";
924
947
  }
925
948
 
@@ -936,6 +959,7 @@
936
959
  ui.headerStop.hidden = isEditorOnlyMode || !rightFocused || (!activeKind && !stopping && !ownedRun);
937
960
  ui.headerStop.textContent = stopping ? "Stopping…" : "Stop";
938
961
  ui.headerStop.disabled = Boolean(stopping) || !activeKind;
962
+ if (run && sendRunBtn) sendRunBtn.classList.toggle("request-pending", false);
939
963
  if (run && !isEditorOnlyMode && sendRunBtn) {
940
964
  const owned = run.origin === "studio" && Boolean(run.requestId && run.kind);
941
965
  if (owned) {
@@ -1484,7 +1508,7 @@
1484
1508
  if (!sessionName) return null;
1485
1509
  return {
1486
1510
  sessionName,
1487
- target: typeof session.target === "string" ? session.target : (sessionName + ":0.0"),
1511
+ target: typeof session.target === "string" ? session.target : "",
1488
1512
  tmuxSessionId: typeof session.tmuxSessionId === "string" ? session.tmuxSessionId : "",
1489
1513
  tmuxSessionCreatedAt: typeof session.tmuxSessionCreatedAt === "number" && Number.isFinite(session.tmuxSessionCreatedAt)
1490
1514
  ? session.tmuxSessionCreatedAt
@@ -1725,14 +1749,20 @@
1725
1749
  function setReplSessions(sessions) {
1726
1750
  const previous = serializeReplSessionsForCompare(replSessions);
1727
1751
  const previousActive = replActiveSessionName;
1728
- replSessions = Array.isArray(sessions)
1729
- ? sessions.map(normalizeReplSession).filter(Boolean)
1730
- : [];
1731
- if (replActiveSessionName && !replSessions.some((session) => session.sessionName === replActiveSessionName)) {
1732
- setActiveReplSession("");
1733
- }
1734
- if (!getActiveReplSessionForCurrentRuntime()) {
1735
- selectReplSessionForRuntime(replRuntime, replActiveSessionName);
1752
+ // While a choice is unanswered, the chosen session keeps the lifetime it was chosen
1753
+ // with: no list, even one after a list that left it out, can swap in a replacement
1754
+ // with the same name (Astra, Sol).
1755
+ const chosen = isReplChoicePending() ? replChoicePin : null;
1756
+ replSessions = (Array.isArray(sessions) ? sessions.map(normalizeReplSession).filter(Boolean) : [])
1757
+ .map((session) => (chosen && session.sessionName === chosen.sessionName ? chosen : session));
1758
+ // The list may change while a choice is unanswered, but the selection waits for its reply.
1759
+ if (!isReplChoicePending()) {
1760
+ if (replActiveSessionName && !replSessions.some((session) => session.sessionName === replActiveSessionName)) {
1761
+ setActiveReplSession("");
1762
+ }
1763
+ if (!getActiveReplSessionForCurrentRuntime()) {
1764
+ selectReplSessionForRuntime(replRuntime, replActiveSessionName);
1765
+ }
1736
1766
  }
1737
1767
  return previous !== serializeReplSessionsForCompare(replSessions) || previousActive !== replActiveSessionName;
1738
1768
  }
@@ -1785,7 +1815,7 @@
1785
1815
  "The right pane is mirroring an active tmux-backed REPL session.",
1786
1816
  "If the user refers to the active REPL, send code to this session rather than inventing a separate one.",
1787
1817
  "Session name: " + session.sessionName,
1788
- "tmux target: " + (session.target || (session.sessionName + ":0.0")),
1818
+ "tmux target: " + (session.target || session.sessionName),
1789
1819
  "runtime: " + runtime,
1790
1820
  "Use the studio_repl_send tool for code execution in this REPL. Pass sessionName when targeting this exact session.",
1791
1821
  "Do not improvise raw tmux paste commands for multiline code; Studio handles runtime-specific safe submission.",
@@ -1800,21 +1830,94 @@
1800
1830
  }
1801
1831
 
1802
1832
  function setActiveReplSession(sessionName) {
1803
- const name = String(sessionName || "").trim();
1804
- if (!name) {
1805
- if (replActiveSessionName) {
1806
- replTranscript = "";
1807
- replCapturedAt = 0;
1808
- }
1809
- replActiveSessionName = "";
1810
- return;
1833
+ replActiveSessionName = String(sessionName || "").trim();
1834
+ retireReplViewIfTargetChanged();
1835
+ }
1836
+
1837
+ // The view belongs to one session lifetime, or to none. When the displayed target
1838
+ // changes, including to or from none or to a replacement with the same name, its
1839
+ // transcript, timestamp and live entry go with it (Sol).
1840
+ function retireReplViewIfTargetChanged() {
1841
+ const owner = getReplSessionLifetimeKey(getActiveReplSession());
1842
+ if (owner === replTranscriptOwner) return false;
1843
+ replTranscript = "";
1844
+ replCapturedAt = 0;
1845
+ activeReplJournalEntryId = "";
1846
+ replTranscriptOwner = owner;
1847
+ return true;
1848
+ }
1849
+
1850
+ function getReplSessionLifetimeKey(session) {
1851
+ return session ? [session.sessionName, session.tmuxSessionId || "", Number(session.tmuxSessionCreatedAt) || 0].join("\u001f") : "";
1852
+ }
1853
+
1854
+ function upsertReplSession(value) {
1855
+ const session = normalizeReplSession(value);
1856
+ if (!session) return false;
1857
+ const index = replSessions.findIndex((candidate) => candidate.sessionName === session.sessionName);
1858
+ if (index < 0) {
1859
+ replSessions = [...replSessions, session];
1860
+ return true;
1811
1861
  }
1812
- if (replActiveSessionName && replActiveSessionName !== name) {
1813
- replTranscript = "";
1814
- replCapturedAt = 0;
1815
- activeReplJournalEntryId = "";
1862
+ if (serializeReplSessionsForCompare([replSessions[index]]) === serializeReplSessionsForCompare([session])) return false;
1863
+ replSessions = replSessions.map((candidate, candidateIndex) => candidateIndex === index ? session : candidate);
1864
+ return true;
1865
+ }
1866
+
1867
+ function isReplChoicePending() {
1868
+ return replChoiceAnswered < replChoiceRevision;
1869
+ }
1870
+
1871
+ // Only the reply to this page's latest choice ends the wait.
1872
+ function noteReplChoiceAnswer(message) {
1873
+ if (typeof message.choiceRevision === "number" && message.choiceRevision > replChoiceAnswered) {
1874
+ replChoiceAnswered = Math.min(message.choiceRevision, replChoiceRevision);
1816
1875
  }
1817
- replActiveSessionName = name;
1876
+ if (!isReplChoicePending()) replChoicePin = null;
1877
+ }
1878
+
1879
+ // Adopts Studio's selection from a message that states it. A session brings its
1880
+ // runtime; none comes for a runtime; with no preference yet, the page keeps its
1881
+ // runtime and picks within it (Sol, Astra).
1882
+ function adoptReplSelection(message) {
1883
+ const sessionName = typeof message.activeSessionName === "string" ? message.activeSessionName.trim() : "";
1884
+ const runtime = getKnownReplRuntime(message.activeRuntime);
1885
+ replServerSelection = { sessionName, runtime };
1886
+ if (typeof message.shown === "number") replShownBasis = message.shown;
1887
+ if (runtime && runtime !== replRuntime) setReplRuntime(runtime);
1888
+ if (sessionName) {
1889
+ setActiveReplSessionForCurrentRuntime(sessionName);
1890
+ } else {
1891
+ selectReplSessionForRuntime(replRuntime, runtime ? "" : replActiveSessionName);
1892
+ }
1893
+ }
1894
+
1895
+ // Reports a selection the page made itself, against the selection it last adopted.
1896
+ // Studio applies it only if nothing has changed since; it isn't a choice (Sol, Astra).
1897
+ function reconcileReplSelection() {
1898
+ if (replShownBasis === null || isReplChoicePending() || wsState === "Disconnected") return false;
1899
+ const session = getActiveReplSessionForCurrentRuntime();
1900
+ const matches = session
1901
+ ? session.sessionName === replServerSelection.sessionName
1902
+ : !replServerSelection.sessionName && replServerSelection.runtime === replRuntime;
1903
+ if (matches) return false;
1904
+ const target = session
1905
+ ? { sessionName: session.sessionName, tmuxSessionId: session.tmuxSessionId, tmuxSessionCreatedAt: session.tmuxSessionCreatedAt }
1906
+ : { runtime: replRuntime };
1907
+ const key = replShownBasis + "\u001f" + JSON.stringify(target);
1908
+ if (key === replReconcileSent) return false;
1909
+ replReconcileSent = key;
1910
+ return sendMessage({ type: "repl_reconcile_request", basis: replShownBasis, ...target });
1911
+ }
1912
+
1913
+ // A new connection has no choice outstanding and no basis until it adopts one.
1914
+ function resetReplSelectionBasis() {
1915
+ replChoiceAnswered = replChoiceRevision;
1916
+ replChoicePin = null;
1917
+ replShownBasis = null;
1918
+ replReconcileSent = "";
1919
+ replServerSelection = { sessionName: "", runtime: "" };
1920
+ if (rightView === "repl") requestReplList();
1818
1921
  }
1819
1922
 
1820
1923
  function trimReplTranscript(text) {
@@ -1828,6 +1931,7 @@
1828
1931
  return sendMessage({ type: "repl_list_request" });
1829
1932
  }
1830
1933
 
1934
+ // A poll: the displayed session as it is now, or the list when there is none.
1831
1935
  function requestReplCapture() {
1832
1936
  if (wsState === "Disconnected") return false;
1833
1937
  if (replActiveSessionName) {
@@ -1836,6 +1940,25 @@
1836
1940
  return sendMessage({ type: "repl_list_request" });
1837
1941
  }
1838
1942
 
1943
+ // A choice on the Session or Runtime selector, which Studio counts. Until its reply
1944
+ // arrives, other replies don't change what the page shows.
1945
+ function requestReplChoice() {
1946
+ if (wsState === "Disconnected") return false;
1947
+ const choiceRevision = replChoiceRevision + 1;
1948
+ const sent = sendMessage({
1949
+ type: "repl_capture_request",
1950
+ choice: true,
1951
+ choiceRevision,
1952
+ runtime: replRuntime,
1953
+ ...(replActiveSessionName ? { sessionName: replActiveSessionName } : {}),
1954
+ });
1955
+ if (sent) {
1956
+ replChoiceRevision = choiceRevision;
1957
+ replChoicePin = getActiveReplSession();
1958
+ }
1959
+ return sent;
1960
+ }
1961
+
1839
1962
  function isReplControlFocused() {
1840
1963
  const activeEl = document.activeElement;
1841
1964
  return activeEl instanceof Element && Boolean(activeEl.closest(".repl-controls, .repl-quick-composer"));
@@ -1901,6 +2024,8 @@
1901
2024
  function startReplPolling() {
1902
2025
  if (rightView !== "repl") return;
1903
2026
  if (replPollTimer !== null) return;
2027
+ // Polls only observe, so the view starts from Studio's current selection.
2028
+ if (replActiveSessionName) requestReplList();
1904
2029
  requestReplCapture();
1905
2030
  replPollTimer = window.setInterval(() => {
1906
2031
  if (rightView !== "repl") {
@@ -2178,6 +2303,7 @@
2178
2303
  runtime: details.runtime || getActiveReplRuntime(),
2179
2304
  origin: details.origin === "pi-repl" ? "pi-repl" : "pi-studio",
2180
2305
  legacyLocal: !sharedSynced,
2306
+ outputClean: sharedSynced || details.outputClean === true,
2181
2307
  label: details.label || "REPL send",
2182
2308
  mode: details.mode || replSendMode,
2183
2309
  prose: String(details.prose || ""),
@@ -2255,7 +2381,7 @@
2255
2381
  return sent;
2256
2382
  }
2257
2383
 
2258
- function recordReplToolSend(message) {
2384
+ function recordReplToolSend(message, selects) {
2259
2385
  const requestId = typeof message.toolCallId === "string" && message.toolCallId.trim()
2260
2386
  ? "tool:" + message.toolCallId.trim()
2261
2387
  : (typeof message.requestId === "string" && message.requestId.trim() ? message.requestId.trim() : "");
@@ -2263,20 +2389,23 @@
2263
2389
  if (!code.trim()) return false;
2264
2390
  const runtime = normalizeReplRuntime(message.runtime || getActiveReplRuntime());
2265
2391
  const sessionName = typeof message.sessionName === "string" ? message.sessionName : replActiveSessionName;
2266
- const output = cleanReplCapturedOutput(String(message.output || ""), { code, runtime });
2392
+ // The server has already cleaned this send's output; cleaning it again could drop real lines.
2393
+ const output = trimReplJournalOutput(String(message.output || "").trimEnd());
2267
2394
  const details = {
2268
2395
  requestId,
2269
2396
  sessionName,
2270
2397
  runtime,
2271
2398
  origin: message.origin === "pi-repl" ? "pi-repl" : "pi-studio",
2272
2399
  sharedSynced: message.sharedSynced === true,
2400
+ outputClean: true,
2273
2401
  label: typeof message.label === "string" && message.label.trim() ? message.label.trim() : "Pi",
2274
2402
  mode: "agent",
2275
2403
  code,
2276
2404
  output,
2277
2405
  status: output.trim() ? "captured" : (message.timedOut ? "timeout" : "sent"),
2278
2406
  };
2279
- activeReplJournalEntryId = "";
2407
+ // A result that doesn't select leaves this page's own entry tracking alone (Astra).
2408
+ if (selects !== false) activeReplJournalEntryId = "";
2280
2409
  if (requestId) {
2281
2410
  const existingIndex = replJournalEntries.findIndex((entry) => entry.requestId === requestId);
2282
2411
  if (existingIndex >= 0) {
@@ -2371,7 +2500,8 @@
2371
2500
  if (entryIndex < 0) return false;
2372
2501
  const entry = replJournalEntries[entryIndex];
2373
2502
  if (entry.sessionName && sessionName && entry.sessionName !== sessionName) return false;
2374
- if (!entry.legacyLocal && (entry.status === "captured" || entry.status === "timeout" || entry.status === "error")) {
2503
+ // Finished output from Studio's server stands, whether or not the shared record took it.
2504
+ if ((entry.outputClean || !entry.legacyLocal) && (entry.status === "captured" || entry.status === "timeout" || entry.status === "error")) {
2375
2505
  activeReplJournalEntryId = "";
2376
2506
  return false;
2377
2507
  }
@@ -11695,13 +11825,13 @@
11695
11825
  selectReplSessionForRuntime(replRuntime, previousActive);
11696
11826
  replError = "";
11697
11827
  replMessage = "";
11698
- if (replActiveSessionName) {
11699
- requestReplCapture();
11700
- } else {
11828
+ if (!replActiveSessionName) {
11701
11829
  replTranscript = "";
11702
11830
  replCapturedAt = 0;
11703
11831
  syncActionButtons();
11704
11832
  }
11833
+ // Counted even with no session, so nothing that finishes later takes it back.
11834
+ requestReplChoice();
11705
11835
  renderReplViewIfActive({ force: true });
11706
11836
  return;
11707
11837
  }
@@ -11723,7 +11853,7 @@
11723
11853
  replError = "";
11724
11854
  replMessage = "";
11725
11855
  replFollow = true;
11726
- requestReplCapture();
11856
+ requestReplChoice();
11727
11857
  renderReplViewIfActive({ force: true });
11728
11858
  }
11729
11859
  }
@@ -17222,6 +17352,13 @@
17222
17352
  },
17223
17353
  replace: bufferId => retireStudioBufferSourceView(bufferId),
17224
17354
  bind: () => bindSelectedStudioBuffer({ focusEditor: false }),
17355
+ opened: bufferId => {
17356
+ const buffer = bufferRecoveryClient?.snapshot()?.buffers.find(b => b.id === bufferId);
17357
+ const path = String(buffer?.sourceState.path || "");
17358
+ const name = path.split(/^(?:[A-Za-z]:\\|\\\\)/.test(path) ? /[\\/]/ : "/").pop();
17359
+ const where = !bufferSwitchingEnabled ? "" : buffer?.role === "document" ? " in the Document" : " in the Prompt";
17360
+ return name ? "Opened " + name + where + "." : "";
17361
+ },
17225
17362
  reuse: async request => {
17226
17363
  const result = await reuseHostedDocumentOwner(request.path, { sourcePath: request.sourcePath, resourceDir: request.resourceDir });
17227
17364
  if (!result.handled) setStatus("The file owner is no longer available. Current work was kept; retry opening explicitly.", "warning");
@@ -27685,6 +27822,9 @@
27685
27822
  // The new-layout Document has the Prompt tab just above, so no Return to Prompt here (Oliver, 10 Oct).
27686
27823
  sendRunBtn.hidden = workspaceLayout && documentView && !directIsStop;
27687
27824
  sendRunBtn.classList.toggle("request-stop-active", directIsStop);
27825
+ // Between the click and Stop the button keeps its look, dimmed, instead of flashing empty.
27826
+ sendRunBtn.classList.toggle("request-pending", !directIsStop && !documentView && uiBusy && pendingKind === "direct" && !studioRuntimeActivity?.run
27827
+ && wsState !== "Disconnected" && wsState !== "Connecting");
27688
27828
  sendRunBtn.classList.toggle("repl-secondary-action", rightView === "repl" && !directIsStop && !workspaceLayout);
27689
27829
  sendRunBtn.disabled = documentView && !directIsStop ? !studioBuffersCanSwitch(true)
27690
27830
  : wsState === "Disconnected" || (!directIsStop && (uiBusy || critiqueIsStop || !promptReady));
@@ -28454,25 +28594,30 @@
28454
28594
  const responseRequestId = typeof message.requestId === "string" ? message.requestId : "";
28455
28595
  const settlesPendingRequest = Boolean(responseRequestId && responseRequestId === replPendingRequestId);
28456
28596
  const previousActiveLifetimeKey = getReplQuickDraftKey(getActiveReplSession());
28597
+ const previousRuntime = replRuntime;
28598
+ // Every repl_state states Studio's selection; while this page's own choice is
28599
+ // unanswered, only that choice's reply changes what the page shows.
28600
+ noteReplChoiceAnswer(message);
28601
+ const present = !isReplChoicePending();
28457
28602
  replTmuxAvailable = typeof message.tmuxAvailable === "boolean" ? message.tmuxAvailable : replTmuxAvailable;
28458
28603
  const sessionsChanged = setReplSessions(message.sessions);
28459
- if (typeof message.activeSessionName === "string" && message.activeSessionName.trim()) {
28460
- setActiveReplSessionForCurrentRuntime(message.activeSessionName);
28461
- }
28604
+ if (present) adoptReplSelection(message);
28462
28605
  const journalChanged = mergeReplJournalEntries(message.journalEntries);
28463
28606
  reconcileReplQuickPending();
28464
28607
  maybeImportLegacyReplJournalEntries(replActiveSessionName);
28465
- if (typeof message.transcript === "string") replTranscript = trimReplTranscript(message.transcript);
28466
- if (typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
28608
+ if (present && typeof message.transcript === "string") replTranscript = trimReplTranscript(message.transcript);
28609
+ if (present && typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
28467
28610
  replError = typeof message.replError === "string" ? message.replError : (typeof message.captureError === "string" ? message.captureError : "");
28468
28611
  replMessage = typeof message.replMessage === "string" ? message.replMessage : "";
28469
28612
  if (settlesPendingRequest && replPendingRequestId === responseRequestId) replPendingRequestId = "";
28470
28613
  replBusy = Boolean(replPendingRequestId);
28614
+ if (present) reconcileReplSelection();
28471
28615
  const activeSessionIdentityChanged = Boolean(previousActiveLifetimeKey)
28472
28616
  && previousActiveLifetimeKey !== getReplQuickDraftKey(getActiveReplSession());
28473
28617
  const controlsChanged = wasBusy
28474
28618
  || sessionsChanged
28475
28619
  || previousTmuxAvailable !== replTmuxAvailable
28620
+ || previousRuntime !== replRuntime
28476
28621
  || previousActiveSessionName !== replActiveSessionName;
28477
28622
  if (controlsChanged) syncActionButtons();
28478
28623
  const viewChanged = controlsChanged
@@ -28487,14 +28632,28 @@
28487
28632
  }
28488
28633
 
28489
28634
  if (message.type === "repl_tool_send") {
28490
- if (typeof message.sessionName === "string" && message.sessionName.trim()) {
28491
- setActiveReplSession(message.sessionName);
28635
+ // The agent's send selects only if nothing was chosen since Studio admitted it,
28636
+ // and not while this page's own choice is unanswered (Sol, Astra).
28637
+ const selects = message.select !== false && !isReplChoicePending();
28638
+ const previousRuntime = replRuntime;
28639
+ const previousActiveSessionName = replActiveSessionName;
28640
+ if (selects) {
28641
+ if (message.session) upsertReplSession(message.session);
28642
+ adoptReplSelection({
28643
+ activeSessionName: typeof message.activeSessionName === "string" ? message.activeSessionName : message.sessionName,
28644
+ activeRuntime: typeof message.activeRuntime === "string" ? message.activeRuntime : message.runtime,
28645
+ shown: message.shown,
28646
+ });
28492
28647
  }
28493
- const changed = recordReplToolSend(message);
28648
+ const changed = recordReplToolSend(message, selects);
28494
28649
  const journalChanged = mergeReplJournalEntries(message.journalEntries);
28495
- if (typeof message.transcript === "string") replTranscript = trimReplTranscript(message.transcript);
28496
- if (typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
28497
- if (changed || journalChanged) renderReplViewIfActive({ force: true });
28650
+ if (selects && message.sessionName === replActiveSessionName) {
28651
+ if (typeof message.transcript === "string") replTranscript = trimReplTranscript(message.transcript);
28652
+ if (typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
28653
+ }
28654
+ if (selects) reconcileReplSelection();
28655
+ if (previousRuntime !== replRuntime || previousActiveSessionName !== replActiveSessionName) syncActionButtons();
28656
+ if (changed || journalChanged || selects) renderReplViewIfActive({ force: true });
28498
28657
  updateReferenceBadge();
28499
28658
  return;
28500
28659
  }
@@ -28509,41 +28668,55 @@
28509
28668
  const responseRequestId = typeof message.requestId === "string" ? message.requestId : "";
28510
28669
  const settlesPendingRequest = Boolean(responseRequestId && responseRequestId === replPendingRequestId);
28511
28670
  const previousActiveLifetimeKey = getReplQuickDraftKey(getActiveReplSession());
28671
+ const previousRuntime = replRuntime;
28512
28672
  let sessionsChanged = false;
28513
- if (message.session) {
28514
- const session = normalizeReplSession(message.session);
28515
- if (session) {
28516
- const existingIndex = replSessions.findIndex((candidate) => candidate.sessionName === session.sessionName);
28517
- if (existingIndex < 0) {
28518
- replSessions = [...replSessions, session];
28519
- sessionsChanged = true;
28520
- } else if (serializeReplSessionsForCompare([replSessions[existingIndex]]) !== serializeReplSessionsForCompare([session])) {
28521
- replSessions = replSessions.map((candidate, index) => index === existingIndex ? session : candidate);
28522
- sessionsChanged = true;
28673
+ let applies = false;
28674
+ if (message.observation === true) {
28675
+ // A poll's reply shows one session as it is now. It applies only to the exact
28676
+ // session this page displays, checked before anything changes (Sol, Astra).
28677
+ const observed = normalizeReplSession(message.session);
28678
+ const displayed = getActiveReplSession();
28679
+ const observedName = observed ? observed.sessionName : (typeof message.sessionName === "string" ? message.sessionName : "");
28680
+ if (!isReplChoicePending() && displayed && observedName === displayed.sessionName) {
28681
+ if (!observed) {
28682
+ // Its capture failed; the list says what Studio selects now.
28683
+ applies = true;
28684
+ requestReplList();
28685
+ } else if (getReplSessionLifetimeKey(observed) === getReplSessionLifetimeKey(displayed)) {
28686
+ applies = true;
28687
+ sessionsChanged = upsertReplSession(observed);
28688
+ } else {
28689
+ requestReplList();
28523
28690
  }
28524
28691
  }
28525
- }
28526
- if (typeof message.activeSessionName === "string" && message.activeSessionName.trim()) {
28527
- setActiveReplSessionForCurrentRuntime(message.activeSessionName);
28692
+ } else {
28693
+ // A choice's reply, or a result that selected: it states Studio's selection.
28694
+ noteReplChoiceAnswer(message);
28695
+ applies = !isReplChoicePending();
28696
+ if (applies && message.session) sessionsChanged = upsertReplSession(message.session);
28697
+ if (applies) adoptReplSelection(message);
28698
+ applies = applies && Boolean(message.session) && message.session.sessionName === replActiveSessionName;
28528
28699
  }
28529
28700
  let journalChanged = mergeReplJournalEntries(message.journalEntries);
28530
28701
  reconcileReplQuickPending();
28531
28702
  maybeImportLegacyReplJournalEntries(replActiveSessionName);
28532
- if (typeof message.transcript === "string") {
28703
+ if (applies && typeof message.transcript === "string") {
28533
28704
  replTranscript = trimReplTranscript(message.transcript);
28534
- journalChanged = updateActiveReplJournalEntryFromTranscript(
28535
- typeof message.activeSessionName === "string" && message.activeSessionName.trim() ? message.activeSessionName : replActiveSessionName,
28536
- replTranscript
28537
- ) || journalChanged;
28705
+ journalChanged = updateActiveReplJournalEntryFromTranscript(replActiveSessionName, replTranscript) || journalChanged;
28706
+ } else if (applies && typeof message.captureError === "string") {
28707
+ replTranscript = "";
28708
+ }
28709
+ if (applies && typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
28710
+ if (applies || message.observation !== true) {
28711
+ replError = typeof message.replError === "string" ? message.replError : (typeof message.captureError === "string" ? message.captureError : "");
28538
28712
  }
28539
- if (typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
28540
- replError = typeof message.replError === "string" ? message.replError : "";
28541
28713
  if (typeof message.replMessage === "string") replMessage = message.replMessage;
28542
28714
  if (settlesPendingRequest && replPendingRequestId === responseRequestId) replPendingRequestId = "";
28543
28715
  replBusy = Boolean(replPendingRequestId);
28716
+ if (message.observation !== true && !isReplChoicePending()) reconcileReplSelection();
28544
28717
  const activeSessionIdentityChanged = Boolean(previousActiveLifetimeKey)
28545
28718
  && previousActiveLifetimeKey !== getReplQuickDraftKey(getActiveReplSession());
28546
- const controlsChanged = wasBusy || sessionsChanged || previousActiveSessionName !== replActiveSessionName;
28719
+ const controlsChanged = wasBusy || sessionsChanged || previousRuntime !== replRuntime || previousActiveSessionName !== replActiveSessionName;
28547
28720
  if (controlsChanged) syncActionButtons();
28548
28721
  const viewChanged = controlsChanged
28549
28722
  || previousTranscript !== replTranscript
@@ -29368,6 +29541,7 @@
29368
29541
  setWsState("Ready");
29369
29542
  setStatus(wasReconnect ? "Reconnected. Syncing…" : "Connected. Syncing…");
29370
29543
  sendMessage({ type: "hello" });
29544
+ resetReplSelectionBasis();
29371
29545
  if (isWatchedFilePreview && watchedFilePreviewState.path) {
29372
29546
  watchedFilePreviewState.generation = 0;
29373
29547
  sendMessage({
@@ -32152,10 +32326,13 @@
32152
32326
  if (restored.restored) {
32153
32327
  const snapshot = client.snapshot();
32154
32328
  const selected = snapshot.buffers.find((entry) => entry.id === snapshot.selectedBufferId);
32155
- applyPersistedWorkspaceState(restored.editor, {
32329
+ const applied = applyPersistedWorkspaceState(restored.editor, {
32156
32330
  verifiedBuffer: documentHostingEnabled,
32157
32331
  metadataAssociations: selected && selected.metadata,
32158
32332
  });
32333
+ // A fresh dual-mode launch is registered on the server first; only an
32334
+ // applied copy kept by this tab (a reload) is reported as restored.
32335
+ workspaceRestoredFromBrowser = applied && restored.fromBrowser === true;
32159
32336
  if (sourceState.path) fileBackedBaselineText = restored.baselineText;
32160
32337
  if (selected) {
32161
32338
  sourceTextEl.setSelectionRange(selected.view.selectionStart, selected.view.selectionEnd, selected.view.selectionDirection);
@@ -32209,7 +32386,7 @@
32209
32386
  if (documentHostingEnabled) syncActionButtons();
32210
32387
  persistWorkspaceStateNow({ skipServer: serverWorkspaceRecovery.status === "unavailable" });
32211
32388
  if (workspaceRestoredFromBrowser) {
32212
- 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");
32389
+ setStatus(bufferSwitchingEnabled ? "Restored the Prompt and Document." : "Restored editor workspace from this browser tab. Use Reset editor to discard it.", "success");
32213
32390
  }
32214
32391
  connect();
32215
32392
  if (documentHostingEnabled && bufferRecoveryClient) { await bufferRecoveryClient.settled(); await setupDocumentHosting(); }
package/client/studio.css CHANGED
@@ -294,6 +294,17 @@
294
294
  filter: brightness(0.95);
295
295
  }
296
296
 
297
+ /* Oliver, 14 Oct: from the click until Stop, Run keeps its shape and colour, dimmed, and
298
+ can't be clicked, so it doesn't flash empty while Pi starts. */
299
+ #sendRunBtn.request-pending:disabled:not(.repl-secondary-action) {
300
+ background: var(--accent);
301
+ border-color: var(--accent);
302
+ color: var(--accent-contrast);
303
+ font-weight: 600;
304
+ opacity: 0.7;
305
+ cursor: progress;
306
+ }
307
+
297
308
  #sendRunBtn.request-stop-active,
298
309
  #critiqueBtn.request-stop-active,
299
310
  #showMeBtn.request-stop-active {
@@ -7528,7 +7539,8 @@
7528
7539
  /* Honor existing handler eligibility after controls are regrouped. */
7529
7540
  body.studio-ui-refresh [hidden] { display: none !important; }
7530
7541
  /* Dogfood workspace: two logical rows, with normal wrapping in small panes. */
7531
- .studio-run-status { color: var(--muted); font-size: 12px; min-width: 0; overflow-wrap: anywhere; }
7542
+ /* Room for "Running 00:00" from the start, so nothing moves when "Starting…" becomes the clock (Oliver, 14 Oct). */
7543
+ .studio-run-status { color: var(--muted); font-size: 12px; min-width: 13ch; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
7532
7544
  body.studio-ui-refresh #rightSectionHeader .studio-follow-button {
7533
7545
  color: var(--muted); border: 0; border-radius: 0; background: transparent;
7534
7546
  }