From b51bb4faf081d39a21e5b6ec29a0ff0658d3db63 Mon Sep 17 00:00:00 2001 From: Haoqian Li Date: Thu, 25 Jun 2026 22:17:51 +0800 Subject: [PATCH] ios: show new opencode sessions immediately --- apps/ios/Sources/Litter/LitterApp.swift | 12 ++- .../Litter/Views/HomeComposerView.swift | 2 +- .../Sources/Litter/Views/SessionsScreen.swift | 2 +- .../codex-mobile-client/src/ffi/client.rs | 6 +- .../src/store/reconcile.rs | 80 +++++++++++++++++++ 5 files changed, 92 insertions(+), 10 deletions(-) diff --git a/apps/ios/Sources/Litter/LitterApp.swift b/apps/ios/Sources/Litter/LitterApp.swift index c235c5d67..c9d4542ac 100644 --- a/apps/ios/Sources/Litter/LitterApp.swift +++ b/apps/ios/Sources/Litter/LitterApp.swift @@ -1318,12 +1318,10 @@ private struct HomeNavigationView: View { return } - guard let resolvedKey = await appModel.ensureThreadLoaded(key: startedKey) - ?? appModel.snapshot?.threadSnapshot(for: startedKey)?.key else { - actionErrorMessage = appModel.lastError ?? "Failed to load the new session." - return - } - + // startThread already created the thread and reconciled it into the + // Rust store. Open immediately so the route can show its loading + // state while any late metadata/hydration catches up. + let resolvedKey = appModel.snapshot?.threadSnapshot(for: startedKey)?.key ?? startedKey openConversation(resolvedKey) } @@ -1350,7 +1348,7 @@ private struct HomeNavigationView: View { let selectedModel = appState.selectedModel.trimmingCharacters(in: .whitespacesAndNewlines) let hasSelectedModel = !selectedModel.isEmpty return AppThreadLaunchConfig( - agentRuntimeKind: hasSelectedModel ? appState.selectedAgentRuntimeKind : nil, + agentRuntimeKind: appState.selectedAgentRuntimeKind, model: hasSelectedModel ? selectedModel : nil, approvalPolicy: appState.launchApprovalPolicy(for: threadKey), sandbox: appState.launchSandboxMode(for: threadKey), diff --git a/apps/ios/Sources/Litter/Views/HomeComposerView.swift b/apps/ios/Sources/Litter/Views/HomeComposerView.swift index bbd5d645f..9501c2c27 100644 --- a/apps/ios/Sources/Litter/Views/HomeComposerView.swift +++ b/apps/ios/Sources/Litter/Views/HomeComposerView.swift @@ -242,7 +242,7 @@ struct HomeComposerView: View { let pendingModel = appState.preferredModel.trimmingCharacters(in: .whitespacesAndNewlines) let modelOverride = pendingModel.isEmpty ? nil : pendingModel - let agentRuntimeOverride = modelOverride == nil ? nil : appState.preferredAgentRuntimeKind + let agentRuntimeOverride = appState.preferredAgentRuntimeKind let pendingEffort = appState.preferredReasoningEffort.trimmingCharacters(in: .whitespacesAndNewlines) let effortOverride = ReasoningEffort(wireValue: pendingEffort.isEmpty ? nil : pendingEffort) let launchConfig = AppThreadLaunchConfig( diff --git a/apps/ios/Sources/Litter/Views/SessionsScreen.swift b/apps/ios/Sources/Litter/Views/SessionsScreen.swift index 8b61adbd7..904329260 100644 --- a/apps/ios/Sources/Litter/Views/SessionsScreen.swift +++ b/apps/ios/Sources/Litter/Views/SessionsScreen.swift @@ -1288,7 +1288,7 @@ struct SessionsScreen: View { let selectedModel = appState.selectedModel.trimmingCharacters(in: .whitespacesAndNewlines) let hasSelectedModel = !selectedModel.isEmpty return AppThreadLaunchConfig( - agentRuntimeKind: hasSelectedModel ? appState.selectedAgentRuntimeKind : nil, + agentRuntimeKind: appState.selectedAgentRuntimeKind, model: hasSelectedModel ? selectedModel : nil, approvalPolicy: appState.launchApprovalPolicy(for: threadKey), sandbox: appState.launchSandboxMode(for: threadKey), diff --git a/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs index 3f449472d..5a71f9dd4 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/client.rs @@ -381,7 +381,11 @@ impl AppClient { ) .await?; let key = c - .apply_thread_start_response(&server_id, &response) + .apply_thread_start_response_for_runtime( + &server_id, + runtime_kind.clone(), + &response, + ) .map_err(ClientError::Serialization)?; c.note_thread_runtime(key.clone(), runtime_kind); Ok(key) diff --git a/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs b/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs index b2845856c..d88a8e750 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs @@ -222,6 +222,15 @@ impl MobileClient { &self, server_id: &str, response: &upstream::ThreadStartResponse, + ) -> Result { + self.apply_thread_start_response_for_runtime(server_id, "codex".to_string(), response) + } + + pub fn apply_thread_start_response_for_runtime( + &self, + server_id: &str, + runtime_kind: AgentRuntimeKind, + response: &upstream::ThreadStartResponse, ) -> Result { let mut snapshot = crate::thread_snapshot_from_upstream_thread_with_overrides( server_id, @@ -235,6 +244,7 @@ impl MobileClient { Some(response.sandbox.clone().into()), ) .map_err(|e| e.to_string())?; + snapshot.agent_runtime_kind = runtime_kind; // A freshly-started thread has no turns to page; mark the initial // page as loaded so UI does not auto-fire `thread/turns/list` // (which the server rejects until the first user message lands). @@ -1318,6 +1328,76 @@ mod tests { assert!(snapshot.older_turns_cursor.is_none()); } + #[tokio::test] + async fn apply_thread_start_response_for_runtime_projects_session_summary() { + let client = MobileClient::new(); + client.app_store.upsert_server( + &ServerConfig { + server_id: "srv".to_string(), + display_name: "Server".to_string(), + host: "localhost".to_string(), + port: 8390, + websocket_url: None, + is_local: true, + tls: false, + }, + ServerHealthSnapshot::Connected, + ); + let response = upstream::ThreadStartResponse { + thread: test_upstream_thread("thread-opencode"), + model: "opencode/default".to_string(), + model_provider: "opencode".to_string(), + service_tier: None, + cwd: test_abs_path("/tmp"), + runtime_workspace_roots: Vec::new(), + instruction_sources: Vec::new(), + approval_policy: upstream::AskForApproval::Never, + approvals_reviewer: upstream::ApprovalsReviewer::User, + sandbox: upstream::SandboxPolicy::DangerFullAccess, + active_permission_profile: None, + reasoning_effort: None, + }; + + let key = client + .apply_thread_start_response_for_runtime("srv", "opencode".to_string(), &response) + .expect("thread/start reconciliation"); + let snapshot = client.app_store.thread_snapshot(&key).expect("snapshot"); + assert_eq!(snapshot.agent_runtime_kind, "opencode"); + + let record = crate::store::AppSnapshotRecord::try_from(client.app_snapshot()) + .expect("snapshot record"); + assert!(record.threads.iter().any(|thread| thread.key == key)); + let summary = record + .session_summaries + .iter() + .find(|summary| summary.key == key) + .expect("new session summary"); + assert_eq!(summary.agent_runtime_kind, "opencode"); + + let default_response = upstream::ThreadStartResponse { + thread: test_upstream_thread("thread-codex"), + model: "gpt-5.5".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: test_abs_path("/tmp"), + runtime_workspace_roots: Vec::new(), + instruction_sources: Vec::new(), + approval_policy: upstream::AskForApproval::Never, + approvals_reviewer: upstream::ApprovalsReviewer::User, + sandbox: upstream::SandboxPolicy::DangerFullAccess, + active_permission_profile: None, + reasoning_effort: None, + }; + let default_key = client + .apply_thread_start_response("srv", &default_response) + .expect("default thread/start reconciliation"); + let default_snapshot = client + .app_store + .thread_snapshot(&default_key) + .expect("default snapshot"); + assert_eq!(default_snapshot.agent_runtime_kind, "codex"); + } + /// `thread/read` carries embedded turns and is authoritative — the /// reducer must mark `initial_turns_loaded = true` so the spinner /// clears and `older_turns_cursor` gets cleared.