Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/mobile-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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())
Expand Down
26 changes: 26 additions & 0 deletions apps/ios/Sources/Litter/Views/ConversationScreenModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?

func bind(
Expand All @@ -155,6 +156,7 @@ final class ConversationScreenModel {
cachedConversationItemProjections = [:]
cachedProjectedConversationItems = []
transcriptRevision = 0
projectedRevision = nil
minigameTask?.cancel()
minigameTask = nil
minigameOverlay = .idle
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -452,6 +477,7 @@ private extension ConversationScreenModel {
cachedHydratedConversationItems = hydratedItems
cachedConversationItemProjections = nextCache
cachedProjectedConversationItems = projectedItems
projectedRevision = revision
return ProjectedConversationItemsResult(items: projectedItems, didChange: true)
}

Expand Down
94 changes: 61 additions & 33 deletions apps/ios/Sources/Litter/Views/PiPContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
37 changes: 28 additions & 9 deletions apps/ios/Sources/Litter/Views/SessionsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ final class SessionsModel {
/// second. Mirrors HomeDashboardModel's debounce pattern.
@ObservationIgnored private var debouncedRefreshTask: Task<Void, Never>?
@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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions shared/rust-bridge/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading