From 5ff09f329aaa20986b26f5fe9c8ae8df958cf914 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 29 Mar 2026 14:35:23 +0800 Subject: [PATCH] fix: stabilize workspace transport and session persistence --- apps/server/src/services/agent.rs | 35 ++- apps/server/src/services/mod.rs | 1 + apps/server/src/services/terminal.rs | 19 +- apps/server/src/services/utf8_stream.rs | 85 ++++++ apps/server/src/ws/protocol.rs | 36 +++ apps/server/src/ws/server.rs | 177 ++++++++++- .../web/src/components/terminal/XtermBase.tsx | 28 +- .../features/agents/AgentWorkspaceFeature.tsx | 5 +- .../app/WorkbenchRuntimeCoordinator.tsx | 14 +- .../features/workspace/WorkspaceScreen.tsx | 174 ++++++----- .../src/features/workspace/runtime-attach.ts | 63 +++- .../workspace/workspace-layout-actions.ts | 18 +- .../workspace/workspace-ready-runtime.ts | 10 + .../workspace/workspace-route-runtime.ts | 7 + .../workspace/workspace-view-persistence.ts | 190 +++++++++++- apps/web/src/services/http/agent.service.ts | 34 ++- apps/web/src/services/http/session.service.ts | 131 +++++++- .../web/src/services/http/terminal.service.ts | 33 ++- .../src/services/http/workspace.service.ts | 18 +- apps/web/src/services/http/ws-rpc-fallback.ts | 27 ++ apps/web/src/shared/utils/stream-snapshot.ts | 88 ++++++ apps/web/src/shared/utils/workspace.ts | 90 ++++-- apps/web/src/styles/app.css | 6 + apps/web/src/ws/client.ts | 5 +- apps/web/src/ws/connection-manager.ts | 27 +- apps/web/src/ws/heartbeat.ts | 7 +- apps/web/src/ws/protocol.ts | 57 ++++ tests/runtime-attach.test.ts | 75 +++++ tests/session-service.test.ts | 140 +++++++++ tests/stream-snapshot.test.ts | 41 +++ tests/workspace-layout-actions.test.ts | 279 ++++++++++++++++++ tests/workspace-ready-runtime.test.ts | 23 ++ tests/workspace-route-runtime.test.ts | 19 ++ tests/workspace-runtime-controller.test.ts | 127 ++++++++ .../workspace-view-persist-scheduler.test.ts | 139 +++++++++ tests/workspace-view-persistence.test.ts | 141 +++++++++ tests/ws-rpc-fallback.test.ts | 73 +++++ 37 files changed, 2262 insertions(+), 180 deletions(-) create mode 100644 apps/server/src/services/utf8_stream.rs create mode 100644 apps/web/src/features/workspace/workspace-ready-runtime.ts create mode 100644 apps/web/src/features/workspace/workspace-route-runtime.ts create mode 100644 apps/web/src/services/http/ws-rpc-fallback.ts create mode 100644 apps/web/src/shared/utils/stream-snapshot.ts create mode 100644 tests/session-service.test.ts create mode 100644 tests/stream-snapshot.test.ts create mode 100644 tests/workspace-layout-actions.test.ts create mode 100644 tests/workspace-ready-runtime.test.ts create mode 100644 tests/workspace-route-runtime.test.ts create mode 100644 tests/workspace-view-persist-scheduler.test.ts create mode 100644 tests/ws-rpc-fallback.test.ts diff --git a/apps/server/src/services/agent.rs b/apps/server/src/services/agent.rs index f18a81e..391b7de 100644 --- a/apps/server/src/services/agent.rs +++ b/apps/server/src/services/agent.rs @@ -1,4 +1,5 @@ use crate::*; +use crate::services::utf8_stream::Utf8StreamDecoder; const DEFAULT_PTY_COLS: u16 = 120; const DEFAULT_PTY_ROWS: u16 = 30; @@ -259,11 +260,41 @@ pub(crate) fn agent_start( std::thread::spawn(move || { let mut reader = reader; let mut buf = [0u8; 4096]; + let mut decoder = Utf8StreamDecoder::new(); loop { match reader.read(&mut buf) { - Ok(0) => break, + Ok(0) => { + let text = decoder.finish(); + if !text.is_empty() { + if let Ok(mut lifecycle_state) = lifecycle_fallback_state_out.lock() { + if let Some((kind, source_event, data)) = + fallback_agent_lifecycle_from_output(&mut lifecycle_state, &text) + { + emit_agent_lifecycle( + &app_handle, + &workspace_id_out, + &session_out, + kind, + source_event, + &data, + ); + } + } + emit_agent( + &app_handle, + &workspace_id_out, + &session_out, + "stdout", + &text, + ); + let state: State = state_handle.state(); + let _ = + append_session_stream(state, &workspace_id_out, session_out_num, &text); + } + break; + } Ok(n) => { - let text = String::from_utf8_lossy(&buf[..n]).to_string(); + let text = decoder.push(&buf[..n]); if text.is_empty() { continue; } diff --git a/apps/server/src/services/mod.rs b/apps/server/src/services/mod.rs index 28a4ba9..e207d9e 100644 --- a/apps/server/src/services/mod.rs +++ b/apps/server/src/services/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod filesystem; pub(crate) mod git; pub(crate) mod system; pub(crate) mod terminal; +pub(crate) mod utf8_stream; pub(crate) mod workspace; pub(crate) mod workspace_runtime; pub(crate) mod workspace_watch; diff --git a/apps/server/src/services/terminal.rs b/apps/server/src/services/terminal.rs index 07e713f..4ab0853 100644 --- a/apps/server/src/services/terminal.rs +++ b/apps/server/src/services/terminal.rs @@ -1,4 +1,5 @@ use crate::*; +use crate::services::utf8_stream::Utf8StreamDecoder; const DEFAULT_PTY_COLS: u16 = 120; const DEFAULT_PTY_ROWS: u16 = 30; @@ -83,11 +84,25 @@ pub(crate) fn terminal_create( std::thread::spawn(move || { let mut reader = reader; let mut buf = [0u8; 4096]; + let mut decoder = Utf8StreamDecoder::new(); loop { match reader.read(&mut buf) { - Ok(0) => break, + Ok(0) => { + let text = decoder.finish(); + if !text.is_empty() { + emit_terminal(&app_handle, &workspace_id_out, terminal_id, &text); + let state: State = state_handle.state(); + let _ = append_workspace_terminal_output( + state, + &workspace_id_out, + terminal_id, + &text, + ); + } + break; + } Ok(n) => { - let text = String::from_utf8_lossy(&buf[..n]).to_string(); + let text = decoder.push(&buf[..n]); if text.is_empty() { continue; } diff --git a/apps/server/src/services/utf8_stream.rs b/apps/server/src/services/utf8_stream.rs new file mode 100644 index 0000000..cb80e14 --- /dev/null +++ b/apps/server/src/services/utf8_stream.rs @@ -0,0 +1,85 @@ +pub(crate) struct Utf8StreamDecoder { + pending: Vec, +} + +impl Utf8StreamDecoder { + pub(crate) fn new() -> Self { + Self { + pending: Vec::new(), + } + } + + pub(crate) fn push(&mut self, chunk: &[u8]) -> String { + if chunk.is_empty() { + return String::new(); + } + + self.pending.extend_from_slice(chunk); + let mut output = String::new(); + + loop { + match std::str::from_utf8(&self.pending) { + Ok(valid) => { + output.push_str(valid); + self.pending.clear(); + break; + } + Err(error) => { + let valid_up_to = error.valid_up_to(); + if valid_up_to > 0 { + let valid = std::str::from_utf8(&self.pending[..valid_up_to]) + .unwrap_or_default(); + output.push_str(valid); + self.pending.drain(..valid_up_to); + } + + match error.error_len() { + Some(len) => { + output.push('\u{FFFD}'); + self.pending.drain(..len); + } + None => break, + } + } + } + } + + output + } + + pub(crate) fn finish(&mut self) -> String { + if self.pending.is_empty() { + return String::new(); + } + let flushed = String::from_utf8_lossy(&self.pending).to_string(); + self.pending.clear(); + flushed + } +} + +#[cfg(test)] +mod tests { + use super::Utf8StreamDecoder; + + #[test] + fn decodes_multibyte_characters_split_across_chunks() { + let mut decoder = Utf8StreamDecoder::new(); + + let first = decoder.push(&[0xE4, 0xBD]); + let second = decoder.push(&[0xA0, 0xE5, 0xA5, 0xBD]); + let flushed = decoder.finish(); + + assert_eq!(first, ""); + assert_eq!(second, "你好"); + assert_eq!(flushed, ""); + } + + #[test] + fn preserves_invalid_bytes_with_replacement_and_continues() { + let mut decoder = Utf8StreamDecoder::new(); + + let output = decoder.push(&[0x66, 0x80, 0x6F, 0x6F]); + + assert_eq!(output, "f\u{FFFD}oo"); + } +} diff --git a/apps/server/src/ws/protocol.rs b/apps/server/src/ws/protocol.rs index d6c78ad..b295f17 100644 --- a/apps/server/src/ws/protocol.rs +++ b/apps/server/src/ws/protocol.rs @@ -13,4 +13,40 @@ pub(crate) enum WsEnvelope { pub(crate) enum WsClientEnvelope { Ping { ts: i64 }, Pong { ts: i64 }, + AgentSend { + workspace_id: String, + session_id: String, + input: String, + append_newline: Option, + fencing_token: i64, + }, + TerminalWrite { + workspace_id: String, + terminal_id: u64, + input: String, + fencing_token: i64, + }, + TerminalResize { + workspace_id: String, + terminal_id: u64, + cols: u16, + rows: u16, + fencing_token: i64, + }, + AgentResize { + workspace_id: String, + session_id: String, + cols: u16, + rows: u16, + fencing_token: i64, + }, + SessionUpdate { + workspace_id: String, + session_id: u64, + patch: SessionPatch, + fencing_token: i64, + }, + WorkspaceControllerHeartbeat { + workspace_id: String, + }, } diff --git a/apps/server/src/ws/server.rs b/apps/server/src/ws/server.rs index 9211474..81590db 100644 --- a/apps/server/src/ws/server.rs +++ b/apps/server/src/ws/server.rs @@ -68,16 +68,21 @@ pub(crate) async fn ws_session( let Ok(envelope) = serde_json::from_str::(&text) else { continue; }; - match envelope { - WsClientEnvelope::Ping { ts } => { - let Ok(body) = serde_json::to_string(&WsEnvelope::Pong { ts }) else { - continue; - }; - if socket.send(Message::Text(body)).await.is_err() { - break; - } + let response = match handle_ws_client_envelope( + envelope, + &app, + workspace_client.as_ref(), + ) { + Ok(response) => response, + Err(response) => Some(response), + }; + if let Some(response) = response { + let Ok(body) = serde_json::to_string(&response) else { + continue; + }; + if socket.send(Message::Text(body)).await.is_err() { + break; } - WsClientEnvelope::Pong { .. } => {} } } Some(Ok(_)) => {} @@ -110,6 +115,160 @@ pub(crate) async fn ws_session( } } +fn require_ws_workspace_controller_mutation( + workspace_id: &str, + fencing_token: i64, + workspace_client: Option<&(String, String)>, + app: &AppHandle, +) -> Result<(), String> { + let (device_id, client_id) = workspace_client.ok_or("workspace_client_missing")?; + assert_workspace_controller_can_mutate( + workspace_id, + device_id, + client_id, + fencing_token, + app, + app.state(), + ) + .map(|_| ()) +} + +fn ws_input_error_envelope(workspace_id: &str, kind: &str, error: &str) -> WsEnvelope { + WsEnvelope::Event { + event: "workspace://input_error".to_string(), + payload: json!({ + "workspace_id": workspace_id, + "kind": kind, + "error": error, + }), + } +} + +fn handle_ws_client_envelope( + envelope: WsClientEnvelope, + app: &AppHandle, + workspace_client: Option<&(String, String)>, +) -> Result, WsEnvelope> { + match envelope { + WsClientEnvelope::Ping { ts } => Ok(Some(WsEnvelope::Pong { ts })), + WsClientEnvelope::Pong { .. } => Ok(None), + WsClientEnvelope::AgentSend { + workspace_id, + session_id, + input, + append_newline, + fencing_token, + } => { + require_ws_workspace_controller_mutation( + &workspace_id, + fencing_token, + workspace_client, + app, + ) + .map_err(|error| ws_input_error_envelope(&workspace_id, "agent_send", &error))?; + agent_send( + workspace_id.clone(), + session_id, + input, + append_newline, + app.state(), + ) + .map_err(|error| ws_input_error_envelope(&workspace_id, "agent_send", &error))?; + Ok(None) + } + WsClientEnvelope::TerminalWrite { + workspace_id, + terminal_id, + input, + fencing_token, + } => { + require_ws_workspace_controller_mutation( + &workspace_id, + fencing_token, + workspace_client, + app, + ) + .map_err(|error| ws_input_error_envelope(&workspace_id, "terminal_write", &error))?; + terminal_write(workspace_id.clone(), terminal_id, input, app.state()) + .map_err(|error| ws_input_error_envelope(&workspace_id, "terminal_write", &error))?; + Ok(None) + } + WsClientEnvelope::TerminalResize { + workspace_id, + terminal_id, + cols, + rows, + fencing_token, + } => { + require_ws_workspace_controller_mutation( + &workspace_id, + fencing_token, + workspace_client, + app, + ) + .map_err(|error| ws_input_error_envelope(&workspace_id, "terminal_resize", &error))?; + terminal_resize(workspace_id.clone(), terminal_id, cols, rows, app.state()) + .map_err(|error| ws_input_error_envelope(&workspace_id, "terminal_resize", &error))?; + Ok(None) + } + WsClientEnvelope::AgentResize { + workspace_id, + session_id, + cols, + rows, + fencing_token, + } => { + require_ws_workspace_controller_mutation( + &workspace_id, + fencing_token, + workspace_client, + app, + ) + .map_err(|error| ws_input_error_envelope(&workspace_id, "agent_resize", &error))?; + agent_resize(workspace_id.clone(), session_id, cols, rows, app.state()) + .map_err(|error| ws_input_error_envelope(&workspace_id, "agent_resize", &error))?; + Ok(None) + } + WsClientEnvelope::SessionUpdate { + workspace_id, + session_id, + patch, + fencing_token, + } => { + require_ws_workspace_controller_mutation( + &workspace_id, + fencing_token, + workspace_client, + app, + ) + .map_err(|error| ws_input_error_envelope(&workspace_id, "session_update", &error))?; + session_update(workspace_id.clone(), session_id, patch, app.state()) + .map_err(|error| ws_input_error_envelope(&workspace_id, "session_update", &error))?; + Ok(None) + } + WsClientEnvelope::WorkspaceControllerHeartbeat { workspace_id } => { + let (device_id, client_id) = workspace_client.ok_or_else(|| { + ws_input_error_envelope( + &workspace_id, + "workspace_controller_heartbeat", + "workspace_client_missing", + ) + })?; + workspace_controller_heartbeat( + workspace_id.clone(), + device_id.clone(), + client_id.clone(), + app.clone(), + app.state(), + ) + .map_err(|error| { + ws_input_error_envelope(&workspace_id, "workspace_controller_heartbeat", &error) + })?; + Ok(None) + } + } +} + pub(crate) fn agent_key(workspace_id: &str, session_id: &str) -> String { format!("{}:{}", workspace_id, session_id) } diff --git a/apps/web/src/components/terminal/XtermBase.tsx b/apps/web/src/components/terminal/XtermBase.tsx index 781bc73..ca315f6 100644 --- a/apps/web/src/components/terminal/XtermBase.tsx +++ b/apps/web/src/components/terminal/XtermBase.tsx @@ -5,6 +5,7 @@ import { Unicode11Addon } from "@xterm/addon-unicode11"; import "@xterm/xterm/css/xterm.css"; import type { TerminalCompatibilityMode } from "../../types/app"; import { resetTerminalMeasurementCache, resolveTerminalFontFamily, XTERM_SCROLLBAR_WIDTH } from "../../shared/utils/terminal"; +import { planTerminalSnapshotUpdate } from "../../shared/utils/stream-snapshot"; import { shouldRefreshTerminalAfterFit } from "./xterm-fit-refresh"; type XtermBaseMode = "interactive" | "readonly"; @@ -70,32 +71,15 @@ const readTerminalTheme = (source?: Element | null) => { }; }; -const resolveXtermAppendDelta = (previous: string, next: string) => { - if (next === previous) return ""; - if (next.startsWith(previous)) { - return next.slice(previous.length); - } - - const probeLength = Math.min(256, next.length); - if (probeLength === 0) return null; - const probe = next.slice(0, probeLength); - const overlapStart = previous.lastIndexOf(probe); - if (overlapStart === -1) return null; - - const overlap = previous.slice(overlapStart); - if (!next.startsWith(overlap)) return null; - return next.slice(overlap.length); -}; - const writeXtermSnapshot = (term: XTerminal, previous: string, next: string) => { - if (next === previous) return; - const delta = resolveXtermAppendDelta(previous, next); - if (delta !== null) { - if (delta) term.write(delta); + const plan = planTerminalSnapshotUpdate(previous, next); + if (plan.kind === "noop") return; + if (plan.kind === "append") { + term.write(plan.data); return; } term.reset(); - if (next) term.write(next); + if (plan.data) term.write(plan.data); }; const resolveTerminalThemeSource = (mount: HTMLElement | null) => { diff --git a/apps/web/src/features/agents/AgentWorkspaceFeature.tsx b/apps/web/src/features/agents/AgentWorkspaceFeature.tsx index f40a589..f1899ff 100644 --- a/apps/web/src/features/agents/AgentWorkspaceFeature.tsx +++ b/apps/web/src/features/agents/AgentWorkspaceFeature.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, type FormEvent, type PointerEventHandler, type ReactNode } from "react"; +import { memo, useCallback, type FormEvent, type PointerEventHandler, type ReactNode, type RefObject } from "react"; import type { Locale, Translator } from "../../i18n"; import type { AppTheme, @@ -37,6 +37,7 @@ type AgentWorkspaceFeatureProps = { onDraftPromptChange: (paneId: string, value: string) => void; setDraftPromptInputRef: (paneId: string, element: HTMLInputElement | null) => void; setAgentTerminalRef: (paneId: string, handle: XtermBaseHandle | null) => void; + archiveTerminalRef?: RefObject; onAgentTerminalData: (paneId: string, data: string) => void; onAgentTerminalSize: (paneId: string, tabId: string, sessionId: string, size: { cols: number; rows: number }) => void; onPaneSplitResizeStart: (splitId: string, axis: "horizontal" | "vertical") => PointerEventHandler; @@ -95,6 +96,7 @@ const AgentPaneLeaf = memo(({ onDraftPromptChange, setDraftPromptInputRef, setAgentTerminalRef, + archiveTerminalRef, onAgentTerminalData, onAgentTerminalSize, t, @@ -452,6 +454,7 @@ export const AgentWorkspaceFeature = ({
{viewedSessionPlainStream.trim() ? ( tab.status === "ready") - .map((tab) => tab.id); + const workspaceIds = collectReadyTabRuntimeRecoveryWorkspaceIds(stateRef.current.tabs); void Promise.all(workspaceIds.map(async (workspaceId) => { await reattachWorkspaceRuntime(workspaceId); })); @@ -523,6 +522,9 @@ export const WorkbenchRuntimeCoordinator = ({ heartbeatInflightRef.current.add(workspaceId); void heartbeatWorkspaceController(workspaceId, deviceId, clientId) .then((controller) => { + if (!controller) { + return; + } updateState((current) => applyWorkspaceControllerEvent(current, { workspace_id: workspaceId, controller, diff --git a/apps/web/src/features/workspace/WorkspaceScreen.tsx b/apps/web/src/features/workspace/WorkspaceScreen.tsx index fdfbbed..7062b10 100644 --- a/apps/web/src/features/workspace/WorkspaceScreen.tsx +++ b/apps/web/src/features/workspace/WorkspaceScreen.tsx @@ -102,9 +102,16 @@ import { } from "./workspace-recovery"; import { attachWorkspaceRuntimeWithRetry } from "./runtime-attach"; import { + shouldAttachRouteRuntimeForExistingTab, +} from "./workspace-route-runtime"; +import { + createWorkspaceViewPatchFromTab, + createWorkspaceViewPersistScheduler, + noteWorkspaceViewPersistRequest, pruneWorkspaceViewBaselines, - rememberWorkspaceViewBaseline, + rememberWorkspaceViewPatchBaseline, shouldPersistWorkspaceView, + type WorkspaceViewPersistScheduler, } from "./workspace-view-persistence"; import { createInitialHistoryExpansion, @@ -167,9 +174,9 @@ import { } from "../../services/http/workspace.service"; import { applyWorkbenchUiState, + applyWorkspaceBootstrapResult, applyWorkspaceControllerEvent, applyWorkspaceRuntimeSnapshot, - buildWorkbenchStateFromBootstrap, upsertWorkspaceSnapshot, workbenchLayoutToBackend } from "../../shared/utils/workspace"; @@ -265,8 +272,6 @@ const REQUIRED_RUNTIME_COMMANDS = [ id: RuntimeRequirementStatus["id"]; command: string; }>; -const ROUTE_RUNTIME_ATTACH_RECOVERY_DELAYS_MS = [0, 1_000, 3_000, 7_000] as const; - type RuntimeRequirementSpec = { id: RuntimeRequirementStatus["id"]; command: string; @@ -371,6 +376,8 @@ const isTextInputTarget = (target: EventTarget | null) => { return target.isContentEditable || tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; }; +const isAgentFocusTransitionSequence = (value: string) => value === "\u001b[I" || value === "\u001b[O"; + export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: WorkspaceScreenProps) { const [state, setState] = useRelaxState(workbenchState); const navigate = useNavigate(); @@ -444,6 +451,7 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: const commandPaletteInputRef = useRef(null); const draftPromptInputRefs = useRef(new Map()); const shellTerminalRef = useRef(null); + const archiveTerminalRef = useRef(null); const shellTerminalViewportRef = useRef(null); const emptyTabRef = useRef(null); const agentTerminalRefs = useRef(new Map()); @@ -455,6 +463,7 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: inflight: boolean; pending?: { cols: number; rows: number }; }>()); + const workspaceViewPersistSchedulerRef = useRef | null>(null); const agentTitleTrackerRef = useRef(new Map { - const scheduler = agentTerminalFitSchedulerRef.current; - if (!scheduler) { - runWorkspaceAgentFit(); - return; - } - scheduler.schedule(runWorkspaceAgentFit); - }, [runWorkspaceAgentFit]); - const flushWorkspaceAgentFit = useCallback(() => { const scheduler = agentTerminalFitSchedulerRef.current; if (!scheduler) { @@ -626,6 +626,33 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: scheduler.flush(); }, [runWorkspaceAgentFit]); + const fitVisibleWorkspaceTerminals = useCallback(() => { + shellTerminalRef.current?.fit(); + archiveTerminalRef.current?.fit(); + flushWorkspaceAgentFit(); + }, [flushWorkspaceAgentFit]); + + const persistWorkspaceView = useCallback(( + workspaceId: string, + patch: ReturnType, + controller: Tab["controller"], + ) => { + rememberWorkspaceViewPatchBaseline(workspaceId, patch); + noteWorkspaceViewPersistRequest(workspaceId, patch); + void withServiceFallback( + () => updateWorkspaceView(workspaceId, patch, controller), + null, + ); + }, []); + + if (!workspaceViewPersistSchedulerRef.current && typeof window !== "undefined") { + workspaceViewPersistSchedulerRef.current = createWorkspaceViewPersistScheduler( + persistWorkspaceView, + window.setTimeout.bind(window), + window.clearTimeout.bind(window), + ); + } + const registerAgentTerminalRef = (paneId: string, handle: XtermBaseHandle | null) => { setAgentTerminalRef(agentRuntimeRefs, paneId, handle); }; @@ -670,6 +697,8 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: }, [state]); useEffect(() => () => { + workspaceViewPersistSchedulerRef.current?.flush(); + workspaceViewPersistSchedulerRef.current?.dispose(); agentTerminalFitSchedulerRef.current?.dispose(); }, []); @@ -683,8 +712,6 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: } return; } - const nextState = buildWorkbenchStateFromBootstrap(stateRef.current, bootstrap, locale, appSettings); - if (routeWorkspaceId) { const syncVersion = advanceWorkspaceSyncVersion(routeWorkspaceId); const uiState = await withServiceFallback( @@ -705,20 +732,38 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: } if (uiState && runtimeSnapshot) { - updateState(() => applyWorkspaceRuntimeSnapshot( - nextState, - runtimeSnapshot, + updateState((current) => applyWorkspaceBootstrapResult( + current, + bootstrap, locale, appSettings, - deviceId, - clientId, - uiState, + { + deviceId, + clientId, + uiState, + runtimeSnapshot, + }, )); } else { - updateState(() => nextState); + updateState((current) => applyWorkspaceBootstrapResult( + current, + bootstrap, + locale, + appSettings, + { + deviceId, + clientId, + uiState, + }, + )); } } else { - updateState(() => nextState); + updateState((current) => applyWorkspaceBootstrapResult( + current, + bootstrap, + locale, + appSettings, + )); } if (!cancelled) { setBootstrapReady(true); @@ -734,33 +779,8 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: if (routeWorkspaceId) { const syncVersion = advanceWorkspaceSyncVersion(routeWorkspaceId); const existing = stateRef.current.tabs.find((tab) => tab.id === routeWorkspaceId); - if (existing) { + if (existing && !shouldAttachRouteRuntimeForExistingTab(existing)) { let cancelled = false; - const attachRouteRuntime = () => { - void attachWorkspaceRuntimeWithRetry( - routeWorkspaceId, - deviceId, - clientId, - withServiceFallback, - ).then((runtimeSnapshot) => { - if ( - cancelled - || !runtimeSnapshot - || !isWorkspaceSyncVersionCurrent(routeWorkspaceId, syncVersion) - ) { - return; - } - updateState((current) => applyWorkspaceRuntimeSnapshot( - current, - runtimeSnapshot, - locale, - appSettings, - deviceId, - clientId, - )); - }); - }; - if (stateRef.current.activeTabId !== routeWorkspaceId) { switchWorkspaceLocally(routeWorkspaceId); void withServiceFallback(() => activateWorkspaceRequest(routeWorkspaceId, deviceId, clientId), null).then((uiState) => { @@ -768,16 +788,9 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: updateState((current) => applyWorkbenchUiState(current, uiState)); }); } - attachRouteRuntime(); - const timers = ROUTE_RUNTIME_ATTACH_RECOVERY_DELAYS_MS - .filter((delayMs) => delayMs > 0) - .map((delayMs) => window.setTimeout(attachRouteRuntime, delayMs)); void ensureWorkspaceTerminal(routeWorkspaceId); return () => { cancelled = true; - timers.forEach((timer) => { - window.clearTimeout(timer); - }); }; } @@ -889,26 +902,31 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: useEffect(() => { if (!bootstrapReady) return; + const scheduler = workspaceViewPersistSchedulerRef.current; const liveWorkspaceIds = new Set(state.tabs.map((tab) => tab.id)); state.tabs.forEach((tab) => { - if (tab.status !== "ready" || !tab.project?.path) return; - if (!canMutateWorkspace(tab.controller, "switch_pane")) return; - if (!shouldPersistWorkspaceView(tab)) return; - const patch = { - active_session_id: tab.activeSessionId, - active_pane_id: tab.activePaneId, - active_terminal_id: tab.activeTerminalId, - pane_layout: tab.paneLayout, - file_preview: tab.filePreview, - }; - rememberWorkspaceViewBaseline(tab); - void withServiceFallback( - () => updateWorkspaceView(tab.id, patch, tab.controller), - null, - ); + if (tab.status !== "ready" || !tab.project?.path) { + scheduler?.cancel(tab.id); + return; + } + if (!canMutateWorkspace(tab.controller, "switch_pane")) { + scheduler?.cancel(tab.id); + return; + } + if (!shouldPersistWorkspaceView(tab)) { + scheduler?.cancel(tab.id); + return; + } + const patch = createWorkspaceViewPatchFromTab(tab); + if (!scheduler) { + persistWorkspaceView(tab.id, patch, tab.controller); + return; + } + scheduler.schedule(tab.id, patch, tab.controller); }); + scheduler?.prune(liveWorkspaceIds); pruneWorkspaceViewBaselines(liveWorkspaceIds); - }, [bootstrapReady, state.tabs]); + }, [bootstrapReady, persistWorkspaceView, state.tabs]); useEffect(() => { if (!slashMenuOpen) return; @@ -2194,7 +2212,7 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: stateRef, updateState, shellTerminalRef, - scheduleFitAgentTerminals: scheduleWorkspaceAgentFit, + archiveTerminalRef, flushFitAgentTerminals: flushWorkspaceAgentFit, }); }; @@ -2205,7 +2223,7 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: setIsCodeExpanded(false); } requestAnimationFrame(() => { - shellTerminalRef.current?.fit(); + fitVisibleWorkspaceTerminals(); }); }; @@ -2251,7 +2269,7 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: splitId, axis, updateTab, - scheduleFitAgentTerminals: scheduleWorkspaceAgentFit, + archiveTerminalRef, flushFitAgentTerminals: flushWorkspaceAgentFit, }); }; @@ -2323,6 +2341,7 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: const onAgentTerminalData = async (paneId: string, data: string) => { if (isArchiveView || !data) return; + if (isAgentFocusTransitionSequence(data)) return; if (!guardWorkspaceMutation("agent_input")) return; const activeTabSnapshot = stateRef.current.tabs.find((tab) => tab.id === stateRef.current.activeTabId); const paneSessionId = activeTabSnapshot @@ -2531,9 +2550,9 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: useEffect(() => { if (!showTerminalPanel || isCodeExpanded) return; requestAnimationFrame(() => { - shellTerminalRef.current?.fit(); + fitVisibleWorkspaceTerminals(); }); - }, [showTerminalPanel, isCodeExpanded, state.layout.rightSplit]); + }, [fitVisibleWorkspaceTerminals, showTerminalPanel, isCodeExpanded, state.layout.rightSplit]); useEffect(() => { if (activeTerminal && showTerminalPanel && !isCodeExpanded) { @@ -2786,6 +2805,7 @@ export default function WorkspaceScreen({ locale, appSettings, onOpenSettings }: }} setDraftPromptInputRef={registerDraftPromptInputRef} setAgentTerminalRef={registerAgentTerminalRef} + archiveTerminalRef={archiveTerminalRef} onAgentTerminalData={(paneId, data) => { void onAgentTerminalData(paneId, data); }} diff --git a/apps/web/src/features/workspace/runtime-attach.ts b/apps/web/src/features/workspace/runtime-attach.ts index b6a4baa..4e21168 100644 --- a/apps/web/src/features/workspace/runtime-attach.ts +++ b/apps/web/src/features/workspace/runtime-attach.ts @@ -4,6 +4,61 @@ import type { WorkspaceRuntimeSnapshot } from "../../types/app.ts"; type WithServiceFallback = (operation: () => Promise, fallback: T) => Promise; export const ATTACH_RUNTIME_RETRY_DELAYS_MS = [0, 250, 750, 1500, 3000, 5000] as const; +export const ATTACH_RUNTIME_SUCCESS_REUSE_MS = 1_200; + +type RuntimeAttachTask = () => Promise; +type RuntimeAttachDeduperOptions = { + now?: () => number; + successReuseMs?: number; +}; + +export const createWorkspaceRuntimeAttachDeduper = ( + options: RuntimeAttachDeduperOptions = {}, +) => { + const now = options.now ?? (() => Date.now()); + const successReuseMs = options.successReuseMs ?? ATTACH_RUNTIME_SUCCESS_REUSE_MS; + const inflight = new Map>(); + const recentSuccess = new Map(); + + return { + run(key: string, attach: RuntimeAttachTask) { + const active = inflight.get(key); + if (active) { + return active; + } + + const cached = recentSuccess.get(key); + if (cached && (now() - cached.at) <= successReuseMs) { + return Promise.resolve(cached.result); + } + + const task = attach() + .then((result) => { + if (result) { + recentSuccess.set(key, { at: now(), result }); + } else { + recentSuccess.delete(key); + } + return result; + }) + .finally(() => { + inflight.delete(key); + }); + + inflight.set(key, task); + return task; + }, + clear(key?: string) { + if (typeof key === "string") { + inflight.delete(key); + recentSuccess.delete(key); + return; + } + inflight.clear(); + recentSuccess.clear(); + }, + }; +}; export const runAttachWithRetry = async ( attach: () => Promise, @@ -25,15 +80,19 @@ export const runAttachWithRetry = async ( return null; }; +const workspaceRuntimeAttachDeduper = createWorkspaceRuntimeAttachDeduper(); + export const attachWorkspaceRuntimeWithRetry = async ( workspaceId: string, deviceId: string, clientId: string, withServiceFallback: WithServiceFallback, ): Promise => { - return runAttachWithRetry(() => withServiceFallback( + return workspaceRuntimeAttachDeduper.run( + `${workspaceId}:${deviceId}:${clientId}`, + () => runAttachWithRetry(() => withServiceFallback( () => attachWorkspaceRuntime(workspaceId, deviceId, clientId), null, - ), + )), ); }; diff --git a/apps/web/src/features/workspace/workspace-layout-actions.ts b/apps/web/src/features/workspace/workspace-layout-actions.ts index afb2348..13e3a97 100644 --- a/apps/web/src/features/workspace/workspace-layout-actions.ts +++ b/apps/web/src/features/workspace/workspace-layout-actions.ts @@ -6,15 +6,15 @@ import { type WorkbenchState, createId, createPaneLeaf, -} from "../../state/workbench"; +} from "../../state/workbench-core.ts"; import { collectPaneLeaves, findPaneIdBySessionId, findPaneSessionId, replacePaneNode, updateSplitRatio, -} from "../../shared/utils/panes"; -import { restoreVisibleStatus, toBackgroundStatus } from "../../shared/utils/session"; +} from "../../shared/utils/panes.ts"; +import { restoreVisibleStatus, toBackgroundStatus } from "../../shared/utils/session.ts"; type UpdateState = (updater: (current: WorkbenchState) => WorkbenchState) => void; type UpdateTab = (tabId: string, updater: (tab: Tab) => Tab) => void; @@ -114,7 +114,7 @@ type StartWorkspacePanelResizeArgs = { stateRef: MutableRefObject; updateState: UpdateState; shellTerminalRef: RefObject; - scheduleFitAgentTerminals: () => void; + archiveTerminalRef?: RefObject; flushFitAgentTerminals: () => void; }; @@ -124,7 +124,7 @@ export const startWorkspacePanelResize = ({ stateRef, updateState, shellTerminalRef, - scheduleFitAgentTerminals, + archiveTerminalRef, flushFitAgentTerminals, }: StartWorkspacePanelResizeArgs) => { event.preventDefault(); @@ -152,7 +152,6 @@ export const startWorkspacePanelResize = ({ rightSplit: type === "right-split" ? pendingSplit : current.layout.rightSplit, }, })); - scheduleFitAgentTerminals(); }; const onMove = (moveEvent: PointerEvent) => { @@ -179,6 +178,7 @@ export const startWorkspacePanelResize = ({ } requestAnimationFrame(() => { shellTerminalRef.current?.fit(); + archiveTerminalRef?.current?.fit(); flushFitAgentTerminals(); }); }; @@ -194,7 +194,7 @@ type StartWorkspacePaneSplitResizeArgs = { splitId: string; axis: "horizontal" | "vertical"; updateTab: UpdateTab; - scheduleFitAgentTerminals: () => void; + archiveTerminalRef?: RefObject; flushFitAgentTerminals: () => void; }; @@ -205,7 +205,7 @@ export const startWorkspacePaneSplitResize = ({ splitId, axis, updateTab, - scheduleFitAgentTerminals, + archiveTerminalRef, flushFitAgentTerminals, }: StartWorkspacePaneSplitResizeArgs) => { event.preventDefault(); @@ -233,7 +233,6 @@ export const startWorkspacePaneSplitResize = ({ ...tab, paneLayout: updateSplitRatio(tab.paneLayout, splitId, pendingRatio), })); - scheduleFitAgentTerminals(); }; const onMove = (moveEvent: PointerEvent) => { @@ -256,6 +255,7 @@ export const startWorkspacePaneSplitResize = ({ flushRatio(); } requestAnimationFrame(() => { + archiveTerminalRef?.current?.fit(); flushFitAgentTerminals(); }); }; diff --git a/apps/web/src/features/workspace/workspace-ready-runtime.ts b/apps/web/src/features/workspace/workspace-ready-runtime.ts new file mode 100644 index 0000000..23f4018 --- /dev/null +++ b/apps/web/src/features/workspace/workspace-ready-runtime.ts @@ -0,0 +1,10 @@ +import type { Tab } from "../../state/workbench-core.ts"; + +export const READY_TAB_RUNTIME_RECOVERY_DELAYS_MS = [0, 3_000] as const; + +export const collectReadyTabRuntimeRecoveryWorkspaceIds = ( + tabs: Array>, +) => tabs + .filter((tab) => tab.status === "ready") + .map((tab) => tab.id) + .filter(Boolean); diff --git a/apps/web/src/features/workspace/workspace-route-runtime.ts b/apps/web/src/features/workspace/workspace-route-runtime.ts new file mode 100644 index 0000000..1fc4587 --- /dev/null +++ b/apps/web/src/features/workspace/workspace-route-runtime.ts @@ -0,0 +1,7 @@ +import type { Tab } from "../../state/workbench-core.ts"; + +export const ROUTE_RUNTIME_ATTACH_RECOVERY_DELAYS_MS = [0, 1_000, 3_000, 7_000] as const; + +export const shouldAttachRouteRuntimeForExistingTab = ( + existingTab: Pick | null | undefined, +) => existingTab?.status !== "ready"; diff --git a/apps/web/src/features/workspace/workspace-view-persistence.ts b/apps/web/src/features/workspace/workspace-view-persistence.ts index f8af615..bb2aa13 100644 --- a/apps/web/src/features/workspace/workspace-view-persistence.ts +++ b/apps/web/src/features/workspace/workspace-view-persistence.ts @@ -7,6 +7,59 @@ type WorkspaceViewTab = Pick< >; const persistedWorkspaceViews = new Map(); +const recentWorkspaceViewRequests = new Map>(); +const RECENT_WORKSPACE_VIEW_REQUEST_LIMIT = 24; +const RECENT_WORKSPACE_VIEW_REQUEST_TTL_MS = 15_000; +export const WORKSPACE_VIEW_PERSIST_DEBOUNCE_MS = 160; + +type ScheduleTimeout = (callback: () => void, delayMs: number) => unknown; +type CancelTimeout = (handle: unknown) => void; + +const serializePaneLayoutNode = (value: unknown): unknown => { + const node = value && typeof value === "object" ? value as Record : {}; + if (node.type === "leaf") { + return { + type: "leaf", + id: typeof node.id === "string" ? node.id : "", + sessionId: typeof node.sessionId === "string" + ? node.sessionId + : (typeof node.session_id === "string" ? node.session_id : ""), + }; + } + return { + type: "split", + id: typeof node.id === "string" ? node.id : "", + axis: node.axis === "horizontal" ? "horizontal" : "vertical", + ratio: typeof node.ratio === "number" ? node.ratio : 0.5, + first: serializePaneLayoutNode(node.first), + second: serializePaneLayoutNode(node.second), + }; +}; + +const serializeFilePreviewValue = (value: unknown) => { + const preview = value && typeof value === "object" ? value as Record : {}; + return { + path: typeof preview.path === "string" ? preview.path : "", + content: typeof preview.content === "string" ? preview.content : "", + mode: preview.mode === "diff" ? "diff" : "preview", + diff: typeof preview.diff === "string" ? preview.diff : "", + originalContent: typeof preview.originalContent === "string" ? preview.originalContent : "", + modifiedContent: typeof preview.modifiedContent === "string" ? preview.modifiedContent : "", + dirty: Boolean(preview.dirty), + source: preview.source === "git" ? "git" : undefined, + statusLabel: typeof preview.statusLabel === "string" ? preview.statusLabel : undefined, + parentPath: typeof preview.parentPath === "string" ? preview.parentPath : undefined, + section: typeof preview.section === "string" ? preview.section : undefined, + }; +}; + +const canonicalizeWorkspaceViewPatch = (patch: WorkspaceViewPatch) => ({ + active_session_id: patch.active_session_id, + active_pane_id: patch.active_pane_id, + active_terminal_id: patch.active_terminal_id, + pane_layout: serializePaneLayoutNode(patch.pane_layout), + file_preview: serializeFilePreviewValue(patch.file_preview), +}); export const createWorkspaceViewPatchFromTab = ( tab: WorkspaceViewTab, @@ -18,14 +71,75 @@ export const createWorkspaceViewPatchFromTab = ( file_preview: tab.filePreview, }); -export const serializeWorkspaceViewPatch = (patch: WorkspaceViewPatch) => JSON.stringify(patch); +export const serializeWorkspaceViewPatch = (patch: WorkspaceViewPatch) => JSON.stringify( + canonicalizeWorkspaceViewPatch(patch), +); const serializeWorkspaceViewTab = (tab: WorkspaceViewTab) => serializeWorkspaceViewPatch( createWorkspaceViewPatchFromTab(tab), ); +const rememberWorkspaceViewSerializedBaseline = (workspaceId: string, serialized: string) => { + persistedWorkspaceViews.set(workspaceId, serialized); +}; + +const pruneRecentWorkspaceViewRequests = (workspaceId: string, now = Date.now()) => { + const entries = recentWorkspaceViewRequests.get(workspaceId); + if (!entries?.length) { + recentWorkspaceViewRequests.delete(workspaceId); + return []; + } + + const next = entries.filter((entry) => now - entry.at <= RECENT_WORKSPACE_VIEW_REQUEST_TTL_MS); + if (next.length === 0) { + recentWorkspaceViewRequests.delete(workspaceId); + return []; + } + recentWorkspaceViewRequests.set(workspaceId, next); + return next; +}; + +export const noteWorkspaceViewPersistRequest = ( + workspaceId: string, + patch: WorkspaceViewPatch, +) => { + const now = Date.now(); + const entries = pruneRecentWorkspaceViewRequests(workspaceId, now); + entries.push({ serialized: serializeWorkspaceViewPatch(patch), at: now }); + recentWorkspaceViewRequests.set( + workspaceId, + entries.slice(-RECENT_WORKSPACE_VIEW_REQUEST_LIMIT), + ); +}; + +export const shouldIgnoreIncomingWorkspaceViewPatch = ( + tab: WorkspaceViewTab, + patch: WorkspaceViewPatch, +) => { + const currentSerialized = serializeWorkspaceViewTab(tab); + const incomingSerialized = serializeWorkspaceViewPatch(patch); + if (incomingSerialized === currentSerialized) { + return false; + } + + const recentEntries = pruneRecentWorkspaceViewRequests(tab.id); + if (recentEntries.length === 0) { + return false; + } + + const recentSerializations = new Set(recentEntries.map((entry) => entry.serialized)); + return recentSerializations.has(currentSerialized) && recentSerializations.has(incomingSerialized); +}; + export const rememberWorkspaceViewBaseline = (tab: WorkspaceViewTab) => { - persistedWorkspaceViews.set(tab.id, serializeWorkspaceViewTab(tab)); + rememberWorkspaceViewSerializedBaseline(tab.id, serializeWorkspaceViewTab(tab)); +}; + +export const rememberWorkspaceViewPatchBaseline = ( + workspaceId: string, + patch: WorkspaceViewPatch, +) => { + rememberWorkspaceViewSerializedBaseline(workspaceId, serializeWorkspaceViewPatch(patch)); }; export const rememberWorkspaceViewBaselines = (tabs: WorkspaceViewTab[]) => { @@ -40,6 +154,7 @@ export const shouldPersistWorkspaceView = (tab: WorkspaceViewTab) => ( export const forgetWorkspaceViewBaseline = (workspaceId: string) => { persistedWorkspaceViews.delete(workspaceId); + recentWorkspaceViewRequests.delete(workspaceId); }; export const pruneWorkspaceViewBaselines = (workspaceIds: ReadonlySet) => { @@ -52,4 +167,75 @@ export const pruneWorkspaceViewBaselines = (workspaceIds: ReadonlySet) = export const resetWorkspaceViewBaselines = () => { persistedWorkspaceViews.clear(); + recentWorkspaceViewRequests.clear(); +}; + +export type WorkspaceViewPersistScheduler = { + schedule: (workspaceId: string, patch: WorkspaceViewPatch, controller: TController) => void; + cancel: (workspaceId: string) => void; + prune: (workspaceIds: ReadonlySet) => void; + flush: (workspaceId?: string) => void; + dispose: () => void; +}; + +export const createWorkspaceViewPersistScheduler = ( + persist: (workspaceId: string, patch: WorkspaceViewPatch, controller: TController) => void, + scheduleTimeout: ScheduleTimeout, + cancelTimeout: CancelTimeout, + delayMs = WORKSPACE_VIEW_PERSIST_DEBOUNCE_MS, +): WorkspaceViewPersistScheduler => { + const pending = new Map(); + + const clearPending = (workspaceId: string) => { + const entry = pending.get(workspaceId); + if (!entry) return null; + cancelTimeout(entry.handle); + pending.delete(workspaceId); + return entry; + }; + + const flushWorkspace = (workspaceId: string) => { + const entry = clearPending(workspaceId); + if (!entry) return; + persist(workspaceId, entry.patch, entry.controller); + }; + + return { + schedule(workspaceId, patch, controller) { + clearPending(workspaceId); + const handle = scheduleTimeout(() => { + pending.delete(workspaceId); + persist(workspaceId, patch, controller); + }, delayMs); + pending.set(workspaceId, { handle, patch, controller }); + }, + cancel(workspaceId) { + clearPending(workspaceId); + }, + prune(workspaceIds) { + Array.from(pending.keys()).forEach((workspaceId) => { + if (!workspaceIds.has(workspaceId)) { + clearPending(workspaceId); + } + }); + }, + flush(workspaceId) { + if (typeof workspaceId === "string") { + flushWorkspace(workspaceId); + return; + } + Array.from(pending.keys()).forEach((pendingWorkspaceId) => { + flushWorkspace(pendingWorkspaceId); + }); + }, + dispose() { + Array.from(pending.keys()).forEach((workspaceId) => { + clearPending(workspaceId); + }); + }, + }; }; diff --git a/apps/web/src/services/http/agent.service.ts b/apps/web/src/services/http/agent.service.ts index 49c0df5..3a0a4dd 100644 --- a/apps/web/src/services/http/agent.service.ts +++ b/apps/web/src/services/http/agent.service.ts @@ -3,6 +3,8 @@ import { createWorkspaceControllerRpcPayload } from "../../features/workspace/wo import type { AgentStartResult } from "../../types/app"; import type { TerminalGridSize } from "../../shared/utils/terminal"; import { invokeRpc } from "./client.ts"; +import { sendWsMessage } from "../../ws/client.ts"; +import { sendWsMutationWithHttpFallback } from "./ws-rpc-fallback.ts"; export type AgentStartRequest = { workspaceId: string; @@ -31,9 +33,19 @@ export const sendAgentInput = ( sessionId: string, input: string, appendNewline: boolean, -) => invokeRpc( - "agent_send", - createWorkspaceControllerRpcPayload(workspaceId, controller, { sessionId, input, appendNewline }), +) => sendWsMutationWithHttpFallback( + () => sendWsMessage({ + type: "agent_send", + workspace_id: workspaceId, + session_id: sessionId, + input, + append_newline: appendNewline, + fencing_token: controller.fencingToken, + }), + () => invokeRpc( + "agent_send", + createWorkspaceControllerRpcPayload(workspaceId, controller, { sessionId, input, appendNewline }), + ), ); export const stopAgent = ( @@ -51,7 +63,17 @@ export const resizeAgent = ( sessionId: string, cols: number, rows: number, -) => invokeRpc( - "agent_resize", - createWorkspaceControllerRpcPayload(workspaceId, controller, { sessionId, cols, rows }), +) => sendWsMutationWithHttpFallback( + () => sendWsMessage({ + type: "agent_resize", + workspace_id: workspaceId, + session_id: sessionId, + cols, + rows, + fencing_token: controller.fencingToken, + }), + () => invokeRpc( + "agent_resize", + createWorkspaceControllerRpcPayload(workspaceId, controller, { sessionId, cols, rows }), + ), ); diff --git a/apps/web/src/services/http/session.service.ts b/apps/web/src/services/http/session.service.ts index 68b9d73..b6d0702 100644 --- a/apps/web/src/services/http/session.service.ts +++ b/apps/web/src/services/http/session.service.ts @@ -9,6 +9,114 @@ import type { SessionRestoreResult, } from "../../types/app.ts"; import { invokeRpc } from "./client.ts"; +import { sendWsMessage } from "../../ws/client.ts"; +import { sendWsMutationWithNullableHttpFallback } from "./ws-rpc-fallback.ts"; + +type ScheduleTimeout = (callback: () => void, delayMs: number) => unknown; +type CancelTimeout = (handle: unknown) => void; + +type SessionActivityPersistScheduler = { + schedule: ( + workspaceId: string, + sessionId: number, + lastActiveAt: number, + controller: TController, + ) => void; + takeLastActiveAt: (workspaceId: string, sessionId: number) => number | undefined; + dispose: () => void; +}; + +export const SESSION_ACTIVITY_PERSIST_DEBOUNCE_MS = 1500; + +const sessionActivityKey = (workspaceId: string, sessionId: number) => `${workspaceId}:${sessionId}`; + +const isLastActiveOnlySessionPatch = (patch: SessionPatch) => { + const keys = Object.entries(patch) + .filter(([, value]) => typeof value !== "undefined") + .map(([key]) => key); + return keys.length === 1 && keys[0] === "last_active_at" && typeof patch.last_active_at === "number"; +}; + +export const createSessionActivityPersistScheduler = ( + persist: ( + workspaceId: string, + sessionId: number, + patch: SessionPatch, + controller: TController, + ) => void | Promise, + scheduleTimeout: ScheduleTimeout, + cancelTimeout: CancelTimeout, + delayMs = SESSION_ACTIVITY_PERSIST_DEBOUNCE_MS, +): SessionActivityPersistScheduler => { + const pending = new Map(); + + const clearPending = (workspaceId: string, sessionId: number) => { + const key = sessionActivityKey(workspaceId, sessionId); + const entry = pending.get(key); + if (!entry) return undefined; + cancelTimeout(entry.handle); + pending.delete(key); + return entry; + }; + + return { + schedule(workspaceId, sessionId, lastActiveAt, controller) { + clearPending(workspaceId, sessionId); + const key = sessionActivityKey(workspaceId, sessionId); + const handle = scheduleTimeout(() => { + pending.delete(key); + void persist(workspaceId, sessionId, { last_active_at: lastActiveAt }, controller); + }, delayMs); + pending.set(key, { + handle, + workspaceId, + sessionId, + lastActiveAt, + controller, + }); + }, + takeLastActiveAt(workspaceId, sessionId) { + return clearPending(workspaceId, sessionId)?.lastActiveAt; + }, + dispose() { + Array.from(pending.values()).forEach((entry) => { + cancelTimeout(entry.handle); + }); + pending.clear(); + }, + }; +}; + +const sendSessionUpdateMutation = ( + workspaceId: string, + sessionId: number, + patch: SessionPatch, + controller: WorkspaceControllerState, +) => sendWsMutationWithNullableHttpFallback( + () => sendWsMessage({ + type: "session_update", + workspace_id: workspaceId, + session_id: sessionId, + patch, + fencing_token: controller.fencingToken, + }), + () => invokeRpc( + "session_update", + createWorkspaceControllerRpcPayload(workspaceId, controller, { sessionId, patch }), + ), +); + +const sessionActivityPersistScheduler = createSessionActivityPersistScheduler( + sendSessionUpdateMutation, + (callback, delayMs) => globalThis.setTimeout(callback, delayMs), + (handle) => globalThis.clearTimeout(handle as ReturnType), +); const createOptionalHistoryMutationPayload = ( workspaceId: string, @@ -34,10 +142,25 @@ export const updateSession = ( sessionId: number, patch: SessionPatch, controller: WorkspaceControllerState, -) => invokeRpc( - "session_update", - createWorkspaceControllerRpcPayload(workspaceId, controller, { sessionId, patch }), -); +) => { + if (isLastActiveOnlySessionPatch(patch)) { + sessionActivityPersistScheduler.schedule( + workspaceId, + sessionId, + patch.last_active_at!, + controller, + ); + return Promise.resolve(null); + } + + const pendingLastActiveAt = sessionActivityPersistScheduler.takeLastActiveAt(workspaceId, sessionId); + const mergedPatch = typeof pendingLastActiveAt === "number" + && (typeof patch.last_active_at !== "number" || pendingLastActiveAt > patch.last_active_at) + ? { ...patch, last_active_at: pendingLastActiveAt } + : patch; + + return sendSessionUpdateMutation(workspaceId, sessionId, mergedPatch, controller); +}; export const switchSession = ( workspaceId: string, diff --git a/apps/web/src/services/http/terminal.service.ts b/apps/web/src/services/http/terminal.service.ts index 93ce269..2c8c9c2 100644 --- a/apps/web/src/services/http/terminal.service.ts +++ b/apps/web/src/services/http/terminal.service.ts @@ -3,6 +3,8 @@ import { createWorkspaceControllerRpcPayload } from "../../features/workspace/wo import type { ExecTarget } from "../../state/workbench"; import type { TerminalGridSize } from "../../shared/utils/terminal"; import { invokeRpc } from "./client"; +import { sendWsMessage } from "../../ws/client.ts"; +import { sendWsMutationWithHttpFallback } from "./ws-rpc-fallback.ts"; export const createTerminal = ( workspaceId: string, @@ -24,9 +26,18 @@ export const writeTerminal = ( controller: WorkspaceControllerState, terminalId: number, input: string, -) => invokeRpc( - "terminal_write", - createWorkspaceControllerRpcPayload(workspaceId, controller, { terminalId, input }), +) => sendWsMutationWithHttpFallback( + () => sendWsMessage({ + type: "terminal_write", + workspace_id: workspaceId, + terminal_id: terminalId, + input, + fencing_token: controller.fencingToken, + }), + () => invokeRpc( + "terminal_write", + createWorkspaceControllerRpcPayload(workspaceId, controller, { terminalId, input }), + ), ); export const resizeTerminal = ( @@ -35,9 +46,19 @@ export const resizeTerminal = ( terminalId: number, cols: number, rows: number, -) => invokeRpc( - "terminal_resize", - createWorkspaceControllerRpcPayload(workspaceId, controller, { terminalId, cols, rows }), +) => sendWsMutationWithHttpFallback( + () => sendWsMessage({ + type: "terminal_resize", + workspace_id: workspaceId, + terminal_id: terminalId, + cols, + rows, + fencing_token: controller.fencingToken, + }), + () => invokeRpc( + "terminal_resize", + createWorkspaceControllerRpcPayload(workspaceId, controller, { terminalId, cols, rows }), + ), ); export const closeTerminal = ( diff --git a/apps/web/src/services/http/workspace.service.ts b/apps/web/src/services/http/workspace.service.ts index bfa29b5..be9ca32 100644 --- a/apps/web/src/services/http/workspace.service.ts +++ b/apps/web/src/services/http/workspace.service.ts @@ -19,6 +19,8 @@ import type { WorkspaceControllerState } from "../../features/workspace/workspac import { createWorkspaceControllerRpcPayload } from "../../features/workspace/workspace-controller.ts"; import { mapSessionHistoryRecord } from "../../features/workspace/session-history.ts"; import { fireAndForgetRpc, invokeRpc } from "./client.ts"; +import { sendWsMessage } from "../../ws/client.ts"; +import { sendWsMutationWithNullableHttpFallback } from "./ws-rpc-fallback.ts"; export const launchWorkspace = (source: { kind: "remote" | "local"; @@ -54,11 +56,17 @@ export const heartbeatWorkspaceController = ( workspaceId: string, deviceId: string, clientId: string, -) => invokeRpc("workspace_controller_heartbeat", { - workspaceId, - deviceId, - clientId, -}); +) => sendWsMutationWithNullableHttpFallback( + () => sendWsMessage({ + type: "workspace_controller_heartbeat", + workspace_id: workspaceId, + }), + () => invokeRpc("workspace_controller_heartbeat", { + workspaceId, + deviceId, + clientId, + }), +); export const requestWorkspaceTakeover = ( workspaceId: string, diff --git a/apps/web/src/services/http/ws-rpc-fallback.ts b/apps/web/src/services/http/ws-rpc-fallback.ts new file mode 100644 index 0000000..38bb3c2 --- /dev/null +++ b/apps/web/src/services/http/ws-rpc-fallback.ts @@ -0,0 +1,27 @@ +const trySendWsMutation = (send: () => boolean) => { + try { + return send(); + } catch { + return false; + } +}; + +export const sendWsMutationWithHttpFallback = async ( + send: () => boolean, + fallback: () => Promise, +) => { + if (trySendWsMutation(send)) { + return; + } + await fallback(); +}; + +export const sendWsMutationWithNullableHttpFallback = async ( + send: () => boolean, + fallback: () => Promise, +): Promise => { + if (trySendWsMutation(send)) { + return null; + } + return fallback(); +}; diff --git a/apps/web/src/shared/utils/stream-snapshot.ts b/apps/web/src/shared/utils/stream-snapshot.ts new file mode 100644 index 0000000..1d9b037 --- /dev/null +++ b/apps/web/src/shared/utils/stream-snapshot.ts @@ -0,0 +1,88 @@ +export type TerminalSnapshotUpdate = + | { kind: "noop" } + | { kind: "append"; data: string } + | { kind: "replace"; data: string }; + +const DEFAULT_OVERLAP_PROBE_LIMIT = 256; + +const trimTail = (value: string, limit: number) => { + if (limit <= 0 || value.length <= limit) { + return value; + } + return value.slice(-limit); +}; + +const resolveAppendDelta = (previous: string, next: string) => { + if (next === previous) return ""; + if (next.startsWith(previous)) { + return next.slice(previous.length); + } + + const maxProbeLength = Math.min(DEFAULT_OVERLAP_PROBE_LIMIT, next.length); + for (let probeLength = maxProbeLength; probeLength >= 1; probeLength -= 1) { + const probe = next.slice(0, probeLength); + const overlapStart = previous.lastIndexOf(probe); + if (overlapStart === -1) continue; + + const overlap = previous.slice(overlapStart); + if (!next.startsWith(overlap)) continue; + return next.slice(overlap.length); + } + + return null; +}; + +export const mergeMonotonicTextSnapshot = ( + current: string, + incoming: string, + limit: number, +) => { + const existing = trimTail(current, limit); + const next = trimTail(incoming, limit); + + if (next === existing || !next) { + return existing; + } + if (!existing) { + return next; + } + if (existing.includes(next)) { + return existing; + } + if (next.includes(existing)) { + return next; + } + + const nextDelta = resolveAppendDelta(existing, next); + if (nextDelta !== null) { + return nextDelta ? trimTail(`${existing}${nextDelta}`, limit) : existing; + } + + const existingDelta = resolveAppendDelta(next, existing); + if (existingDelta !== null) { + return existingDelta ? trimTail(`${next}${existingDelta}`, limit) : existing; + } + + return existing.length >= next.length ? existing : next; +}; + +export const planTerminalSnapshotUpdate = ( + previous: string, + next: string, +): TerminalSnapshotUpdate => { + if (next === previous) { + return { kind: "noop" }; + } + if (!next || previous.includes(next)) { + return { kind: "noop" }; + } + + const delta = resolveAppendDelta(previous, next); + if (delta !== null) { + return delta + ? { kind: "append", data: delta } + : { kind: "noop" }; + } + + return { kind: "replace", data: next }; +}; diff --git a/apps/web/src/shared/utils/workspace.ts b/apps/web/src/shared/utils/workspace.ts index d8e9a64..9611b8c 100644 --- a/apps/web/src/shared/utils/workspace.ts +++ b/apps/web/src/shared/utils/workspace.ts @@ -34,10 +34,16 @@ import { isDraftSession, resolveVisibleStatus, } from "./session.ts"; +import { mergeMonotonicTextSnapshot } from "./stream-snapshot.ts"; import { rememberWorkspaceViewBaseline, rememberWorkspaceViewBaselines, + shouldIgnoreIncomingWorkspaceViewPatch, } from "../../features/workspace/workspace-view-persistence.ts"; +import { + AGENT_STREAM_BUFFER_LIMIT, + TERMINAL_STREAM_BUFFER_LIMIT, +} from "../app/constants.ts"; const unique = (values: string[]) => Array.from(new Set(values.filter(Boolean))); @@ -202,7 +208,11 @@ const mapTerminals = ( return { id, title: existingTerminal?.title ?? formatTerminalTitle(index + 1, locale), - output: terminal.output ?? existingTerminal?.output ?? "", + output: mergeMonotonicTextSnapshot( + existingTerminal?.output ?? "", + terminal.output ?? existingTerminal?.output ?? "", + TERMINAL_STREAM_BUFFER_LIMIT, + ), recoverable: terminal.recoverable ?? existingTerminal?.recoverable ?? false, }; }); @@ -274,7 +284,15 @@ export const createTabFromWorkspaceSnapshot = ( ): Tab => { const backendSessions = snapshot.sessions.map((session) => { const current = existing?.sessions.find((item) => item.id === String(session.id)); - return createSessionFromBackend(session, locale, current); + const hydrated = createSessionFromBackend(session, locale, current); + return { + ...hydrated, + stream: mergeMonotonicTextSnapshot( + current?.stream ?? "", + session.stream ?? current?.stream ?? "", + AGENT_STREAM_BUFFER_LIMIT, + ), + }; }); const existingDraftSessions = existing?.sessions.filter((session) => isDraftSession(session)) ?? []; @@ -406,6 +424,39 @@ export const buildWorkbenchStateFromBootstrap = ( return nextState; }; +export const applyWorkspaceBootstrapResult = ( + current: WorkbenchState, + bootstrap: WorkbenchBootstrap, + locale: Locale, + appSettings: AppSettings, + routeRuntime?: { + deviceId: string; + clientId: string; + uiState?: WorkbenchUiState | null; + runtimeSnapshot?: WorkspaceRuntimeSnapshot | null; + }, +): WorkbenchState => { + const nextState = buildWorkbenchStateFromBootstrap(current, bootstrap, locale, appSettings); + if (!routeRuntime) { + return nextState; + } + if (routeRuntime.uiState && routeRuntime.runtimeSnapshot) { + return applyWorkspaceRuntimeSnapshot( + nextState, + routeRuntime.runtimeSnapshot, + locale, + appSettings, + routeRuntime.deviceId, + routeRuntime.clientId, + routeRuntime.uiState, + ); + } + if (routeRuntime.uiState) { + return applyWorkbenchUiState(nextState, routeRuntime.uiState); + } + return nextState; +}; + export const upsertWorkspaceSnapshot = ( current: WorkbenchState, snapshot: WorkspaceSnapshot, @@ -493,22 +544,25 @@ export const applyWorkspaceRuntimeStateEvent = ( const nextState = { ...current, tabs: current.tabs.map((tab) => { - if (tab.id !== payload.workspace_id) return tab; - const nextActiveSessionId = tab.sessions.some((session) => session.id === payload.view_state.active_session_id) - ? payload.view_state.active_session_id - : tab.activeSessionId; - const nextActiveTerminalId = tab.terminals.some((terminal) => terminal.id === payload.view_state.active_terminal_id) - ? payload.view_state.active_terminal_id - : tab.activeTerminalId; - return { - ...tab, - activeSessionId: nextActiveSessionId, - activePaneId: payload.view_state.active_pane_id || tab.activePaneId, - activeTerminalId: nextActiveTerminalId, - paneLayout: normalizePaneLayout(payload.view_state.pane_layout, nextActiveSessionId), - filePreview: normalizeFilePreview(payload.view_state.file_preview, tab.filePreview), - viewingArchiveId: undefined, - }; + if (tab.id !== payload.workspace_id) return tab; + if (shouldIgnoreIncomingWorkspaceViewPatch(tab, payload.view_state)) { + return tab; + } + const nextActiveSessionId = tab.sessions.some((session) => session.id === payload.view_state.active_session_id) + ? payload.view_state.active_session_id + : tab.activeSessionId; + const nextActiveTerminalId = tab.terminals.some((terminal) => terminal.id === payload.view_state.active_terminal_id) + ? payload.view_state.active_terminal_id + : tab.activeTerminalId; + return { + ...tab, + activeSessionId: nextActiveSessionId, + activePaneId: payload.view_state.active_pane_id || tab.activePaneId, + activeTerminalId: nextActiveTerminalId, + paneLayout: normalizePaneLayout(payload.view_state.pane_layout, nextActiveSessionId), + filePreview: normalizeFilePreview(payload.view_state.file_preview, tab.filePreview), + viewingArchiveId: undefined, + }; }), }; const nextTab = nextState.tabs.find((tab) => tab.id === payload.workspace_id); diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index d2038e8..9fcf79f 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -7527,6 +7527,12 @@ pre.diff, background: var(--surface-chip) !important; } +.workspace-panel-toggle.icon-only.active { + border-color: color-mix(in srgb, var(--accent) 44%, var(--border-subtle)) !important; + background: color-mix(in srgb, var(--surface-chip) 48%, var(--accent) 52%) !important; + color: color-mix(in srgb, var(--text) 82%, var(--accent) 18%) !important; +} + .workspace-panel-toggle.icon-only svg { width: 14px; height: 14px; diff --git a/apps/web/src/ws/client.ts b/apps/web/src/ws/client.ts index 7265a87..fef2518 100644 --- a/apps/web/src/ws/client.ts +++ b/apps/web/src/ws/client.ts @@ -1,4 +1,5 @@ -import { WsConnectionManager, type WsConnectionState } from "./connection-manager"; +import { WsConnectionManager, type WsConnectionState } from "./connection-manager.ts"; +import type { WsClientEnvelope } from "./protocol.ts"; const manager = new WsConnectionManager(); @@ -8,4 +9,6 @@ export const subscribeWsEvent = (event: string, handler: (payload: export const subscribeWsConnectionState = (handler: (state: WsConnectionState) => void) => manager.subscribeConnectionState(handler); +export const sendWsMessage = (message: WsClientEnvelope) => manager.send(message); + export type { WsConnectionState }; diff --git a/apps/web/src/ws/connection-manager.ts b/apps/web/src/ws/connection-manager.ts index d7860ac..930c8c5 100644 --- a/apps/web/src/ws/connection-manager.ts +++ b/apps/web/src/ws/connection-manager.ts @@ -1,12 +1,12 @@ -import { healthUrl, websocketUrl } from "../shared/runtime/backend"; -import { isAuthenticated, isPublicModeActive } from "../services/http/auth.service"; +import { healthUrl, websocketUrl } from "../shared/runtime/backend.ts"; +import { isAuthenticated, isPublicModeActive } from "../services/http/auth.service.ts"; import { getOrCreateClientId, getOrCreateDeviceId, -} from "../features/workspace/workspace-controller"; -import { WsHeartbeat } from "./heartbeat"; -import { parseWsEnvelope, type WsEventEnvelope } from "./protocol"; -import { getReconnectDelayMs } from "./reconnect-policy"; +} from "../features/workspace/workspace-controller.ts"; +import { WsHeartbeat } from "./heartbeat.ts"; +import { parseWsEnvelope, type WsClientEnvelope, type WsEventEnvelope } from "./protocol.ts"; +import { getReconnectDelayMs } from "./reconnect-policy.ts"; type EventHandler = (payload: T) => void; export type WsConnectionState = { @@ -68,6 +68,21 @@ export class WsConnectionManager { }; } + send(message: WsClientEnvelope) { + this.bindOnlineListeners(); + this.connect(); + if (this.socket?.readyState !== WebSocket.OPEN) { + return false; + } + try { + this.socket.send(JSON.stringify(message)); + return true; + } catch { + this.socket?.close(); + return false; + } + } + private bindOnlineListeners() { if (typeof window === "undefined" || this.onlineListenerBound) { return; diff --git a/apps/web/src/ws/heartbeat.ts b/apps/web/src/ws/heartbeat.ts index 4f0bffd..ea87367 100644 --- a/apps/web/src/ws/heartbeat.ts +++ b/apps/web/src/ws/heartbeat.ts @@ -1,4 +1,4 @@ -import type { WsPingEnvelope } from "./protocol"; +import type { WsPingEnvelope } from "./protocol.ts"; const PING_INTERVAL_MS = 15000; const PONG_TIMEOUT_MS = 10000; @@ -11,8 +11,11 @@ type WsHeartbeatOptions = { export class WsHeartbeat { private pingTimer: number | null = null; private pongTimer: number | null = null; + private readonly options: WsHeartbeatOptions; - constructor(private readonly options: WsHeartbeatOptions) {} + constructor(options: WsHeartbeatOptions) { + this.options = options; + } start() { this.stop(); diff --git a/apps/web/src/ws/protocol.ts b/apps/web/src/ws/protocol.ts index 4df1a4d..201a083 100644 --- a/apps/web/src/ws/protocol.ts +++ b/apps/web/src/ws/protocol.ts @@ -4,6 +4,54 @@ export type WsEventEnvelope = { payload: unknown; }; +export type WsAgentSendEnvelope = { + type: "agent_send"; + workspace_id: string; + session_id: string; + input: string; + append_newline?: boolean; + fencing_token: number; +}; + +export type WsTerminalWriteEnvelope = { + type: "terminal_write"; + workspace_id: string; + terminal_id: number; + input: string; + fencing_token: number; +}; + +export type WsTerminalResizeEnvelope = { + type: "terminal_resize"; + workspace_id: string; + terminal_id: number; + cols: number; + rows: number; + fencing_token: number; +}; + +export type WsAgentResizeEnvelope = { + type: "agent_resize"; + workspace_id: string; + session_id: string; + cols: number; + rows: number; + fencing_token: number; +}; + +export type WsSessionUpdateEnvelope = { + type: "session_update"; + workspace_id: string; + session_id: number; + patch: Record; + fencing_token: number; +}; + +export type WsWorkspaceControllerHeartbeatEnvelope = { + type: "workspace_controller_heartbeat"; + workspace_id: string; +}; + export type WsPingEnvelope = { type: "ping"; ts: number; @@ -15,6 +63,15 @@ export type WsPongEnvelope = { }; export type WsEnvelope = WsEventEnvelope | WsPingEnvelope | WsPongEnvelope; +export type WsClientEnvelope = + | WsPingEnvelope + | WsPongEnvelope + | WsAgentSendEnvelope + | WsAgentResizeEnvelope + | WsSessionUpdateEnvelope + | WsTerminalWriteEnvelope + | WsTerminalResizeEnvelope + | WsWorkspaceControllerHeartbeatEnvelope; export const parseWsEnvelope = (message: string): WsEnvelope | null => { try { diff --git a/tests/runtime-attach.test.ts b/tests/runtime-attach.test.ts index fd4968e..6752aa1 100644 --- a/tests/runtime-attach.test.ts +++ b/tests/runtime-attach.test.ts @@ -2,6 +2,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { ATTACH_RUNTIME_RETRY_DELAYS_MS, + ATTACH_RUNTIME_SUCCESS_REUSE_MS, + createWorkspaceRuntimeAttachDeduper, runAttachWithRetry, } from "../apps/web/src/features/workspace/runtime-attach.ts"; @@ -36,3 +38,76 @@ test("runtime attach retry delays reserve a longer recovery window for slow envi [0, 250, 750, 1500, 3000, 5000], ); }); + +test("createWorkspaceRuntimeAttachDeduper shares an inflight attach across callers for the same workspace key", async () => { + let calls = 0; + let resolveAttach: ((value: { workspace: string }) => void) | null = null; + const dedupe = createWorkspaceRuntimeAttachDeduper<{ workspace: string }>(); + + const first = dedupe.run("ws-1:device-a:client-a", () => new Promise((resolve) => { + calls += 1; + resolveAttach = resolve; + })); + const second = dedupe.run("ws-1:device-a:client-a", async () => { + calls += 1; + return { workspace: "unexpected" }; + }); + + assert.equal(calls, 1); + + resolveAttach?.({ workspace: "ws-1" }); + const [firstResult, secondResult] = await Promise.all([first, second]); + + assert.deepEqual(firstResult, { workspace: "ws-1" }); + assert.deepEqual(secondResult, { workspace: "ws-1" }); + assert.equal(calls, 1); +}); + +test("createWorkspaceRuntimeAttachDeduper reuses a recent successful attach result for a short cooldown window", async () => { + let now = 10_000; + let calls = 0; + const dedupe = createWorkspaceRuntimeAttachDeduper<{ workspace: string }>({ + now: () => now, + }); + + const first = await dedupe.run("ws-1:device-a:client-a", async () => { + calls += 1; + return { workspace: "ws-1" }; + }); + const second = await dedupe.run("ws-1:device-a:client-a", async () => { + calls += 1; + return { workspace: "ws-1-second" }; + }); + + assert.deepEqual(first, { workspace: "ws-1" }); + assert.deepEqual(second, { workspace: "ws-1" }); + assert.equal(calls, 1); + + now += ATTACH_RUNTIME_SUCCESS_REUSE_MS + 1; + + const third = await dedupe.run("ws-1:device-a:client-a", async () => { + calls += 1; + return { workspace: "ws-1-third" }; + }); + + assert.deepEqual(third, { workspace: "ws-1-third" }); + assert.equal(calls, 2); +}); + +test("createWorkspaceRuntimeAttachDeduper does not cache null attach results", async () => { + let calls = 0; + const dedupe = createWorkspaceRuntimeAttachDeduper<{ workspace: string }>(); + + const first = await dedupe.run("ws-1:device-a:client-a", async () => { + calls += 1; + return null; + }); + const second = await dedupe.run("ws-1:device-a:client-a", async () => { + calls += 1; + return { workspace: "ws-1" }; + }); + + assert.equal(first, null); + assert.deepEqual(second, { workspace: "ws-1" }); + assert.equal(calls, 2); +}); diff --git a/tests/session-service.test.ts b/tests/session-service.test.ts new file mode 100644 index 0000000..743c7e1 --- /dev/null +++ b/tests/session-service.test.ts @@ -0,0 +1,140 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createWorkspaceControllerState } from "../apps/web/src/features/workspace/workspace-controller.ts"; +import { + createSessionActivityPersistScheduler, + updateSession, +} from "../apps/web/src/services/http/session.service.ts"; +import { WsConnectionManager } from "../apps/web/src/ws/connection-manager.ts"; + +const createFakeTimeouts = () => { + const timers = new Map void; delayMs: number }>(); + const cancelled: number[] = []; + let nextHandle = 1; + + return { + cancelled, + timers, + schedule(callback: () => void, delayMs: number) { + const handle = nextHandle++; + timers.set(handle, { callback, delayMs }); + return handle; + }, + cancel(handle: unknown) { + cancelled.push(handle as number); + timers.delete(handle as number); + }, + runNext() { + const next = timers.entries().next(); + if (next.done) return false; + const [handle, timer] = next.value; + timers.delete(handle); + timer.callback(); + return true; + }, + }; +}; + +test("createSessionActivityPersistScheduler waits for stability and only persists the latest activity timestamp", () => { + const timeouts = createFakeTimeouts(); + const persisted: Array<{ + workspaceId: string; + sessionId: number; + lastActiveAt?: number; + controller: string; + }> = []; + const scheduler = createSessionActivityPersistScheduler( + (workspaceId, sessionId, patch, controller) => { + persisted.push({ + workspaceId, + sessionId, + lastActiveAt: patch.last_active_at, + controller, + }); + }, + timeouts.schedule, + timeouts.cancel, + 1200, + ); + + scheduler.schedule("ws-1", 7, 101, "controller-a"); + scheduler.schedule("ws-1", 7, 205, "controller-b"); + + assert.deepEqual(persisted, []); + assert.equal(timeouts.timers.size, 1); + assert.deepEqual(timeouts.cancelled, [1]); + + timeouts.runNext(); + + assert.deepEqual(persisted, [ + { + workspaceId: "ws-1", + sessionId: 7, + lastActiveAt: 205, + controller: "controller-b", + }, + ]); +}); + +test("createSessionActivityPersistScheduler can hand the latest pending activity timestamp to an immediate patch", () => { + const timeouts = createFakeTimeouts(); + const scheduler = createSessionActivityPersistScheduler( + () => { + throw new Error("pending activity should not flush when an immediate patch takes over"); + }, + timeouts.schedule, + timeouts.cancel, + 1200, + ); + + scheduler.schedule("ws-1", 7, 333, "controller-a"); + + const pending = scheduler.takeLastActiveAt("ws-1", 7); + + assert.equal(pending, 333); + assert.equal(timeouts.timers.size, 0); + assert.deepEqual(timeouts.cancelled, [1]); +}); + +test("updateSession prefers websocket transport when available", async () => { + const messages: unknown[] = []; + const originalSend = WsConnectionManager.prototype.send; + const originalFetch = globalThis.fetch; + + WsConnectionManager.prototype.send = function send(message) { + messages.push(message); + return true; + }; + globalThis.fetch = (async () => { + throw new Error("http fallback should not run when websocket send succeeds"); + }) as typeof fetch; + + try { + const result = await updateSession( + "ws-1", + 7, + { title: "Renamed Session" }, + createWorkspaceControllerState({ + role: "controller", + deviceId: "device-a", + clientId: "client-a", + fencingToken: 9, + }), + ); + + assert.equal(result, null); + assert.deepEqual(messages, [ + { + type: "session_update", + workspace_id: "ws-1", + session_id: 7, + patch: { title: "Renamed Session" }, + fencing_token: 9, + }, + ]); + } finally { + WsConnectionManager.prototype.send = originalSend; + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/stream-snapshot.test.ts b/tests/stream-snapshot.test.ts new file mode 100644 index 0000000..2d9c8cc --- /dev/null +++ b/tests/stream-snapshot.test.ts @@ -0,0 +1,41 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + mergeMonotonicTextSnapshot, + planTerminalSnapshotUpdate, +} from "../apps/web/src/shared/utils/stream-snapshot.ts"; + +test("mergeMonotonicTextSnapshot keeps the richer local snapshot when replay is shorter", () => { + assert.equal( + mergeMonotonicTextSnapshot("abcdef", "abc", 32), + "abcdef", + ); +}); + +test("mergeMonotonicTextSnapshot stitches a truncated-head replay with new tail output", () => { + assert.equal( + mergeMonotonicTextSnapshot("abcdef", "cdefgh", 32), + "abcdefgh", + ); +}); + +test("planTerminalSnapshotUpdate appends new tail output without resetting on head truncation", () => { + assert.deepEqual( + planTerminalSnapshotUpdate("abcdef", "cdefgh"), + { kind: "append", data: "gh" }, + ); +}); + +test("planTerminalSnapshotUpdate ignores a shorter replay already contained in the terminal buffer", () => { + assert.deepEqual( + planTerminalSnapshotUpdate("abcdef", "cdef"), + { kind: "noop" }, + ); +}); + +test("planTerminalSnapshotUpdate replaces genuinely divergent snapshots", () => { + assert.deepEqual( + planTerminalSnapshotUpdate("abcdef", "xyz"), + { kind: "replace", data: "xyz" }, + ); +}); diff --git a/tests/workspace-layout-actions.test.ts b/tests/workspace-layout-actions.test.ts new file mode 100644 index 0000000..b43ae4d --- /dev/null +++ b/tests/workspace-layout-actions.test.ts @@ -0,0 +1,279 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createDefaultWorkbenchState } from "../apps/web/src/state/workbench-core.ts"; +import { + startWorkspacePaneSplitResize, + startWorkspacePanelResize, +} from "../apps/web/src/features/workspace/workspace-layout-actions.ts"; + +const installResizeEnvironment = () => { + const root = globalThis as typeof globalThis & { + window?: { + addEventListener: (type: string, listener: EventListener) => void; + removeEventListener: (type: string, listener: EventListener) => void; + requestAnimationFrame: (callback: FrameRequestCallback) => number; + cancelAnimationFrame: (handle: number) => void; + }; + document?: { + body: { + classList: { + add: (...tokens: string[]) => void; + remove: (...tokens: string[]) => void; + }; + }; + }; + requestAnimationFrame?: (callback: FrameRequestCallback) => number; + cancelAnimationFrame?: (handle: number) => void; + }; + const previousWindow = root.window; + const previousDocument = root.document; + const previousRequestAnimationFrame = root.requestAnimationFrame; + const previousCancelAnimationFrame = root.cancelAnimationFrame; + const listeners = new Map>(); + const frames = new Map(); + const classNames = new Set(); + let nextFrameId = 1; + + const requestAnimationFrame = (callback: FrameRequestCallback) => { + const handle = nextFrameId++; + frames.set(handle, callback); + return handle; + }; + + const cancelAnimationFrame = (handle: number) => { + frames.delete(handle); + }; + + root.window = { + addEventListener(type, listener) { + const current = listeners.get(type) ?? new Set(); + current.add(listener); + listeners.set(type, current); + }, + removeEventListener(type, listener) { + listeners.get(type)?.delete(listener); + }, + requestAnimationFrame, + cancelAnimationFrame, + }; + root.document = { + body: { + classList: { + add: (...tokens) => { + tokens.forEach((token) => classNames.add(token)); + }, + remove: (...tokens) => { + tokens.forEach((token) => classNames.delete(token)); + }, + }, + }, + }; + root.requestAnimationFrame = requestAnimationFrame; + root.cancelAnimationFrame = cancelAnimationFrame; + + return { + classNames, + dispatch(type: string, event: Event) { + Array.from(listeners.get(type) ?? []).forEach((listener) => { + listener(event); + }); + }, + flushAnimationFrame() { + const next = frames.entries().next(); + if (next.done) return false; + const [handle, callback] = next.value; + frames.delete(handle); + callback(0); + return true; + }, + restore() { + if (previousWindow) { + root.window = previousWindow; + } else { + delete root.window; + } + if (previousDocument) { + root.document = previousDocument; + } else { + delete root.document; + } + if (previousRequestAnimationFrame) { + root.requestAnimationFrame = previousRequestAnimationFrame; + } else { + delete root.requestAnimationFrame; + } + if (previousCancelAnimationFrame) { + root.cancelAnimationFrame = previousCancelAnimationFrame; + } else { + delete root.cancelAnimationFrame; + } + }, + }; +}; + +test("startWorkspacePanelResize updates layout during drag without fitting terminals until pointerup", () => { + const env = installResizeEnvironment(); + + try { + const stateRef = { + current: { + ...createDefaultWorkbenchState(), + layout: { + leftWidth: 320, + rightWidth: 320, + rightSplit: 64, + showCodePanel: true, + showTerminalPanel: true, + }, + }, + }; + const widths: number[] = []; + let scheduledFits = 0; + let flushedFits = 0; + let shellFits = 0; + let archiveFits = 0; + + startWorkspacePanelResize({ + event: { + preventDefault() {}, + clientX: 400, + clientY: 120, + currentTarget: {}, + } as never, + type: "left", + stateRef: stateRef as never, + updateState: (updater) => { + stateRef.current = updater(stateRef.current); + widths.push(stateRef.current.layout.rightWidth); + }, + shellTerminalRef: { + current: { + fit: () => { + shellFits += 1; + }, + }, + } as never, + archiveTerminalRef: { + current: { + fit: () => { + archiveFits += 1; + }, + }, + } as never, + flushFitAgentTerminals: () => { + flushedFits += 1; + }, + }); + + env.dispatch("pointermove", { clientX: 360, clientY: 120 } as Event); + assert.deepEqual(widths, []); + + env.flushAnimationFrame(); + + assert.deepEqual(widths, [360]); + assert.equal(scheduledFits, 0); + assert.equal(flushedFits, 0); + assert.equal(shellFits, 0); + + env.dispatch("pointerup", {} as Event); + assert.equal(flushedFits, 0); + assert.equal(shellFits, 0); + + env.flushAnimationFrame(); + + assert.equal(scheduledFits, 0); + assert.equal(flushedFits, 1); + assert.equal(shellFits, 1); + assert.equal(archiveFits, 1); + assert.equal(env.classNames.has("is-resizing-panels"), false); + assert.equal(env.classNames.has("is-resizing-columns"), false); + } finally { + env.restore(); + } +}); + +test("startWorkspacePaneSplitResize keeps pane repaint live but defers terminal fitting until pointerup", () => { + const env = installResizeEnvironment(); + + try { + let tab = { + id: "ws-1", + paneLayout: { + type: "split" as const, + id: "split-1", + axis: "vertical" as const, + ratio: 0.5, + first: { + type: "leaf" as const, + id: "pane-1", + sessionId: "session-1", + }, + second: { + type: "leaf" as const, + id: "pane-2", + sessionId: "session-2", + }, + }, + }; + const ratios: number[] = []; + let scheduledFits = 0; + let flushedFits = 0; + let archiveFits = 0; + + startWorkspacePaneSplitResize({ + event: { + preventDefault() {}, + clientX: 500, + clientY: 200, + currentTarget: { + parentElement: { + getBoundingClientRect: () => ({ + width: 1000, + height: 600, + }), + }, + }, + } as never, + tabId: "ws-1", + paneLayout: tab.paneLayout, + splitId: "split-1", + axis: "vertical", + updateTab: (_tabId, updater) => { + tab = updater(tab as never) as typeof tab; + ratios.push(tab.paneLayout.type === "split" ? tab.paneLayout.ratio : -1); + }, + archiveTerminalRef: { + current: { + fit: () => { + archiveFits += 1; + }, + }, + } as never, + flushFitAgentTerminals: () => { + flushedFits += 1; + }, + }); + + env.dispatch("pointermove", { clientX: 700, clientY: 200 } as Event); + assert.deepEqual(ratios, []); + + env.flushAnimationFrame(); + + assert.deepEqual(ratios, [0.7]); + assert.equal(scheduledFits, 0); + assert.equal(flushedFits, 0); + + env.dispatch("pointerup", {} as Event); + assert.equal(flushedFits, 0); + + env.flushAnimationFrame(); + + assert.equal(scheduledFits, 0); + assert.equal(flushedFits, 1); + assert.equal(archiveFits, 1); + assert.equal(env.classNames.has("is-resizing-panels"), false); + assert.equal(env.classNames.has("is-resizing-columns"), false); + } finally { + env.restore(); + } +}); diff --git a/tests/workspace-ready-runtime.test.ts b/tests/workspace-ready-runtime.test.ts new file mode 100644 index 0000000..e9bda72 --- /dev/null +++ b/tests/workspace-ready-runtime.test.ts @@ -0,0 +1,23 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + READY_TAB_RUNTIME_RECOVERY_DELAYS_MS, + collectReadyTabRuntimeRecoveryWorkspaceIds, +} from "../apps/web/src/features/workspace/workspace-ready-runtime.ts"; + +test("ready-tab runtime recovery uses a single delayed follow-up attach", () => { + assert.deepEqual(READY_TAB_RUNTIME_RECOVERY_DELAYS_MS, [0, 3_000]); +}); + +test("collectReadyTabRuntimeRecoveryWorkspaceIds only includes ready tabs", () => { + assert.deepEqual( + collectReadyTabRuntimeRecoveryWorkspaceIds([ + { id: "ws-ready-a", status: "ready" }, + { id: "ws-init", status: "init" }, + { id: "ws-ready-b", status: "ready" }, + { id: "ws-loading", status: "loading" }, + ]), + ["ws-ready-a", "ws-ready-b"], + ); +}); diff --git a/tests/workspace-route-runtime.test.ts b/tests/workspace-route-runtime.test.ts new file mode 100644 index 0000000..70b9eda --- /dev/null +++ b/tests/workspace-route-runtime.test.ts @@ -0,0 +1,19 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + ROUTE_RUNTIME_ATTACH_RECOVERY_DELAYS_MS, + shouldAttachRouteRuntimeForExistingTab, +} from "../apps/web/src/features/workspace/workspace-route-runtime.ts"; + +test("route runtime recovery delays keep the slower fallback windows for cold route loads", () => { + assert.deepEqual(ROUTE_RUNTIME_ATTACH_RECOVERY_DELAYS_MS, [0, 1_000, 3_000, 7_000]); +}); + +test("ready route tabs skip direct runtime reattach because coordinator recovery owns that path", () => { + assert.equal(shouldAttachRouteRuntimeForExistingTab({ status: "ready" }), false); +}); + +test("missing or initializing route tabs still attach runtime directly", () => { + assert.equal(shouldAttachRouteRuntimeForExistingTab(undefined), true); + assert.equal(shouldAttachRouteRuntimeForExistingTab({ status: "init" }), true); +}); diff --git a/tests/workspace-runtime-controller.test.ts b/tests/workspace-runtime-controller.test.ts index fbff7ec..3b2efd6 100644 --- a/tests/workspace-runtime-controller.test.ts +++ b/tests/workspace-runtime-controller.test.ts @@ -11,6 +11,7 @@ import { } from "../apps/web/src/features/workspace/workspace-controller.ts"; import { applyWorkspaceControllerEvent, + applyWorkspaceBootstrapResult, applyWorkspaceRuntimeSnapshot, } from "../apps/web/src/shared/utils/workspace.ts"; import { createDefaultWorkbenchState } from "../apps/web/src/state/workbench-core.ts"; @@ -98,6 +99,32 @@ const createRuntimeSnapshot = (controller: { lifecycle_events: [], }); +const createOutputSnapshot = ({ + sessionStream, + terminalOutput = "", +}: { + sessionStream: string; + terminalOutput?: string; +}) => { + const runtime = createRuntimeSnapshot({ + workspace_id: "ws-1", + controller_device_id: "device-a", + controller_client_id: "client-a", + lease_expires_at: Date.now() + 30_000, + fencing_token: 1, + takeover_request_id: null, + takeover_requested_by_device_id: null, + takeover_requested_by_client_id: null, + takeover_deadline_at: null, + }); + runtime.snapshot.sessions[0].stream = sessionStream; + runtime.snapshot.terminals = terminalOutput + ? [{ id: 7, output: terminalOutput, recoverable: true }] + : []; + runtime.snapshot.view_state.active_terminal_id = terminalOutput ? "term-7" : ""; + return runtime; +}; + test("observer role blocks session switches and shell input", () => { const controller = createWorkspaceControllerState({ role: "observer", fencingToken: 1 }); @@ -480,3 +507,103 @@ test("controller events can clear takeover state after a preserved runtime merge assert.equal(cleared.tabs[0]?.controller.takeoverPending, false); assert.equal(cleared.tabs[0]?.controller.takeoverRequestId, undefined); }); + +test("runtime snapshot does not overwrite a newer live session stream with a shorter replay", () => { + const attached = applyWorkspaceRuntimeSnapshot( + createDefaultWorkbenchState(), + createOutputSnapshot({ sessionStream: "abcdef", terminalOutput: "terminal-output" }), + "en", + APP_SETTINGS, + "device-a", + "client-a", + ); + + const merged = applyWorkspaceRuntimeSnapshot( + attached, + createOutputSnapshot({ sessionStream: "abc", terminalOutput: "term" }), + "en", + APP_SETTINGS, + "device-a", + "client-a", + ); + + assert.equal(merged.tabs[0]?.sessions[0]?.stream, "abcdef"); + assert.equal(merged.tabs[0]?.terminals[0]?.output, "terminal-output"); +}); + +test("runtime snapshot bridges truncated-head replays with newer appended output", () => { + const attached = applyWorkspaceRuntimeSnapshot( + createDefaultWorkbenchState(), + createOutputSnapshot({ sessionStream: "abcdef", terminalOutput: "123456" }), + "en", + APP_SETTINGS, + "device-a", + "client-a", + ); + + const merged = applyWorkspaceRuntimeSnapshot( + attached, + createOutputSnapshot({ sessionStream: "cdefgh", terminalOutput: "345678" }), + "en", + APP_SETTINGS, + "device-a", + "client-a", + ); + + assert.equal(merged.tabs[0]?.sessions[0]?.stream, "abcdefgh"); + assert.equal(merged.tabs[0]?.terminals[0]?.output, "12345678"); +}); + +test("bootstrap replay merges against the latest store state instead of replacing newer streams", () => { + const current = applyWorkspaceRuntimeSnapshot( + createDefaultWorkbenchState(), + createOutputSnapshot({ sessionStream: "abcdef", terminalOutput: "123456" }), + "en", + APP_SETTINGS, + "device-a", + "client-a", + ); + + const next = applyWorkspaceBootstrapResult( + current, + { + ui_state: { + open_workspace_ids: ["ws-1"], + active_workspace_id: "ws-1", + layout: { + left_width: 320, + right_width: 320, + right_split: 64, + show_code_panel: false, + show_terminal_panel: false, + }, + }, + workspaces: [ + { + ...createOutputSnapshot({ sessionStream: "abc", terminalOutput: "123" }).snapshot, + }, + ], + }, + "en", + APP_SETTINGS, + { + deviceId: "device-a", + clientId: "client-a", + uiState: { + open_workspace_ids: ["ws-1"], + active_workspace_id: "ws-1", + layout: { + left_width: 320, + right_width: 320, + right_split: 64, + show_code_panel: false, + show_terminal_panel: false, + }, + }, + runtimeSnapshot: createOutputSnapshot({ sessionStream: "abc", terminalOutput: "123" }), + }, + ); + + assert.equal(next.tabs[0]?.sessions[0]?.stream, "abcdef"); + assert.equal(next.tabs[0]?.terminals[0]?.output, "123456"); +}); diff --git a/tests/workspace-view-persist-scheduler.test.ts b/tests/workspace-view-persist-scheduler.test.ts new file mode 100644 index 0000000..ca31bb6 --- /dev/null +++ b/tests/workspace-view-persist-scheduler.test.ts @@ -0,0 +1,139 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as workspaceViewPersistence from "../apps/web/src/features/workspace/workspace-view-persistence.ts"; + +const createPatch = (ratio: number) => ({ + active_session_id: "session-1", + active_pane_id: "pane-1", + active_terminal_id: "", + pane_layout: { + type: "split" as const, + id: "split-1", + axis: "vertical" as const, + ratio, + first: { + type: "leaf" as const, + id: "pane-1", + sessionId: "session-1", + }, + second: { + type: "leaf" as const, + id: "pane-2", + sessionId: "session-2", + }, + }, + file_preview: { + path: "", + content: "", + mode: "preview" as const, + originalContent: "", + modifiedContent: "", + dirty: false, + }, +}); + +const createFakeTimeouts = () => { + const timers = new Map void; delayMs: number }>(); + const cancelled: number[] = []; + let nextHandle = 1; + + return { + cancelled, + timers, + schedule(callback: () => void, delayMs: number) { + const handle = nextHandle++; + timers.set(handle, { callback, delayMs }); + return handle; + }, + cancel(handle: unknown) { + cancelled.push(handle as number); + timers.delete(handle as number); + }, + runNext() { + const next = timers.entries().next(); + if (next.done) return false; + const [handle, timer] = next.value; + timers.delete(handle); + timer.callback(); + return true; + }, + }; +}; + +test("createWorkspaceViewPersistScheduler waits for stability and only persists the latest view patch", () => { + const createScheduler = (workspaceViewPersistence as Record).createWorkspaceViewPersistScheduler; + assert.equal(typeof createScheduler, "function"); + + const timeouts = createFakeTimeouts(); + const persisted: Array<{ workspaceId: string; ratio: number; controller: string }> = []; + const scheduler = (createScheduler as ( + persist: (workspaceId: string, patch: ReturnType, controller: string) => void, + scheduleTimeout: (callback: () => void, delayMs: number) => number, + cancelTimeout: (handle: unknown) => void, + delayMs: number, + ) => { + schedule: (workspaceId: string, patch: ReturnType, controller: string) => void; + })( + (workspaceId, patch, controller) => { + persisted.push({ + workspaceId, + ratio: patch.pane_layout.type === "split" ? patch.pane_layout.ratio : -1, + controller, + }); + }, + timeouts.schedule, + timeouts.cancel, + 180, + ); + + scheduler.schedule("ws-1", createPatch(0.4), "controller-a"); + scheduler.schedule("ws-1", createPatch(0.72), "controller-b"); + + assert.deepEqual(persisted, []); + assert.equal(timeouts.timers.size, 1); + assert.deepEqual(timeouts.cancelled, [1]); + + timeouts.runNext(); + + assert.deepEqual(persisted, [ + { workspaceId: "ws-1", ratio: 0.72, controller: "controller-b" }, + ]); +}); + +test("createWorkspaceViewPersistScheduler can flush the final pending view immediately", () => { + const createScheduler = (workspaceViewPersistence as Record).createWorkspaceViewPersistScheduler; + assert.equal(typeof createScheduler, "function"); + + const timeouts = createFakeTimeouts(); + const persisted: Array<{ workspaceId: string; ratio: number }> = []; + const scheduler = (createScheduler as ( + persist: (workspaceId: string, patch: ReturnType, controller: string) => void, + scheduleTimeout: (callback: () => void, delayMs: number) => number, + cancelTimeout: (handle: unknown) => void, + delayMs: number, + ) => { + schedule: (workspaceId: string, patch: ReturnType, controller: string) => void; + flush: (workspaceId?: string) => void; + })( + (workspaceId, patch) => { + persisted.push({ + workspaceId, + ratio: patch.pane_layout.type === "split" ? patch.pane_layout.ratio : -1, + }); + }, + timeouts.schedule, + timeouts.cancel, + 180, + ); + + scheduler.schedule("ws-1", createPatch(0.61), "controller-a"); + assert.equal(timeouts.timers.size, 1); + + scheduler.flush("ws-1"); + + assert.deepEqual(persisted, [ + { workspaceId: "ws-1", ratio: 0.61 }, + ]); + assert.equal(timeouts.timers.size, 0); + assert.deepEqual(timeouts.cancelled, [1]); +}); diff --git a/tests/workspace-view-persistence.test.ts b/tests/workspace-view-persistence.test.ts index ad762e8..c71352b 100644 --- a/tests/workspace-view-persistence.test.ts +++ b/tests/workspace-view-persistence.test.ts @@ -6,6 +6,7 @@ import { } from "../apps/web/src/shared/utils/workspace.ts"; import { createDefaultWorkbenchState } from "../apps/web/src/state/workbench-core.ts"; import { + noteWorkspaceViewPersistRequest, resetWorkspaceViewBaselines, shouldPersistWorkspaceView, } from "../apps/web/src/features/workspace/workspace-view-persistence.ts"; @@ -169,3 +170,143 @@ test("runtime state events refresh workspace view persistence baselines", () => assert.equal(shouldPersistWorkspaceView(tab), false); assert.equal(shouldPersistWorkspaceView({ ...tab, activePaneId: "pane-2" }), true); }); + +test("runtime state ignores a stale local echo when a newer pane layout was already persisted", () => { + const initial = buildWorkbenchStateFromBootstrap( + createDefaultWorkbenchState(), + { + ui_state: { + open_workspace_ids: ["ws-1"], + active_workspace_id: "ws-1", + layout: { + left_width: 320, + right_width: 320, + right_split: 64, + show_code_panel: false, + show_terminal_panel: false, + }, + }, + workspaces: [createWorkspaceSnapshot("1")], + }, + "en", + APP_SETTINGS, + ); + + const olderView = { + active_session_id: "1", + active_pane_id: "pane-1", + active_terminal_id: "", + pane_layout: { + type: "split" as const, + id: "split-1", + axis: "vertical" as const, + ratio: 0.4, + first: { + type: "leaf" as const, + id: "pane-1", + sessionId: "1", + }, + second: { + type: "leaf" as const, + id: "pane-2", + sessionId: "2", + }, + }, + file_preview: { + path: "", + content: "", + mode: "preview" as const, + originalContent: "", + modifiedContent: "", + dirty: false, + }, + }; + + const newerView = { + ...olderView, + pane_layout: { + ...olderView.pane_layout, + ratio: 0.72, + }, + }; + + noteWorkspaceViewPersistRequest("ws-1", olderView); + noteWorkspaceViewPersistRequest("ws-1", newerView); + + const current = applyWorkspaceRuntimeStateEvent(initial, { + workspace_id: "ws-1", + view_state: newerView, + }); + + const next = applyWorkspaceRuntimeStateEvent(current, { + workspace_id: "ws-1", + view_state: olderView, + }); + + assert.equal(next.tabs[0]?.paneLayout.type, "split"); + if (next.tabs[0]?.paneLayout.type === "split") { + assert.equal(next.tabs[0].paneLayout.ratio, 0.72); + } +}); + +test("runtime state still applies a remote pane layout that was not sent locally", () => { + const initial = buildWorkbenchStateFromBootstrap( + createDefaultWorkbenchState(), + { + ui_state: { + open_workspace_ids: ["ws-1"], + active_workspace_id: "ws-1", + layout: { + left_width: 320, + right_width: 320, + right_split: 64, + show_code_panel: false, + show_terminal_panel: false, + }, + }, + workspaces: [createWorkspaceSnapshot("1")], + }, + "en", + APP_SETTINGS, + ); + + const remoteView = { + active_session_id: "1", + active_pane_id: "pane-1", + active_terminal_id: "", + pane_layout: { + type: "split" as const, + id: "split-remote", + axis: "vertical" as const, + ratio: 0.61, + first: { + type: "leaf" as const, + id: "pane-1", + sessionId: "1", + }, + second: { + type: "leaf" as const, + id: "pane-2", + sessionId: "2", + }, + }, + file_preview: { + path: "", + content: "", + mode: "preview" as const, + originalContent: "", + modifiedContent: "", + dirty: false, + }, + }; + + const next = applyWorkspaceRuntimeStateEvent(initial, { + workspace_id: "ws-1", + view_state: remoteView, + }); + + assert.equal(next.tabs[0]?.paneLayout.type, "split"); + if (next.tabs[0]?.paneLayout.type === "split") { + assert.equal(next.tabs[0].paneLayout.ratio, 0.61); + } +}); diff --git a/tests/ws-rpc-fallback.test.ts b/tests/ws-rpc-fallback.test.ts new file mode 100644 index 0000000..d1a5453 --- /dev/null +++ b/tests/ws-rpc-fallback.test.ts @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + sendWsMutationWithHttpFallback, + sendWsMutationWithNullableHttpFallback, +} from "../apps/web/src/services/http/ws-rpc-fallback.ts"; + +test("sendWsMutationWithHttpFallback prefers websocket when available", async () => { + const calls: string[] = []; + + await sendWsMutationWithHttpFallback( + () => { + calls.push("ws"); + return true; + }, + async () => { + calls.push("http"); + }, + ); + + assert.deepEqual(calls, ["ws"]); +}); + +test("sendWsMutationWithHttpFallback falls back to http when websocket send fails", async () => { + const calls: string[] = []; + + await sendWsMutationWithHttpFallback( + () => { + calls.push("ws"); + return false; + }, + async () => { + calls.push("http"); + }, + ); + + assert.deepEqual(calls, ["ws", "http"]); +}); + +test("sendWsMutationWithHttpFallback falls back to http when websocket send throws", async () => { + const calls: string[] = []; + + await sendWsMutationWithHttpFallback( + () => { + calls.push("ws"); + throw new Error("socket unavailable"); + }, + async () => { + calls.push("http"); + }, + ); + + assert.deepEqual(calls, ["ws", "http"]); +}); + +test("sendWsMutationWithNullableHttpFallback returns null when websocket send succeeds", async () => { + const result = await sendWsMutationWithNullableHttpFallback( + () => true, + async () => "http-result", + ); + + assert.equal(result, null); +}); + +test("sendWsMutationWithNullableHttpFallback returns the http result when websocket send fails", async () => { + const result = await sendWsMutationWithNullableHttpFallback( + () => false, + async () => "http-result", + ); + + assert.equal(result, "http-result"); +});