From 340c59ea695e4e5f727c162d47498d10a777d5fa Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Sun, 13 Sep 2026 12:29:18 -0400 Subject: [PATCH 1/5] bridge: reconcile agent probe metadata against the built-in catalog --- .../src/mobile_client/mod.rs | 21 ++- .../src/store/agent_catalog.rs | 138 ++++++++++++++++++ 2 files changed, 152 insertions(+), 7 deletions(-) diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs index 621215176..c670d37dd 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client/mod.rs @@ -1746,13 +1746,20 @@ impl MobileClient { } // Cache metadata so platforms can render labels/icons/capability // flags from anywhere in the app, not just at probe time. - self.agent_metadata - .upsert_all(agents.iter().map(|agent| crate::store::AppAgentMetadata { - name: agent.name.clone(), - display_name: agent.display_name.clone(), - presentation: agent.presentation.clone().map(Into::into), - capabilities: agent.capabilities.clone().map(Into::into), - })); + // Reconciled against the built-in catalog first so a host can't + // erase what litter already knows, and so `supports_ssh_bridge` + // keeps meaning "litter can launch this over SSH" rather than + // "the alleycat host could". + self.agent_metadata.upsert_all(agents.iter().map(|agent| { + crate::store::agent_catalog::reconcile_probe_metadata( + crate::store::AppAgentMetadata { + name: agent.name.clone(), + display_name: agent.display_name.clone(), + presentation: agent.presentation.clone().map(Into::into), + capabilities: agent.capabilities.clone().map(Into::into), + }, + ) + })); Ok(agents) } diff --git a/shared/rust-bridge/codex-mobile-client/src/store/agent_catalog.rs b/shared/rust-bridge/codex-mobile-client/src/store/agent_catalog.rs index 97ea4906c..0ce409c89 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/agent_catalog.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/agent_catalog.rs @@ -406,6 +406,44 @@ pub fn seed_metadata() -> Vec { CATALOG.iter().map(AgentCatalogEntry::metadata).collect() } +/// Fold a probe response into what litter already knows before it is +/// cached. +/// +/// Two corrections: +/// +/// * **`supports_ssh_bridge` is re-stated from the catalog.** Every +/// consumer of that flag — the iOS SSH picker, the iOS SSH probe +/// filter, the Android discovery screen — asks "can *litter* start +/// this agent over SSH?", and litter is the SSH client, so only +/// litter's linked bridges decide the answer. The host manifest flag +/// means something different ("could an alleycat host bridge it"), +/// and taking it verbatim would enable picker rows that fail on +/// connect (the alleycat manifest marks devin and grok +/// SSH-bridgeable; litter links no bridge for either). +/// * **Missing blocks fall back to the seeded entry** instead of +/// erasing what litter already had. Older hosts omit `presentation` +/// / `capabilities` entirely; a probe from one should not downgrade a +/// known agent to an unlabelled, unsorted row. +pub fn reconcile_probe_metadata(mut metadata: AppAgentMetadata) -> AppAgentMetadata { + let Some(entry) = entry(&metadata.name) else { + return metadata; + }; + let builtin = entry.metadata(); + if metadata.display_name.trim().is_empty() { + metadata.display_name = builtin.display_name; + } + if metadata.presentation.is_none() { + metadata.presentation = builtin.presentation; + } + match metadata.capabilities.as_mut() { + Some(capabilities) => { + capabilities.supports_ssh_bridge = entry.reach.supports_ssh_bridge(); + } + None => metadata.capabilities = builtin.capabilities, + } + metadata +} + /// POSIX `sh` fragment that reports one `\t` line per /// PATH-probed SSH-bridge agent. Local Studio is excluded — it has its /// own `crate::local_studio::probe_script()` fragment, which @@ -574,6 +612,106 @@ mod tests { ); } + fn probed(name: &str, capabilities: Option) -> AppAgentMetadata { + AppAgentMetadata { + name: name.to_string(), + display_name: name.to_string(), + presentation: Some(AppAgentPresentation { + title: None, + is_beta: true, + sort_order: 99, + description: Some("host blurb".to_string()), + aliases: Vec::new(), + }), + capabilities, + } + } + + fn host_capabilities(supports_ssh_bridge: bool) -> AppAgentCapabilities { + AppAgentCapabilities { + locks_reasoning_effort_after_activity: false, + visible_modes: None, + supports_ssh_bridge, + uses_direct_codex_port: false, + supports_thread_permission_overrides: true, + reports_effective_thread_permissions: true, + } + } + + /// The alleycat manifest marks devin SSH-bridgeable because *its* + /// host can bridge it. Litter links no devin bridge, so the flag it + /// caches has to say so — otherwise the SSH picker offers a row that + /// cannot connect. + #[test] + fn probe_cannot_claim_ssh_bridge_support_litter_does_not_have() { + let reconciled = + reconcile_probe_metadata(probed("devin", Some(host_capabilities(true)))); + assert!( + !reconciled + .capabilities + .expect("capabilities") + .supports_ssh_bridge + ); + } + + /// …and the reverse: a host that under-reports must not hide a + /// bridge litter actually links. + #[test] + fn probe_cannot_hide_ssh_bridge_support_litter_does_have() { + let reconciled = + reconcile_probe_metadata(probed("claude", Some(host_capabilities(false)))); + assert!( + reconciled + .capabilities + .expect("capabilities") + .supports_ssh_bridge + ); + } + + #[test] + fn probe_keeps_its_own_presentation_but_inherits_missing_blocks() { + let reconciled = + reconcile_probe_metadata(probed("claude", Some(host_capabilities(true)))); + let presentation = reconciled.presentation.expect("presentation"); + assert_eq!(presentation.sort_order, 99, "host presentation must win"); + assert_eq!(presentation.description.as_deref(), Some("host blurb")); + + let legacy = reconcile_probe_metadata(AppAgentMetadata { + name: "claude".to_string(), + display_name: String::new(), + presentation: None, + capabilities: None, + }); + assert_eq!(legacy.display_name, "Claude"); + assert_eq!( + legacy + .presentation + .expect("seeded presentation") + .sort_order, + 4 + ); + assert!( + legacy + .capabilities + .expect("seeded capabilities") + .supports_ssh_bridge + ); + } + + #[test] + fn probe_for_an_unknown_agent_passes_through_untouched() { + let reconciled = + reconcile_probe_metadata(probed("brand-new", Some(host_capabilities(true)))); + assert_eq!(reconciled.name, "brand-new"); + assert!( + reconciled + .capabilities + .expect("capabilities") + .supports_ssh_bridge, + "litter has no opinion about agents it does not know" + ); + } + #[test] fn amp_visible_modes_survive_seeding() { let amp = seed_metadata() From 145123af66ee3598fa388759d60ffd6708fe295a Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Sun, 13 Sep 2026 12:29:19 -0400 Subject: [PATCH 2/5] build: compile the ios-dev profile at opt-level 2 --- shared/rust-bridge/Cargo.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/shared/rust-bridge/Cargo.toml b/shared/rust-bridge/Cargo.toml index 170640d13..2af9d93aa 100644 --- a/shared/rust-bridge/Cargo.toml +++ b/shared/rust-bridge/Cargo.toml @@ -66,6 +66,11 @@ panic = "abort" # and a correspondingly faster Swift link step. [profile.ios-dev] inherits = "dev" +# opt-level 2 instead of the dev default (0): the staticlib is still small +# (debug = 1 keeps line tables only) but the interpreter-side hot loops — +# streaming snapshot assembly, conversation projection, JSON parsing — run +# roughly an order of magnitude faster, which is what the mobile UI feels. +opt-level = 2 debug = 1 codegen-units = 256 From e02023695ac2860d071b3383ef54e1db8186bbba Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Sun, 13 Sep 2026 12:29:19 -0400 Subject: [PATCH 3/5] ios: gate expensive derivations behind snapshot revision --- .../Views/ConversationScreenModel.swift | 26 +++++ .../Sources/Litter/Views/PiPContentView.swift | 94 ++++++++++++------- .../Sources/Litter/Views/SessionsModel.swift | 37 ++++++-- 3 files changed, 115 insertions(+), 42 deletions(-) diff --git a/apps/ios/Sources/Litter/Views/ConversationScreenModel.swift b/apps/ios/Sources/Litter/Views/ConversationScreenModel.swift index fb9be38b2..022b24bfb 100644 --- a/apps/ios/Sources/Litter/Views/ConversationScreenModel.swift +++ b/apps/ios/Sources/Litter/Views/ConversationScreenModel.swift @@ -135,6 +135,7 @@ final class ConversationScreenModel { @ObservationIgnored private var cachedHydratedConversationItems: [HydratedConversationItem] = [] @ObservationIgnored private var cachedProjectedConversationItems: [ConversationItem] = [] @ObservationIgnored private var transcriptRevision: Int = 0 + @ObservationIgnored private var projectedRevision: UInt64? @ObservationIgnored private var minigameTask: Task? func bind( @@ -155,6 +156,7 @@ final class ConversationScreenModel { cachedConversationItemProjections = [:] cachedProjectedConversationItems = [] transcriptRevision = 0 + projectedRevision = nil minigameTask?.cancel() minigameTask = nil minigameOverlay = .idle @@ -380,7 +382,30 @@ private struct ProjectedConversationItemsResult { private extension ConversationScreenModel { func projectConversationItems(from hydratedItems: [HydratedConversationItem]) -> ProjectedConversationItemsResult { let previousHydratedItems = cachedHydratedConversationItems + let revision = appModel?.snapshotRevision + + // Cheap change signals first — the deep equality walk over the whole + // hydrated array is the most expensive part of `refreshState`, and it + // re-ran on every coalesced snapshot bump (~8 fps while streaming) + // plus on duplicate binds (the revision and composerPrefillRequest + // onChange handlers both call `bindScreenModel`). + // + // 1. Same snapshot revision as the last projection: the snapshot only + // changes when the revision bumps, so the arrays are identical. + // 2. Item count / last item id: appends and truncations are detected + // in O(1). Only when all cheap signals agree do we fall back to + // deep equality. + if let revision, revision == projectedRevision, + previousHydratedItems.count == hydratedItems.count, + previousHydratedItems.last?.id == hydratedItems.last?.id { + return ProjectedConversationItemsResult( + items: cachedProjectedConversationItems, + didChange: false + ) + } + if previousHydratedItems == hydratedItems { + projectedRevision = revision return ProjectedConversationItemsResult( items: cachedProjectedConversationItems, didChange: false @@ -452,6 +477,7 @@ private extension ConversationScreenModel { cachedHydratedConversationItems = hydratedItems cachedConversationItemProjections = nextCache cachedProjectedConversationItems = projectedItems + projectedRevision = revision return ProjectedConversationItemsResult(items: projectedItems, didChange: true) } diff --git a/apps/ios/Sources/Litter/Views/PiPContentView.swift b/apps/ios/Sources/Litter/Views/PiPContentView.swift index 66e38846b..214c3fa32 100644 --- a/apps/ios/Sources/Litter/Views/PiPContentView.swift +++ b/apps/ios/Sources/Litter/Views/PiPContentView.swift @@ -16,6 +16,57 @@ struct PiPContentView: View { static let minHeight: CGFloat = 160 static let maxHeight: CGFloat = 720 + /// Cached derivation of the active session, keyed off + /// `AppModel.snapshotRevision` (~8 fps while streaming) and the resolved + /// active thread key. `body` re-evaluates on every ImageRenderer tick + /// (up to 30 fps), and the derivation re-sorts, rebuilds dictionaries and + /// scans threads — far too expensive to repeat per tick. With the cache, + /// the derivation only re-runs when the snapshot actually moved or the + /// pin/active thread changed; `body` otherwise reads the derived result. + @MainActor + private func activeSession() -> HomeDashboardRecentSession? { + let revision = AppModel.shared.snapshotRevision + let activeKey = StreamingPiPController.shared.pinnedThreadKey + ?? AppModel.shared.snapshot?.activeThread + if ActiveSessionCache.revision == revision, + ActiveSessionCache.activeKey == activeKey { + return ActiveSessionCache.session + } + let session = computeActiveSession(activeKey: activeKey) + ActiveSessionCache.revision = revision + ActiveSessionCache.activeKey = activeKey + ActiveSessionCache.session = session + return session + } + + @MainActor + private func computeActiveSession(activeKey: ThreadKey?) -> HomeDashboardRecentSession? { + guard let snapshot = AppModel.shared.snapshot, let activeKey = activeKey else { return nil } + let servers = HomeDashboardSupport.sortedConnectedServers( + from: snapshot.servers, + savedServers: [], + activeServerId: activeKey.serverId + ) + let serversById = Dictionary(uniqueKeysWithValues: servers.map { ($0.id, $0) }) + let sessions = HomeDashboardSupport.recentConnectedSessions( + from: snapshot.sessionSummaries, + serversById: serversById, + limit: nil + ) + guard let base = sessions.first(where: { $0.key == activeKey }) else { return nil } + // The summary's `model` (and runtime kind) can be stale relative to + // what the user actually selected for the active thread — the + // conversation header reads from the live AppThreadSnapshot. Mirror + // that here so PiP always shows the truly-current model. + let liveThread = snapshot.threads.first { $0.key == activeKey } + let liveModel = liveThread?.model?.trimmingCharacters(in: .whitespacesAndNewlines) + let liveRuntime = liveThread?.agentRuntimeKind + return base.overriding( + model: (liveModel?.isEmpty == false) ? liveModel : nil, + agentRuntimeKindString: liveRuntime + ) + } + var body: some View { ZStack(alignment: .topLeading) { Color.black @@ -46,40 +97,17 @@ struct PiPContentView: View { .background(Color.black) .clipped() } +} - @MainActor - private func activeSession() -> HomeDashboardRecentSession? { - guard let snapshot = AppModel.shared.snapshot else { return nil } - // Prefer the explicit pin from the home-card menu over whatever - // thread is currently active in the app. - guard let activeKey = - StreamingPiPController.shared.pinnedThreadKey - ?? snapshot.activeThread - else { return nil } - let servers = HomeDashboardSupport.sortedConnectedServers( - from: snapshot.servers, - savedServers: [], - activeServerId: activeKey.serverId - ) - let serversById = Dictionary(uniqueKeysWithValues: servers.map { ($0.id, $0) }) - let sessions = HomeDashboardSupport.recentConnectedSessions( - from: snapshot.sessionSummaries, - serversById: serversById, - limit: nil - ) - guard let base = sessions.first(where: { $0.key == activeKey }) else { return nil } - // The summary's `model` (and runtime kind) can be stale relative to - // what the user actually selected for the active thread — the - // conversation header reads from the live AppThreadSnapshot. Mirror - // that here so PiP always shows the truly-current model. - let liveThread = snapshot.threads.first { $0.key == activeKey } - let liveModel = liveThread?.model?.trimmingCharacters(in: .whitespacesAndNewlines) - let liveRuntime = liveThread?.agentRuntimeKind - return base.overriding( - model: (liveModel?.isEmpty == false) ? liveModel : nil, - agentRuntimeKindString: liveRuntime - ) - } +/// File-scoped cache for the PiP card's derived session. `StreamingPiPController` +/// reassigns `renderer.content = PiPContentView()` on every render tick, so a +/// per-instance cache never survives between body evaluations. Keyed by +/// (snapshot revision, resolved active thread key) — see `activeSession()`. +@MainActor +private enum ActiveSessionCache { + static var revision: UInt64? + static var activeKey: ThreadKey? + static var session: HomeDashboardRecentSession? } private extension HomeDashboardRecentSession { diff --git a/apps/ios/Sources/Litter/Views/SessionsModel.swift b/apps/ios/Sources/Litter/Views/SessionsModel.swift index 217db7373..5e5a2bb51 100644 --- a/apps/ios/Sources/Litter/Views/SessionsModel.swift +++ b/apps/ios/Sources/Litter/Views/SessionsModel.swift @@ -41,6 +41,14 @@ final class SessionsModel { /// second. Mirrors HomeDashboardModel's debounce pattern. @ObservationIgnored private var debouncedRefreshTask: Task? @ObservationIgnored private let observedRefreshDelayNanoseconds: UInt64 = 120_000_000 // 120 ms + /// Cache of the last `SessionsDerivation.build` result, keyed by a + /// fingerprint of (snapshot revision + filter inputs). `refreshState` + /// re-runs `withObservationTracking` on every snapshot bump, but an + /// unrelated bump (e.g. background-thread item churn) or a duplicate + /// trigger at the same revision must not re-sort and re-group the whole + /// session list — the cached derivation is reused instead. + @ObservationIgnored private var cachedDerivationFingerprint: String? + @ObservationIgnored private var cachedDerivedData: SessionsDerivedData? func bind(appModel: AppModel, appState: AppState) { let needsRebind = self.appModel !== appModel || self.appState !== appState @@ -84,6 +92,8 @@ final class SessionsModel { observationGeneration &+= 1 let generation = observationGeneration + let revision = appModel.snapshotRevision + let derivationFingerprint = "\(revision)|\(appState.sessionsSelectedServerFilterId ?? "all")|\(appState.sessionsShowOnlyForks)|\(selectedRuntimeKind ?? "any")|\(appState.sessionsWorkspaceSortModeRaw)|\(searchQuery)" let snapshot = withObservationTracking { let selectedServerFilterId = appState.sessionsSelectedServerFilterId let showOnlyForks = appState.sessionsShowOnlyForks @@ -120,15 +130,24 @@ final class SessionsModel { previousDisplayedOrder: previousDisplayedOrder ) - let nextDerivedData = SessionsDerivation.build( - sessions: appSnapshot?.sessionSummaries ?? [], - selectedServerFilterId: selectedServerFilterId, - showOnlyForks: showOnlyForks, - selectedRuntimeKind: currentRuntimeKindFilter, - workspaceSortMode: workspaceSortMode, - searchQuery: currentSearchQuery, - frozenMostRecentOrder: nextFrozenMostRecentThreadOrder - ) + let nextDerivedData: SessionsDerivedData + if cachedDerivationFingerprint == derivationFingerprint, let cached = cachedDerivedData { + // Unrelated snapshot bump or duplicate trigger at the same + // revision: skip the expensive sort/group pass entirely. + nextDerivedData = cached + } else { + nextDerivedData = SessionsDerivation.build( + sessions: appSnapshot?.sessionSummaries ?? [], + selectedServerFilterId: selectedServerFilterId, + showOnlyForks: showOnlyForks, + selectedRuntimeKind: currentRuntimeKindFilter, + workspaceSortMode: workspaceSortMode, + searchQuery: currentSearchQuery, + frozenMostRecentOrder: nextFrozenMostRecentThreadOrder + ) + cachedDerivationFingerprint = derivationFingerprint + cachedDerivedData = nextDerivedData + } return Snapshot( derivedData: nextDerivedData, From 1e3bffcf7a17c96ef9877345a5af180d0046c26b Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Sun, 13 Sep 2026 12:29:19 -0400 Subject: [PATCH 4/5] android: stabilize composer callbacks and persist drafts immediately --- .../android/ui/conversation/ComposerBar.kt | 18 +++-- .../ui/conversation/ConversationScreen.kt | 69 ++++++++++++------- 2 files changed, 57 insertions(+), 30 deletions(-) diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ComposerBar.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ComposerBar.kt index e666c9014..68204f98e 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ComposerBar.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ComposerBar.kt @@ -183,10 +183,16 @@ fun ComposerBar( ) { val appModel = LocalAppModel.current val appSnapshot by appModel.snapshot.collectAsState() - val hasFixedFullAccess = appSnapshot?.threads - ?.firstOrNull { it.key == threadKey } - ?.agentRuntimeKind - ?.hasFixedFullAccess == true + // Rescan only when the snapshot input changes; readers recompose only when + // the derived flag flips, not on every unrelated recomposition. + val hasFixedFullAccess by remember(threadKey) { + derivedStateOf { + appSnapshot?.threads + ?.firstOrNull { it.key == threadKey } + ?.agentRuntimeKind + ?.hasFixedFullAccess == true + } + } val context = LocalContext.current val scope = rememberCoroutineScope() val composerPrefillRequest by appModel.composerPrefillRequest.collectAsState() @@ -205,6 +211,10 @@ fun ComposerBar( var attachedFiles by remember(threadKey) { mutableStateOf(appModel.composerDraft(threadKey).fileAttachments) } + // Persist the composer draft on every state change. Debounced/coalesced + // variants lose typed text when the composable leaves composition before + // the trailing write fires (quick navigation, backgrounding) — so this + // stays an immediate, unconditional write. LaunchedEffect(threadKey, text, attachedImage, attachedFiles) { appModel.setComposerDraft( threadKey, diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt index 172136d2d..ce646cab8 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt @@ -778,25 +778,17 @@ fun ConversationScreen( } // Composer bar - ComposerBar( - threadKey = threadKey, - collaborationMode = thread?.collaborationMode ?: uniffi.codex_mobile_client.AppModeKind.DEFAULT, - activePlanProgress = thread?.activePlanProgress, - activeTurnId = thread?.activeTurnId, - contextPercent = thread?.composerContextPercent(), - isThinking = isThinking, - activeTaskSummary = activeTaskSummary, - queuedFollowUps = thread?.queuedFollowUps ?: emptyList(), - goal = thread?.goal, - rateLimits = thread?.agentRuntimeKind?.let { runtimeKind -> - server?.rateLimitsByRuntime?.firstOrNull { it.runtimeKind == runtimeKind }?.rateLimits - }, - showCollaborationModeChip = pinnedContext?.diffSummary == null, - onOpenCollaborationModePicker = { showCollaborationModeSelector = true }, - onToggleModelSelector = { showModelSelector = !showModelSelector }, - onNavigateToSessions = onNavigateToSessions, - onShowDirectoryPicker = onShowDirectoryPicker, - onShowRenameDialog = { initialName -> + // Stable callbacks: freshly-allocated inline lambdas here + // re-allocate on every recomposition (snapshot emissions are + // frequent while streaming), disabling ComposerBar's + // argument-level skipping. remember()ed captures keep + // identity stable across unrelated recompositions. + val onOpenCollaborationModePicker = remember { { showCollaborationModeSelector = true } } + val onToggleModelSelector = remember { { showModelSelector = !showModelSelector } } + val onShowDirectoryPickerStable = remember { onShowDirectoryPicker } + val onNavigateToSessionsStable = remember { onNavigateToSessions } + val onShowRenameDialog: (String?) -> Unit = remember(scope, appModel, threadKey, thread?.info?.title) { + { initialName: String? -> val trimmed = initialName?.trim().orEmpty() if (trimmed.isNotEmpty()) { scope.launch { @@ -817,15 +809,40 @@ fun ConversationScreen( renameDraft = thread?.info?.title?.takeIf { it.isNotBlank() }.orEmpty() showRenameDialog = true } + } + } + val onShowPermissionsSheet = remember { { showPermissionsSheet = true } } + val onShowExperimentalSheet = remember { { showExperimentalSheet = true } } + val onShowSkillsSheet = remember { { showSkillsSheet = true } } + val onSlashError = remember { { message: String -> slashErrorMessage = message } } + val onDismissPendingUserInput: () -> Unit = remember(pendingInput) { + { pendingInput?.let { dismissedUserInputs.dismiss(it.id) }; Unit } + } + ComposerBar( + threadKey = threadKey, + collaborationMode = thread?.collaborationMode ?: uniffi.codex_mobile_client.AppModeKind.DEFAULT, + activePlanProgress = thread?.activePlanProgress, + onOpenCollaborationModePicker = onOpenCollaborationModePicker, + onToggleModelSelector = onToggleModelSelector, + onNavigateToSessions = onNavigateToSessionsStable, + onShowDirectoryPicker = onShowDirectoryPickerStable, + activeTurnId = thread?.activeTurnId, + contextPercent = thread?.composerContextPercent(), + isThinking = isThinking, + activeTaskSummary = activeTaskSummary, + queuedFollowUps = thread?.queuedFollowUps ?: emptyList(), + goal = thread?.goal, + rateLimits = thread?.agentRuntimeKind?.let { runtimeKind -> + server?.rateLimitsByRuntime?.firstOrNull { it.runtimeKind == runtimeKind }?.rateLimits }, - onShowPermissionsSheet = { showPermissionsSheet = true }, - onShowExperimentalSheet = { showExperimentalSheet = true }, - onShowSkillsSheet = { showSkillsSheet = true }, - onSlashError = { slashErrorMessage = it }, + showCollaborationModeChip = pinnedContext?.diffSummary == null, + onShowRenameDialog = onShowRenameDialog, + onShowPermissionsSheet = onShowPermissionsSheet, + onShowExperimentalSheet = onShowExperimentalSheet, + onShowSkillsSheet = onShowSkillsSheet, + onSlashError = onSlashError, pendingUserInput = pendingInput, - onDismissPendingUserInput = { - pendingInput?.let { dismissedUserInputs.dismiss(it.id) } - }, + onDismissPendingUserInput = onDismissPendingUserInput, ) Spacer(Modifier.navigationBarsPadding()) From 2551bf37997b4eced88c51196a3ec054cb2a52f1 Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Mon, 14 Sep 2026 09:30:39 -0400 Subject: [PATCH 5/5] ci: raise ios job timeout to 90 minutes for cold builds --- .github/workflows/mobile-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml index 641c4318d..77580c4cd 100644 --- a/.github/workflows/mobile-ci.yml +++ b/.github/workflows/mobile-ci.yml @@ -266,7 +266,7 @@ jobs: ios: needs: shared-prep runs-on: macos-26 - timeout-minutes: 60 + timeout-minutes: 90 env: HOMEBREW_NO_AUTO_UPDATE: "1" SCCACHE_BUCKET: rust-cache