Skip to content

fix(workspace): a restored workspace paints before its terminals reconnect - #22810

Merged
brennanb2025 merged 8 commits into
mainfrom
brennanb2025/workspace-paint-before-terminals
Sep 27, 2026
Merged

brennanb2025 merged 8 commits into
mainfrom
brennanb2025/workspace-paint-before-terminals

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

ELI5

When you restart Orca, the workspace you were on now shows up right away: its tabs, editor, browser and chat panes. Before, the whole area stayed blank until every saved terminal had reconnected.

Terminals still wait for their saved sessions to come back, so nothing spawns twice. While they wait, a terminal tab says "Restoring terminal…" instead of sitting blank.

On a workspace hosted over SSH, the browser and editor now wait politely while the SSH connection comes up and load by themselves once it does. Before, a slow reconnect could leave the browser stuck on "SSH connection unavailable" until you clicked Retry. That also fixes the same stuck states after an SSH connection drops and comes back mid-session.

This PR carries the terminal half of #22293 onto current main, plus what live testing showed the early paint needs. #22293's chat half is left out because the native-chat redesign replaces it.

What Changed

Before: after a restart the saved tab model was in memory within a few seconds, but the workspace area painted nothing until the whole startup chain had run: SSH reconnect, reconnecting every saved terminal, and recovering legacy worker terminals. On a large profile or a slow SSH host that is a long blank screen.

After:

  • The workspace mounts as soon as its tab model is loaded. Editor, browser and chat panes render immediately.
  • Terminal panes stay unmounted until startup restoration has published which terminal owns which process; the normal activation plan then admits them. A terminal tab that is visible while held shows a "Restoring terminal…" state in its slot, including a terminal opened with "+" during that window.
  • On an SSH workspace, the browser shows "Connecting through the SSH host" and the editor shows "Connecting to the remote host…" while the host connects. Each loads by itself when the host connects, both at startup and after a mid-session drop. If the host never comes back they end on the same error and Retry as before.

Mechanism, all in the renderer:

  • canMountTerminalWorkspaceForStartup is renamed canAdmitTerminalTabsForStartup. It used to gate mounting the whole workspace surface; now it gates only whether terminal tabs may mount their panes.
  • While that gate is closed, terminal-cold-activation installs a startup hold on the active workspace (startup-terminal-tab-hold.ts): an empty admitted-tab set, with no deferral entry, in the per-workspace restriction map that background mounts and cold-activation deferral already use. Reveal and idle admission act only on workspaces with a deferral entry, so nothing admits a held tab until the gate opens and the activation plan replaces the hold. The hold is released on any pass where that workspace is no longer the held one (switching away, or leaving for no workspace mid-startup).
  • Held tabs count as parked for the background watchers, so bells, titles and completions from those terminals are still observed while held.
  • The placeholder is derived, not stored: a terminal tab that is visible but excluded by the restriction map renders TerminalRestoringPlaceholder in the same slot a pane uses. Only the startup hold produces that case, and the real pane replaces it in the same render.
  • One shared per-workspace host-connection signal, selectWorktreeHostConnectionPhase (src/renderer/src/lib/worktree-host-connection-phase.ts): local | connecting | connected | unavailable | unverifiable, the status as published, and a connection identity that changes on every reconnect. It is derived from the SSH state already in the store; nothing new is stored. The terminal host state, the SSH browser route and the editor's file loading all read it; none re-derives SSH status itself.
    • Before startup restoration has published anything, a target with no state yet reads as connecting. That ends by itself when restoration finishes. Only the phase applies this rule; the terminal keeps showing the status as published, so terminal behaviour is unchanged.
    • A target owned by a paired runtime this client cannot currently see reads unverifiable, not down. No pane waits on it.
  • Browser route: while connecting it stays in its existing preparing state without calling prepare; on connect, or a new connection, it prepares again, so an earlier "unavailable" result no longer sticks. A page that is already loaded stays mounted across a reconnect, because the main process keeps the prepared route for the target.
  • Editor: a read that fails through a host that is still connecting shows the existing "Connecting to the remote host…" state without spending its retries, and the file reloads once when the host connects.
  • Degraded startup: when startup falls back to degraded mode and its reconnect then succeeds, terminalStartupRestorationReady is now released, as the other degraded outcomes already did. Without it, anything waiting on restoration waited for the whole session. That includes the new signal's "connecting" rule, the fallback terminal for an empty worktree, and structured-session tab sync.

Why

The blank screen was not a slow restore; the data was already there. One flag was doing two jobs: "may the surface paint" and "may a terminal pane bind a process". Only the second needs the reconnect chain. Splitting them along the per-workspace admitted-tab set, which the code already has, keeps the process fence exactly where it was.

Painting early exposed panes that had never had to handle a host that was not ready yet. The browser route asked the SSH host once and kept the failure. The editor told the user to click Reconnect while the app was already connecting. Fixing each pane with its own check would give several answers to one question, so every pane now reads one derived signal. Each pane waits while connecting and re-derives on connect instead of keeping a failure. The same fix covers a mid-session drop, which had the same stuck states on main.

