mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
1b5db5e90b
commit
1128a4ae92
20 changed files with 857 additions and 71 deletions
21
apps/.i18n/native-source.json
generated
21
apps/.i18n/native-source.json
generated
|
|
@ -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"}]},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -679,6 +679,7 @@ struct RootSidebar: View {
|
|||
}
|
||||
.commandSessionActions(
|
||||
session: session,
|
||||
mainSessionKey: self.resolvedMainSessionKey,
|
||||
categories: self.sessionCategories,
|
||||
isEnabled: self.appModel.isOperatorGatewayConnected,
|
||||
canArchive: ChatSessionSidebarModel.canArchiveSession(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)"))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
96
apps/ios/UITests/SessionSnoozeUITests.swift
Normal file
96
apps/ios/UITests/SessionSnoozeUITests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue