fix(ssh): pinned expanded pending preview commit-unstable

The SSH renderer previously declared 'provisionalPendingPreview: "collapsed"',
which only opted the COLLAPSED pending shape out of the transcript's
stable-prefix ratchet. Once the user expanded an in-flight SSH preview (ctrl+o)
and the framed block outgrew the viewport, the pending rows became
ratchet-eligible and committed to native scrollback before the result render
inserted the 'Output' section. The settled render then re-anchored the frame,
producing two distinct stranded shapes in history:

- a stale 'pending SSH: [host]' header pinned above the final '<- SSH: [host]'
  frame (header variant), and
- the pending bottom border row reused in-place as the new 'Output' separator,
  with a fresh '...' footer pushed below it (footer variant).

Flip 'provisionalPendingPreview' to 'true' so every pending shape — collapsed
or expanded — is treated as provisional and stays out of native scrollback
until the result render commits a settled frame. The 'collapsed'-only opt-out
remains correct for renderers (bash, eval) whose expanded pending preview is
top-anchored and survives the result render without re-anchoring.

Added two contract tests asserting expanded pending SSH is commit-unstable
and that bash/eval expanded pending preview is still commit-stable — keeping
the opt-in renderer-scoped.

Fixes #3714
This commit is contained in:
roboomp
2026-06-28 10:40:18 +00:00
parent 4373dde48a
commit 2024821d67
3 changed files with 51 additions and 4 deletions
@@ -46,6 +46,34 @@ describe("ssh tool block commit stability", () => {
expect(component.isTranscriptBlockCommitStable()).toBe(false);
});
it("keeps the collapsed pending SSH preview commit-unstable until a result arrives", () => {
// Issue #3714: with `provisionalPendingPreview: true` the pending call
// preview is commit-unstable regardless of expansion, so neither the
// `⏳ SSH: [host]` header nor the framed bottom border can leak into
// native scrollback before the result render inserts `Output`.
const component = makeSshComponent();
component.setArgsComplete();
expect(component.isTranscriptBlockFinalized()).toBe(false);
expect(component.isTranscriptBlockCommitStable()).toBe(false);
});
it("keeps the expanded pending SSH preview commit-unstable until a result arrives", () => {
// Issue #3714: the previous `"collapsed"` opt-out left expanded pending
// rows commit-stable. Once the box outgrew the viewport the stale
// `⏳ SSH: [host]` header and the pending `╰──╯` footer reached native
// scrollback, then the final result re-anchored the frame and stranded
// the pending rows above (header variant) or reused the footer row in
// place as `├── Output ──┤` (footer variant). Expanded MUST also be
// commit-unstable until the result render replaces the pending shape.
const component = makeSshComponent();
component.setExpanded(true);
component.setArgsComplete();
expect(component.isTranscriptBlockFinalized()).toBe(false);
expect(component.isTranscriptBlockCommitStable()).toBe(false);
});
it("flips commit-stable as soon as the SSH result settles", () => {
const component = makeSshComponent();
component.updateResult(partialResult("connecting…"), true);
@@ -65,4 +93,18 @@ describe("ssh tool block commit stability", () => {
expect(component.isTranscriptBlockFinalized()).toBe(false);
expect(component.isTranscriptBlockCommitStable()).toBe(true);
});
it("does not opt other foreground tools out of expanded pending-preview commits", () => {
// Sanity: bash/eval still use `provisionalPendingPreview: "collapsed"`,
// so once expanded their pending preview is commit-stable. The SSH
// `true` opt-in MUST remain renderer-scoped — flipping the default
// here would block long top-anchored streams (e.g. a task call's
// context/assignment markdown) from reaching native scrollback.
const component = new ToolExecutionComponent("bash", { command: "ls" }, {}, undefined, uiStub);
component.setExpanded(true);
component.setArgsComplete();
expect(component.isTranscriptBlockFinalized()).toBe(false);
expect(component.isTranscriptBlockCommitStable()).toBe(true);
});
});