Alternatives considered:

  • Setting hydrationSucceeded earlier. This would let terminal panes attach before reconnect and risk duplicate shells.
  • Gating inside each terminal pane's attach path. The attach path reads tab.ptyId, which startup reconnect writes later, so every attach and spawn path would have to re-derive on its arrival. That reaches much further into the riskiest code for the same outcome; the hold changes no attach code.
  • Keeping SSH-hosted workspaces blank until SSH connects. SSH users would keep the blank screen for up to the 15 s startup timeout, a second gate would stay, and the mid-session stuck states would remain.
  • Reusing the cold-activation deferral entry for the hold. Reveal and idle admission act on deferral entries, so they would mount held tabs before the gate opens unless both learned about the startup gate.

Differences from the common pattern

In the common pattern, each terminal pane attaches to its own restored session, with its own loading and error state. Non-terminal panes that depend on a remote host wait while the connection comes up and refresh when it becomes ready.

  • What matches: the layout paints from the client's tab model independent of terminal readiness. Each terminal pane shows its own restoring state. Host-dependent panes wait while connecting and re-derive on connect instead of keeping a failure.
  • What differs: Orca's terminal fence is a workspace-level admitted-tab set opened by one global startup gate, not per-pane attach readiness. In Orca the renderer reconnects saved processes at startup and publishes ownership for all terminals in one update, so a per-pane gate would still key on that same global signal. Host readiness is derived per pane from one shared selector, not enforced once in the transport layer. "Not dialed yet" at startup is inferred from the restoration flag, because the set of targets startup will dial is not kept in app state.

Linked Issue

Carries the terminal half of #22293, which was reported directly: after a restart on a native chat, the workspace stayed blank until the sidebar's reveal button was clicked. No ticket was filed.

Visual Proof

Isolated dev profiles. A temporary delay before terminal reconnect (local) or a paused SSH container (SSH) makes the startup window observable.

Before (main), local, during the startup window: the workspace area is blank.

before-main-blank-during-startup

After, local, during the startup window: tabs and editor painted, and the held terminal says it is restoring (light and dark).

after-restoring-placeholder-light

after-restoring-placeholder-dark

After, local, once the gate opens: the same shell (original PID) with its scrollback.

after-gate-original-shell

SSH, before the host-connection fix: the early-mounted browser stayed on "SSH connection unavailable" after SSH was back.

ssh-browser-stuck-before-fix

SSH, after: "Connecting through the SSH host" while SSH is down, then the page loads by itself.

ssh-browser-connecting

ssh-browser-loaded-after-connect

SSH, editor while connecting: connecting copy instead of "Click Reconnect", then the file loads by itself.

ssh-editor-connecting

ssh-editor-loaded

SSH, host never returns: every pane ends on a truthful error with Retry.

ssh-never-returns-browser

Testing

Manual, macOS, background-launched dev builds on isolated profiles, probed over CDP.

Local, with a 25 s delay injected before terminal reconnect:

  • Tabs, editor and browser painted with all three gate flags false and no terminal mounted; a visible held terminal showed "Restoring terminal…", including one opened with "+".
  • In a split layout the placeholder appeared only in the terminal group; clicking it only focused that group, and typing into the editor in the other group worked.
  • When the gate opened, the placeholder left and the terminal arrived in the same DOM update, with exactly one terminal per tab. The session ids were unchanged, echo $$ printed the original shells, and scrollback was intact. A "+" terminal spawned exactly one shell. Across four restarts the daemon held exactly one shell per terminal.
  • Switching workspace during the window, and back after, left nothing blank. With no delay, no placeholder appeared at all.

SSH, against a throwaway Linux container, paused before relaunch so SSH hangs:

  • SSH back before and after the 15 s startup timeout: the browser showed "Connecting through the SSH host" and loaded by itself; the editor showed connecting copy and loaded by itself; the terminal showed "Restoring terminal…", then mounted once on the same remote shell. The remote process list showed one shell in every run.
  • Mid-session drop of 20 s and 45 s: the loaded browser page and the editor content stayed, and navigation worked afterwards; the terminal reconnected without remounting.
  • SSH never returning: panes showed connecting while SSH really was connecting, then ended on the usual errors with Retry. After unpausing, one Retry recovered SSH and the browser and editor followed by themselves.
  • Control, not paused: everything restored normally.

Automated:

  • New tests for the startup hold, the placeholder (split-group and legacy paths), the shared host-connection signal, the connecting → connected transition in each pane (terminal, browser, editor), and the degraded-startup restoration release. Each goes red with its change removed.
  • pnpm tc:web, oxlint, check:code-quality:changed, check:react-doctor:changed and the localization verifiers are clean. The new string is in all six locales.

