diff --git a/apps/ios/Sources/Design/AgentProTab+Overview.swift b/apps/ios/Sources/Design/AgentProTab+Overview.swift index 2ce8cd391bc9..c5f1f5fc8bfd 100644 --- a/apps/ios/Sources/Design/AgentProTab+Overview.swift +++ b/apps/ios/Sources/Design/AgentProTab+Overview.swift @@ -285,7 +285,7 @@ extension AgentProTab { Circle().strokeBorder(self.iconButtonStroke, lineWidth: 1) } } - .accessibilityLabel(isActive ? "Active agent" : "Make active agent") + .accessibilityLabel(isActive ? "Default agent" : "Set default agent") } .padding(.vertical, 14) .padding(.horizontal, 13) diff --git a/apps/ios/Sources/Design/CommandCenterTab.swift b/apps/ios/Sources/Design/CommandCenterTab.swift index e74966b7fae9..10fb9d858b72 100644 --- a/apps/ios/Sources/Design/CommandCenterTab.swift +++ b/apps/ios/Sources/Design/CommandCenterTab.swift @@ -5,6 +5,7 @@ struct CommandCenterTab: View { @Environment(NodeAppModel.self) private var appModel @Environment(\.colorScheme) private var colorScheme @Environment(\.scenePhase) private var scenePhase + @State private var defaultChatSessionEntry: OpenClawChatSessionEntry? @State private var recentChatSessions: [OpenClawChatSessionEntry] = [] var openChat: () -> Void var openSettings: () -> Void @@ -44,6 +45,7 @@ struct CommandCenterTab: View { VStack(alignment: .leading, spacing: 10) { self.header self.gatewayCard + self.defaultChatSessionSection self.pendingApprovals self.recentSessions self.liveActivity @@ -152,6 +154,30 @@ struct CommandCenterTab: View { .padding(.horizontal, 10) } + private var defaultChatSessionSection: some View { + CommandPanel(padding: 0) { + VStack(spacing: 0) { + self.cardHeader( + title: "Agent session", + value: nil, + color: OpenClawBrand.accent) + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 3) + + Button { + self.open(.chat(nil)) + } label: { + CommandSessionRow(item: self.defaultChatWorkItem) + } + .buttonStyle(.plain) + .padding(.horizontal, 10) + .padding(.bottom, 10) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + private var pendingApprovals: some View { self.pendingApprovalsContent .padding(.horizontal, OpenClawProMetric.pagePadding) @@ -424,6 +450,27 @@ struct CommandCenterTab: View { self.colorScheme == .dark ? Color.black.opacity(0.12) : Color.black.opacity(0.022) } + private var defaultChatWorkItem: WorkItem { + let isOpen = self.appModel.chatSessionKey == self.appModel.defaultChatSessionKey + return WorkItem( + id: "default-chat", + icon: isOpen ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill", + title: self.appModel.activeAgentName, + detail: self.defaultChatActivityText, + state: isOpen ? "open" : "default", + trailing: "chat", + color: isOpen ? OpenClawBrand.accent : OpenClawBrand.ok, + progress: nil, + route: .chat(nil)) + } + + private var defaultChatActivityText: String { + guard let updatedAt = self.defaultChatSessionEntry?.updatedAt, updatedAt > 0 else { + return "No recent activity" + } + return Self.relativeTimeText(forMilliseconds: updatedAt) + } + private var recentSessionRows: [WorkItem] { self.sessionItems } @@ -485,6 +532,7 @@ struct CommandCenterTab: View { let currentSessionKey = self.appModel.chatSessionKey return self.recentChatSessions .filter { !Self.isHiddenInternalSession($0.key) } + .filter { $0.key != self.appModel.defaultChatSessionKey } .map { session in Self.sessionWorkItem(for: session, currentSessionKey: currentSessionKey) } @@ -503,6 +551,9 @@ struct CommandCenterTab: View { private func refreshRecentSessionsIfNeeded() async { guard self.scenePhase == .active else { return } guard self.appModel.isOperatorGatewayConnected else { + if self.defaultChatSessionEntry != nil { + self.defaultChatSessionEntry = nil + } if !self.recentChatSessions.isEmpty { self.recentChatSessions = [] } @@ -512,29 +563,38 @@ struct CommandCenterTab: View { do { let transport = IOSGatewayChatTransport(gateway: appModel.operatorSession) let response = try await transport.listSessions(limit: 20) + self.defaultChatSessionEntry = response.sessions.first { + $0.key == self.appModel.defaultChatSessionKey + } self.recentChatSessions = Self.sessionChoices( response.sessions, - currentSessionKey: self.appModel.chatSessionKey) + currentSessionKey: self.appModel.chatSessionKey, + defaultSessionKey: self.appModel.defaultChatSessionKey) } catch { + self.defaultChatSessionEntry = nil self.recentChatSessions = [] } } private static func sessionChoices( _ sessions: [OpenClawChatSessionEntry], - currentSessionKey: String) -> [OpenClawChatSessionEntry] + currentSessionKey: String, + defaultSessionKey: String) -> [OpenClawChatSessionEntry] { let sorted = sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } var result: [OpenClawChatSessionEntry] = [] var included = Set() - if let current = sorted.first(where: { $0.key == currentSessionKey }) { + if currentSessionKey != defaultSessionKey, + let current = sorted.first(where: { $0.key == currentSessionKey }) + { result.append(current) included.insert(current.key) } for session in sorted { guard !included.contains(session.key) else { continue } + guard session.key != defaultSessionKey else { continue } guard !Self.isHiddenInternalSession(session.key) else { continue } result.append(session) included.insert(session.key) @@ -554,7 +614,7 @@ struct CommandCenterTab: View { icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill", title: Self.sessionTitle(session), detail: Self.sessionDetail(session), - state: isCurrent ? "current" : "recent", + state: isCurrent ? "open" : "recent", trailing: "chat", color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok, progress: nil, @@ -765,6 +825,7 @@ private struct CommandSessionsScreen: View { private var sessionRows: [CommandCenterTab.WorkItem] { self.sessions .filter { !CommandCenterTab.isHiddenInternalSession($0.key) } + .filter { $0.key != self.appModel.defaultChatSessionKey } .sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } .map { CommandCenterTab.sessionWorkItem( diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 8161ebf70666..875de6b54360 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -231,7 +231,7 @@ extension SettingsProTab { Divider() self.detailRow("Discovered", value: "\(self.gatewayController.gateways.count)") Divider() - self.detailRow("Active Agent", value: self.appModel.activeAgentName) + self.detailRow("Default Agent", value: self.appModel.activeAgentName) Divider() self.detailRow("Agents", value: "\(self.appModel.gatewayAgents.count)") } @@ -499,7 +499,7 @@ extension SettingsProTab { var agentSelectionCard: some View { ProCard(radius: SettingsLayout.cardRadius) { VStack(alignment: .leading, spacing: 10) { - Text("Active Agent") + Text("Default Agent") .font(.subheadline.weight(.semibold)) Picker("Agent", selection: self.$selectedAgentPickerId) { Text("Default").tag("") @@ -510,7 +510,7 @@ extension SettingsProTab { Text(name.isEmpty ? agent.id : name).tag(agent.id) } } - Text("Controls which agent Chat and Talk use.") + Text("Used for new Chat and Talk sessions.") .font(.caption) .foregroundStyle(.secondary) } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index e807ffa9ca18..43be1690d80b 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -310,6 +310,22 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.chatSessionKey == "incident-42") } + @Test @MainActor func openingNilChatSessionClearsExplicitChatFocus() { + let appModel = NodeAppModel() + appModel.gatewayDefaultAgentId = "main" + appModel.setSelectedAgentId("rust-claw") + appModel.openChat(sessionKey: "incident-42") + + appModel.openChat(sessionKey: nil) + + #expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey( + agentId: "rust-claw", + baseKey: "main")) + + appModel.setSelectedAgentId("main") + #expect(appModel.chatSessionKey == "main") + } + @Test @MainActor func execApprovalPromptPresentationTracksLatestNotificationTap() throws { let appModel = NodeAppModel() try appModel._test_presentExecApprovalPrompt(