diff --git a/apps/ios/Sources/Design/ChatProTab.swift b/apps/ios/Sources/Design/ChatProTab.swift index a8362a74e148..0bde2fb36837 100644 --- a/apps/ios/Sources/Design/ChatProTab.swift +++ b/apps/ios/Sources/Design/ChatProTab.swift @@ -6,6 +6,7 @@ struct ChatProTab: View { @Environment(NodeAppModel.self) private var appModel @Environment(\.colorScheme) private var colorScheme @State private var viewModel: OpenClawChatViewModel? + @State private var programmaticSessionSwitchCounts: [String: Int] = [:] var body: some View { NavigationStack { @@ -104,6 +105,14 @@ struct ChatProTab: View { sessionKey: sessionKey, transport: IOSGatewayChatTransport(gateway: self.appModel.operatorSession), onSessionChanged: { sessionKey in + if self.consumeProgrammaticSessionSwitch(sessionKey) { + // Programmatic switches complete asynchronously; stale completions + // must repair back to the current app-model session, not become focus. + if sessionKey != self.appModel.chatSessionKey { + self.syncChatViewModel() + } + return + } self.appModel.focusChatSession(sessionKey) }, diagnosticsLog: { message in @@ -112,9 +121,26 @@ struct ChatProTab: View { return } guard viewModel.sessionKey != sessionKey else { return } + self.recordProgrammaticSessionSwitch(sessionKey) viewModel.switchSession(to: sessionKey) } + private func recordProgrammaticSessionSwitch(_ sessionKey: String) { + self.programmaticSessionSwitchCounts[sessionKey, default: 0] += 1 + } + + private func consumeProgrammaticSessionSwitch(_ sessionKey: String) -> Bool { + guard let count = self.programmaticSessionSwitchCounts[sessionKey] else { + return false + } + if count <= 1 { + self.programmaticSessionSwitchCounts[sessionKey] = nil + } else { + self.programmaticSessionSwitchCounts[sessionKey] = count - 1 + } + return true + } + private var talkControl: OpenClawChatTalkControl { OpenClawChatTalkControl( isEnabled: self.appModel.talkMode.isEnabled, diff --git a/apps/ios/Sources/Design/CommandCenterTab.swift b/apps/ios/Sources/Design/CommandCenterTab.swift index f0f81c192f66..97b2f1b6a4db 100644 --- a/apps/ios/Sources/Design/CommandCenterTab.swift +++ b/apps/ios/Sources/Design/CommandCenterTab.swift @@ -502,7 +502,7 @@ struct CommandCenterTab: View { trailing: "session", color: self.gatewayConnected ? OpenClawBrand.ok : .secondary, progress: nil, - route: .chat(self.appModel.chatSessionKey)), + route: .chat(self.appModel.defaultChatSessionKey)), WorkItem( id: "talk-mode", icon: "waveform", diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 6220857d3d4f..4a9b97da389c 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -762,6 +762,7 @@ final class NodeAppModel { let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if !selected.isEmpty, !decoded.agents.contains(where: { $0.id == selected }) { self.selectedAgentId = nil + self.focusedChatSessionKey = nil } self.talkMode.updateMainSessionKey(self.mainSessionKey) self.homeCanvasRevision &+= 1 @@ -779,13 +780,19 @@ final class NodeAppModel { func setSelectedAgentId(_ agentId: String?) { let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let nextSelectedAgentId = trimmed.isEmpty ? nil : trimmed + let currentSelectedAgentId = self.selectedAgentId?.trimmingCharacters(in: .whitespacesAndNewlines) + let selectedAgentChanged = currentSelectedAgentId != nextSelectedAgentId let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if stableID.isEmpty { - self.selectedAgentId = trimmed.isEmpty ? nil : trimmed + self.selectedAgentId = nextSelectedAgentId } else { - self.selectedAgentId = trimmed.isEmpty ? nil : trimmed + self.selectedAgentId = nextSelectedAgentId GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId) } + if selectedAgentChanged { + self.focusedChatSessionKey = nil + } self.talkMode.updateMainSessionKey(self.mainSessionKey) self.homeCanvasRevision &+= 1 if let relay = ShareGatewayRelaySettings.loadConfig() { @@ -1837,9 +1844,13 @@ extension NodeAppModel { { return focused } + return self.defaultChatSessionKey + } + + var defaultChatSessionKey: String { // Keep chat aligned with the gateway's resolved main session key. // A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI. - return self.mainSessionKey + self.mainSessionKey } func openChat(sessionKey: String?) { @@ -2005,6 +2016,7 @@ extension NodeAppModel { self.gatewayDefaultAgentId = nil self.gatewayAgents = [] self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID) + self.focusedChatSessionKey = nil self.homeCanvasRevision &+= 1 self.apnsLastRegisteredTokenHex = nil } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 6f8f9ab753f1..07f0259bf97d 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -214,6 +214,44 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.mainSessionKey == "agent:agent-123:main") } + @Test @MainActor func selectingAgentClearsExplicitChatFocus() { + let appModel = NodeAppModel() + appModel.gatewayDefaultAgentId = "main" + let rustSessionKey = SessionKey.makeAgentSessionKey(agentId: "rust-claw", baseKey: "main") + + appModel.setSelectedAgentId("rust-claw") + #expect(appModel.chatSessionKey == rustSessionKey) + appModel.focusChatSession(rustSessionKey) + + appModel.setSelectedAgentId("main") + #expect(appModel.defaultChatSessionKey == "main") + #expect(appModel.mainSessionKey == "main") + #expect(appModel.chatSessionKey == "main") + } + + @Test @MainActor func sameSelectedAgentKeepsExplicitChatFocus() { + let appModel = NodeAppModel() + appModel.gatewayDefaultAgentId = "main" + appModel.setSelectedAgentId("main") + appModel.openChat(sessionKey: "incident-42") + + appModel.setSelectedAgentId("main") + #expect(appModel.defaultChatSessionKey == "main") + #expect(appModel.chatSessionKey == "incident-42") + } + + @Test @MainActor func defaultChatSessionKeyIgnoresExplicitChatFocus() { + let appModel = NodeAppModel() + appModel.gatewayDefaultAgentId = "main" + appModel.setSelectedAgentId("rust-claw") + appModel.openChat(sessionKey: "incident-42") + + #expect(appModel.defaultChatSessionKey == SessionKey.makeAgentSessionKey( + agentId: "rust-claw", + baseKey: "main")) + #expect(appModel.chatSessionKey == "incident-42") + } + @Test @MainActor func execApprovalPromptPresentationTracksLatestNotificationTap() throws { let appModel = NodeAppModel() try appModel._test_presentExecApprovalPrompt(