Startup timing, before vs after (does the early paint delay terminals?):

  • Setup: production builds of the merge base f5d2ce5de7 and this PR 849d58d918, on a second Mac. Each build had its own profile, seeded identically: 3 workspaces, 10 terminals with 3000 lines of scrollback each, an editor tab and a browser tab. Each restart quits normally, so the terminal daemon keeps its shells, and relaunches. There were 12 restarts per build, alternated, after one discarded warm-up each. All 24 reattached to the same terminal sessions.
  • Timestamps come from the app's own startup diagnostics plus a DOM observer injected before the app loads. No source changes were made to either build.
Median Before After Difference (95% bootstrap interval)
Startup start → restored terminal scrollback visible 1706 ms 1468 ms −238 ms (−4479 to +104): no significant change
Startup gate opens → terminal scrollback visible 850 ms 376 ms −474 ms (−1730 to −180): faster
Startup start → gate opens 914 ms 1066 ms +152 ms (−2363 to +340): no significant change
Terminal reconnect step 20 ms 65 ms +44 ms (+30 to +65): slower
Startup start → tab strip painted 914 ms 664 ms −250 ms (−2760 to −63): faster
Typed input echoed, right after restore 74 ms 74 ms no change
  • Result: restored terminals are not slower to become usable. The panes that now mount early cost the terminal reconnect step about 44 ms. Before this PR, the whole workspace mounted at the moment the gate opened; now it is already up, so the terminal mounts about 470 ms sooner after the gate, which more than makes up for it. The tab strip paints about 250 ms earlier.
  • Limits: the test machine was heavily loaded by unrelated work (1-minute load average 19–160), so times measured from app launch varied by seconds; the within-run intervals above are the reliable comparison. The builds were production bundles launched unpackaged, not a signed app. The profile had no native chat tab. Only warm restarts, where the terminal daemon survives, were measured.

Not tested:

  • Linux and Windows desktop builds; a packaged build.

  • Native chat on an SSH workspace (the test host had no agent CLI).

  • The legacy no-layout rendering path live (covered by unit tests only).

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

AI Disclosure

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Scope beyond startup: the browser and editor fixes also apply after a mid-session SSH drop and reconnect, which had the same stuck states on main. They are kept here because they read the same signal the startup fix needs; splitting them would give two behaviours for one fact.
  • Known, unchanged: while connecting, the editor's card still uses its existing "Unable to load file" title and Retry button around the connecting copy. The right-sidebar file explorer and the setup-script toast still show their own SSH errors during a reconnect, as on main.
  • Remote SSH: terminal panes on SSH workspaces are fenced by the same gate as before; the terminal overlay reads the same published status as before.
  • Mixed versions: renderer-only, with no RPC, stream or persisted change.
  • Folder workspaces: the hold and the signal are keyed by workspace id and the shared connection-owner lookup, so folders behave like worktrees.

Author X handle: @BrennanKB5

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…nnect

After a restart the workspace content area stayed blank — no tab strip, no
pane — until the whole startup chain (SSH reconnect, PTY reconnect, legacy
worker recovery) had finished, even though the restored tab model had been in
the store for seconds. Only terminal panes need that chain, because a pane
binds a PTY on mount.

The workbench now mounts the active workspace from its hydrated tab model, and
holds its terminal tabs unadmitted (an empty admitted-tab restriction with no
deferral entry) until startup restoration has published PTY ownership; the
activation plan then replaces the hold. Chat, browser and editor panes mount
immediately.

Carried from #22293 (922c1c2), renderer terminal files only.
…ts tabs watched

The startup hold was inferred from the restriction map's shape and released only
when a new hold was installed, so a workspace left for no workspace mid-startup
stayed mounted with no terminal panes and no background watchers. Track the held
workspace explicitly, release it on any pass where it is no longer held, apply it
after prune, and count held tabs as parked so their bells, titles, and
completions are still observed during the window.

Carried from #22293 (3fd421c), renderer terminal files only.
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

📝 Walkthrough

Walkthrough

The change separates active-worktree surface mounting from terminal-tab admission during startup. It adds startup hold state and helpers, applies the hold during cold activation, and uses held or activation-deferred tab IDs for mount selection and watcher synchronization. Visible tabs that are not admitted show a restoring placeholder. The change also adds worktree host connection state and uses it to control SSH browser preparation and editor file retries. Terminal pane host state now uses the shared host resolver, and degraded startup marks terminal restoration ready after a non-cancelled recovery pass.

Priority: ⬇️ Low

Merge Risk: 🔵 Low · up to 849d5

After switching runtime environments, an affected browser route or editor file may remain in an error state instead of retrying. Include the environment in the connection identity before merging, or accept this bounded risk.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 849d5

Restored workspaces will appear sooner while terminals reconnect, and host connection changes will trigger browser and editor work. Existing target and file-owner checks limit the apparent exposure, but interrupted startup and file-read enforcement need further verification.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The changed timing affects restored panes and registered SSH targets reachable from the application renderer. The available evidence does not establish new service, tenant, or environment authority.

Trust Boundaries and Controls

  • observed — Editor loading refuses an unresolved worktree owner before a local read and passes connection, worktree, and expected external SSH owner information into the file-read operation. The receiving operation's enforcement was not established here.

