feat(ios): snooze sessions from the session menu (#162399)

* feat(ios): snooze sessions from the session menu

Add shared calendar presets and identity-bound snooze/wake patches while preserving existing Swift transport call sites. iOS gains Active, Snoozed, and Archived scopes, wake labels, and deadline-driven updates for Sessions, the sidebar, and Overview. Snoozing keeps ongoing work and the open conversation intact.

* test(ios): capture session snooze proof states

Extend the synthetic Gateway with awake and snoozed sessions. Capture Active and Snoozed lists, snooze presets, and Wake through the real iOS session menu, using All Sessions navigation and a row selector that excludes the retained sidebar.

* fix(ios): show the weekday for the next-week snooze preset

* chore(ios): record snooze strings in the native i18n source inventory

* fix(ios): format snooze wake labels through the localized catalog

Apple i18n verification rejects runtime-interpolated localized strings
because they bypass the generated catalog, so the "Wake session" menu item
and the "tomorrow" wake description now use the repository's
String(format: String(localized: "… %@")) pattern, and the native string
inventory records the format sources.

* refactor(ios): drop the superseded mutation lease initializer

* fix(ios): keep snoozed sessions browsable offline

Separate cached browsing scopes from connected mutation controls. Keep Archived connected-only and reset an archived selection to Active on disconnect. Cover scope availability and the offline cached roster, including snoozed rows and wake labels.
This commit is contained in:
Peter Steinberger 2026-10-01 03:22:22 -07:00 • committed by GitHub
parent 1b5db5e90b
commit 1128a4ae92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 857 additions and 71 deletions

View file

@ -2095,7 +2095,7 @@
{"id":"native.apple.8338be9a1e12c4f0","source":"Action required","surface":"apple","sites":[{"kind":"ui-call","path":"apps/ios/ActivityWidget/OpenClawLiveActivity.swift"},{"kind":"ui-localized-call","path":"apps/ios/Sources/LiveActivity/LiveActivityManager.swift"},{"kind":"ui-localized-call","path":"apps/ios/Sources/Model/NodeAppModel.swift"}]},
{"id":"native.apple.db71b9918897a484","source":"Actions","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatCommandPaletteModel.swift"}]},
{"id":"native.apple.56772a58fca11c95","source":"Activate voice audio before connecting.","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/WatchApp/Sources/WatchRealtimeMediaSession.swift"}]},
{"id":"native.apple.3032c9a934a24421","source":"Active","surface":"apple","sites":[{"kind":"conditional-branch","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSheets.swift"}]},
{"id":"native.apple.3032c9a934a24421","source":"Active","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"},{"kind":"conditional-branch","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSheets.swift"}]},
{"id":"native.apple.794509366cc11cad","source":"Activity","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/RootTabsNavigation.swift"},{"kind":"ui-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionManagementViews.swift"}]},
{"id":"native.apple.19374c2c6dd1e19b","source":"Activity for %@","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar+People.swift"}]},
{"id":"native.apple.f5a7a88010ab33c6","source":"Activity unavailable","surface":"apple","sites":[{"kind":"ui-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar+People.swift"}]},
@ -2192,7 +2192,7 @@
{"id":"native.apple.752f034581ae2df4","source":"Approve this request (⌘Return)","surface":"apple","sites":[{"kind":"conditional-branch","path":"apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift"}]},
{"id":"native.apple.8415cb934f57008a","source":"Approving this repair replaces the device’s access token.","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift"}]},
{"id":"native.apple.b08e18c89de8d46c","source":"Archive","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterSupport.swift"},{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar+ContextMenu.swift"},{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar+Row.swift"},{"kind":"ui-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSheets.swift"},{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSheets.swift"},{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWindowShell.swift"}]},
{"id":"native.apple.ba6d42821bf02f25","source":"Archived","surface":"apple","sites":[{"kind":"ui-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionManagementViews.swift"},{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarRowFacts.swift"},{"kind":"conditional-branch","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSheets.swift"}]},
{"id":"native.apple.ba6d42821bf02f25","source":"Archived","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"},{"kind":"ui-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionManagementViews.swift"},{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarRowFacts.swift"},{"kind":"conditional-branch","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSheets.swift"}]},
{"id":"native.apple.4bf05e4bdc096680","source":"Archived sessions","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"}]},
{"id":"native.apple.26aad9b4464e6a81","source":"Archived sessions will appear here.","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"}]},
{"id":"native.apple.9a6317bf808261e4","source":"Ask Discord","surface":"apple","sites":[{"kind":"ui-call","path":"apps/macos/Sources/OpenClaw/PostUpdate.swift"}]},
@ -2839,6 +2839,8 @@
{"id":"native.apple.98639fd22a00b876","source":"Images","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift"}]},
{"id":"native.apple.4cf83bc1200584ed","source":"Import","surface":"apple","sites":[{"kind":"ui-call","path":"apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift"}]},
{"id":"native.apple.698e6761fdfb80d7","source":"Importing browser cookies…","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift"}]},
{"id":"native.apple.8260e8fe9e68bf3c","source":"In 1 hour","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSnooze.swift"}]},
{"id":"native.apple.0cb76e7bd64bb0b1","source":"In 3 hours","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSnooze.swift"}]},
{"id":"native.apple.e9ea5c79232eb608","source":"Inbox","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/WatchApp/Sources/WatchInboxView.swift"},{"kind":"ui-named-argument","path":"apps/ios/WatchApp/Sources/WatchInboxView.swift"}]},
{"id":"native.apple.653497d92cfa11ca","source":"Include existing %@ conversations in the sidebar. This discovers them in place; it does not copy transcripts.","surface":"apple","sites":[{"kind":"ui-localized-call-multiline","path":"apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift"}]},
{"id":"native.apple.4f484e75f2906940","source":"Incognito","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarRowFacts.swift"}]},
@ -2930,6 +2932,7 @@
{"id":"native.apple.47e586ef7d8ede85","source":"Loading recent sessions","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"}]},
{"id":"native.apple.348fd05d405dfcb4","source":"Loading sessions","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/RootSidebar.swift"}]},
{"id":"native.apple.99c1b78044e82bbb","source":"Loading skills…","surface":"apple","sites":[{"kind":"ui-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer+Capabilities.swift"}]},
{"id":"native.apple.0fd687e088ac5633","source":"Loading snoozed sessions","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"}]},
{"id":"native.apple.6bd9932ed4f9b096","source":"Loading usage…","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/macos/Sources/OpenClaw/StatusMenuSummaries.swift"}]},
{"id":"native.apple.d5d4f42ecf5b28f7","source":"Loading video…","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMediaVideoAttachment.swift"}]},
{"id":"native.apple.87494904c7e79a85","source":"Lobstering","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift"}]},
@ -3035,6 +3038,7 @@
{"id":"native.apple.2282d2bdeca50246","source":"Next Match","surface":"apple","sites":[{"kind":"ui-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptSearch.swift"}]},
{"id":"native.apple.de0f4838e9554464","source":"Next match (⌘G)","surface":"apple","sites":[{"kind":"ui-modifier","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptSearch.swift"}]},
{"id":"native.apple.f5f840287aa988ff","source":"Next up","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/WatchApp/Sources/WatchInboxView.swift"}]},
{"id":"native.apple.98037be501aeabf3","source":"Next week","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSnooze.swift"}]},
{"id":"native.apple.fedfabab4844f04b","source":"No Chrome, Brave, Edge, or Chromium profile with cookies was found on this Mac.","surface":"apple","sites":[{"kind":"ui-localized-call-multiline","path":"apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift"}]},
{"id":"native.apple.37db63fd5d16b721","source":"No Gateways saved","surface":"apple","sites":[{"kind":"ui-call","path":"apps/macos/Sources/OpenClaw/GatewaySettings.swift"}]},
{"id":"native.apple.cb16b17de2bb55f4","source":"No Sound","surface":"apple","sites":[{"kind":"conditional-branch","path":"apps/macos/Sources/OpenClaw/VoiceWakeChime.swift"}]},
@ -3084,6 +3088,7 @@
{"id":"native.apple.86cd8b8de6fc5513","source":"No secure gateway endpoint was detected at %1$@:%2$@. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.","surface":"apple","sites":[{"kind":"ui-localized-call-multiline","path":"apps/ios/Sources/Gateway/GatewayConnectionSupport.swift"}]},
{"id":"native.apple.168fc1131e0ed8f2","source":"No sessions","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/RootSidebar.swift"}]},
{"id":"native.apple.5cd8735ef9fc41a4","source":"No skills configured","surface":"apple","sites":[{"kind":"ui-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer+Capabilities.swift"}]},
{"id":"native.apple.f5ea9474f46b6c37","source":"No snoozed sessions","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"}]},
{"id":"native.apple.cbbbadf8c46d760c","source":"No speech heard. Try again and speak near the microphone.","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Model/NodeAppModel.swift"}]},
{"id":"native.apple.c48f4e3bb761e2fc","source":"No threads found","surface":"apple","sites":[{"kind":"conditional-branch","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSheets.swift"}]},
{"id":"native.apple.076927e136c61103","source":"No threads yet","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar.swift"}]},
@ -3630,6 +3635,7 @@
{"id":"native.apple.e2edbfaaeee0ff8d","source":"Session reset","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptRows.swift"}]},
{"id":"native.apple.aac327611078fdad","source":"Session settings changed. Review and retry this message.","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift"}]},
{"id":"native.apple.dffe560b2135ce37","source":"Session settings were not captured. Review and retry this message.","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift"}]},
{"id":"native.apple.177774fe4407198f","source":"Session status","surface":"apple","sites":[{"kind":"ui-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"}]},
{"id":"native.apple.1f57a516f0eeb364","source":"Session store","surface":"apple","sites":[{"kind":"ui-call","path":"apps/macos/Sources/OpenClaw/DebugSettings.swift"}]},
{"id":"native.apple.d271f2d7ceb3ff7a","source":"Session tool controls require a newer Gateway.","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+ComposerCapabilities.swift"}]},
{"id":"native.apple.6a28642332102135","source":"Session tool controls require operator.admin access.","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+ComposerCapabilities.swift"}]},
@ -3662,7 +3668,7 @@
{"id":"native.apple.d7e0639ff1da5abb","source":"Share file","surface":"apple","sites":[{"kind":"ui-modifier","path":"apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift"}]},
{"id":"native.apple.6d3051411967bcc4","source":"Shared owner","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarPeople.swift"}]},
{"id":"native.apple.5b80f1144c25316f","source":"Shelling","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift"}]},
{"id":"native.apple.34d830023283d9d3","source":"Show Archived","surface":"apple","sites":[{"kind":"ui-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"}]},
{"id":"native.apple.34d830023283d9d3","source":"Show Archived","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"}]},
{"id":"native.apple.7b6cd8b4f8cb3d44","source":"Show Pairing Panel (Demo)","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/macos/Sources/OpenClaw/StatusMenuRenderer.swift"}]},
{"id":"native.apple.8725830caa063047","source":"Show Reasoning","surface":"apple","sites":[{"kind":"ui-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWindowShell.swift"}]},
{"id":"native.apple.b8850e683c88022c","source":"Show Sidebar","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/RootTabs.swift"}]},
@ -3708,6 +3714,10 @@
{"id":"native.apple.80006987fdb875be","source":"Skipping…","surface":"apple","sites":[{"kind":"ui-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatQuestionCard.swift"}]},
{"id":"native.apple.4d6fa2fb801b575c","source":"Smarter","surface":"apple","sites":[{"kind":"ui-call","path":"apps/ios/Sources/Design/ChatModelControlsMenu.swift"},{"kind":"ui-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatEffortControl.swift"}]},
{"id":"native.apple.10f82d82056b15dc","source":"Snapping","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWorkingProgress.swift"}]},
{"id":"native.apple.07db09e84b7d8360","source":"Snooze","surface":"apple","sites":[{"kind":"ui-call","path":"apps/ios/Sources/Design/CommandCenterSupport.swift"}]},
{"id":"native.apple.7e5f2531d9455524","source":"Snoozed","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"}]},
{"id":"native.apple.bd383db80a9930f6","source":"Snoozed sessions","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"}]},
{"id":"native.apple.e46b7a1d62eb3355","source":"Snoozed sessions will appear here until they wake.","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"}]},
{"id":"native.apple.7e25b2a702a07140","source":"Some sessions could not be loaded. Refresh to try again.","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/RootSidebarModel.swift"}]},
{"id":"native.apple.60dbd7c5b1de36ba","source":"Sort","surface":"apple","sites":[{"kind":"ui-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebar+Header.swift"}]},
{"id":"native.apple.b021a5eb45b487aa","source":"Sources","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSourcePreviewsView.swift"}]},
@ -3919,6 +3929,7 @@
{"id":"native.apple.c82ee689dfef17aa","source":"This device needs pairing approval","surface":"apple","sites":[{"kind":"conditional-branch","path":"apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift"}]},
{"id":"native.apple.c6d7e86453f48411","source":"This device's saved device token is no longer valid","surface":"apple","sites":[{"kind":"ui-named-argument","path":"apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift"}]},
{"id":"native.apple.c89753e79e3f9f0b","source":"This disconnects, clears saved gateway credentials, and reopens onboarding.","surface":"apple","sites":[{"kind":"ui-call","path":"apps/ios/Sources/Design/SettingsProTab.swift"}]},
{"id":"native.apple.53cba14ae6eb0811","source":"This evening","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSnooze.swift"}]},
{"id":"native.apple.0a1fca4f9ec8694a","source":"This folder is empty.","surface":"apple","sites":[{"kind":"ui-call","path":"apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift"}]},
{"id":"native.apple.5b4cdd521b868111","source":"This gateway has token auth enabled, but no gateway.auth.token is configured on the host.","surface":"apple","sites":[{"kind":"conditional-branch","path":"apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift"}]},
{"id":"native.apple.e91d0c6f33dd733b","source":"This gateway host needs token setup","surface":"apple","sites":[{"kind":"conditional-branch","path":"apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift"}]},
@ -3966,6 +3977,7 @@
{"id":"native.apple.25bacc49f5d7a01f","source":"Token","surface":"apple","sites":[{"kind":"ui-call","path":"apps/macos/Sources/OpenClaw/GatewaySettings.swift"}]},
{"id":"native.apple.660526421dc1f4e3","source":"Token or password","surface":"apple","sites":[{"kind":"ui-call","path":"apps/macos/Sources/OpenClaw/GatewaySettings.swift"}]},
{"id":"native.apple.bf61b9107f8ae06e","source":"Tokens","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"}]},
{"id":"native.apple.25790b1cc1f883f9","source":"Tomorrow","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSnooze.swift"}]},
{"id":"native.apple.fad811608482da37","source":"Too large to send: %@","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift"}]},
{"id":"native.apple.1e1c6b725d461eaf","source":"Too many failed attempts","surface":"apple","sites":[{"kind":"ui-named-argument","path":"apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectionProblem.swift"}]},
{"id":"native.apple.fc5d2cd380045e19","source":"Tool","surface":"apple","sites":[{"kind":"ui-named-argument","path":"apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift"},{"kind":"conditional-branch","path":"apps/macos/Sources/OpenClaw/SessionMenuPreviewView.swift"}]},
@ -4136,6 +4148,8 @@
{"id":"native.apple.f14aef00ad9a1a39","source":"Waiting for spoken reply…","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/WatchApp/Sources/WatchInboxView.swift"}]},
{"id":"native.apple.b263fede10ff98c1","source":"Waiting for the previous AI test to finish…","surface":"apple","sites":[{"kind":"conditional-branch","path":"apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift"}]},
{"id":"native.apple.7c918800b353e72e","source":"Waiting for your iPhone","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/WatchApp/Sources/WatchInboxView.swift"}]},
{"id":"native.apple.7a02c3c9e1c333a7","source":"Wake session · %@","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterSupport.swift"}]},
{"id":"native.apple.63e22b4f68067be8","source":"Wakes %@","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Design/CommandCenterTab.swift"}]},
{"id":"native.apple.b232cc4ae996912f","source":"Warning","surface":"apple","sites":[{"kind":"conditional-branch","path":"apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift"}]},
{"id":"native.apple.df8c198d15ed70b9","source":"Warning: %@ percent of context used","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift"}]},
{"id":"native.apple.e7ebc9a0e2bfcec4","source":"Watch delivery could not be saved. Open Apple Watch settings to review it.","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Model/WatchReplyCoordinator.swift"}]},
@ -4297,6 +4311,7 @@
{"id":"native.apple.14a9977c8e5dd6b1","source":"that request","surface":"apple","sites":[{"kind":"conditional-branch","path":"apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift"}]},
{"id":"native.apple.405475b3a812acf0","source":"this Mac","surface":"apple","sites":[{"kind":"conditional-branch","path":"apps/macos/Sources/OpenClaw/VoiceWakeForwarder.swift"}]},
{"id":"native.apple.8793dee0cafb94df","source":"token","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/macos/Sources/OpenClaw/GatewaysMainMenu.swift"}]},
{"id":"native.apple.180f4a3a6270303f","source":"tomorrow %@","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSnooze.swift"}]},
{"id":"native.apple.7209bdd560ff69f7","source":"under a minute","surface":"apple","sites":[{"kind":"ui-localized-call","path":"apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift"}]},
{"id":"native.apple.f4c5b493be9f6510","source":"unknown error","surface":"apple","sites":[{"kind":"conditional-branch","path":"apps/macos/Sources/OpenClaw/GatewayProcessManager.swift"}]},
{"id":"native.apple.4aa0a356f580cb09","source":"unknown gateway error","surface":"apple","sites":[{"kind":"conditional-branch","path":"apps/macos/Sources/OpenClaw/ControlChannel.swift"}]},

