Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
# REMOTE-926: Reopening a closed cloud-mode tab should rejoin the session
## 1. Summary
When a user closes a tab that is viewing a cloud conversation (ambient agent / shared-session viewer) and then undoes the close via `Cmd-Shift-T` within the grace period, the restored tab should be live again: the viewer reconnects to the ongoing conversation, new blocks continue to stream in, the input is interactive (if the viewer had an editor role), and the "session ended" affordances do not appear. If the grace period expires without a restore, behavior is unchanged from today.
## 2. Problem
Closing a cloud-mode tab today tears down the shared-session viewer's WebSocket, marks the viewer's `SharedSessionStatus` as `FinishedViewer`, clears remote peers, inserts a "session ended" banner, and flips the input to `Selectable` (read-only). The tab is still held on the undo-close stack and can be restored with `Cmd-Shift-T`, but the restore path does not reverse any of that teardown. The user is left looking at a frozen snapshot of the conversation at the moment of close, even though the cloud agent keeps producing output server-side. This is a lossy experience that effectively discards in-flight agent work from the user's perspective.
## 3. Goals
- Restoring a closed cloud-mode tab within the undo-close grace period produces a tab indistinguishable from the one before close: connected, streaming, and interactive.
- Post-close, pre-restore: the tab's WebSocket to the session-sharing server remains healthy so the restore is instant and does not rely on a new rejoin.
- If the user does not restore the tab within the grace period, cleanup and teardown behavior match today exactly (network closes, conversations marked historical, blocks deleted, view dropped).
- No regressions for non-cloud tabs (local terminals, code panes, etc.) — close/restore must behave identically for those.
## 4. Non-goals
- Cross-window tab restore (undo-close across windows).
- Protocol-level "suspended viewer" state — we accept that during the grace period, the sharer may briefly see the closed-tab user in the participant list.
- Restoring a cloud conversation after the tab has been fully discarded from the undo stack — the existing "OpenAmbientAgentSession" / transcript-viewer paths already cover that.
- Changing the default 60s grace period (`UndoCloseGracePeriod`).
- Unifying the TerminalManager close/restore paths across local/remote/shared-session viewers.
- Supporting this for ambient-agent transcript viewers (the read-only "Open Conversation Transcript" flow) — that path does not hold a live WebSocket.
## 5. Figma / design references
Figma: none provided. This change is behavioral and is expected to have no new visual surface area.
## 6. User experience
All behavior applies to a tab whose active pane is a shared-session viewer for a cloud conversation (ambient agent session). Behavior for local-terminal, code, notebook, workflow, etc. panes is unchanged.
### Baseline state
- Tab `T` is open and actively viewing a cloud conversation. The cloud agent may be mid-response, idle, or finished.
- The viewer holds a live WebSocket to the session-sharing server via `shared_session::viewer::Network`, and its `SharedSessionStatus` reflects its role (Viewer, Editor, etc.).
- Input may be interactive or not depending on role; `self.shared_session` on the `TerminalView` is `Some(...)`; no "session ended" banner is present.
### Closing the tab (`Cmd-W`)
- The tab is removed from the tab bar.
- The tab is pushed onto the `UndoCloseStack` with its full `TabData` intact.
- The viewer's network connection and `SharedSessionStatus` are preserved (see invariants below). No "session ended" banner is inserted and the input interaction state is not changed.
- Agent events (new blocks, status updates, etc.) received from the server are still processed by the view model, but since the view is not rendered, nothing is drawn.
### Restoring the tab (`Cmd-Shift-T`) within the grace period
- The tab reappears in the tab bar at its original index and is activated.
- The tab's content matches the cloud conversation's current server-side state at the moment of restore — not the snapshot at close time. Any blocks, status transitions, or selected-conversation changes that occurred while the tab was hidden are visible.
- The input's editability matches the viewer's role, identical to pre-close behavior.
- No "session ended" banner is visible.
- The `shareable_object` for the pane (the link that drives copy-link / share dialog) is whatever it was before close.
- Further agent activity streams in normally.
### Discarding the tab (grace period expires without restore, or stack is bumped past limit)
- Identical to today. Conversations are marked historical, blocks are deleted from SQLite, `ActiveAgentViewsModel` unregisters the view, CLI agent sessions are cleaned up, the WebSocket is closed, `SharedSessionStatus` is set to `FinishedViewer`, and the `TerminalView` is dropped. The conversation remains searchable as historical but is no longer live.
### Restoring a tab after its in-memory WebSocket died
The grace period is bounded, but the WebSocket may still die independently of user action (e.g., network glitch, server restart). If the tab is restored while the network is attempting to reconnect or has entered `Stage::Finished` due to irrecoverable failure:
- If reconnect is in-flight (`Stage::Reconnecting`), the restored tab renders the reconnecting state (existing behavior in `NetworkEvent::Reconnecting`).
- If the network gave up (`Stage::Finished` due to `FailedToReconnect` or `SessionEnded`), the restored tab shows the same "session ended" banner it would have shown had the tab been open when the failure occurred. The restore does not mask genuine session-ended signals from the server.
### Non-cloud tabs
- Close and restore behavior for local-terminal, code, notebook, and other pane types is unchanged.
### Invariants
- While a closed cloud-mode tab sits on the undo-close stack:
- Its viewer `Network` model is alive and in `Stage::JoinedSuccessfully` (or `Reconnecting`) — it has not been told `close_without_reconnection`.
- Its `TerminalModel::shared_session_status()` is not `FinishedViewer`.
- `TerminalView::shared_session` is `Some(...)`.
- The "shared session ended" banner has not been inserted.
- The input's `InteractionState` has not been flipped to `Selectable` by `on_session_share_ended`.
- `ActiveAgentViewsModel` has unregistered the view (conversation moves to "Past" in the conversation list for the duration of the grace period). On restore, `TerminalPane::attach` re-registers with `ActiveAgentViewsModel`, moving the conversation back to "Active".
- On grace-period expiry, all of the above are torn down exactly as they would be if the tab had never been closeable (i.e., matches current "Closed" behavior).
- On restore, no additional work is needed to make the tab live — the view simply becomes visible again and the already-live network drives it.
## 7. Success criteria
- Closing a cloud-mode tab and immediately restoring it (`Cmd-W` followed by `Cmd-Shift-T`) results in a tab that is fully live, identical in state to before close (save for any server-driven updates that happened in between).
- If the user sends a new prompt in the live cloud conversation (e.g., from another client) while the tab is closed, restoring the tab reflects the new agent output.
- After restore, the input editable state matches the viewer's role. If the role was Editor, `Cmd-Enter` / typing works as before.
- After restore, no "session ended" banner is present on the restored tab.
- After grace-period expiry, the conversation transitions from "Active" to "Past" in the conversation list (same as today).
- After grace-period expiry, SQLite no longer contains blocks for that session UUID (same as today).
- Closing and restoring a non-shared-session tab (local terminal, code pane, notebook, workflow) produces identical behavior to today.
- Closing a cloud-mode tab, waiting past the grace period, and then attempting `Cmd-Shift-T` does not reopen the tab (stack is empty) and produces no errors.
## 8. Validation
- Integration test (new): open a cloud conversation viewer, close the tab, simulate the sharer emitting a new terminal event while the tab is closed, restore the tab, assert the new event is visible in the restored view.
- Integration test (new): open a cloud conversation viewer, close the tab, restore the tab, assert no "session ended" banner is present and input is editable (for Editor role).
- Integration test (new): open a cloud conversation viewer, close the tab, wait for grace-period expiry, assert network is closed, `SharedSessionStatus::FinishedViewer` is set, and conversation is marked historical.
- Unit test (new): `TerminalManager::on_view_closed` with `DetachType::HiddenForClose` for a shared-session viewer leaves `network_state` in `Active` and `SharedSessionStatus` unchanged.
- Unit test (new): `TerminalManager::on_view_closed` with `DetachType::Closed` for a shared-session viewer performs today's full teardown.
- Regression test: existing local-terminal close/restore tests continue to pass.
- Manual: run `cargo run` locally, reproduce the original bug (close tab mid-agent-response, `Cmd-Shift-T`), confirm the tab is live and updating.
## 9. Open questions
- During the grace period, the sharer (and any co-viewers) will still see the closing user in the participant list via heartbeats/presence. Is that an acceptable ghost-viewer window, or should we also send a lightweight "presence: suspended" hint to the server so UI elsewhere can dim or hide the ghost? Recommended resolution: accept today and track a protocol-level suspension as a follow-up (Non-goal in §4).
- Should any UI indicate that the tab is "suspended for undo" while it's on the stack (not visible), for debugging or transparency? Recommended resolution: no UI — the tab is not visible to the user during this period.
+213
View File
@@ -0,0 +1,213 @@
# REMOTE-926: Reopening a closed cloud-mode tab should rejoin the session — Tech Spec
## 1. Problem
`TerminalPane::detach` unconditionally invokes `TerminalManager::on_view_closed` on every detach, including the "reversible" `DetachType::HiddenForClose` case that is used when a tab is closed but remains on the `UndoCloseStack`. For the shared-session viewer `TerminalManager`, `on_view_closed` is destructive: it closes the WebSocket with `close_without_reconnection`, flips `SharedSessionStatus` to `FinishedViewer`, and calls `TerminalView::on_session_share_ended` (which drops `shared_session`, inserts the ended banner, unregisters peers, and makes input read-only). None of this is reversed by `restore_closed_tab` / `PaneGroup::reattach_panes`, so restoring a cloud-mode tab yields a frozen, read-only snapshot instead of a live viewer.
The goal of this spec is to make `HiddenForClose` genuinely non-destructive for the shared-session viewer, so that `attach` on restore needs to do no extra work to bring the view back to a live state. `Closed` remains fully destructive.
## 2. Relevant code
- `app/src/pane_group/pane/mod.rs (516-526)` — `DetachType` enum (`Closed` / `HiddenForClose` / `Moved`).
- `app/src/pane_group/pane/mod.rs:594` — `PaneContent::detach` trait signature.
- `app/src/pane_group/pane/terminal_pane.rs (311-364)` — `TerminalPane::detach`; gates block deletion / conversation clearing on `DetachType::Closed` but calls `on_view_closed` unconditionally (line 333).
- `app/src/pane_group/pane/terminal_pane.rs (208-309)` — `TerminalPane::attach`; re-wires focus + event subscriptions + re-registers with `ActiveAgentViewsModel`.
- `app/src/pane_group/mod.rs (6302-6351)` — `clean_up_panes` (uses `Closed`), `detach_panes` / `detach_panes_for_close` (uses `HiddenForClose`), `reattach_panes`, `attach_pane`.
- `app/src/terminal/mod.rs` / `TerminalManager` trait — `fn on_view_closed(&self, app: &mut AppContext)`. Implemented in:
- `app/src/terminal/shared_session/viewer/terminal_manager.rs (1414-1441)` — destructive viewer teardown we need to split.
- `app/src/terminal/local_tty/terminal_manager.rs:2076` — local-tty `on_view_closed` (effectively a no-op for network state).
- `app/src/terminal/remote_tty/terminal_manager.rs` — remote-tty `on_view_closed`.
- `app/src/terminal/MockTerminalManager` — used by conversation transcript viewers and tests.
- `app/src/terminal/view/shared_session/view_impl.rs (683-735)` — `TerminalView::on_session_share_ended`, the UI-side teardown routine.
- `app/src/terminal/shared_session/viewer/network.rs (764-777)` — `Network::close` / `close_without_reconnection`.
- `app/src/workspace/view.rs (10104-10120)` — `Workspace::restore_closed_tab`.
- `app/src/workspace/view.rs (9716-9785)` — `Workspace::remove_tab`; calls `detach_panes_for_close` (line 9755).
- `app/src/undo_close/stack.rs (354-388)` — `UndoCloseStack::push_item`; spawns the grace-period expiry task.
- `app/src/undo_close/stack.rs (70-150)` — `ClosedItem::discard` / `clean_up_pane_group`; runs on grace-period expiry and invokes `clean_up_panes` → `DetachType::Closed`.
## 3. Current state
On `Cmd-W` for a cloud-mode tab:
1. `Workspace::remove_tab` → `PaneGroup::detach_panes_for_close` → `detach_panes` → `pane.detach(.., HiddenForClose, ..)`.
2. `TerminalPane::detach`:
- Skips block deletion / conversation clearing (gated on `DetachType::Closed`).
- **Still** calls `terminal_manager.on_view_closed(ctx)` for every view in the pane stack.
- Unregisters from `ActiveAgentViewsModel`, `CLIAgentSessionsModel`, and unsubscribes from pane-stack / view / Manager / history events.
3. Shared-session viewer `TerminalManager::on_view_closed`:
- `network.close_without_reconnection()` (WS is gone; no reconnect will ever be tried).
- `model.set_shared_session_status(FinishedViewer)`.
- `view.on_session_share_ended(ctx)` — drops `shared_session`, inserts ended banner, unregisters remote peers, flips input to `Selectable` (if viewer role).
4. `TabData` is placed on the `UndoCloseStack` with a 60s expiry task (`push_item` at stack.rs:354).
On `Cmd-Shift-T`:
5. `UndoCloseStack::undo_close` → `Workspace::restore_closed_tab` → `PaneGroup::reattach_panes` → `TerminalPane::attach` for each pane.
6. `TerminalPane::attach` just re-wires focus handles + event subscriptions + re-registers with `ActiveAgentViewsModel`. It does nothing to undo steps 3a-3c.
Net: the restored tab shows the frozen close-time snapshot with an ended banner, while the cloud agent continues to run server-side.
The grace-period expiry path is already correctly wired up for full teardown: `ClosedItem::discard` for `ClosedItem::Tab` → `clean_up_pane_group` → `pane_group.clean_up_panes(ctx)` → `pane.detach(.., Closed, ..)`. That path calls `on_view_closed` a second time on the same managers (harmless-but-redundant today; in the new design this becomes the *actual* destructive call).
## 4. Proposed changes
The core change is to thread `DetachType` into the `TerminalManager` detach lifecycle so shared-session viewers can distinguish "suspended, will likely come back" from "permanently closed".
### 4.1 Widen `TerminalManager::on_view_closed` to take a `DetachType`
Rename or extend the trait method. Preferred shape:
```rust path=null start=null
pub trait TerminalManager {
/// Called when the owning pane is detaching. `detach_type` indicates whether
/// the detach is reversible (`HiddenForClose`), a permanent close (`Closed`),
/// or a move (`Moved`).
fn on_view_detached(&self, detach_type: DetachType, app: &mut AppContext);
// ... existing methods unchanged
}
```
Rationale for rename: today's `on_view_closed` is a misleading name given we want to call it for non-close detaches too. The new name also opens the door to `Moved` behavior if we ever need to customize it. `DetachType` already lives in `app/src/pane_group/pane/mod.rs` and is appropriate to expose to the terminal layer.
### 4.2 Update `TerminalPane::detach` to pass the detach type
In `app/src/pane_group/pane/terminal_pane.rs`:
```rust path=null start=null
for (manager, view) in contents {
manager.update(ctx, |terminal_manager, ctx| {
terminal_manager.on_view_detached(detach_type, ctx);
});
ctx.unsubscribe_to_view(&view);
}
```
`ActiveAgentViewsModel::unregister_agent_view_controller` is called unconditionally (including on `HiddenForClose`), so the conversation moves from Active → Past in the conversation list during the grace period. This is intentional: showing an "Active" conversation whose pane is not in any tab group would be misleading — clicking it would not navigate anywhere. On restore, `TerminalPane::attach` calls `register_agent_view_controller` (line 301-308), moving the conversation back to Active. `CLIAgentSessionsModel::remove_session` is also called unconditionally except for `Moved` (where the session is still running in the relocated pane).
### 4.3 Shared-session viewer `TerminalManager` — branch on `DetachType`
In `app/src/terminal/shared_session/viewer/terminal_manager.rs`:
```rust path=null start=null
fn on_view_detached(&self, detach_type: DetachType, app: &mut AppContext) {
// Keep network alive for non-permanent detaches:
// - HiddenForClose: may be restored from the undo-close stack.
// - Moved: the same TerminalManager is reused in the target pane group
// (see §4.6 for the code-path analysis).
if !matches!(detach_type, DetachType::Closed) {
return;
}
// Destructive teardown — only reached on DetachType::Closed.
let terminal_view_id = self.view.id();
ActiveAgentViewsModel::handle(app).update(app, |model, ctx| {
model.unregister_agent_view_controller(terminal_view_id, ctx);
model.unregister_ambient_session(terminal_view_id, ctx);
});
if let NetworkState::Active(ref network) = self.network_state {
network.update(app, |network, _| {
network.close_without_reconnection();
});
}
self.model
.lock()
.set_shared_session_status(SharedSessionStatus::FinishedViewer);
self.view
.update(app, |view, ctx| view.on_session_share_ended(ctx));
}
```
### 4.4 Local-tty / remote-tty / Mock managers — behavior unchanged
For `local_tty::TerminalManager`, `remote_tty::TerminalManager`, and `MockTerminalManager`, forward any value of `DetachType` to today's logic. Those `on_view_closed` bodies are already effectively no-ops for network concerns and don't need to differentiate between detach types. A one-line migration: rename `on_view_closed` → `on_view_detached(_: DetachType, ..)` and ignore the parameter.
### 4.5 `TerminalPane::attach` — re-registration on restore
`attach` re-registers with `ActiveAgentViewsModel` (terminal_pane.rs:301-308). Because `HiddenForClose` unconditionally unregisters the view (§4.2), `attach` on restore performs a fresh registration — the conversation moves back from Past to Active in the conversation list at the moment the tab is restored.
No changes are required in `Workspace::restore_closed_tab` or `PaneGroup::reattach_panes` beyond what `attach` already does — because the shared-session viewer was never torn down, the view picks up where it left off.
### 4.6 Handle `DetachType::Moved` — TerminalManager is reused, not replaced
The move-pane path calls `PaneGroup::remove_pane_for_move`, which calls `pane.detach(.., Moved, ..)` and then returns the same `Box<dyn AnyPaneContent>`. That box is immediately inserted into the target pane group via `init_pane` → `try_attach_pane` → `attach_pane` → `pane.attach(..)`. The same `TerminalPane` (and therefore the same `TerminalManager`) is re-attached; nothing is replaced. Consequently, `on_view_detached(Moved, ..)` should be a no-op for the viewer, just like `HiddenForClose`. The implementation uses `!matches!(detach_type, DetachType::Closed)` so both cases are preserved.
### 4.7 Sanity: do not double-teardown on expiry
Under the new design, when the grace period expires, the discard path calls `clean_up_panes` → `pane.detach(.., Closed, ..)` → `on_view_detached(Closed, ..)`. This is now the *first* destructive call for the shared-session viewer. No redundant work. No behavior changes for local panes.
## 5. End-to-end flow
Close → restore (happy path):
1. User hits `Cmd-W` on a cloud-mode tab.
2. `Workspace::remove_tab` → `detach_panes_for_close` → `detach_panes` → `TerminalPane::detach(.., HiddenForClose, ..)`.
3. `TerminalPane::detach` calls `ActiveAgentViewsModel::unregister_agent_view_controller` and `CLIAgentSessionsModel::remove_session` unconditionally (conversation moves Active→Past); unsubscribes from view events; invokes `TerminalManager::on_view_detached(HiddenForClose, ..)`.
4. Shared-session viewer's `on_view_detached(HiddenForClose, ..)` is a no-op. Network stays `Active`; `SharedSessionStatus` unchanged; `TerminalView::shared_session` still `Some(...)`.
5. `TabData` is pushed onto `UndoCloseStack` with a 60s expiry timer.
6. While the tab is on the stack, `NetworkEvent`s from the server continue to be dispatched into the (now-detached) view, updating the underlying `TerminalModel` / `BlocklistAIHistoryModel`. Visual rendering is skipped because the view is not in any active pane group layout.
7. User hits `Cmd-Shift-T`. `UndoCloseStack::undo_close` pops the item (drop of `ExpiryData` aborts the expiry timer). `Workspace::restore_closed_tab` reinserts the `TabData` and calls `pane_group.reattach_panes(ctx)`.
8. `TerminalPane::attach` re-subscribes to view events, re-registers with `ActiveAgentViewsModel` (conversation moves Past→Active). The view is now rendered again, backed by live model state.
Close → expire (no restore):
1-6 as above.
7'. 60s later, the `spawn_abortable` timer fires in `UndoCloseStack::push_item`'s closure. It removes the item from the stack and calls `ClosedItem::discard`.
8'. `discard` marks conversations historical, then `clean_up_pane_group` → `pane_group.clean_up_panes(ctx)` → `TerminalPane::detach(.., Closed, ..)` → `on_view_detached(Closed, ..)` → full teardown. Matches today's final state.
## 6. Diagrams
```mermaid
sequenceDiagram
participant User
participant Workspace
participant PaneGroup
participant TerminalPane
participant TM as SharedSessionViewer<br/>TerminalManager
participant Net as Network (WS)
participant Stack as UndoCloseStack
User->>Workspace: Cmd-W
Workspace->>PaneGroup: detach_panes_for_close
PaneGroup->>TerminalPane: detach(HiddenForClose)
TerminalPane->>TM: on_view_detached(HiddenForClose)
Note over TM,Net: no-op — network stays Active
Workspace->>Stack: push_item(TabData, 60s expiry)
Note over User,Net: Cloud agent keeps emitting events over the live WS
alt User hits Cmd-Shift-T within 60s
User->>Stack: undo_close
Stack->>Workspace: restore_closed_tab
Workspace->>PaneGroup: reattach_panes
PaneGroup->>TerminalPane: attach
Note over TerminalPane,Net: view re-renders;<br/>network was never torn down
else 60s elapse with no restore
Stack->>Stack: expiry timer fires
Stack->>PaneGroup: clean_up_panes
PaneGroup->>TerminalPane: detach(Closed)
TerminalPane->>TM: on_view_detached(Closed)
TM->>Net: close_without_reconnection
TM->>TM: set SharedSessionStatus::FinishedViewer
TM->>TM: view.on_session_share_ended
end
```
## 7. Risks and mitigations
- **Ghost viewer in participant list during grace period.** The sharer and any co-viewers see this user in the participant list for up to 60s after close. Mitigation: accept for v1 (non-goal §4 in PRODUCT.md). Follow-up: protocol-level "suspended" state.
- **Leaked network if expiry task fails to fire.** The grace-period `Timer::after` is driven by the app's async runtime; if the process doesn't crash, expiry always fires. If the process *does* crash, the network dies with it. No new leak risk beyond what exists today for any spawned task.
- **Conversation flickers Active→Past→Active during restore.** When the tab is closed, `unregister_agent_view_controller` fires immediately (conversation moves to Past). On restore, `attach` re-registers (conversation moves back to Active). This brief flicker is acceptable and preferable to showing an "Active" conversation that cannot be navigated to while the pane is off-screen.
- **In-flight `NetworkEvent`s reaching a detached view.** During the grace period, `weak_view_handle.upgrade(ctx)` still succeeds (the view is still alive, just not laid out), so event handlers will keep running. This is desirable — it keeps the model in sync — but we should verify there are no handlers that assume the view is currently visible (e.g. toast-show paths). Mitigation: audit `handle_network_events` (terminal_manager.rs:500-1170) for visibility assumptions; in particular `show_persistent_toast` calls should still be safe. Add an integration test that exercises a `SessionEnded` server message arriving while the tab is hidden.
- **Terminal transcript viewer / conversation viewer tabs.** These use `MockTerminalManager` and do not hold a live network. The rename to `on_view_detached` is a mechanical no-op for them.
- **Move pane path.** `DetachType::Moved` is now a no-op for the shared-session viewer because the `TerminalManager` is reused (not replaced) when a pane is dragged to another tab. Tests around moving a cloud-mode pane across tabs should verify the session stays live after the move.
- **Preview / dogfood rollout.** The change is not feature-flagged; it modifies fundamental detach/attach semantics. Risk is moderate because non-cloud panes should see no behavior change. Mitigation: thorough test coverage in §8 and manual QA of local-terminal close/restore, move-pane, and close-all-tabs-except flows.
## 8. Testing and validation
- **Unit tests**
- `viewer::TerminalManager::on_view_detached(HiddenForClose)` leaves `network_state` as `Active`, `SharedSessionStatus` unchanged, and does not insert the ended banner on the view.
- `viewer::TerminalManager::on_view_detached(Closed)` performs today's full teardown.
- `viewer::TerminalManager::on_view_detached(Moved)` is a no-op (network stays `Active`, `SharedSessionStatus` unchanged) because `TerminalManager` is reused across the move.
- `local_tty::TerminalManager::on_view_detached` behaves identically for all `DetachType` values (regression).
- **Integration tests** (using the `crates/integration` framework; see skill `warp-integration-test`)
- Close a shared-session viewer tab, emit a new `DownstreamMessage::OrderedTerminalEvent` from a mock server, restore the tab, assert the new block is present.
- Close a shared-session viewer tab with editor role, restore the tab, assert input is still `Editable`.
- Close a shared-session viewer tab, assert no ended banner was inserted, restore, assert no ended banner.
- Close a shared-session viewer tab, let the grace period expire (use a short override), assert conversation is marked historical, assert `TerminalView` is dropped, assert `SharedSessionStatus::FinishedViewer`.
- Close a local-terminal tab and restore it — existing tests continue to pass.
- Close cloud tab, then close another cloud tab, then `Cmd-Shift-T` twice — both restore live.
- **Presubmit**
- `./script/presubmit` (cargo fmt + clippy + tests). Note the `WARP.md` rule about running this before PR.
- **Manual**
- Run `cargo run --features with_local_server` against a local `warp-server`, spawn a cloud agent conversation, close the tab mid-agent-response, `Cmd-Shift-T`, verify the view keeps streaming.
- Verify the conversation list moves the conversation to "Past" immediately on close, then back to "Active" on restore; and that it stays "Past" → finalizes to historical after grace-period expiry.
## 9. Follow-ups
- Send a "viewer suspended" hint to the session-sharing server so the sharer's participant UI can dim / hide the ghost viewer during the grace period. Requires a protocol change (`UpstreamMessage::Suspend` or similar) and server-side handling.
- Once the `DetachType`-aware trait method is in place, consider extending the same pattern to non-terminal panes (code, notebook, workflow) if any of them grow network state that would benefit from suspension.
- Reconsider the 60s default `UndoCloseGracePeriod` specifically for cloud-mode tabs — may want a tighter bound to reduce the ghost-viewer window without penalizing the common local-tab case.
- Long term: reconcile the shared-session viewer detach/attach lifecycle with the deferred-join flow (`new_deferred` + `connect_to_session` in `viewer/terminal_manager.rs (268-292)`) so that a truly re-joinable viewer can be expressed as a first-class `NetworkState::Suspended` variant, making the "HiddenForClose no-op" case an explicit state transition rather than an absence of action.