Resilience and Maintainability Implications

  • observed — Automatic terminal creation is separately gated on session and terminal-restoration readiness, limiting what terminal admission alone can initiate during recovery.

Hardening Proposals

  • proposed — Verify that the receiving file-read operation revalidates worktree and external-host ownership when restored editor tabs retry after reconnect; renderer retry state should not serve as authorization.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.40% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 47 functions across 35 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: the restored workspace renders before terminal reconnection completes.
Description check ✅ Passed The description is detailed and mostly complete. It covers the user impact, implementation, rationale, alternatives, linked issue context, visual proof, testing, limitations, and checklist status. The…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 9f04e587-79b4-4588-8357-d09b350e63a2

📥 Commits

Reviewing files that changed from the base of the PR and between f5d2ce5 and 51f891f.

📒 Files selected for processing (12)
  • src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx
  • src/renderer/src/components/terminal-cold-activation.ts
  • src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx
  • src/renderer/src/components/terminal-workspace-surface-ids.test.tsx
  • src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts
  • src/renderer/src/components/terminal/background-terminal-worktree-mount.ts
  • src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts
  • src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx
  • src/renderer/src/components/terminal/startup-terminal-tab-hold.test.ts
  • src/renderer/src/components/terminal/startup-terminal-tab-hold.ts
  • src/renderer/src/components/use-terminal-parking-foundation.ts
  • src/renderer/src/components/use-terminal-watcher-effects.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.

…d fold the held branch

A held worktree now falls through to the existing else, which already resets the activation marker.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one rough edge worth a look.

Reviewed changes

  • Split the startup gate from surface mounting: canMountTerminalWorkspaceForStartup → canAdmitTerminalTabsForStartup, so the workspace surface mounts from the hydrated tab model while terminal panes stay fenced.
  • New startup hold (startup-terminal-tab-hold.ts): installs an empty restriction for the active workspace, tracks it explicitly, releases it on switch-away / no-workspace / gate-open, and preserves a hold widened by a targeted background mount.
  • Held tabs treated as parked: selectParkedEquivalentMountTabIds feeds both the surface prop and the top-level watcher effect so held tabs keep watcher coverage.
  • Carried from #22293: activationDeferralPlanRevision as an admission-effect dep, plus the updated mount/watcher/cold-activation tests.

ℹ️ A held workspace mounted without an effective layout can fall out of watcher coverage

holdTerminalTabsForStartup adds the held workspace to mountedWorktreeIdsRef unconditionally, but the two watcher-coverage paths assume a mounted workspace either has an effective layout (the pane layer covers it) or is not mounted at all. A held workspace that is mounted, has no effective layout, while some other mounted workspace does have one produces a sync entry with an empty parked set and no deferred set; because TerminalSplitWorkspaceSurfaces skips layout-less workspaces and TerminalLegacyWorkspaceSurface is suppressed while any mounted workspace has a layout, no pane layer runs for it. Its held tabs then have neither a pane nor a watcher, and syncParkedTerminalTabWatchersForWorkspaces disposes any watcher a previous pass had started.

Technical details
# Held workspace mounted without a layout loses watcher coverage

## Affected sites
- `src/renderer/src/components/terminal/startup-terminal-tab-hold.ts:56` — adds the held workspace to `mountedWorktreeIdsRef` with no layout precondition.
- `src/renderer/src/components/use-terminal-watcher-effects.ts:93-99` — early `continue` when `anyMountedWorktreeHasLayout && mounted.has(workspaceId) && getEffectiveLayoutForWorktree(workspaceId)`.
- `src/renderer/src/components/use-terminal-watcher-effects.ts:103` — mounted branch only runs when `!anyMountedWorktreeHasLayout`.
- `src/renderer/src/components/use-terminal-watcher-effects.ts:143` — background branch only runs when `!mounted.has(workspaceId)`.
- `src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx:49-51` — layout-less workspaces are skipped.
- `src/renderer/src/components/TerminalLegacyWorkspaceSurface.tsx:11` — legacy panes suppressed while any mounted workspace has a layout.

## Required outcome
- While a hold is active, a held workspace's tabs must be covered by a parked watcher (or a mounted pane) in every pass, independent of whether that workspace currently has an effective layout.

## Open questions for the human
- Is a held workspace without an effective layout actually reachable? Groups are hydrated with the tab model and `ensureWorktreeRootGroup` runs in an effect, so the window may be a single commit; I could not construct an end-to-end startup sequence where another mounted workspace already has a layout during it. If it is reachable, the mounted branch at `use-terminal-watcher-effects.ts:103` could also run for a held workspace whose own `getEffectiveLayoutForWorktree` is falsy.