View file

@ -432,6 +432,32 @@ struct IOSGatewayChatTransport: OpenClawChatGatewayTransport {
unread: unread)
}
func patchSession(
key: String,
expectedSessionID: String? = nil,
label: String?? = nil,
category: String?? = nil,
color: String?? = nil,
pinned: Bool? = nil,
archived: Bool? = nil,
snoozedUntil: OpenClawChatSnoozePatch?,
unread: Bool? = nil) async throws
{
guard let routeLease = await acquireSessionMutationRouteLease() else {
throw OpenClawChatTransportSendError.notDispatched
}
try await routeLease.patchSession(
key: key,
expectedSessionID: expectedSessionID,
label: label,
category: category,
color: color,
pinned: pinned,
archived: archived,
snoozedUntil: snoozedUntil,
unread: unread)
}
func forkSession(parentKey: String) async throws -> String {
try await self.forkSession(parentKey: parentKey, fromLastCompleted: false)
}

View file

@ -79,6 +79,8 @@ struct CommandSessionActions {
let moveToGroup: (String?) -> Void
let setColor: (String?) -> Void
let togglePinned: () -> Void
let snooze: (Date) -> Void
let wake: () -> Void
let toggleUnread: () -> Void
let fork: () -> Void
let toggleArchived: () -> Void
@ -111,11 +113,22 @@ struct CommandSessionActions {
}
}
func patchSnooze(_ snoozedUntil: OpenClawChatSnoozePatch) {
performMutation(nil) { transport in
try await transport.patchSession(
key: session.key,
expectedSessionID: session.sessionId,
snoozedUntil: snoozedUntil)
}
}
return Self(
rename: { patch(label: .some($0)) },
moveToGroup: { patch(category: .some($0)) },
setColor: { patch(color: .some($0)) },
togglePinned: { patch(pinned: session.pinned != true) },
snooze: { patchSnooze(.until($0)) },
wake: { patchSnooze(.wake) },
toggleUnread: { patch(unread: session.unread != true) },
fork: fork,
toggleArchived: { patch(archived: archivesSession()) },
@ -134,6 +147,7 @@ struct CommandSessionActionsModifier: ViewModifier {
}
let session: OpenClawChatSessionEntry
let mainSessionKey: String
let categories: [String]
let isArchived: Bool
let isEnabled: Bool
@ -166,6 +180,9 @@ struct CommandSessionActionsModifier: ViewModifier {
{
self.actions.togglePinned()
}
if self.canSnooze {
self.snoozeMenu
}
self.actionButton(
self.session.unread == true
? OpenClawTextValue.localized("Mark as Read")
@ -238,6 +255,58 @@ struct CommandSessionActionsModifier: ViewModifier {
}
}
private var canSnooze: Bool {
let key = self.session.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let agentID = OpenClawChatSessionKey.agentID(from: key)
let sessionName = agentID == nil
? key
: String(key.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)[2])
let mainKey = self.mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let configuredMain = OpenClawChatSessionKey.agentID(from: mainKey) == nil
? mainKey
: String(mainKey.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)[2])
guard !self.isArchived, !self.session.isArchived, self.session.isMain != true,
self.normalized(self.session.sessionId) != nil,
self.session.kind != "global", self.session.kind != "unknown",
key != "main", key != "global", key != "unknown", sessionName != configuredMain,
!sessionName.hasPrefix("subagent:"), self.normalized(self.session.spawnedBy) == nil
else { return false }
guard let parent = self.normalized(self.session.parentSessionKey) else { return true }
// Ordinary dashboard conversations link to Home without becoming nested children.
return agentID.map { parent == "agent:\($0):main" } ?? false
}
@ViewBuilder
private var snoozeMenu: some View {
let now = Date.now
if self.session.isSnoozed(at: now), let snoozedUntil = self.session.snoozedUntil {
let wakeDescription = OpenClawChatSessionSnooze.wakeDescription(
Date(timeIntervalSince1970: snoozedUntil / 1000),
now: now)
self.actionButton(
.verbatim(String(format: String(localized: "Wake session · %@"), wakeDescription)),
systemImage: "clock")
{
self.actions.wake()
}
} else {
Menu {
ForEach(OpenClawChatSessionSnooze.presets(now: now), id: \.id) { preset in
// "Next week" needs its weekday; the other titles already name the day.
let when = preset.id == "next-week"
? OpenClawChatSessionSnooze.wakeDescription(preset.wakeAt, now: now)
: preset.wakeAt.formatted(date: .omitted, time: .shortened)
self.actionButton(.verbatim("\(preset.title) · \(when)"), systemImage: "clock") {
self.actions.snooze(preset.wakeAt)
}
}
} label: {
Label("Snooze", systemImage: "clock")
.font(OpenClawType.subhead)
}
}
}
private var groupMenu: some View {
Menu {
ForEach(self.categories, id: \.self) { category in
@ -337,6 +406,7 @@ struct CommandSessionActionsModifier: ViewModifier {
extension View {
func commandSessionActions(
session: OpenClawChatSessionEntry,
mainSessionKey: String = "main",
categories: [String],
isArchived: Bool = false,
isEnabled: Bool = true,
@ -346,6 +416,7 @@ extension View {
{
self.modifier(CommandSessionActionsModifier(
session: session,
mainSessionKey: mainSessionKey,
categories: categories,
isArchived: isArchived,
isEnabled: isEnabled,

View file

@ -210,14 +210,16 @@ struct CommandCenterTab: View {
}
private var overviewSessions: [OpenClawChatSessionEntry] {
Self.visibleOverviewSessions(self.dashboardModel.sessions)
Self.visibleOverviewSessions(self.dashboardModel.sessions, now: self.dashboardModel.now)
}
static func visibleOverviewSessions(
_ sessions: [OpenClawChatSessionEntry]) -> [OpenClawChatSessionEntry]
_ sessions: [OpenClawChatSessionEntry],
now: Date = .now) -> [OpenClawChatSessionEntry]
{
sessions.filter {
$0.archived != true && !ChatSessionSidebarModel.isHiddenInternalSession($0.key)
SessionStatusScope.active.includes($0, at: now) &&
!ChatSessionSidebarModel.isHiddenInternalSession($0.key)
}
}
@ -392,6 +394,7 @@ struct CommandCenterTab: View {
.buttonStyle(.plain)
.commandSessionActions(
session: session,
mainSessionKey: self.appModel.defaultChatSessionKey,
categories: self.sessionCategories,
isEnabled: self.sessionControlsAvailable,
canArchive: ChatSessionSidebarModel.canArchiveSession(
@ -521,7 +524,8 @@ struct CommandCenterTab: View {
private var effectiveRecentChatSessions: [OpenClawChatSessionEntry] {
self.dashboardModel.sessions.filter {
Self.isRecentChatSession($0.key, defaultSessionKey: self.appModel.defaultChatSessionKey)
SessionStatusScope.active.includes($0, at: self.dashboardModel.now) &&
Self.isRecentChatSession($0.key, defaultSessionKey: self.appModel.defaultChatSessionKey)
}
}
@ -567,13 +571,14 @@ struct CommandCenterTab: View {
static func sessionWorkItem(
for session: OpenClawChatSessionEntry,
currentSessionKey: String) -> WorkItem
currentSessionKey: String,
now: Date = .now) -> WorkItem
{
let isCurrent = session.key == currentSessionKey
return WorkItem(
icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
title: Self.sessionTitle(session),
detail: Self.sessionDetail(session),
detail: Self.sessionDetail(session, now: now),
state: isCurrent ? "open" : "recent",
trailing: "chat",
color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok,
@ -636,10 +641,15 @@ struct CommandCenterTab: View {
.joined(separator: " ")
}
static func sessionDetail(_ session: OpenClawChatSessionEntry) -> String {
static func sessionDetail(_ session: OpenClawChatSessionEntry, now: Date = .now) -> String {
if session.archived != true, session.isSnoozed(at: now), let milliseconds = session.snoozedUntil {
let wake = OpenClawChatSessionSnooze.wakeDescription(
Date(timeIntervalSince1970: milliseconds / 1000), now: now)
return String(format: String(localized: "Wakes %@"), wake)
}
let activityAt = session.lastActivityAt ?? session.updatedAt
if let activityAt, activityAt > 0 {
return self.relativeTimeText(forMilliseconds: activityAt)
return self.relativeTimeText(forMilliseconds: activityAt, relativeTo: now)
}
return session.key
}
@ -722,6 +732,32 @@ struct CommandCenterTab: View {
}
}
enum SessionStatusScope: String, CaseIterable {
case active
case snoozed
case archived
static func available(isConnected: Bool) -> [Self] {
isConnected ? self.allCases : [.active, .snoozed]
}
var title: String {
switch self {
case .active: String(localized: "Active")
case .snoozed: String(localized: "Snoozed")
case .archived: String(localized: "Archived")
}
}
func includes(_ session: OpenClawChatSessionEntry, at now: Date) -> Bool {
switch self {
case .active: session.archived != true && !session.isSnoozed(at: now)
case .snoozed: session.archived != true && session.isSnoozed(at: now)
case .archived: session.archived == true
}
}
}
struct CommandSessionsScreen: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.dismiss) private var dismiss
@ -736,7 +772,8 @@ struct CommandSessionsScreen: View {
@State private var sessions: [OpenClawChatSessionEntry] = []
@State private var isLoading = false
@State private var loadErrorText: String?
@State private var showArchived = false
@State private var statusScope: SessionStatusScope = .active
@State private var now: Date = .now
@State private var knownGroups = SessionGroupStore.load()
@State private var groupEditor: GroupEditor?
@State private var groupDraftText = ""
@ -763,6 +800,23 @@ struct CommandSessionsScreen: View {
.task(id: self.refreshID) {
await self.refreshSessions()
}
.onChange(of: self.availableStatusScopes, initial: true) { _, scopes in
if !scopes.contains(self.statusScope) {
self.statusScope = .active
}
}
.task(id: self.sessions) {
self.now = .now
while let wake = OpenClawChatSessionSnooze.nextWake(in: self.sessions, now: self.now) {
do {
try await Task.sleep(for: .seconds(max(0, wake.timeIntervalSinceNow) + 0.001))
try Task.checkCancellation()
} catch {
return
}
self.now = .now
}
}
.alert(self.groupEditorTitle, isPresented: self.groupEditorBinding) {
TextField("Group name", text: self.$groupDraftText)
.font(OpenClawType.body)
@ -825,28 +879,38 @@ struct CommandSessionsScreen: View {
ProCard(padding: 0) {
VStack(spacing: 0) {
HStack(spacing: 8) {
Text(self.showArchived
? LocalizedStringKey("Archived sessions")
: LocalizedStringKey("Recent sessions"))
Text(verbatim: self.panelTitle)
.font(OpenClawType.subheadBold)
Spacer(minLength: 8)
if self.isLoading {
ProgressView()
.controlSize(.small)
}
if self.sessionControlsAvailable {
Toggle(isOn: self.$showArchived) {
Text("Show Archived")
.font(OpenClawType.captionMedium)
}
.toggleStyle(.switch)
.controlSize(.mini)
}
}
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 3)
if self.appModel.isCommandSessionListAvailable || !self.sessions.isEmpty {
Picker(selection: self.$statusScope) {
ForEach(self.availableStatusScopes, id: \.self) { scope in
Text(verbatim: scope.title)
.font(OpenClawType.captionMedium)
.tag(scope)
.accessibilityLabel(scope == .archived
? String(localized: "Show Archived") : scope.title)
.accessibilityIdentifier("Sessions.Status.\(scope.rawValue)")
}
} label: {
Text("Session status")
.font(OpenClawType.captionMedium)
}
.pickerStyle(.segmented)
.accessibilityIdentifier("Sessions.StatusScope")
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
if let loadErrorText {
CommandEmptyStateRow(
icon: "exclamationmark.triangle.fill",
@ -888,9 +952,11 @@ struct CommandSessionsScreen: View {
private var headerDetail: String {
if self.isLoading, self.sessions.isEmpty {
return self.showArchived
? String(localized: "Loading archived sessions")
: String(localized: "Loading recent sessions")
switch self.statusScope {
case .active: return String(localized: "Loading recent sessions")
case .snoozed: return String(localized: "Loading snoozed sessions")
case .archived: return String(localized: "Loading archived sessions")
}
}
let count = self.visibleSessions.count
if count == 0 {
@ -905,9 +971,8 @@ struct CommandSessionsScreen: View {
.filter { CommandCenterTab.isRecentChatSession(
$0.key,
defaultSessionKey: self.appModel.defaultChatSessionKey) }
// Gate on the entry's own archived flag so a stale pre-toggle list can
// never render active sessions with archived-only actions mid-refresh.
.filter { self.showArchived ? $0.archived == true : $0.archived != true }
// A stale list must still obey the selected scope while its replacement loads.
.filter { self.statusScope.includes($0, at: self.now) }
}
private var sessionSections: [CommandSessionSection] {
@ -922,23 +987,39 @@ struct CommandSessionsScreen: View {
!self.appModel.isLocalChatFixtureEnabled && self.appModel.isOperatorGatewayConnected
}
private var availableStatusScopes: [SessionStatusScope] {
SessionStatusScope.available(isConnected: self.appModel.isOperatorGatewayConnected)
}
private var emptyTitle: String {
guard self.appModel.isCommandSessionListAvailable else {
return String(localized: "Gateway offline")
}
return self.showArchived
? String(localized: "No archived sessions")
: String(localized: "No recent sessions")
switch self.statusScope {
case .active: return String(localized: "No recent sessions")
case .snoozed: return String(localized: "No snoozed sessions")
case .archived: return String(localized: "No archived sessions")
}
}
private var emptyDetail: String {
self.showArchived
? String(localized: "Archived sessions will appear here.")
: String(localized: "Start a chat and it will appear here.")
switch self.statusScope {
case .active: String(localized: "Start a chat and it will appear here.")
case .snoozed: String(localized: "Snoozed sessions will appear here until they wake.")
case .archived: String(localized: "Archived sessions will appear here.")
}
}
private var panelTitle: String {
switch self.statusScope {
case .active: String(localized: "Recent sessions")
case .snoozed: String(localized: "Snoozed sessions")
case .archived: String(localized: "Archived sessions")
}
}
private var refreshID: String {
"\(self.appModel.chatViewModelIdentityID):\(self.showArchived)"
"\(self.appModel.chatViewModelIdentityID):\(self.statusScope.rawValue)"
}
@ViewBuilder
@ -1073,7 +1154,8 @@ struct CommandSessionsScreen: View {
private func sessionRow(_ session: OpenClawChatSessionEntry) -> some View {
let item = CommandCenterTab.sessionWorkItem(
for: session,
currentSessionKey: self.appModel.chatSessionKey)
currentSessionKey: self.appModel.chatSessionKey,
now: self.now)
return Button {
self.openSessionKey(session.key)
} label: {
@ -1082,6 +1164,7 @@ struct CommandSessionsScreen: View {
.buttonStyle(.plain)
.commandSessionActions(
session: session,
mainSessionKey: self.appModel.defaultChatSessionKey,
categories: self.sessionCategories,
isArchived: session.archived == true,
isEnabled: self.sessionControlsAvailable,
@ -1090,7 +1173,7 @@ struct CommandSessionsScreen: View {
mainSessionKey: self.appModel.defaultChatSessionKey),
actions: .gateway(
session: session,
archivesSession: { !self.showArchived && session.archived != true },
archivesSession: { self.statusScope != .archived && session.archived != true },
performMutation: self.performMutation,
fork: { self.forkSession(session) }))
}
@ -1136,24 +1219,31 @@ struct CommandSessionsScreen: View {
// Pick up groups stored by other surfaces (for example the per-session
// New Group editor) alongside the fresh session list.
self.knownGroups = SessionGroupStore.load()
let requestsArchived = self.showArchived
let requestedScope = self.statusScope
let requestsArchived = requestedScope == .archived
let sourceGatewayID = self.appModel.chatTranscriptCacheGatewayID
let sourceAgentID = self.appModel.chatDeliveryAgentId
self.isLoading = true
self.loadErrorText = nil
defer { self.isLoading = false }
defer {
if requestedScope == self.statusScope { self.isLoading = false }
}
do {
let roster = try await self.appModel.loadChatSessionRoster(
limit: CommandCenterTab.recentSessionsFetchLimit,
archived: requestsArchived)
guard requestsArchived == self.showArchived else { return }
guard !Task.isCancelled, requestedScope == self.statusScope else { return }
self.sessions = roster.sessions
} catch is CancellationError {
return
} catch {
guard requestsArchived == self.showArchived else { return }
self.sessions = requestsArchived ? [] : await self.appModel.loadCachedChatSessions(
guard !Task.isCancelled, requestedScope == self.statusScope else { return }
let cached = requestsArchived ? [] : await self.appModel.loadCachedChatSessions(
gatewayID: sourceGatewayID,
agentID: sourceAgentID)
guard !Task.isCancelled, requestedScope == self.statusScope else { return }
self.sessions = cached
self.loadErrorText = self.sessions.isEmpty ? "Try again after the gateway reconnects." : nil
}
}

View file

@ -679,6 +679,7 @@ struct RootSidebar: View {
}
.commandSessionActions(
session: session,
mainSessionKey: self.resolvedMainSessionKey,
categories: self.sessionCategories,
isEnabled: self.appModel.isOperatorGatewayConnected,
canArchive: ChatSessionSidebarModel.canArchiveSession(

View file

@ -184,7 +184,11 @@ final class RootSidebarModel {
let generation: UInt64
}
private(set) var sessions: [OpenClawChatSessionEntry] = []
private(set) var sessions: [OpenClawChatSessionEntry] = [] {
didSet { self.updateSnoozeWakeDeadline() }
}
private(set) var now: Date = .now
private(set) var usage: CostUsageSummaryLite?
private(set) var cronJobs: [CronJob] = []
private(set) var isRefreshing = false
@ -196,6 +200,8 @@ final class RootSidebarModel {
private var sessionObserverGeneration: UInt64 = 0
private var sessionObserverDeclaration: SessionObserverDeclaration<GatewayNodeSessionRoute>?
private var sessionObserverSync: (id: UUID, task: Task<Void, Never>)?
@ObservationIgnored private var snoozeWakeUpdatesActive = false
@ObservationIgnored private var snoozeWakeTask: Task<Void, Never>?
var failedCronJobCount: Int {
self.cronJobs.count { Self.isFailedCronJob($0) }
@ -216,17 +222,72 @@ final class RootSidebarModel {
groups: [OpenClawChatSessionGroup],
sessionRoutingContract: String? = nil) -> [ChatSessionSidebarModel.Section]
{
ChatSessionSidebarModel.sections(
Self.sections(
sessions: self.sessions,
query: query,
currentSessionKey: currentSessionKey,
mainSessionKey: mainSessionKey,
activeAgentID: activeAgentID,
groups: groups,
sessionRoutingContract: sessionRoutingContract,
now: self.now)
}
static func sections(
sessions: [OpenClawChatSessionEntry],
query: String,
currentSessionKey: String,
mainSessionKey: String,
activeAgentID: String?,
groups: [OpenClawChatSessionGroup],
sessionRoutingContract: String? = nil,
now: Date = .now) -> [ChatSessionSidebarModel.Section]
{
let selectedSessionKey = ChatSessionSidebarModel.selectedSessionKey(
sessions: sessions,
currentSessionKey: currentSessionKey,
mainSessionKey: mainSessionKey,
activeAgentID: activeAgentID,
sessionRoutingContract: sessionRoutingContract)
let selectedIsSnoozed = sessions.contains {
$0.key == selectedSessionKey && $0.isSnoozed(at: now)
}
return ChatSessionSidebarModel.sections(
sessions: sessions.filter { !$0.isSnoozed(at: now) },
// The shared sidebar otherwise creates a placeholder for a missing selected row.
currentSessionKey: selectedIsSnoozed ? "" : currentSessionKey,
mainSessionKey: mainSessionKey,
activeAgentID: activeAgentID,
groups: groups,
excludesMainSession: true,
query: query,
sessionRoutingContract: sessionRoutingContract)
}
func setSnoozeWakeUpdatesActive(_ active: Bool) {
self.snoozeWakeUpdatesActive = active
self.updateSnoozeWakeDeadline()
}
private func updateSnoozeWakeDeadline() {
self.snoozeWakeTask?.cancel()
self.snoozeWakeTask = nil
self.now = .now
guard self.snoozeWakeUpdatesActive,
let wakeAt = OpenClawChatSessionSnooze.nextWake(in: self.sessions, now: self.now)
else { return }
self.snoozeWakeTask = Task { @MainActor [weak self] in
do {
try await Task.sleep(for: .seconds(max(0, wakeAt.timeIntervalSinceNow) + 0.001))
} catch {
return
}
guard !Task.isCancelled else { return }
self?.updateSnoozeWakeDeadline()
}
}
func refresh(appModel: NodeAppModel) async {
self.rosterGeneration &+= 1
let rosterGeneration = self.rosterGeneration

View file

@ -585,6 +585,7 @@ struct RootTabs: View {
private func rootAppearLifecycle(_ content: some View) -> some View {
content
.onAppear {
self.sidebarModel.setSnoozeWakeUpdatesActive(self.scenePhase == .active)
self.updateIdleTimer()
self.evaluateOnboardingPresentation(force: false)
self.maybeAutoOpenSettings()
@ -597,6 +598,7 @@ struct RootTabs: View {
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
.onChange(of: self.appModel.talkMode.isEnabled) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, newValue in
self.sidebarModel.setSnoozeWakeUpdatesActive(newValue == .active)
self.updateIdleTimer()
guard newValue == .active else {
self.clearVoiceWakeToast()
@ -609,6 +611,7 @@ struct RootTabs: View {
}
}
.onDisappear {
self.sidebarModel.setSnoozeWakeUpdatesActive(false)
UIApplication.shared.isIdleTimerDisabled = false
self.clearVoiceWakeToast()
}

View file

@ -1,8 +1,39 @@
import Testing
import Foundation
import OpenClawChatUI
import Testing
@testable import OpenClaw
struct CommandCenterTabSessionFilterTests {
@Test func `cached browsing scopes remain available offline while archives require a connection`() {
#expect(SessionStatusScope.available(isConnected: false) == [.active, .snoozed])
#expect(SessionStatusScope.available(isConnected: true) == [.active, .snoozed, .archived])
}
@Test func `status scopes separate future snoozes and archives while expired snoozes return to active`() throws {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let entries = try JSONDecoder().decode([OpenClawChatSessionEntry].self, from: Data("""
[
{"key":"awake"},
{"key":"later","snoozedUntil":1800000060000},
{"key":"expired","snoozedUntil":1799999999999},
{"key":"boundary","snoozedUntil":1800000000000},
{"key":"archived","archived":true},
{"key":"archived-snooze","archived":true,"snoozedUntil":1800000060000}
]
""".utf8))
let active = entries.filter { SessionStatusScope.active.includes($0, at: now) }
#expect(active.map(\.key) == ["awake", "expired", "boundary"])
#expect(entries.filter { SessionStatusScope.snoozed.includes($0, at: now) }.map(\.key) == ["later"])
#expect(entries.filter { SessionStatusScope.archived.includes($0, at: now) }.map(\.key) == [
"archived", "archived-snooze",
])
let sections = CommandSessionGrouping.sections(from: active)
#expect(sections.flatMap(\.entries).map(\.key).sorted() == ["awake", "boundary", "expired"])
#expect(CommandCenterTab.sessionDetail(entries[1], now: now).hasPrefix("Wakes "))
#expect(!CommandCenterTab.sessionDetail(entries[2], now: now).hasPrefix("Wakes "))
}
@Test func `hides direct agent device sessions`() {
#expect(!CommandCenterTab.isRecentChatSession("main", defaultSessionKey: "main"))
#expect(!CommandCenterTab.isRecentChatSession("agent:main:main", defaultSessionKey: "main"))

View file

@ -2995,6 +2995,8 @@ private func waitUntil(
var ownerlessPrefixed = session
ownerlessPrefixed.key = "agent:main:legacy"
ownerlessPrefixed.agentId = nil
let now = Date(timeIntervalSince1970: 1_800_000_000)
session.snoozedUntil = now.addingTimeInterval(3600).timeIntervalSince1970 * 1000
appModel.gatewayDefaultAgentId = "main"
await appModel.storeCachedChatSessions(
@ -3018,6 +3020,14 @@ private func waitUntil(
matchingBare,
expectedPrefixed,
])
#expect(!appModel.isOperatorGatewayConnected)
let roster = try await appModel.loadChatSessionRoster(limit: 200)
#expect(roster.isCached)
#expect(roster.sessions == cachedSessions)
#expect(SessionStatusScope.available(isConnected: appModel.isOperatorGatewayConnected).contains(.snoozed))
let snoozed = roster.sessions.filter { SessionStatusScope.snoozed.includes($0, at: now) }
#expect(snoozed == [session])
#expect(CommandCenterTab.sessionDetail(session, now: now).hasPrefix("Wakes "))
appModel.selectedAgentId = "work"
#expect(await appModel.loadCachedChatSessions(gatewayID: gatewayA, agentID: "work") == [workGlobal])
}

View file

@ -569,6 +569,29 @@ struct IOSGatewayChatTransportTests {
}
}
@Test func `snooze and wake carry the observed session identity`() async throws {
try await self.withSessionTransport { transport, recorder in
try await transport.patchSession(
key: "global",
expectedSessionID: " session-a ",
snoozedUntil: .until(Date(timeIntervalSince1970: 2_000_000_000.125)))
let erasedTransport: any OpenClawChatTransport = transport
try await erasedTransport.patchSession(
key: "global",
expectedSessionID: "session-a",
snoozedUntil: .wake)
let requests = await recorder.all()
try #require(requests.count == 2)
#expect(requests.map(\.method) == ["sessions.patch", "sessions.patch"])
#expect(requests.allSatisfy { $0.params["key"]?.value as? String == "global" })
#expect(requests.allSatisfy { $0.params["agentId"]?.value as? String == "reviewer" })
#expect(requests.allSatisfy { $0.params["expectedSessionId"]?.value as? String == "session-a" })
#expect(requests[0].params["snoozedUntil"]?.value as? Int == 2_000_000_000_125)
#expect(requests[1].params["snoozedUntil"]?.value is NSNull)
}
}
@Test func `thinking changes dispatch through selected agent session target`() async throws {
try await self.withSessionTransport { transport, recorder in
try await transport.setSessionThinking(sessionKey: "global", thinkingLevel: "high")

View file

@ -57,8 +57,7 @@ struct OpenClawTypographyTests {
#expect(support.contains("Label(\"Delete…\""))
#expect(support.contains(".font(OpenClawType.subhead)"))
#expect(support.contains(".font(OpenClawType.subheadSemiBold)"))
#expect(commandCenter.contains("Toggle(isOn: self.$showArchived)"))
#expect(commandCenter.contains("Text(\"Show Archived\")"))
#expect(!commandCenter.contains(".font(."))
#expect(commandCenter.contains(".font(OpenClawType.captionMedium)"))
}

View file

@ -80,15 +80,18 @@ struct RootTabsPresentationTests {
#expect(appModel.consumeNewChatRequest(appModel.newChatRequestID))
}
@Test func `overview session metrics exclude archived and internal sessions`() {
@Test func `overview session metrics exclude snoozed archived and internal sessions`() {
let now = Date(timeIntervalSince1970: 1_750_000_000)
let visible = CommandCenterTab.visibleOverviewSessions([
Self.sessionEntry(key: "main"),
Self.sessionEntry(key: "onboarding"),
Self.sessionEntry(key: "agent:main:onboarding"),
Self.sessionEntry(key: "archived", archived: true),
])
Self.sessionEntry(key: "snoozed", snoozedUntil: now.addingTimeInterval(3600).timeIntervalSince1970 * 1000),
Self.sessionEntry(key: "expired", snoozedUntil: now.timeIntervalSince1970 * 1000),
], now: now)
#expect(visible.map(\.key) == ["main"])
#expect(visible.map(\.key) == ["main", "expired"])
}
@Test func `overview token usage sums known totals and marks stale or missing rows partial`() {
@ -591,20 +594,30 @@ struct RootTabsPresentationTests {
#expect(RootSidebar.sessionAccessibilityValue(isPinned: true, isUnread: true) == "Pinned, Unread")
}
@Test func `iOS sidebar moves pinned sessions ahead of the remaining inventory`() {
let sections = ChatSessionSidebarModel.sections(
@Test(arguments: ["recent", "agent:main:later", "later"])
func `iOS sidebar groups awake sessions without restoring a snoozed selection`(currentSessionKey: String) {
let now = Date(timeIntervalSince1970: 1_750_000_000)
let sections = RootSidebarModel.sections(
sessions: [
Self.sessionEntry(key: "pinned", pinned: true),
Self.sessionEntry(key: "recent"),
Self.sessionEntry(
key: "agent:main:later",
snoozedUntil: now.addingTimeInterval(3600).timeIntervalSince1970 * 1000),
Self.sessionEntry(key: "expired", snoozedUntil: now.timeIntervalSince1970 * 1000),
],
currentSessionKey: "recent",
excludesMainSession: true,
query: "")
query: "",
currentSessionKey: currentSessionKey,
mainSessionKey: "main",
activeAgentID: "main",
groups: [],
now: now)
let layout = RootSidebar.sessionLayout(sections)
#expect(layout.pinnedNodes.map(\.session.key) == ["pinned"])
#expect(layout.sections.map(\.id) == ["recent"])
#expect(Set(layout.sections.flatMap(\.nodes).map(\.session.key)) == ["recent", "expired"])
}
@Test func `sidebar agent badges use canonical identity fallback`() {
@ -1002,6 +1015,7 @@ struct RootTabsPresentationTests {
private static func sessionEntry(
key: String,
archived: Bool? = nil,
snoozedUntil: Double? = nil,
pinned: Bool? = nil,
totalTokens: Int? = nil,
totalTokensFresh: Bool? = nil,
@ -1033,6 +1047,7 @@ struct RootTabsPresentationTests {
contextTokens: contextTokens,
pinned: pinned,
archived: archived,
snoozedUntil: snoozedUntil,
observerDigest: observerDigest,
lastReadAt: lastReadAt,
worktree: worktree)

View file

@ -0,0 +1,96 @@
import UIKit
import XCTest
@MainActor
final class SessionSnoozeUITests: XCTestCase {
func testSessionSnoozeStates() async throws {
let environment = ProcessInfo.processInfo.environment
try XCTSkipUnless(
environment["OPENCLAW_IOS_ATTENTION_FIXTURE_URL"] != nil,
"Requires the isolated synthetic sidebar Gateway")
let fixture = try XCTUnwrap(environment["OPENCLAW_IOS_ATTENTION_FIXTURE_URL"].flatMap(URL.init(string:)))
let setupCode = try XCTUnwrap(environment["OPENCLAW_IOS_LIVE_SETUP_CODE"])
var reset = URLRequest(url: fixture.appendingPathComponent("reset"))
reset.httpMethod = "POST"
let (_, response) = try await URLSession.shared.data(for: reset)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
let app = XCUIApplication()
defer { app.terminate() }
addUIInterruptionMonitor(withDescription: "Local network access") { alert in
guard alert.buttons["Allow"].exists else { return false }
alert.buttons["Allow"].tap()
return true
}
app.launchArguments = [
"--openclaw-reset-onboarding", "--openclaw-initial-tab", "chat",
"--openclaw-initial-destination", "chat", "-AppleLanguages", "(en)",
]
app.launch()
XCTAssertTrue(app.buttons["Continue"].waitForExistence(timeout: 15))
app.buttons["Continue"].tap()
XCTAssertTrue(app.buttons["Connect Manually"].waitForExistence(timeout: 10))
app.buttons["Connect Manually"].tap()
let setupField = app.textFields["Enter setup code"]
XCTAssertTrue(setupField.waitForExistence(timeout: 5))
setupField.tap()
setupField.typeText(setupCode)
app.buttons["Apply"].tap()
XCTAssertTrue(app.staticTexts["You're connected"].waitForExistence(timeout: 60))
app.buttons["Go to Chat"].tap()
XCTAssertTrue(app.staticTexts["Your research workspace is ready."].waitForExistence(timeout: 30))
app.buttons["RootTabs.Sidebar.Show"].tap()
let sessions = app.buttons["All Sessions…"]
XCTAssertTrue(sessions.waitForExistence(timeout: 10))
for _ in 0..<5 where !sessions.isHittable {
app.swipeUp()
}
XCTAssertTrue(sessions.isHittable)
sessions.tap()
XCTAssertTrue(app.staticTexts["Recent sessions"].waitForExistence(timeout: 15))
let active = self.sessionRow("Planning notes", in: app)
let snoozed = self.sessionRow("Follow-up notes", in: app)
XCTAssertTrue(active.waitForExistence(timeout: 10))
XCTAssertTrue(active.isHittable)
XCTAssertFalse(snoozed.exists)
self.capture(app, named: "after-sessions-active")
active.press(forDuration: 1)
let snoozeMenu = app.buttons["Snooze"]
XCTAssertTrue(snoozeMenu.waitForExistence(timeout: 5), app.debugDescription)
snoozeMenu.tap()
let hour = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "In 1 hour ·")).firstMatch
XCTAssertTrue(hour.waitForExistence(timeout: 5), app.debugDescription)
self.capture(app, named: "after-snooze-presets")
app.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.94)).tap()
XCTAssertTrue(hour.waitForNonExistence(timeout: 5))
let snoozedScope = app.segmentedControls.firstMatch.buttons["Snoozed"]
XCTAssertTrue(snoozedScope.waitForExistence(timeout: 5))
snoozedScope.tap()
XCTAssertTrue(snoozed.waitForExistence(timeout: 10), app.debugDescription)
XCTAssertFalse(active.exists)
XCTAssertTrue(snoozed.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Wakes "))
.firstMatch.exists)
self.capture(app, named: "after-sessions-snoozed")
snoozed.press(forDuration: 1)
let wake = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Wake session ·")).firstMatch
XCTAssertTrue(wake.waitForExistence(timeout: 5), app.debugDescription)
self.capture(app, named: "after-wake-menu")
}
private func sessionRow(_ title: String, in app: XCUIApplication) -> XCUIElement {
// The retained sidebar exposes the same title, even while visually hidden.
app.buttons.matching(NSPredicate(
format: "label BEGINSWITH %@ AND label CONTAINS %@", "\(title),", ", chat,")).firstMatch
}
private func capture(_ app: XCUIApplication, named name: String) {
let screenshot = XCTAttachment(screenshot: app.screenshot())
screenshot.name = name
screenshot.lifetime = .keepAlways
add(screenshot)
let hierarchy = XCTAttachment(string: app.debugDescription)
hierarchy.name = "\(name)-hierarchy"
hierarchy.lifetime = .keepAlways
add(hierarchy)
}
}

View file

@ -437,6 +437,7 @@ public enum OpenClawChatGatewayRequests {
color: String?? = nil,
pinned: Bool?,
archived: Bool?,
snoozedUntil: OpenClawChatSnoozePatch? = nil,
unreadPatch: OpenClawChatSessionUnreadPatch?) -> OpenClawChatGatewayRequest
{
var params = self.sessionParams(sessionKey: sessionKey, agentID: agentID)
@ -452,6 +453,14 @@ public enum OpenClawChatGatewayRequests {
}
params["pinned"] = pinned.map(AnyCodable.init)
params["archived"] = archived.map(AnyCodable.init)
if let snoozedUntil {
switch snoozedUntil {
case let .until(wakeAt):
params["snoozedUntil"] = AnyCodable(Int(wakeAt.timeIntervalSince1970 * 1000))
case .wake:
params["snoozedUntil"] = AnyCodable(NSNull())
}
}
switch unreadPatch {
case .markUnread:
params["unread"] = AnyCodable(true)

View file

@ -0,0 +1,82 @@
import Foundation
public enum OpenClawChatSnoozePatch: Sendable {
case until(Date)
case wake
}
public enum OpenClawChatSessionSnooze: Sendable {
public struct Preset: Sendable {
public let id: String
public let title: String
public let wakeAt: Date
public init(id: String, title: String, wakeAt: Date) {
self.id = id
self.title = title
self.wakeAt = wakeAt
}
}
public static func presets(now: Date = .now, calendar: Calendar = .current) -> [Preset] {
guard let oneHour = calendar.date(byAdding: .hour, value: 1, to: now),
let threeHours = calendar.date(byAdding: .hour, value: 3, to: now)
else { return [] }
var presets = [
Preset(id: "hour", title: String(localized: "In 1 hour"), wakeAt: oneHour),
Preset(id: "three-hours", title: String(localized: "In 3 hours"), wakeAt: threeHours),
]
let today = calendar.startOfDay(for: now)
if let evening = calendar.date(bySettingHour: 18, minute: 0, second: 0, of: today), evening > oneHour {
presets.append(Preset(id: "evening", title: String(localized: "This evening"), wakeAt: evening))
}
guard let tomorrow = calendar.date(byAdding: .day, value: 1, to: today),
let tomorrowMorning = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: tomorrow)
else { return presets }
presets.append(Preset(id: "tomorrow", title: String(localized: "Tomorrow"), wakeAt: tomorrowMorning))
let daysUntilMonday = (9 - calendar.component(.weekday, from: today)) % 7
if let monday = calendar.date(byAdding: .day, value: daysUntilMonday == 0 ? 7 : daysUntilMonday, to: today),
let mondayMorning = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: monday),
mondayMorning != tomorrowMorning
{
presets.append(Preset(id: "next-week", title: String(localized: "Next week"), wakeAt: mondayMorning))
}
return presets
}
public static func wakeDescription(
_ wakeAt: Date,
now: Date = .now,
calendar: Calendar = .current) -> String
{
var style = Date.FormatStyle(
locale: calendar.locale ?? .current,
calendar: calendar,
timeZone: calendar.timeZone)
.hour().minute()
let time = wakeAt.formatted(style)
if calendar.isDate(wakeAt, inSameDayAs: now) {
return time
}
if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now),
calendar.isDate(wakeAt, inSameDayAs: tomorrow)
{
return String(format: String(localized: "tomorrow %@"), time)
}
if let nextWeek = calendar.date(byAdding: .day, value: 7, to: now), wakeAt > now, wakeAt <= nextWeek {
style = style.weekday(.abbreviated)
} else {
style = style.month(.abbreviated).day()
}
return wakeAt.formatted(style)
}
public static func nextWake(in entries: [OpenClawChatSessionEntry], now: Date = .now) -> Date? {
entries.lazy
.filter { $0.isSnoozed(at: now) }
.compactMap(\.snoozedUntil)
.min()
.map { Date(timeIntervalSince1970: $0 / 1000) }
}
}

View file

@ -613,6 +613,8 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
public var pinnedAt: Double?
public var archived: Bool?
public var archivedAt: Double?
public var snoozedUntil: Double?
public var snoozedAt: Double?
public var unread: Bool?
public var agentStatus: OpenClawChatSessionAgentStatus?
public var observerDigest: OpenClawChatSessionObserverDigest?
@ -709,6 +711,8 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
pinnedAt: Double? = nil,
archived: Bool? = nil,
archivedAt: Double? = nil,
snoozedUntil: Double? = nil,
snoozedAt: Double? = nil,
unread: Bool? = nil,
agentStatus: OpenClawChatSessionAgentStatus? = nil,
observerDigest: OpenClawChatSessionObserverDigest? = nil,
@ -759,6 +763,8 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
self.pinnedAt = pinnedAt
self.archived = archived
self.archivedAt = archivedAt
self.snoozedUntil = snoozedUntil
self.snoozedAt = snoozedAt
self.unread = unread
self.agentStatus = agentStatus
self.observerDigest = observerDigest
@ -840,6 +846,11 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
public var isArchived: Bool {
self.archived == true
}
public func isSnoozed(at now: Date = .now) -> Bool {
guard let snoozedUntil, snoozedUntil.isFinite else { return false }
return snoozedUntil / 1000 > now.timeIntervalSince1970
}
}
/// Client-side session list policy shared by every session list surface.

View file

@ -360,7 +360,9 @@ public struct OpenClawChatSessionMutationRouteLease: Sendable {
_ archived: Bool?,
_ unread: Bool?) async throws -> Void
public typealias DeleteSession = @Sendable (_ key: String) async throws -> Void
public typealias PatchTarget = @Sendable (
public typealias DeleteTarget = @Sendable (_ target: OpenClawChatSessionTarget) async throws -> Void
private typealias SnoozePatchTarget = @Sendable (
_ target: OpenClawChatSessionTarget,
_ expectedSessionID: String?,
_ expectedMarkedUnreadAt: Double??,
@ -369,10 +371,10 @@ public struct OpenClawChatSessionMutationRouteLease: Sendable {
_ color: String??,
_ pinned: Bool?,
_ archived: Bool?,
_ snoozedUntil: OpenClawChatSnoozePatch?,
_ unread: Bool?) async throws -> OpenClawChatSessionPatchReceipt?
public typealias DeleteTarget = @Sendable (_ target: OpenClawChatSessionTarget) async throws -> Void
private let patchSessionImpl: PatchTarget
private let patchSessionImpl: SnoozePatchTarget
private let deleteSessionImpl: DeleteTarget?
public init(
@ -381,8 +383,10 @@ public struct OpenClawChatSessionMutationRouteLease: Sendable {
{
self
.patchSessionImpl =
{ target, expectedID, expectedUnreadAt, label, category, color, pinned, archived, unread in
guard target.agentID == nil else { throw OpenClawChatTransportSendError.notDispatched }
{ target, expectedID, expectedUnreadAt, label, category, color, pinned, archived, snoozedUntil, unread in
guard target.agentID == nil, snoozedUntil == nil else {
throw OpenClawChatTransportSendError.notDispatched
}
try await patchSession(
target.sessionKey, expectedID, expectedUnreadAt, label, category, color, pinned, archived, unread)
return nil
@ -397,8 +401,8 @@ public struct OpenClawChatSessionMutationRouteLease: Sendable {
}
}
public init(patchTarget: @escaping PatchTarget, deleteTarget: DeleteTarget?) {
self.patchSessionImpl = patchTarget
private init(patchSnoozeTarget: @escaping SnoozePatchTarget, deleteTarget: DeleteTarget?) {
self.patchSessionImpl = patchSnoozeTarget
self.deleteSessionImpl = deleteTarget
}
@ -411,7 +415,7 @@ public struct OpenClawChatSessionMutationRouteLease: Sendable {
request: @escaping @Sendable (OpenClawChatGatewayRequest) async throws -> Data)
{
self.init(
patchTarget: { requested, expectedID, expectedUnreadAt, label, category, color, pinned, archived, unread in
patchSnoozeTarget: { requested, id, unreadAt, label, category, color, pinned, archived, snooze, unread in
guard unread != false || unreadAckContract != nil else {
throw OpenClawChatTransportSendError.notDispatched
}
@ -419,15 +423,16 @@ public struct OpenClawChatSessionMutationRouteLease: Sendable {
let data = try await request(OpenClawChatGatewayRequests.patchSession(
sessionKey: target.sessionKey,
agentID: target.agentID,
expectedSessionID: expectedID,
expectedSessionID: id,
label: label,
category: category,
color: color,
pinned: pinned,
archived: archived,
snoozedUntil: snooze,
unreadPatch: .routed(
unread: unread,
expectedMarkedUnreadAt: expectedUnreadAt,
expectedMarkedUnreadAt: unreadAt,
supportsReadContract: unreadAckContract == true)))
guard receivesPatchReceipts else { return nil }
var receipt = try JSONDecoder().decode(OpenClawChatSessionPatchReceipt.self, from: data)
@ -453,6 +458,7 @@ public struct OpenClawChatSessionMutationRouteLease: Sendable {
color: String?? = nil,
pinned: Bool? = nil,
archived: Bool? = nil,
snoozedUntil: OpenClawChatSnoozePatch? = nil,
unread: Bool? = nil) async throws -> OpenClawChatSessionPatchReceipt?
{
try await self.patchSessionImpl(
@ -464,6 +470,7 @@ public struct OpenClawChatSessionMutationRouteLease: Sendable {
color,
pinned,
archived,
snoozedUntil,
unread)
}
@ -1290,6 +1297,32 @@ extension OpenClawChatTransport {
throw Self.unsupportedOperation("sessions.patch not supported by this transport")
}
public func patchSession(
key: String,
expectedSessionID: String? = nil,
label: String?? = nil,
category: String?? = nil,
color: String?? = nil,
pinned: Bool? = nil,
archived: Bool? = nil,
snoozedUntil: OpenClawChatSnoozePatch?,
unread: Bool? = nil) async throws
{
guard let lease = await self.acquireSessionMutationRouteLease() else {
throw OpenClawChatTransportSendError.notDispatched
}
try await lease.patchSession(
key: key,
expectedSessionID: expectedSessionID,
label: label,
category: category,
color: color,
pinned: pinned,
archived: archived,
snoozedUntil: snoozedUntil,
unread: unread)
}
public func deleteSession(key _: String) async throws {
throw Self.unsupportedOperation("sessions.delete not supported by this transport")
}

View file

@ -0,0 +1,169 @@
import Foundation
import Testing
@testable import OpenClawChatUI
struct ChatSessionSnoozeTests {
@Test func `weekday morning presets preserve local clock times and next Monday`() throws {
let now = try Self.date("2026-09-29T10:00:00-07:00")
let presets = OpenClawChatSessionSnooze.presets(now: now, calendar: Self.calendar)
#expect(presets.map(\.id) == ["hour", "three-hours", "evening", "tomorrow", "next-week"])
#expect(presets.map(\.title) == ["In 1 hour", "In 3 hours", "This evening", "Tomorrow", "Next week"])
#expect(try presets.map(\.wakeAt) == ([
"2026-09-29T11:00:00-07:00",
"2026-09-29T13:00:00-07:00",
"2026-09-29T18:00:00-07:00",
"2026-09-30T09:00:00-07:00",
"2026-10-05T09:00:00-07:00",
].map(Self.date)))
}
@Test(arguments: ["2026-09-29T17:00:00-07:00", "2026-09-29T17:30:00-07:00"])
func `evening is omitted when it is no more than an hour away`(timestamp: String) throws {
let now = try Self.date(timestamp)
let presets = OpenClawChatSessionSnooze.presets(now: now, calendar: Self.calendar)
#expect(presets.map(\.id) == ["hour", "three-hours", "tomorrow", "next-week"])
let tomorrow = try Self.date("2026-09-30T09:00:00-07:00")
#expect(presets.first(where: { $0.id == "tomorrow" })?.wakeAt == tomorrow)
}
@Test func `Sunday omits the duplicate Monday preset and Monday selects the following week`() throws {
let sunday = try OpenClawChatSessionSnooze.presets(
now: Self.date("2026-09-27T10:00:00-07:00"), calendar: Self.calendar)
#expect(sunday.map(\.id) == ["hour", "three-hours", "evening", "tomorrow"])
#expect(try sunday.last?.wakeAt == (Self.date("2026-09-28T09:00:00-07:00")))
let monday = try OpenClawChatSessionSnooze.presets(
now: Self.date("2026-09-28T08:00:00-07:00"), calendar: Self.calendar)
#expect(monday.last?.id == "next-week")
#expect(try monday.last?.wakeAt == (Self.date("2026-10-05T09:00:00-07:00")))
}
@Test(arguments: [
("2026-03-08T01:30:00-08:00", [
"2026-03-08T03:30:00-07:00", "2026-03-08T05:30:00-07:00",
"2026-03-08T18:00:00-07:00", "2026-03-09T09:00:00-07:00",
]),
("2026-11-01T00:30:00-07:00", [
"2026-11-01T01:30:00-07:00", "2026-11-01T02:30:00-08:00",
"2026-11-01T18:00:00-08:00", "2026-11-02T09:00:00-08:00",
]),
])
func `DST transition presets use elapsed hours and calendar mornings`(
timestamp: String,
expected: [String]) throws
{
let presets = try OpenClawChatSessionSnooze.presets(now: Self.date(timestamp), calendar: Self.calendar)
#expect(try presets.map(\.wakeAt) == (expected.map(Self.date)))
}
@Test func `tomorrow does not skip a calendar day before the spring transition`() throws {
let presets = try OpenClawChatSessionSnooze.presets(
now: Self.date("2026-03-07T23:30:00-08:00"), calendar: Self.calendar)
let tomorrow = try Self.date("2026-03-08T09:00:00-07:00")
#expect(presets.first(where: { $0.id == "tomorrow" })?.wakeAt == tomorrow)
#expect(try presets.last?.wakeAt == (Self.date("2026-03-09T09:00:00-07:00")))
}
@Test func `snoozed status accepts only finite future milliseconds`() {
let now = Date(timeIntervalSince1970: 1000)
let cases: [(Double?, Bool)] = [
(nil, false), (999_999, false), (1_000_000, false), (1_000_001, true),
(.nan, false), (.infinity, false), (-.infinity, false),
]
for (wakeAt, expected) in cases {
#expect(Self.entry(snoozedUntil: wakeAt).isSnoozed(at: now) == expected)
}
}
@Test func `wake descriptions distinguish today tomorrow next week and later dates`() throws {
let now = try Self.date("2026-09-29T10:00:00-07:00")
func description(_ timestamp: String) throws -> String {
try OpenClawChatSessionSnooze.wakeDescription(
Self.date(timestamp), now: now, calendar: Self.calendar)
.replacingOccurrences(of: "\u{202F}", with: " ")
.replacingOccurrences(of: "\u{00A0}", with: " ")
}
#expect(try description("2026-09-29T18:00:00-07:00") == "6:00 PM")
#expect(try description("2026-09-30T09:00:00-07:00") == "tomorrow 9:00 AM")
let weekday = try description("2026-10-01T09:00:00-07:00")
#expect(weekday.contains("Thu"))
#expect(weekday.contains("9:00 AM"))
#expect(!weekday.contains("Oct"))
let boundary = try description("2026-10-06T10:00:00-07:00")
#expect(boundary.contains("Tue"))
#expect(boundary.contains("10:00 AM"))
#expect(!boundary.contains("Oct"))
let later = try description("2026-10-06T10:01:00-07:00")
#expect(later.contains("Oct 6"))
#expect(later.contains("10:01 AM"))
#expect(!later.contains("Tue"))
}
@Test func `next wake ignores expired absent and invalid deadlines`() {
let now = Date(timeIntervalSince1970: 1000)
let deadlines: [Double?] = [nil, .infinity, .nan, 999_999, 1_000_000, 1_005_000, 1_002_000]
let entries = deadlines.map { Self.entry(snoozedUntil: $0) }
#expect(OpenClawChatSessionSnooze.nextWake(in: entries, now: now) == Date(timeIntervalSince1970: 1002))
#expect(OpenClawChatSessionSnooze.nextWake(in: entries, now: Date(timeIntervalSince1970: 1005)) == nil)
#expect(OpenClawChatSessionSnooze.nextWake(in: [], now: now) == nil)
}
@Test func `session list decoding preserves snooze facts and omitted fields`() throws {
let response = try OpenClawChatGatewayPayloadCodec.decodeSessionsList(Data(#"""
{"sessions":[
{"key":"agent:main:later","snoozedUntil":1800000000125,"snoozedAt":1799996400125},
{"key":"agent:main:active"}
]}
"""#.utf8), agentID: nil)
try #require(response.sessions.count == 2)
#expect(response.sessions[0].snoozedUntil == 1_800_000_000_125)
#expect(response.sessions[0].snoozedAt == 1_799_996_400_125)
#expect(response.sessions[1].snoozedUntil == nil)
#expect(response.sessions[1].snoozedAt == nil)
}
@Test func `snooze patches encode integer milliseconds null and the observed identity`() throws {
let cases: [(OpenClawChatSnoozePatch?, String)] = [
(
.until(Date(timeIntervalSince1970: 1_800_000_000.125)),
#"{"expectedSessionId":"session-a","key":"agent:main:work","snoozedUntil":1800000000125}"#),
(.wake, #"{"expectedSessionId":"session-a","key":"agent:main:work","snoozedUntil":null}"#),
(nil, #"{"expectedSessionId":"session-a","key":"agent:main:work"}"#),
]
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
for (patch, expected) in cases {
let request = OpenClawChatGatewayRequests.patchSession(
sessionKey: "agent:main:work",
agentID: nil,
expectedSessionID: "session-a",
label: nil,
category: nil,
pinned: nil,
archived: nil,
snoozedUntil: patch,
unreadPatch: nil)
#expect(request.method == "sessions.patch")
#expect(try encoder.encode(request.params) == Data(expected.utf8))
}
}
private static var calendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.locale = Locale(identifier: "en_US_POSIX")
calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")!
return calendar
}
private static func date(_ timestamp: String) throws -> Date {
try Date(timestamp, strategy: .iso8601)
}
private static func entry(snoozedUntil: Double?) -> OpenClawChatSessionEntry {
var entry = OpenClawChatSessionEntry.placeholder(key: "agent:main:work")
entry.snoozedUntil = snoozedUntil
return entry
}
}

View file

@ -9,6 +9,7 @@ read_when:
- Running the iOS app from source
- Debugging Gateway discovery or iOS node commands
- Choosing colors for native chat sessions
- Snoozing or waking a session
title: "iOS app"
---
@ -23,6 +24,7 @@ Availability: The official iPhone app is available on the [App Store](https://ap
- Keeps a small read-only offline cache of recent chat sessions and transcripts per paired Gateway: cold opens paint the last known transcript immediately and refresh once the Gateway responds, recent chats stay browsable while disconnected, and reset/forget purges the protected local cache.
- Queues text messages sent while disconnected in a durable per-gateway outbox (up to 50): queued bubbles show in the transcript, flush in order on reconnect with idempotent retries, remain durable until canonical history confirms the send, retry with backoff before surfacing a retry/delete action, and expire instead of sending after 48 hours offline; reset/forget clears the queue with the cache.
- Chat is the single text-and-voice surface. Chat actions can open the full Sessions screen without leaving Chat and can show or hide assistant reasoning and tool activity. Tap the microphone for draft dictation, open its menu to record a voice note, or use the inline Talk control for realtime voice; the Talk control animates from live microphone or playback level while listening or speaking.
- Sessions has **Active**, **Snoozed**, and **Archived** scopes. Snoozed sessions stay out of the active sidebar and Overview until they wake.
- Agent narration appears inline as each segment finishes, including after reconnect. Narration, tool activity, and the reply stay grouped in one response with a single top-aligned agent avatar. Completed chat turns fold earlier work into a **Worked for …** disclosure above the reply on iPhone and iPad. Tap it to inspect the work. Final answers and media stay visible, and active or unanswered work stays expanded.
- Chat accepts images from the photo picker, camera, Files, paste, and the iOS share sheet. Assistant-generated images render inline from short-lived Gateway artifact URLs, open in a full-screen preview, and remain available after reconnect or history reload without storing image bytes in the transcript cache.
- Choose **+ → File** to attach audio, video, PDFs, text/code, CSV, JSON, Markdown, ZIP archives, and Office documents from Files. Removable chips show filenames and sizes. Files use the Gateway’s advertised attachment size limits and preserve their original bytes through the durable outbox. The file limit also caps the total attachment bytes per message, counting images after resizing; oversized drafts stay in the composer when you try to send. For older Gateways that do not advertise limits, native chat caps non-image files and the combined attachment budget at 19,464,192 bytes (the decoded budget for a 25 MiB frame), and processed images at 5 MB after resizing. Image source reads have a separate 64 MiB cap to bound resize-input memory; a larger source photo within that cap can be sent when its resized JPEG fits the image and batch budgets. Empty or unreadable files show **Could not attach**; oversized files show **Too large to send**. Sent uploads remain visible after history refresh; downloading inbound uploads from native history is not supported yet. Recorded voice notes keep their separate recording flow.
@ -117,6 +119,27 @@ delaying selection; configured names keep precedence and the Gateway's default
identity is **Assistant**. The catalog refreshes when the picker opens and stays
bound to the selected Gateway.
## Sessions
Choose **All Sessions…** in the sidebar to browse **Active**, **Snoozed**, or
**Archived** sessions. Long-press an eligible session and choose **Snooze**:
**In 1 hour**, **In 3 hours**, **This evening** (18:00 local, only when more than
one hour away), **Tomorrow** (09:00 on the next calendar day), or **Next week**
(next Monday at 09:00). On Sundays, Next week is omitted because it matches
Tomorrow. Each preset shows its local wake time.
The **Snoozed** scope shows **Wakes** and the scheduled time. Long-press a
snoozed row and choose **Wake session** to bring it back early. A session also
wakes when its deadline arrives, a real inbound message arrives, or an agent
run completes. Pinning or archiving clears its snooze. Protected main sessions,
child/subagent sessions, and archived sessions cannot be snoozed.
Cached snoozed sessions remain browsable offline under the **Snoozed** scope.
Snooze only hides a session from active lists. It never stops a run, prevents
messages, or disables automations. The Gateway stores the wake time, so it is
shared across connected clients; an open conversation stays open.
## Session colors
Long-press a session in the sidebar or Sessions screen to open its session actions, then choose **Color**. Select red, blue, green, yellow, purple, orange, pink, or cyan. **Default** clears the color.

View file

@ -6,7 +6,7 @@
import { createServer } from "node:http";
import { WebSocket, WebSocketServer } from "ws";
const port = 19877;
const port = Number(process.env.OPENCLAW_IOS_ATTENTION_FIXTURE_PORT ?? 19877);
const mainKey = "agent:main:main";
const parentKey = "agent:main:attention-parent";
const reviewKey = "agent:main:attention-review";
@ -336,19 +336,37 @@ wss.on("connection", (ws) => {
durationMs: 1,
channels: {},
agents: [],
sessions: { count: 3 },
sessions: { count: 5 },
});
break;
case "sessions.list":
reply({
ts: Date.now(),
count: 3,
totalCount: 3,
count: 5,
totalCount: 5,
offset: 0,
nextOffset: 3,
nextOffset: 5,
hasMore: false,
defaults: {},
sessions: [
{
key: "agent:main:snooze-active",
sessionId: "synthetic-snooze-active",
displayName: "Planning notes",
label: "Planning notes",
kind: "direct",
updatedAt: created,
},
{
key: "agent:main:snooze-later",
sessionId: "synthetic-snooze-later",
displayName: "Follow-up notes",
label: "Follow-up notes",
kind: "direct",
updatedAt: created - 1000,
snoozedAt: created,
snoozedUntil: Date.parse("2099-01-05T17:00:00Z"),
},
{
key: mainKey,
displayName: "Home",