fix(profiles): don't load the server-default session list before the profile resolves

On cold start the session drawer (and the restored session context) could
hydrate with the SERVER-DEFAULT profile's sessions and then visibly snap to
the persisted profile a beat later. The chat client became ready — and the
first refreshSessions() fired — before the per-connection agent-profile
list arrived to resolve the persisted selection, so the first
profile-scoped read ran with a null (server-default) profile; the list
landed a tick later, re-resolved the profile, and re-fetched correctly.

Add ProfileController.selectionSettled (true once the selection has
resolved, OR no non-default profile is pending, OR the profile list has
arrived so resolution was attempted) and gate the cold-start LaunchedEffect
on it. While a non-default profile is still resolving the first load waits
on a 2.5s backstop instead of fetching; the effect re-fires the instant the
profile resolves, cancelling the wait so only the correct, profile-scoped
load lands. The backstop keeps the drawer from ever stranding empty if the
profile list never arrives. Also defers the per-profile session-context /
transcript restore in the same effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bailey Dixon
2026-06-22 20:18:17 -04:00
co-authored by Claude Opus 4.8
parent 440f34080e
commit 889273aa85
3 changed files with 68 additions and 8 deletions
@@ -397,6 +397,7 @@ fun RelayApp() {
val chatApiClient by connectionViewModel.chatApiClient.collectAsState()
val lastSessionId by connectionViewModel.lastSessionId.collectAsState()
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
@@ -667,15 +668,29 @@ fun RelayApp() {
// refreshSessions() that would flash/reload the chat. `switchProfileContext`
// already no-ops when the context key + session are unchanged.
val chatClientReady = chatApiClient != null
LaunchedEffect(chatClientReady, activeConnectionId, selectedProfile?.name, lastSessionId) {
LaunchedEffect(chatClientReady, activeConnectionId, selectedProfile?.name, lastSessionId, profileSelectionSettled) {
if (!chatClientReady) return@LaunchedEffect
// Coalesce the rapid lastSessionId null→value churn a profile switch
// produces: selectProfile() nulls lastSessionId, then the persisted
// per-profile session resolves a tick later. This effect re-fires on that
// change, cancelling the delay below before it commits — so we skip
// painting the intermediate empty draft and land straight on the resolved
// session (or a genuine fresh draft when the profile has no history).
delay(160)
// Cold-start profile-isolation guard: hold the first profile-scoped load
// until the persisted profile selection has SETTLED, so the session
// drawer (and the restored session context) don't briefly load the
// SERVER-DEFAULT profile and then visibly snap to the real one. While a
// non-default profile is still resolving we wait on a backstop instead of
// fetching now; this effect re-fires the instant the profile resolves
// (selectedProfile / profileSelectionSettled change), cancelling the wait
// so only the correct, profile-scoped load lands. The backstop guarantees
// the drawer is never permanently empty if the profile list never lands.
if (!profileSelectionSettled) {
delay(2_500L)
} else {
// Coalesce the rapid lastSessionId null→value churn a profile switch
// produces: selectProfile() nulls lastSessionId, then the persisted
// per-profile session resolves a tick later. This effect re-fires on
// that change, cancelling the delay below before it commits — so we
// skip painting the intermediate empty draft and land straight on the
// resolved session (or a genuine fresh draft when the profile has no
// history).
delay(160)
}
chatViewModel.switchProfileContext(
contextKey = AgentDisplay.profileContextKey(
connectionId = activeConnectionId,
@@ -1059,6 +1059,14 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val selectedProfile: StateFlow<Profile?> get() = profileController.selectedProfile
/**
* True once the active connection's persisted profile selection has settled,
* so cold-start profile-scoped reads (e.g. the session drawer + restored
* session context) don't race the restore and load the server-default
* profile. See [ProfileController.selectionSettled].
*/
val profileSelectionSettled: StateFlow<Boolean> get() = profileController.selectionSettled
val profileDisplayAlias: StateFlow<String?> get() = profileController.profileDisplayAlias
fun setProfileDisplayAlias(alias: String?) = profileController.setProfileDisplayAlias(alias)
@@ -106,6 +106,43 @@ class ProfileController(
private val _pendingSelectedProfileConnectionId = MutableStateFlow<String?>(null)
private val _pendingSelectedProfileName = MutableStateFlow<String?>(null)
/**
* True once the active connection's persisted profile selection has SETTLED
* — i.e. profile-scoped reads (session drawer, transcript restore, voice
* prefs) can run without racing the cold-start restore and wrongly loading
* the SERVER-DEFAULT profile. Settled when any of these hold:
* - there's no active connection yet (nothing profile-scoped to gate), or
* - the selection has resolved into [selectedProfile], or
* - no NON-default profile is pending for the active connection (server
* default / nothing to wait for), or
* - the agent-profile list has arrived, so resolution has been ATTEMPTED —
* a genuinely-missing profile then falls back to server default rather
* than gating forever.
*
* False only in the cold-start window where a non-default profile name is
* persisted but the profile list hasn't landed yet to resolve it — exactly
* when an unscoped read would load the server-default profile by mistake.
*/
val selectionSettled: StateFlow<Boolean> = combine(
activeConnectionId,
selectedProfile,
_pendingSelectedProfileConnectionId,
_pendingSelectedProfileName,
agentProfiles,
) { connId, selected, pendingConnId, pendingName, profiles ->
when {
connId == null -> true
selected != null -> true
// Pending state still points at a previous connection mid-switch —
// hold until this connection's restore re-stamps the pending name.
pendingConnId != connId -> false
pendingName == null || AgentDisplay.isServerDefaultAlias(pendingName) -> true
// Non-default name pending: settled once the profile list is present
// (resolution attempted), even if the name turns out to be gone.
else -> profiles.isNotEmpty()
}
}.stateIn(scope, SharingStarted.Eagerly, false)
/**
* DataStore-backed persistence for the selected profile keyed by
* connection id. Public so the ViewModel's connection-lifecycle