ℹ️ Nitpicks

  • The new terminal-parked-watcher-sync-entries case "watches the tabs a startup hold keeps unmounted on the visible workspace" leaves getEffectiveLayoutForWorktree: () => undefined and anyMountedWorktreeHasLayout: false, so it exercises the no-layout branch rather than the real hold (layout present) path; the title reads as if it covers the visible-with-layout case.
  • Pre-existing, not a regression: a whole-worktree background mount (requestBackgroundTerminalWorktreeMount without tabIds) deletes the hold's empty restriction, and once the workspace is in mountedWorktreeIdsRef the hold cannot re-install it (background-terminal-worktree-mount.ts:116), so panes can still bind during the window. Worth a one-line caveat in the hold's comment if "nothing spawns twice" is meant literally.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Since the prior review at 51f891f, commit f43a9bd folds the held branch and adds a surface-wiring test:

  • Folded the held branch in applyTerminalColdActivation: if (renderedActiveWorktreeId && !startupHeldWorktreeId) { …plan… } else { lastActivationWorktreeIdRef.current = null }. Behavior-preserving — the held and no-active cases both still null the ref so the gate-open pass runs the plan, and the plan body is unchanged.
  • New test terminal-split-workspace-surfaces-startup-hold.test.tsx: pins that TerminalSplitWorkspaceSurfaces hands the held workspace its heldTabIds, a deferred workspace its deferral set, and other workspaces null, plus the released case. Verified the new/changed suites pass (16 tests).

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

A restored SSH workspace now mounts its panes before the host reconnects.
Derive one per-worktree host phase from the published SSH state, treating a
target startup restoration has not dialed yet as connecting. The browser route
holds its prepare until the host connects and re-derives a failed route on
connect or a new connection generation; the editor shows the connecting state
instead of a dropped connection and reloads once the host connects.
While startup restoration holds terminal panes unmounted, the visible tab's
slot was blank. Fill the slot of a visible held tab, including one created
during the hold, with a restoring state until its pane mounts.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Since the prior pullfrog review at f43a9bd, two commits widen the change set beyond the terminal hold:

  • New worktree-host-connection-phase.ts: derives a per-worktree local | connecting | connected | unavailable phase plus connection generation from the published SSH/runtime state, and adds selectRuntimeAwareSshConnectionGeneration.
  • Browser routes wait for a connecting SSH host (use-ssh-workspace-browser-route.ts): a route whose host is connecting reports preparing instead of probing, a fresh connection/reconnection generation re-derives a route that failed or was held, and a ready route is exempt so a reconnect does not unmount the page.
  • Editor file loads wait for a connecting SSH host (useEditorPanelFileContentLoader.ts, useEditorPanelFileLoadRetry.ts): a read that fails through a still-connecting target is reclassified as owner-not-ready (machine code dropped), and the retry effect pauses while connecting, then reloads once on the connected/reconnected epoch, including re-arming an exhausted budget.
  • Held terminal tab placeholder (TerminalRestoringPlaceholder.tsx, TerminalPaneOverlayLayer.tsx, TerminalLegacyTerminalPanes.tsx): the visible held tab now paints a "Restoring terminal…" slot instead of nothing in both the overlay and legacy pane layers.
  • Tests: new host-phase selector suite, browser-route and editor-loader/retry host-connection suites, held-placeholder rendering suite, and the accompanying i18n entries.

ℹ️ The description no longer matches the change set

The PR body still frames the change as terminal-pane-only ("the chat, browser and editor panes render immediately") and lists "SSH-hosted workspaces" under Not tested, but the two new commits change exactly that: SSH browser and editor panes now hold until the host connects, and no manual SSH startup run is recorded.

Technical details
# Stale PR description vs. SSH-host behavior changes

## Affected sites
- PR body `## ELI5` / `## What Changed` / `## Testing` — describe the change as terminal-pane-only and state "Not tested: SSH-hosted workspaces", which the two new commits contradict.
- `src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts:68-101` — SSH browser routes now hold at `preparing` while `phase === 'connecting'`.
- `src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts:83-137` — SSH editor loads now pause retries while connecting.

## Required outcome
- Update the ELI5 / What Changed / Testing sections to cover the SSH browser/editor gating and the held-tab placeholder, and correct the "SSH-hosted workspaces" not-tested line (or record a manual SSH run).

## Open questions for the human
- Was an SSH-hosted startup run done for the new gating, or is the unit coverage intended to stand in for it?

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

The terminal host state now reads its SSH status from the same per-worktree
signal as the browser route and the editor, instead of resolving it on its own.
The signal carries the resolved status (an undialed target during startup
restoration reads as connecting), the owning environment, and a connection
epoch that changes on every reconnect, so no pane re-derives the no-entry case
or the reconnect generation. A nested target whose runtime cannot be seen is
reported as unverifiable rather than unavailable.
…shared signal

