@@ -1652,21 +1652,28 @@ final class SyncService: ObservableObject {
16521652
16531653 private func normalizeActiveProjectSelection( allowSingleProjectFallback: Bool ) {
16541654 let projectIds = Set ( projects. map ( \. id) )
1655+
1656+ // Keep a still-valid active project before considering any remote-catalog
1657+ // remap. If the cached row is in `projects`, we must not override it with
1658+ // a different id from `remoteProjectCatalog` — `refreshProjectCatalog`
1659+ // intentionally preserves the existing selection when it keeps a cached
1660+ // row for the same root.
1661+ if let activeProjectId, projectIds. contains ( activeProjectId) {
1662+ database. setActiveProjectId ( activeProjectId)
1663+ return
1664+ }
1665+
16551666 if let activeProjectId,
16561667 let activeProjectRootPath,
16571668 let remoteProject = deduplicatedRemoteProjectCatalog ( ) . first ( where: { project in
16581669 project. id != activeProjectId
16591670 && normalizedProjectRoot ( project. rootPath) == activeProjectRootPath
1660- } ) {
1671+ } ) ,
1672+ projectIds. contains ( remoteProject. id) {
16611673 setActiveProjectId ( remoteProject. id, rootPath: remoteProject. rootPath)
16621674 return
16631675 }
16641676
1665- if let activeProjectId, projectIds. contains ( activeProjectId) {
1666- database. setActiveProjectId ( activeProjectId)
1667- return
1668- }
1669-
16701677 if let activeProjectId,
16711678 let activeProjectRootPath,
16721679 let matchingProject = projects. first ( where: { normalizedProjectRoot ( $0. rootPath) == activeProjectRootPath } ) {
@@ -3751,7 +3758,25 @@ final class SyncService: ObservableObject {
37513758
37523759 @MainActor
37533760 func cachedChatModelCatalog( ) -> AgentChatModelCatalog ? {
3754- guard let cached = chatModelCatalogCache. values. sorted ( by: { $0. fetchedAt > $1. fetchedAt } ) . first else { return nil }
3761+ // Scope the fallback lookup to the current host/project. `chatModelsCacheKey`
3762+ // mixes host + project root into every key it writes, so returning the newest
3763+ // entry from *any* cache row could leak a different host's catalog into the
3764+ // active session. Match the key prefix instead and only consider entries that
3765+ // belong to the same host/project scope.
3766+ let scopePrefix = chatModelsCacheKey ( provider: " " )
3767+ let scopedEntries = chatModelCatalogCache
3768+ . filter { key, _ in
3769+ // Strip the provider component from the stored key and compare scopes.
3770+ // Both `scopePrefix` and the stored key use `\u{1f}` separators between
3771+ // provider, host, and project root; everything after the first separator
3772+ // is the host+project portion we care about.
3773+ guard let sepIndex = scopePrefix. firstIndex ( of: " \u{1f} " ) ,
3774+ let storedSepIndex = key. firstIndex ( of: " \u{1f} " )
3775+ else { return false }
3776+ return scopePrefix [ sepIndex... ] == key [ storedSepIndex... ]
3777+ }
3778+ . values
3779+ guard let cached = scopedEntries. sorted ( by: { $0. fetchedAt > $1. fetchedAt } ) . first else { return nil }
37553780 guard Date ( ) . timeIntervalSince ( cached. fetchedAt) < chatModelsCacheTTL else { return nil }
37563781 return cached. catalog
37573782 }
@@ -7383,6 +7408,12 @@ extension SyncService {
73837408 /// which token kind (alert vs activity) to target based on what it last saw
73847409 /// from us.
73857410 func sendTestPush( ) async -> SyncSendTestPushResult {
7411+ // Fail fast when the socket is offline. Without this guard, `sendEnvelope`
7412+ // silently drops the frame and `awaitResponse` would sit until timeout,
7413+ // making the test-push button look unresponsive.
7414+ guard canSendLiveRequests ( ) else {
7415+ return SyncSendTestPushResult ( ok: false , message: " The paired machine is offline. " )
7416+ }
73867417 let requestId = makeRequestId ( )
73877418 do {
73887419 let raw = try await awaitResponse (
@@ -7609,15 +7640,15 @@ extension SyncService {
76097640 laneName: session. laneName. isEmpty ? nil : session. laneName,
76107641 title: session. title. isEmpty ? session. goal : session. title,
76117642 status: isFailedStatus ? " failed " : ( isRunningRuntime ? " running " : ( isIdleRuntime ? " idle " : status) ) ,
7612- awaitingInput: isAwaiting,
7613- lastActivityAt: lastActivity,
7614- elapsedSeconds: elapsed,
7615- preview: session. lastOutputPreview,
7643+ awaitingInput: isAwaiting,
7644+ lastActivityAt: lastActivity,
7645+ elapsedSeconds: elapsed,
7646+ preview: session. lastOutputPreview,
76167647 pendingInputItemId: isAwaiting ? ( session. pendingInputItemId ?? pendingInputItemIdForSnapshot ( sessionId: session. id) ) : nil ,
7617- progress: nil ,
7618- phase: nil ,
7619- toolCalls: 0
7620- )
7648+ progress: nil ,
7649+ phase: nil ,
7650+ toolCalls: 0
7651+ )
76217652
76227653 allAgents. append ( snap)
76237654
@@ -7672,17 +7703,17 @@ extension SyncService {
76727703 )
76737704 }
76747705
7675- scheduleWorkspaceSnapshotWrite ( )
7676- }
7706+ scheduleWorkspaceSnapshotWrite ( )
7707+ }
76777708
7678- private func pendingInputItemIdForSnapshot( sessionId: String ) -> String ? {
7679- let events = chatEventEnvelopesBySession [ sessionId] ?? [ ]
7680- guard !events. isEmpty else { return nil }
7681- let transcript = makeWorkChatTranscript ( from: events)
7682- return derivePendingWorkInputs ( from: transcript) . last? . itemId
7683- }
7709+ private func pendingInputItemIdForSnapshot( sessionId: String ) -> String ? {
7710+ let events = chatEventEnvelopesBySession [ sessionId] ?? [ ]
7711+ guard !events. isEmpty else { return nil }
7712+ let transcript = makeWorkChatTranscript ( from: events)
7713+ return derivePendingWorkInputs ( from: transcript) . last? . itemId
7714+ }
76847715
7685- /// Debounced writer for the App Group `WorkspaceSnapshot`. Bounces for 2s
7716+ /// Debounced writer for the App Group `WorkspaceSnapshot`. Bounces for 2s
76867717 /// so bursty sync traffic collapses into a single widget-timeline reload.
76877718 private func scheduleWorkspaceSnapshotWrite( ) {
76887719 snapshotDebouncerTask? . cancel ( )
0 commit comments