Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions apps/ios/Sources/Litter/LitterApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion apps/ios/Sources/Litter/Views/HomeComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion apps/ios/Sources/Litter/Views/SessionsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
6 changes: 5 additions & 1 deletion shared/rust-bridge/codex-mobile-client/src/ffi/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
80 changes: 80 additions & 0 deletions shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,15 @@ impl MobileClient {
&self,
server_id: &str,
response: &upstream::ThreadStartResponse,
) -> Result<ThreadKey, String> {
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<ThreadKey, String> {
let mut snapshot = crate::thread_snapshot_from_upstream_thread_with_overrides(
server_id,
Expand All @@ -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).
Expand Down Expand Up @@ -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.
Expand Down