The shared host signal now carries the published status separately from its
phase. Only the phase reads a target startup restoration has not dialed yet
as connecting, so the terminal's reconnect overlay and error ownership keep
reporting what the host published, while the browser and editor still wait.
Tests pin that window for the terminal and pin that a host the client cannot
verify never holds the browser or editor.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Since the prior pullfrog review at 0c40d85, commit f56bbf9 refactors the SSH host signal into one shared reading:

  • One WorktreeHostConnection shape — worktree-host-connection-phase.ts now returns { phase, targetId, environmentId, status, connectedEpoch } and drops connectionGeneration; resolveWorktreeSshTarget becomes resolveWorktreeHostConnection, and selectTargetStatus keeps the pre-existing "not dialed yet reads connecting" startup fallback.
  • unverifiable phase added — a nested runtime target whose owning runtime is unreachable or not hydrated now reads unverifiable (where it used to read unavailable). Traced every consumer: all only branch on phase === 'connecting', so the rename is behavior-neutral.
  • Terminal pane reads the shared status — terminal-pane-host-state.ts uses host.status instead of calling selectRuntimeAwareSshStatus directly, so a direct target startup restoration has not dialed yet reads connecting rather than disconnected. Deliberate, and pinned by a new test.
  • Browser route and editor retry read connectedEpoch — both consume the shared reconnect identity instead of re-deriving it; the epoch string format changes from connected:${gen} to ${targetId}:${gen} (internal only), and the mocks/fixture were updated to the new shape.
  • selectRuntimeAwareSshConnectionGeneration added and re-exported through runtime-environment-ssh.ts.

Verified locally: the 4 affected suites (21 tests) pass, pnpm tc:web is clean, and check:code-quality:changed reports 0 new findings.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Since the prior pullfrog review at f56bbf9, commit 5a0ac95 corrects how the shared host signal names the raw SSH status:

  • Renamed status → publishedStatus on WorktreeHostConnection and extracted derivePhase, so the shared reading exposes both the phase (which still reads an undialed startup target as connecting) and the raw published status. Traced the derivation against f56bbf9: equivalent for every phase, including the environmentId === null startup fallback (local status defaults to 'disconnected', never null, so the null short-circuit only applies to nested targets).
  • The terminal names the published status — terminal-pane-host-state.ts reads host.publishedStatus rather than the phase-derived status, so an undialed direct target reads 'disconnected' (as on main) instead of the 'connecting' the prior commit briefly introduced. Deliberate, and the pane is held during startup so the overlay is only read once the gate opens.
  • Tests pin the divergence and the unverifiable path — terminal-pane-host-state.test.ts asserts phase connecting alongside status disconnected; the browser-route and editor-retry suites assert an unverifiable host prepares / keeps the owner-not-ready poll rather than waiting as connecting.

Verified locally at 5a0ac95: the 4 directly affected suites pass (24 tests), pnpm tc:web is clean, and check:code-quality:changed reports 0 new findings.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…fter reconnect

The degraded recovery path's successful reconnect set workspaceSessionReady
but never terminalStartupRestorationReady; only its failure branches did. Every
reader waiting on restoration then waited for the whole session: no fallback
terminal, no structured tab sync, the activation gate timing out, and an SSH
target never dialed at startup reading as connecting forever. Release the flag
once the reconnect finishes, unless a newer startup pass has taken over.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Since the prior pullfrog review at 5a0ac95, commit 849d58d releases terminal startup restoration on a degraded boot:

  • Degraded recovery publishes terminalStartupRestorationReady (startup-degraded-recovery.ts): after the recovery path's own reconnectPersistedTerminals and legacy-worker recovery succeed, the flag flips so restoration readers (agent activation gate, parked-terminal watchers, structured-session-tabs sync, and the host-connection startup fallback) stop waiting. Guarded by isCancelled() so a cancelled pass cannot stomp a newer one.
  • New startup-degraded-recovery.test.ts: pins the flag flip on a successful degraded reconnect and its suppression when the pass is cancelled mid-reconnect.

The fix closes a gap between the two degraded paths: the reconnectStarted branch already set the flag through forceWorkspaceSessionReady(), while the path that ran its own reconnect did not, leaving every reader gated on the flag closed for good. The new set does not touch hydrationSucceeded, so degraded no-save semantics are unchanged.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 78372f01-7860-4c02-8673-379121b1850e

📥 Commits

Reviewing files that changed from the base of the PR and between 0c40d85 and 849d58d.

📒 Files selected for processing (11)
  • src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.host-connection.test.tsx
  • src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts
  • src/renderer/src/components/editor/local-host-test-fixture.ts
  • src/renderer/src/components/editor/useEditorPanelFileLoadRetry.host-connection.test.tsx
  • src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts
  • src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts
  • src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts
  • src/renderer/src/lib/worktree-host-connection-phase.test.ts
  • src/renderer/src/lib/worktree-host-connection-phase.ts
  • src/renderer/src/startup/startup-degraded-recovery.test.ts
  • src/renderer/src/startup/startup-degraded-recovery.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.

targetId,
environmentId,
publishedStatus,
connectedEpoch: publishedStatus === 'connected' ? `${targetId}:${generation ?? ''}` : null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '75,120p' src/renderer/src/lib/worktree-host-connection-phase.ts
sed -n '45,150p' src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts
sed -n '65,155p' src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts
sed -n '50,90p' src/renderer/src/store/slices/runtime-environment-ssh-selectors.ts

Repository: stablyai/orca

