fix(terminals): decouple PTY lifecycle from view lifecycle (no kill on navigation)

Navigating (layout/tab switch) tore the xterm view down and called
handle.close(), killing the backend PTY and cutting off running AIs. Now
the view's cleanup only detaches; only an explicit user action kills a PTY.

Backend:
- PortablePtyAdapter: per-session scrollback ring buffer (~100KB, most
  recent) + re-subscribable fan-out broadcast replacing the single-take
  output_rx. Reader thread feeds both the ring buffer and current
  subscribers; on EOF it closes subscribers (streams end) while keeping
  scrollback for late re-attach.
- PtyPort: new scrollback() method; subscribe_output is now re-subscribable
  (all impls + test fakes updated).
- reattach_terminal IPC command: returns scrollback and re-wires a fresh
  output channel on the live session without re-spawning.
- CloseRequested hook kills all live PTYs cleanly on app shutdown.
- TerminalSessions::handles() to enumerate live sessions at shutdown.

Frontend:
- TerminalHandle.detach(); TerminalGateway/AgentGateway.reattach() + mocks.
- TerminalView cleanup detaches (never close); on mount it re-attaches to a
  persisted session (repainting scrollback) instead of opening a new PTY.
- LayoutGrid persists the cell's session id via setSession; AgentsPanel
  tracks per-agent session ids — both drive reattach-vs-open.

Tests: ring buffer bounds to 100KB keeping newest bytes; scrollback retained;
re-subscription delivers post-reattach output; TerminalView detaches (not
closes) on unmount and reattaches with a known session; mock detach/reattach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 12:24:48 +02:00
parent 307ae71857
commit 0660f52e2b
19 changed files with 879 additions and 150 deletions

View File

@ -28,7 +28,7 @@ use crate::dto::{
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, OpenTerminalRequestDto,
ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto,
RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto,
ReattachResultDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto,
SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto,
UpdateTemplateRequestDto, WriteTerminalRequestDto,
@ -244,6 +244,69 @@ pub async fn close_terminal(
result
}
/// `reattach_terminal` — re-bind a view to a **still-living** PTY without
/// re-spawning it.
///
/// Navigation (switching layout/tab) tears the xterm view down but must NOT kill
/// the backend PTY (the AI keeps running). When the view comes back it calls this
/// command, which:
/// 1. reads the session's retained **scrollback** so the terminal can repaint,
/// 2. registers the new per-session [`Channel`] in the [`PtyBridge`],
/// 3. starts a fresh output pump subscribed to the live PTY (re-subscribable
/// broadcast), so new bytes flow to the new channel.
///
/// Returns the scrollback bytes; the frontend writes them into xterm first, then
/// receives subsequent output over `on_output`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND`/`PROCESS`
/// if the session is no longer alive).
#[tauri::command]
pub fn reattach_terminal(
session_id: String,
on_output: Channel<PtyChunk>,
state: State<'_, AppState>,
) -> Result<ReattachResultDto, ErrorDto> {
let sid = parse_session_id(&session_id)?;
let handle = PtyHandle { session_id: sid };
// (1) Snapshot the scrollback. A NotFound here means the PTY is gone (was
// explicitly closed or exited) — surfaced as an error so the caller falls
// back to opening a fresh terminal.
let scrollback = state
.pty_port
.scrollback(&handle)
.map_err(|e| ErrorDto::from(AppError::from(e)))?;
// (2) Register the new output channel for this session, replacing any stale
// one from a previous attach.
state.pty_bridge.register(sid, on_output);
// (3) Subscribe afresh to the live byte stream and pump it to the channel.
match state.pty_port.subscribe_output(&handle) {
Ok(stream) => {
let bridge: std::sync::Arc<PtyBridge> = std::sync::Arc::clone(&state.pty_bridge);
std::thread::spawn(move || {
for chunk in stream {
if !bridge.send_output(&sid, chunk) {
break;
}
}
bridge.unregister(&sid);
});
}
Err(e) => {
state.pty_bridge.unregister(&sid);
return Err(ErrorDto::from(AppError::from(e)));
}
}
Ok(ReattachResultDto {
session_id,
scrollback,
})
}
// ---------------------------------------------------------------------------
// Layout (L4)
// ---------------------------------------------------------------------------

View File

@ -245,6 +245,18 @@ impl From<OpenTerminalOutput> for TerminalSessionDto {
}
}
/// Response DTO for `reattach_terminal`: the retained scrollback of a still-live
/// session, repainted into the re-mounting xterm before the new output stream is
/// wired. Bytes are serialised as a number array, matching the PTY output channel.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReattachResultDto {
/// The session that was re-attached (echoed back for the frontend).
pub session_id: String,
/// The most-recent retained output bytes (scrollback ring buffer).
pub scrollback: Vec<u8>,
}
/// Request DTO for `write_terminal`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]

View File

@ -48,6 +48,28 @@ pub fn run() {
events::spawn_relay(app.handle().clone(), &app_state.event_bus);
app.manage(app_state);
// Kill all live PTYs cleanly when the main window is closing. This is
// independent of the per-view (navigation/layout) lifecycle — those
// must NEVER kill a PTY — and only fires on a genuine app shutdown.
// A brutal crash is best-effort and out of scope.
if let Some(window) = app.get_webview_window("main") {
let handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
if let Some(state) = handle.try_state::<AppState>() {
let pty = std::sync::Arc::clone(&state.pty_port);
let handles = state.terminal_sessions.handles();
tauri::async_runtime::block_on(async move {
for h in handles {
let _ = pty.kill(&h).await;
}
});
}
}
});
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@ -60,6 +82,7 @@ pub fn run() {
commands::write_terminal,
commands::resize_terminal,
commands::close_terminal,
commands::reattach_terminal,
commands::load_layout,
commands::mutate_layout,
commands::list_layouts,