Skip to content

Commit 32762da

Browse files
fix(ios): return chat to originating control detail (#99245)
* fix(ios): return chat to originating control detail Co-authored-by: Colin <[email protected]> * fix(ios): reset retained state before control chat handoff Co-authored-by: Colin <[email protected]> --------- Co-authored-by: Colin <[email protected]>
1 parent d506201 commit 32762da

9 files changed

Lines changed: 303 additions & 89 deletions

apps/.i18n/native-source.json

Lines changed: 68 additions & 68 deletions
Large diffs are not rendered by default.

apps/ios/Sources/Design/IPadWorkboardScreen.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,9 @@ struct IPadWorkboardScreen: View {
788788

789789
private func open(_ card: IPadWorkboardCard) {
790790
guard let sessionKey = normalized(card.sessionKey) else { return }
791+
// Card details are a sheet. Dismiss it before changing tabs or the requested
792+
// Chat session and contextual return action remain obscured by the old card.
793+
self.presentedSheet = nil
791794
self.appModel.openChat(sessionKey: sessionKey)
792795
self.openChat()
793796
}

apps/ios/Sources/Design/RootTabsPhoneControlHub.swift

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ struct RootTabsPhoneControlHub: View {
55
@Environment(NodeAppModel.self) private var appModel
66
@State private var navigationPath: [RootTabs.SidebarDestination] = []
77
@State private var didApplyInitialDestination = false
8+
@State private var handledNavigationRequestID = 0
89

910
let groups: [RootTabs.SidebarGroup]
1011
let initialDestination: RootTabs.SidebarDestination?
12+
let navigationRequest: RootTabs.PhoneControlNavigationRequest?
1113
let openRootDestination: (RootTabs.SidebarDestination) -> Void
14+
let openChatFromControlDetail: (RootTabs.SidebarDestination) -> Void
1215

1316
var body: some View {
1417
NavigationStack(path: self.$navigationPath) {
@@ -42,6 +45,10 @@ struct RootTabsPhoneControlHub: View {
4245
}
4346
.onAppear {
4447
self.applyInitialDestinationIfNeeded()
48+
self.applyNavigationRequestIfNeeded()
49+
}
50+
.onChange(of: self.navigationRequest) { _, _ in
51+
self.applyNavigationRequestIfNeeded()
4552
}
4653
}
4754
}
@@ -124,18 +131,18 @@ struct RootTabsPhoneControlHub: View {
124131
usesNativeNavigationChrome: true,
125132
headerTitle: "Overview",
126133
showsHeaderMark: false,
127-
openChat: { self.openPhoneRootDestination(.chat) },
134+
openChat: { self.openChatFromControlDetail(.overview) },
128135
openSettings: { self.openGatewayDetail() },
129136
openSessions: { self.navigationPath.append(.sessions) })
130137
case .activity:
131138
IPadActivityScreen(
132139
usesNativeNavigationChrome: true,
133-
openChat: { self.openPhoneRootDestination(.chat) },
140+
openChat: { self.openChatFromControlDetail(.activity) },
134141
openSettings: { self.openGatewayDetail() })
135142
case .workboard:
136143
IPadWorkboardScreen(
137144
usesNativeNavigationChrome: true,
138-
openChat: { self.openPhoneRootDestination(.chat) },
145+
openChat: { self.openChatFromControlDetail(.workboard) },
139146
openSettings: { self.openGatewayDetail() })
140147
case .skillWorkshop:
141148
IPadSkillWorkshopScreen(
@@ -149,7 +156,7 @@ struct RootTabsPhoneControlHub: View {
149156
case .sessions:
150157
CommandSessionsScreen(
151158
usesNativeNavigationChrome: true,
152-
openChat: { self.openPhoneRootDestination(.chat) })
159+
openChat: { self.openChatFromControlDetail(.sessions) })
153160
case .dreaming:
154161
AgentProTab(
155162
directRoute: .dreaming,
@@ -201,10 +208,25 @@ struct RootTabsPhoneControlHub: View {
201208
guard !self.didApplyInitialDestination else { return }
202209
self.didApplyInitialDestination = true
203210
guard let initialDestination, initialDestination != .overview else { return }
204-
if self.opensRootTab(initialDestination) {
205-
self.openPhoneRootDestination(initialDestination)
211+
self.applyDestination(initialDestination)
212+
}
213+
214+
private func applyNavigationRequestIfNeeded() {
215+
guard let navigationRequest, navigationRequest.id != self.handledNavigationRequestID else { return }
216+
self.handledNavigationRequestID = navigationRequest.id
217+
switch navigationRequest.target {
218+
case .root:
219+
self.navigationPath.removeAll()
220+
case let .detail(destination):
221+
self.applyDestination(destination)
222+
}
223+
}
224+
225+
private func applyDestination(_ destination: RootTabs.SidebarDestination) {
226+
if self.opensRootTab(destination) {
227+
self.openPhoneRootDestination(destination)
206228
} else {
207-
self.navigationPath = [initialDestination]
229+
self.navigationPath = [destination]
208230
}
209231
}
210232

@@ -300,7 +322,9 @@ extension RootTabsPhoneControlHub {
300322
RootTabsPhoneControlHub(
301323
groups: RootTabs.phoneControlGroups,
302324
initialDestination: nil,
303-
openRootDestination: { _ in })
325+
navigationRequest: nil,
326+
openRootDestination: { _ in },
327+
openChatFromControlDetail: { _ in })
304328
.environment(appModel)
305329
}
306330
}

apps/ios/Sources/RootTabs.swift

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ struct RootTabs: View {
2626
@State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination
2727
@State private var selectedSettingsRoute: SettingsRoute? = Self.initialSidebarDestination.settingsRoute
2828
@State private var selectedSettingsRouteRequestID: Int = 0
29+
@State private var phoneControlNavigationRequest: PhoneControlNavigationRequest?
30+
@State private var phoneChatReturn: PhoneChatReturn?
31+
@State private var phoneChatSettingsResetRequestID: Int = 0
2932
// Embedded Settings rows push onto the sidebar stack; clear it before
3033
// changing sidebar roots so stale settings detail screens cannot survive.
3134
@State private var sidebarNavigationPath: [SettingsRoute] = []
@@ -158,9 +161,10 @@ struct RootTabs: View {
158161
}
159162

160163
private var phoneTabContent: some View {
161-
TabView(selection: self.$selectedTab) {
162-
PhoneTabSettingsHost { openSettingsRoute in
164+
TabView(selection: self.phoneTabSelection) {
165+
PhoneTabSettingsHost(resetRequestID: self.phoneChatSettingsResetRequestID) { openSettingsRoute in
163166
ChatProTab(
167+
headerLeadingAction: self.phoneChatReturnAction,
164168
ownsNavigationStack: false,
165169
openSettings: { openSettingsRoute(.gateway) })
166170
}
@@ -183,7 +187,9 @@ struct RootTabs: View {
183187
RootTabsPhoneControlHub(
184188
groups: Self.phoneControlGroups,
185189
initialDestination: Self.requestedInitialSidebarDestination,
186-
openRootDestination: { self.selectSidebarDestination($0) })
190+
navigationRequest: self.phoneControlNavigationRequest,
191+
openRootDestination: { self.selectSidebarDestination($0) },
192+
openChatFromControlDetail: { self.openChatFromControlDetail($0) })
187193
.tabItem { Label("Control", systemImage: "square.grid.2x2") }
188194
.badge(self.appModel.pendingExecApprovalPrompt == nil ? 0 : 1)
189195
.tag(AppTab.control)
@@ -580,6 +586,23 @@ struct RootTabs: View {
580586
action: { self.showSidebar() })
581587
}
582588

589+
private var phoneChatReturnAction: OpenClawSidebarHeaderAction? {
590+
guard !self.usesSidebarTabs, let phoneChatReturn else { return nil }
591+
return OpenClawSidebarHeaderAction(
592+
systemName: "chevron.left",
593+
accessibilityLabel: "Back to \(phoneChatReturn.destination.title)",
594+
accessibilityIdentifier: "OpenClawChatBackToControlDetailButton",
595+
action: { self.openPhoneControlDetail(phoneChatReturn.destination) })
596+
}
597+
598+
/// TabView writes through this binding; internal routing writes selectedTab directly.
599+
/// That distinction keeps only a user-selected Control tab responsible for resetting its child stack.
600+
private var phoneTabSelection: Binding<AppTab> {
601+
Binding(
602+
get: { self.selectedTab },
603+
set: { self.handlePhoneTabSelection($0) })
604+
}
605+
583606
private var sidebarHideButton: some View {
584607
Button {
585608
self.hideSidebar()
@@ -835,8 +858,8 @@ struct RootTabs: View {
835858
guard !newValue else { return }
836859
self.maybeRequestLocalNetworkAccess(reason: "onboarding_dismissed")
837860
}
838-
.onChange(of: self.appModel.openChatRequestID) { _, _ in
839-
self.selectSidebarDestination(.chat)
861+
.onChange(of: self.appModel.openChatRequestID) { _, newValue in
862+
self.handleOpenChatRequest(newValue)
840863
}
841864
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
842865
self.maybeOpenSettingsForGatewaySetup()
@@ -1026,21 +1049,78 @@ struct RootTabs: View {
10261049
}
10271050

10281051
extension RootTabs {
1029-
private func selectSidebarDestination(_ destination: SidebarDestination) {
1052+
private func selectSidebarDestination(
1053+
_ destination: SidebarDestination,
1054+
preservingChatReturn: Bool = false)
1055+
{
1056+
if destination != .chat || !preservingChatReturn {
1057+
self.phoneChatReturn = nil
1058+
}
10301059
self.sidebarNavigationPath.removeAll()
10311060
if destination.settingsRoute != .notifications {
10321061
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
10331062
}
10341063
self.selectedSidebarDestination = destination
10351064
self.selectedSettingsRoute = destination.settingsRoute
10361065
self.selectedTab = destination.appTab
1066+
self.requestPhoneControlDestinationIfNeeded(destination)
10371067
guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return }
10381068
withAnimation(.easeInOut(duration: 0.22)) {
10391069
self.setSidebarVisible(false)
10401070
}
10411071
}
10421072

1073+
private func openChatFromControlDetail(_ returnDestination: SidebarDestination) {
1074+
// Detail screens focus a session before invoking this route callback. Remember that
1075+
// synchronous request so its later observation cannot erase the contextual return.
1076+
self.phoneChatReturn = PhoneChatReturn(
1077+
destination: returnDestination,
1078+
openChatRequestID: self.appModel.openChatRequestID)
1079+
// Chat owns an embedded Settings stack. Pop it before routing so the requested
1080+
// session and contextual return action cannot remain hidden behind Settings.
1081+
self.phoneChatSettingsResetRequestID &+= 1
1082+
self.selectSidebarDestination(.chat, preservingChatReturn: true)
1083+
}
1084+
1085+
private func handleOpenChatRequest(_ requestID: Int) {
1086+
guard requestID != self.phoneChatReturn?.openChatRequestID else { return }
1087+
self.selectSidebarDestination(.chat)
1088+
}
1089+
1090+
private func openPhoneControlDetail(_ destination: SidebarDestination) {
1091+
self.selectSidebarDestination(destination)
1092+
if destination == .overview {
1093+
self.requestPhoneControlDestinationIfNeeded(destination, force: true)
1094+
}
1095+
}
1096+
1097+
private func handlePhoneTabSelection(_ selectedTab: AppTab) {
1098+
if selectedTab != .chat {
1099+
self.phoneChatReturn = nil
1100+
}
1101+
if selectedTab == .control {
1102+
self.requestPhoneControlNavigation(.root)
1103+
}
1104+
self.selectedTab = selectedTab
1105+
}
1106+
1107+
private func requestPhoneControlDestinationIfNeeded(
1108+
_ destination: SidebarDestination,
1109+
force: Bool = false)
1110+
{
1111+
guard !self.usesSidebarTabs else { return }
1112+
guard destination.appTab == .control else { return }
1113+
guard force || destination != .overview else { return }
1114+
self.requestPhoneControlNavigation(.detail(destination))
1115+
}
1116+
1117+
private func requestPhoneControlNavigation(_ target: PhoneControlNavigationRequest.Target) {
1118+
let requestID = (self.phoneControlNavigationRequest?.id ?? 0) &+ 1
1119+
self.phoneControlNavigationRequest = PhoneControlNavigationRequest(id: requestID, target: target)
1120+
}
1121+
10431122
private func selectSettingsRoute(_ route: SettingsRoute) {
1123+
self.phoneChatReturn = nil
10441124
self.sidebarNavigationPath.removeAll()
10451125
if route != .notifications {
10461126
self.suppressedExecApprovalPromptIDForNotificationSettings = nil
@@ -1263,9 +1343,14 @@ extension RootTabs {
12631343
/// (deep links, onboarding, problem banner) jump to the canonical Settings tab.
12641344
private struct PhoneTabSettingsHost<Content: View>: View {
12651345
@State private var settingsPath: [SettingsRoute] = []
1346+
private let resetRequestID: Int
12661347
private let content: (_ openSettingsRoute: @escaping (SettingsRoute) -> Void) -> Content
12671348

1268-
init(@ViewBuilder content: @escaping (_ openSettingsRoute: @escaping (SettingsRoute) -> Void) -> Content) {
1349+
init(
1350+
resetRequestID: Int = 0,
1351+
@ViewBuilder content: @escaping (_ openSettingsRoute: @escaping (SettingsRoute) -> Void) -> Content)
1352+
{
1353+
self.resetRequestID = resetRequestID
12691354
self.content = content
12701355
}
12711356

@@ -1278,6 +1363,9 @@ private struct PhoneTabSettingsHost<Content: View>: View {
12781363
SettingsProTab(directRoute: route)
12791364
}
12801365
}
1366+
.onChange(of: self.resetRequestID) { _, _ in
1367+
self.settingsPath.removeAll()
1368+
}
12811369
}
12821370
}
12831371

apps/ios/Sources/RootTabsNavigation.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@ import Foundation
33
import SwiftUI
44

55
extension RootTabs {
6+
struct PhoneChatReturn: Equatable {
7+
let destination: SidebarDestination
8+
let openChatRequestID: Int
9+
}
10+
11+
struct PhoneControlNavigationRequest: Equatable {
12+
enum Target: Equatable {
13+
case root
14+
case detail(SidebarDestination)
15+
}
16+
17+
let id: Int
18+
let target: Target
19+
}
20+
621
private static var sidebarPersistentWidthThreshold: CGFloat {
722
980
823
}

apps/ios/Tests/RootTabsSidebarRegressionTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ import Testing
7070
to: "private var usesSidebarTabs: Bool")
7171
let selection = try Self.extract(
7272
source,
73-
from: "private func selectSidebarDestination(_ destination: SidebarDestination)",
73+
from: "private func selectSidebarDestination(",
7474
to: "private func showSidebar()")
7575
let resetRange = try #require(selection.range(of: "self.sidebarNavigationPath.removeAll()"))
7676
let destinationRange = try #require(selection.range(of: "self.selectedSidebarDestination = destination"))

apps/ios/Tests/RootTabsSourceGuardTests.swift

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ struct RootTabsSourceGuardTests {
8484
#expect(talkRange.lowerBound < controlRange.lowerBound)
8585
#expect(controlRange.lowerBound < agentRange.lowerBound)
8686
#expect(agentRange.lowerBound < settingsRange.lowerBound)
87-
#expect(phoneTabContent.matches(of: /PhoneTabSettingsHost \{/).count == 3)
87+
#expect(phoneTabContent.matches(of: /PhoneTabSettingsHost(?:\([^\n]+\))? \{/).count == 3)
8888
}
8989

9090
@Test func `sidebar keeps navigation model destination only`() throws {
@@ -424,6 +424,20 @@ struct RootTabsSourceGuardTests {
424424
#expect(!source.contains("Multi-column queue control"))
425425
}
426426

427+
@Test func `workboard dismisses card sheet before opening chat`() throws {
428+
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
429+
let openFunction = try Self.extract(
430+
source,
431+
from: "private func open(_ card: IPadWorkboardCard)",
432+
to: "private func replace(_ card: IPadWorkboardCard)")
433+
let dismiss = try #require(openFunction.range(of: "self.presentedSheet = nil"))
434+
let focus = try #require(openFunction.range(of: "self.appModel.openChat(sessionKey: sessionKey)"))
435+
let route = try #require(openFunction.range(of: "self.openChat()"))
436+
437+
#expect(dismiss.lowerBound < focus.lowerBound)
438+
#expect(focus.lowerBound < route.lowerBound)
439+
}
440+
427441
@Test func `workboard create action surfaces unavailable reasons`() throws {
428442
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
429443
let createFunction = try Self.extract(
@@ -863,12 +877,10 @@ struct RootTabsSourceGuardTests {
863877
@Test func `native chat uses gateway transport`() throws {
864878
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
865879
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
866-
let settingsSectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
867880
let appModelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
868881

869882
#expect(chatSource.matches(of: /self\.appModel\.makeChatTransport\(\)/).count == 2)
870883
#expect(appModelSource.contains("return IOSGatewayChatTransport(gateway: self.operatorSession)"))
871-
#expect(settingsSectionsSource.contains("Connected services and message routing"))
872884
#expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata"))
873885
#expect(channelsSource.contains("label: \"ClickClack\""))
874886
#expect(channelsSource.contains("Self-hosted chat bot routing."))

apps/ios/Tests/SwiftUIRenderSmokeTests.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,9 @@ struct SwiftUIRenderSmokeTests {
191191
let root = RootTabsPhoneControlHub(
192192
groups: RootTabs.phoneControlGroups,
193193
initialDestination: nil,
194-
openRootDestination: { _ in })
194+
navigationRequest: nil,
195+
openRootDestination: { _ in },
196+
openChatFromControlDetail: { _ in })
195197
.environment(appModel)
196198

197199
_ = Self.host(root)
@@ -203,7 +205,9 @@ struct SwiftUIRenderSmokeTests {
203205
let root = RootTabsPhoneControlHub(
204206
groups: RootTabs.phoneControlGroups,
205207
initialDestination: nil,
206-
openRootDestination: { _ in })
208+
navigationRequest: nil,
209+
openRootDestination: { _ in },
210+
openChatFromControlDetail: { _ in })
207211
.environment(appModel)
208212
.environment(\.horizontalSizeClass, .regular)
209213
.environment(\.verticalSizeClass, .compact)

0 commit comments

Comments
 (0)