Length of output: 11328


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- target resolver file outline/search ---'
rg -n "getExplicitRuntimeEnvironmentIdForWorktree|isRuntimeOwnedSshTargetId|resolveWorktreeHostConnection|connectedEpoch" src/renderer/src/lib src/renderer/src/components src/renderer/src/store
printf '%s\n' '--- resolver imports and surrounding source ---'
sed -n '1,110p' src/renderer/src/lib/worktree-host-connection-phase.ts
printf '%s\n' '--- environment ownership definitions ---'
rg -n -C 6 "function getExplicitRuntimeEnvironmentIdForWorktree|const getExplicitRuntimeEnvironmentIdForWorktree|export .*getExplicitRuntimeEnvironmentIdForWorktree|runtimeEnvironmentId|environmentId.*worktree|worktree.*environment" src/renderer/src
printf '%s\n' '--- browser reset continuation ---'
sed -n '1,190p' src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts
printf '%s\n' '--- editor effect continuation ---'
sed -n '1,220p' src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts

Repository: stablyai/orca

Length of output: 45650


🏁 Script executed:

rg -n -C 8 "getExplicitRuntimeEnvironmentIdForWorktree|connectedEpoch|selectRuntimeAwareSshConnectionGeneration" src/renderer/src && sed -n '1,120p' src/renderer/src/lib/worktree-host-connection-phase.ts && sed -n '1,190p' src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts && sed -n '1,180p' src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts

Repository: stablyai/orca

Length of output: 42092


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- worktree runtime owner ---'
sed -n '1,180p' src/renderer/src/lib/worktree-runtime-owner.ts
printf '%s\n' '--- runtime SSH selectors and generation references ---'
rg -n -C 5 "connectionGeneration|sshStateByEnvironment|runtimeEnvironmentId|environmentId" src/renderer/src/store/slices/runtime-environment-ssh* src/renderer/src/runtime src/renderer/src/lib/worktree-runtime-owner.test.ts
printf '%s\n' '--- focused owner tests ---'
sed -n '160,380p' src/renderer/src/lib/worktree-runtime-owner.test.ts
printf '%s\n' '--- host connection tests ---'
sed -n '130,230p' src/renderer/src/lib/worktree-host-connection-phase.test.ts

Repository: stablyai/orca

Length of output: 45662


🏁 Script executed:

sed -n '1,180p' src/renderer/src/lib/worktree-runtime-owner.ts; rg -n -C 5 "connectionGeneration|sshStateByEnvironment|runtimeEnvironmentId|environmentId" src/renderer/src/store/slices/runtime-environment-ssh* src/renderer/src/runtime src/renderer/src/lib/worktree-runtime-owner.test.ts; sed -n '160,380p' src/renderer/src/lib/worktree-runtime-owner.test.ts; sed -n '130,230p' src/renderer/src/lib/worktree-host-connection-phase.test.ts

Repository: stablyai/orca

Length of output: 45662


🏁 Script executed:

sed -n '90,260p' src/renderer/src/store/slices/runtime-environment-ssh.ts
rg -n -C 4 "runtimeOwnerEnvironmentId" src/renderer/src/lib src/renderer/src/store src/renderer/src/runtime shared | head -n 500
sed -n '1,240p' src/renderer/src/lib/worktree-operation-route.ts

Repository: stablyai/orca

Length of output: 42011


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- SSH state type ---'
rg -n -C 8 "type SshConnectionState|interface SshConnectionState|connectionGeneration" src shared 2>/dev/null | head -n 500
printf '%s\n' '--- environment SSH state producers ---'
rg -n -C 10 "setEnvironmentSshConnectionState\\(" src | head -n 500
printf '%s\n' '--- connection state construction ---'
rg -n -C 8 "connectionGeneration:" src shared 2>/dev/null | head -n 500

Repository: stablyai/orca

Length of output: 42156


🏁 Script executed:

set -e
printf '%s\n' '--- SSH state type ---'
rg -n -C 8 "type SshConnectionState|interface SshConnectionState|connectionGeneration" src shared 2>/dev/null | head -n 500
printf '%s\n' '--- environment SSH state producers ---'
rg -n -C 10 "setEnvironmentSshConnectionState\\(" src | head -n 500
printf '%s\n' '--- connection state construction ---'
rg -n -C 8 "connectionGeneration:" src shared 2>/dev/null | head -n 500

Repository: stablyai/orca

Length of output: 41744


🏁 Script executed:

set -e
rg -n -C 12 "export (type|interface) SshConnectionState|type SshConnectionState|interface SshConnectionState|connectionGeneration" src/main src/renderer src/preload | grep -E "ssh-types|SshConnectionState|connectionGeneration|providerEpoch" | head -n 300
rg -n -C 10 "connectionGeneration" src/main/ssh src/main/runtime src/renderer/src/ssh src/renderer/src/runtime/runtime-environment-ssh-state.ts 2>/dev/null | head -n 400

Repository: stablyai/orca

Length of output: 42798


Include environmentId in connectedEpoch.

When a worktree moves from runtime environment A to B, both environments can publish the same SSH target ID and generation. The current epoch then stays unchanged. The browser does not prepare the failed route again, and the editor does not reload an exhausted owner-unreachable file.

Suggested fix
-    connectedEpoch: publishedStatus === 'connected' ? `${targetId}:${generation ?? ''}` : null
+    connectedEpoch:
+      publishedStatus === 'connected'
+        ? `${environmentId ?? 'local'}:${targetId}:${generation ?? ''}`
+        : null
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
connectedEpoch: publishedStatus === 'connected' ? `${targetId}:${generation ?? ''}` : null
connectedEpoch:
publishedStatus === 'connected'
? `${environmentId ?? 'local'}:${targetId}:${generation ?? ''}`
: null

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready for review at 849d58d918

Summary: three review rounds, an architecture review, and live QA locally and over SSH. The last review round and both QA runs are clean. CI is green.

Review rounds

  • Round 1: the startup hold itself was correct. Two fixes landed in f43a9bd3ce. A render test now covers the held-tab watcher wiring in the split surfaces; it goes red without that wiring. The flagged reset of the last-activation marker during the hold never changed state, because the startup gate never closes again once open. That special branch is folded into the existing "no active workspace" branch.
  • Round 2: clean.
  • Live QA after round 2: early painting exposed panes that could not handle a host that was not ready yet:
    • a held terminal was a blank pane;
    • on SSH, the browser stayed on "SSH connection unavailable" after a slow reconnect that succeeded (a regression, 2 of 2 runs);
    • on SSH, the editor said "Click Reconnect" while the app was already connecting.
  • Architecture review: it kept the startup hold and recommended one derived per-workspace host-connection signal that every pane reads. Panes wait while connecting and re-derive on connect instead of keeping a failure. Gating inside each pane's attach path and keeping SSH workspaces blank were rejected; the PR body gives the reasons. Built in 55e0e81d6b, 0c40d85e0a, f56bbf9e23 and 5a0ac95823.
  • Round 3: clean. Every pane reads the one signal. Terminal behaviour is unchanged: the terminal shows the status as published, and only the phase treats a not-yet-dialed startup target as connecting.
  • Closing readiness check: one finding. After a degraded startup whose reconnect succeeds, the restoration flag was never released. That already affected existing readers on main, and it would have left a never-dialed SSH target reading "connecting" forever. Fixed at the source in 849d58d918, with tests.

Live QA (macOS dev builds, isolated profiles; Claude agents did this because the Grok QA seat was out of quota)

  • Local, with a 25 s delay before reconnect:
    • the surface painted, and held terminals showed "Restoring terminal…" in light and dark;
    • the placeholder was replaced in the same DOM update as the terminal mounted;
    • original shells reattached with scrollback, and a "+" terminal spawned exactly once;
    • with no delay, no placeholder flashed.
  • SSH, against a throwaway Linux container paused before relaunch:
    • the browser and editor showed connecting states and loaded by themselves, both before and after the 15 s startup timeout;
    • a mid-session drop left the loaded page and editor content intact;
    • when SSH never returned, each pane ended on its usual error with Retry;
    • terminals always kept exactly one remote shell.
  • Screenshots are in the PR body.
  • QA ran at 5a0ac95823. 849d58d918 only changes the degraded-startup path, which QA does not exercise; it is covered by unit tests.

Scope note: the browser and editor fixes also apply after a mid-session SSH drop and reconnect, not only at startup. They stay in this PR because they read the same signal the startup fix needs.

Not covered:

  • Linux and Windows desktop builds, and a packaged build.
  • Native chat on an SSH workspace (the test host had no agent CLI).
  • The legacy no-layout rendering path live (unit tests only).

Known and unchanged: while connecting, the editor keeps its existing "Unable to load file" title and Retry button around the connecting copy. The right-sidebar file explorer still shows its own SSH error during a reconnect, as on main.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Ready to merge. This is my view as coordinator of this PR set.

  • Scope: this PR carries fix(workspace): a restored workspace paints before its terminals reconnect #22293's terminal half (a restored workspace paints before its terminals reconnect), plus a "Restoring terminal…" placeholder. Review and QA also added one shared per-workspace host-connection signal that the browser and editor read. Brennan approved that full fix. It also fixes those panes after a mid-session SSH drop and reconnect, so it goes beyond startup, and the description says so. The closing readiness pass found and fixed a pre-existing startup-restoration latch at its source (849d58d), with tests.
  • Review: round 3 came back clean, with every pane reading the one signal. The readiness checklist passed.
  • QA: local QA passed. SSH QA passed 7 of 7 against a paused container: connecting states, self-recovery before and after the 15 s timeout, a mid-session drop, and truthful errors with Retry when SSH never returns.
  • CI: all green (18 pass, 12 skipped by design). Mergeable against main.
  • Gaps (stated in the description): Linux, Windows and packaged builds; native chat over SSH; and the legacy no-layout path, which is unit-tested only.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant