From 2fd8e321baea18b32c64d61da04cf690a11c6f45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 20:38:08 -0700 Subject: [PATCH] feat(android): snooze sessions from the thread menu (#162293) * feat(android): snooze sessions from the thread menu Session rows now carry the Gateway's snoozedUntil/snoozedAt facts in the hand-written ChatSessionEntry (parsed from sessions.list, merged from sessions.changed with null receipts clearing them), and sessions.patch can set a wake time or clear it with the session's expected identity. The Threads page gains a Snoozed pill and long-press Snooze presets (In 1 hour, In 3 hours, This evening, Tomorrow, Next week) or "Wake session" with the wake time; Recent, Current, and the sidebar's recent list hide snoozed rows until the wake instant, which the existing expiry scheduler now awaits. The screenshot fixture gains a snoozed session so the owned-emulator lane captures the states. * fix(android): keep snooze strings in the native i18n source inventory The native locale refresh workflow owns the generated string resources, so the new snooze strings are recorded in apps/.i18n/native-source.json and the generated NativeStringResources.kt / values/strings.xml are restored to main; nativeString falls back to the source text until the refresh lands. --- apps/.i18n/native-source.json | 13 ++ apps/android/README.md | 8 ++ .../openclaw/app/AndroidScreenshotFixture.kt | 46 +++++- .../ai/openclaw/app/AndroidScreenshotMode.kt | 1 + .../java/ai/openclaw/app/MainViewModel.kt | 4 + .../ai/openclaw/app/chat/ChatController.kt | 19 ++- .../java/ai/openclaw/app/chat/ChatModels.kt | 8 +- .../ai/openclaw/app/chat/SessionSnooze.kt | 101 +++++++++++++ .../java/ai/openclaw/app/ui/SessionsScreen.kt | 98 +++++++++++-- .../java/ai/openclaw/app/ui/SidebarContent.kt | 20 ++- .../chat/ChatControllerSessionActionsTest.kt | 106 ++++++++++++++ .../ai/openclaw/app/chat/SessionSnoozeTest.kt | 136 ++++++++++++++++++ .../app/ui/SessionsScreenGroupingTest.kt | 56 +++++++- .../openclaw/app/ui/SidebarShellLogicTest.kt | 17 ++- .../android/scripts/capture-session-snooze.py | 96 +++++++++++++ docs/platforms/android.md | 1 + scripts/android-screenshots.sh | 47 +++++- 17 files changed, 756 insertions(+), 21 deletions(-) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/chat/SessionSnooze.kt create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/chat/SessionSnoozeTest.kt create mode 100644 apps/android/scripts/capture-session-snooze.py diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index fa51d0e9b7c5..5824771b57e3 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -51,6 +51,7 @@ {"id":"native.android.79d3c99d8ed134c5","source":"$summary $details","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]}, {"id":"native.android.981a9c864555bd3d","source":"$text ","surface":"android","sites":[{"kind":"conditional-branch","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatCommandControls.kt"}]}, {"id":"native.android.b873c6b026310bef","source":"$title copied","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, + {"id":"native.android.98049361ed86c777","source":"$title · $time","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt"}]}, {"id":"native.android.ae41eaa995f585ea","source":"$title ×$count","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ToolActivityPresentation.kt"}]}, {"id":"native.android.d7c1a4b02d181297","source":"${(transform.scale * 100).roundToInt()}%","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageViewer.kt"}]}, {"id":"native.android.fbb5461346146008","source":"${channel.accountCount} accounts","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ChannelsSettingsScreen.kt"}]}, @@ -745,6 +746,8 @@ {"id":"native.android.c1118ebd21c4f766","source":"In ${deltaMinutes}m","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/DreamingSettingsScreen.kt"}]}, {"id":"native.android.8b22f05b41ca461f","source":"In ${hours / 24L}d","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/DreamingSettingsScreen.kt"}]}, {"id":"native.android.783cb59d0d0ed80d","source":"In ${hours}h","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/DreamingSettingsScreen.kt"}]}, + {"id":"native.android.3e0ff93d60025fd1","source":"In 1 hour","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/chat/SessionSnooze.kt"}]}, + {"id":"native.android.ce2d70c7602aca39","source":"In 3 hours","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/chat/SessionSnooze.kt"}]}, {"id":"native.android.456a59837d52b9fd","source":"Include Android and background packages.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.5b5accae1960a930","source":"Includes additional permissions: ${additionalPermissionNames}. Review Android's permission prompts. Camera and location features are enabled separately.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]}, {"id":"native.android.181a9e3cb5196b8e","source":"Input","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatContextPopoverContent.kt"}]}, @@ -903,6 +906,7 @@ {"id":"native.android.bb158502872c3423","source":"Next page","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt"}]}, {"id":"native.android.7653538ae5e521a9","source":"Next part","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]}, {"id":"native.android.c073ff8a7c62593d","source":"Next session","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, + {"id":"native.android.487decd282bbb6dd","source":"Next week","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/chat/SessionSnooze.kt"}]}, {"id":"native.android.b870fb51a946da5f","source":"No","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ProviderSignInDialog.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.d5a94ea4b2a8b66c","source":"No ${issue.target.title} provider is configured on the Gateway","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/GatewayTalkSetupReadiness.kt"}]}, {"id":"native.android.4cc17d997ce12ef7","source":"No QR code found in the selected image.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayAdditionDialog.kt"}]}, @@ -961,6 +965,7 @@ {"id":"native.android.06ff45bfde483a7b","source":"No setup QR code was found in that image. Choose the QR generated by openclaw qr, or enter the setup code manually.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]}, {"id":"native.android.684a9b1b635ad834","source":"No sign-in methods available. Refresh to try again.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ProviderSignInDialog.kt"}]}, {"id":"native.android.7511cbf3e426ef99","source":"No skills installed.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt"}]}, + {"id":"native.android.56e32b177d65792f","source":"No snoozed threads","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt"}]}, {"id":"native.android.5b5c2899d0836e98","source":"No speech detected. Tap the microphone and try again.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt"}]}, {"id":"native.android.8feaee691786066c","source":"No text patch available","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionDiffSheet.kt"}]}, {"id":"native.android.57eaea76823e43f4","source":"No threads yet","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt"}]}, @@ -1473,6 +1478,9 @@ {"id":"native.android.d7de605b01a333fa","source":"Smarter","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.656b2de8cc0cb37e","source":"Snapping","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt"}]}, {"id":"native.android.cf7b99fc56670b51","source":"Snapshot: $snapshotLabel","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityDevActivity.kt"}]}, + {"id":"native.android.7bc0387bd64b3fd8","source":"Snooze","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt"}]}, + {"id":"native.android.8413b81367990957","source":"Snoozed","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt"}]}, + {"id":"native.android.530a5e97cd9483b1","source":"Snoozed threads will show up here.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt"}]}, {"id":"native.android.e50e16c71fc204f1","source":"Some channel status checks did not complete.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ChannelsSettingsScreen.kt"}]}, {"id":"native.android.629c8378be81324f","source":"Some models could not be refreshed. Listed models are still available.","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]}, {"id":"native.android.44fb00ccaf9544cc","source":"Some models could not be refreshed. Tap Refresh to retry.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]}, @@ -1602,6 +1610,7 @@ {"id":"native.android.0c28336bde41dbdc","source":"This conversation is no longer available. Reopen it and try again.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionDiffSheet.kt"}]}, {"id":"native.android.f85c386f2ad6613d","source":"This conversation’s workspace is not a Git repository.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionDiffSheet.kt"}]}, {"id":"native.android.f6a04d629e72725f","source":"This device will lose its trusted Gateway access.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/NodesDevicesSettingsScreen.kt"}]}, + {"id":"native.android.ed24e63b5a13129e","source":"This evening","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/chat/SessionSnooze.kt"}]}, {"id":"native.android.7d2fb3a34c47813a","source":"This file cannot be previewed. It may be binary or too large.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/WorkspaceFilesScreen.kt"}]}, {"id":"native.android.6abc80a0a79a1ccb","source":"This file’s patch was truncated.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionDiffSheet.kt"}]}, {"id":"native.android.87f8661a8ddb224b","source":"This folder has no files yet.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/WorkspaceFilesScreen.kt"}]}, @@ -1642,6 +1651,7 @@ {"id":"native.android.e5bd27eaef4e0526","source":"Toggle thread layout","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt"}]}, {"id":"native.android.db6e7dc515787042","source":"Token","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.aefc42b77ff1a612","source":"Token (optional)","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayAdditionDialog.kt"}]}, + {"id":"native.android.67c05140269eb221","source":"Tomorrow","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/chat/SessionSnooze.kt"}]}, {"id":"native.android.c2b152fd48621b0b","source":"Too many shares are waiting to be added.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt"}]}, {"id":"native.android.0deafe1f1bd75ff1","source":"Tool","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.7d78349e5c226d59","source":"Tool activity","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, @@ -1746,9 +1756,11 @@ {"id":"native.android.e7515263c535f956","source":"Waiting for review","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.d7472fd5c8c1e34b","source":"Wake Mode","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.52f02181d6a28ff1","source":"Wake listener","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, + {"id":"native.android.6eb2aefb3fa8f1d2","source":"Wake session · $wakeLabel","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt"}]}, {"id":"native.android.afc7d0c919648dbf","source":"Wake word or phrase","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.5d43506007ddaf09","source":"Wake words","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.9ce3afc4d0d3732a","source":"Wake words saved","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]}, + {"id":"native.android.6e68328d2c814879","source":"Wakes $wakeLabel","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt"}]}, {"id":"native.android.1345d7d0d831f903","source":"Watch audio failed","surface":"android","sites":[{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]}, {"id":"native.android.1d29f6c4c2d4bdd9","source":"We could not reach your Gateway.\nLet's fix this.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]}, {"id":"native.android.5a4c93d56ea9f1c6","source":"Web Fetch","surface":"android","sites":[{"kind":"conditional-branch","path":"apps/android/app/src/main/java/ai/openclaw/app/chat/ChatSourceTools.kt"}]}, @@ -1813,6 +1825,7 @@ {"id":"native.android.ff912c72bbd602f2","source":"tell OpenClaw to $prompt","surface":"android","sites":[{"kind":"resource-item","path":"apps/android/app/src/main/res/values/assistant.xml"}]}, {"id":"native.android.cf576be7d9474fa6","source":"text: $value","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityDevActivity.kt"}]}, {"id":"native.android.6fe24eb749fb03c3","source":"this gateway","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, + {"id":"native.android.61a461e668320541","source":"tomorrow $time","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/chat/SessionSnooze.kt"}]}, {"id":"native.android.15e2709f1d0cc737","source":"unknown","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"},{"kind":"ui-call","path":"apps/android/app/src/thirdParty/java/ai/openclaw/app/accessibility/AccessibilityDevActivity.kt"}]}, {"id":"native.android.4eecf1acc255f9eb","source":"v$it available","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.bf56f784f294c325","source":"video","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt"}]}, diff --git a/apps/android/README.md b/apps/android/README.md index ef7359ac5ccb..46aca5cdec07 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -27,6 +27,12 @@ OpenClaw Android is the officially released Google Play app. It connects to an O Long-press a row on the **Threads** page and choose **Color**, then select a swatch or **Default** to clear it. The eight colors are red, blue, green, yellow, purple, orange, pink, and cyan. Colored sessions show a narrow leading stripe in the sidebar and Threads page, plus a colored ring around the agent avatar in the open chat header. Unset colors add no indicator. Colors sync through the Gateway and remain visible in the local session cache while offline. +## Session snooze + +Long-press an eligible session on **Threads** and choose **Snooze**, then **In 1 hour**, **In 3 hours**, **This evening** (18:00 local, when more than one hour away), **Tomorrow** (09:00 local), or **Next week** (next Monday at 09:00 local). Next week is omitted on Sundays because it matches Tomorrow. Archived, child, and protected sessions cannot be snoozed. + +Snoozed sessions leave **Recent**, **Current**, and sidebar recents. Open the **Snoozed** pill to see their **Wakes** times, or long-press and choose **Wake session** to bring one back early. The Gateway saves the wake time across clients. A session wakes at its deadline, when a real message arrives, or when a run completes; pinning or archiving also clears its snooze. Snooze never stops a run, blocks messages, or disables an automation. + ## Camera attachments Tap **+ → Camera** to open the viewfinder directly. Switch between **Photo** and @@ -305,6 +311,8 @@ reuses the process runtime. Same-scene re-entry retains the selected branch. For sidebar attention proof, use `openclaw.screenshotScene=attention`. The native drawer contains inactive sessions with multiple questions and execution, plugin, and Gateway-settings approvals. Tap or keyboard-focus an attention icon to inspect the oldest request. The `attention-expiry` scene uses successive short deadlines to exercise live removal without another Gateway event. These fixtures use the normal request parsers and lifecycle owners with synthetic in-memory responses; start a fresh app process between scenes. +For session snooze proof, run `pnpm android:screenshots --snooze-proof before` before adding the feature, then `pnpm android:screenshots --snooze-proof after`. The synthetic `snooze` scene supplies one active and one snoozed session through the production list parser. The capture steps open Threads and both row menus, saving PNGs under `.artifacts/android-snooze-proof/before/` and `after/`. This mode requires no existing ADB devices, starts and stops its own phone emulator, and leaves store screenshots unchanged. + For completed-work proof, use `openclaw.screenshotScene=completed-work`, `active-work`, or `work-boundaries`. These scenes use the same Chat screen with synthetic history in a node-owned app conversation. They cover disclosure diff --git a/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt b/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt index 6887bb81ac3b..1b6acf6e23d1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt @@ -21,6 +21,7 @@ import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject import java.time.Instant +import java.time.ZoneId import java.util.concurrent.atomic.AtomicReference internal object AndroidScreenshotFixture { @@ -39,7 +40,13 @@ internal object AndroidScreenshotFixture { const val gatewayId = "android-screenshot-gateway" val browserFocusAvailable: Boolean get() = scene == AndroidScreenshotScene.Browser const val controlUiBaseUrl = "http://127.0.0.1:18789" - val mainSessionKey: String get() = if (workScene) "agent:main:node-work-proof" else "agent:main:node-screenshot" + val mainSessionKey: String + get() = + when { + workScene -> "agent:main:node-work-proof" + scene == AndroidScreenshotScene.Snooze -> "agent:main:dashboard:snooze-active" + else -> "agent:main:node-screenshot" + } val sourcePreviewConfig: GatewaySourcePreviewConfig? get() = if (scene == AndroidScreenshotScene.Sources) GatewaySourcePreviewConfig(controlUiBaseUrl, "", "https://gateway.example", true, 0L) else null @@ -55,6 +62,7 @@ internal object AndroidScreenshotFixture { private val branchLeaves = (1..12).map { "android-screenshot-branch-${it.toString().padStart(2, '0')}" } fun createRequester(branchesEnabled: Boolean = this.branchesEnabled): (String, String?) -> String { + val fixtureNowMs = System.currentTimeMillis() val activeLeaf = AtomicReference(branchLeaves.first()) // A runtime gets a fresh lifetime; list refreshes and scene re-entry keep its exact record. val pendingQuestion = @@ -141,7 +149,7 @@ internal object AndroidScreenshotFixture { } "sessions.list" -> { - if (branchesEnabled) branchSessionList(paramsJson, activeLeaf.get()) else sessionList(paramsJson) + if (branchesEnabled) branchSessionList(paramsJson, activeLeaf.get()) else sessionList(paramsJson, fixtureNowMs) } "sessions.branches.list" -> { @@ -848,7 +856,39 @@ internal object AndroidScreenshotFixture { cost?.let { put("cost", it) } } - private fun sessionList(paramsJson: String?): String { + private fun sessionList( + paramsJson: String?, + fixtureNowMs: Long, + ): String { + if (scene == AndroidScreenshotScene.Snooze) { + val wakeAtMs = + Instant + .ofEpochMilli(fixtureNowMs) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .plusDays(1) + .atTime(9, 0) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + return buildJsonObject { + putJsonArray("sessions") { + addJsonObject { + session("agent:main:dashboard:snooze-active", "Trip checklist", fixtureNowMs).forEach { (key, value) -> put(key, value) } + put("sessionId", "screenshot-snooze-active") + } + addJsonObject { + session("agent:main:dashboard:snooze-sleeping", "Weekend reading", fixtureNowMs - 60_000).forEach { (key, value) -> put(key, value) } + put("sessionId", "screenshot-snooze-sleeping") + put("snoozedAt", fixtureNowMs) + put("snoozedUntil", wakeAtMs) + } + } + put("count", 2) + put("totalCount", 2) + put("hasMore", false) + }.toString() + } if (scene == AndroidScreenshotScene.Browser) { return buildJsonObject { putJsonArray("sessions") { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt b/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt index cd75389e2683..99e6608036ab 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt @@ -16,6 +16,7 @@ enum class AndroidScreenshotScene( Browser("browser", HomeDestination.Chat), Attention("attention", HomeDestination.Chat), AttentionExpiry("attention-expiry", HomeDestination.Chat), + Snooze("snooze", HomeDestination.Connect), Sources("sources", HomeDestination.Chat), CompletedWork("completed-work", HomeDestination.Chat), ActiveWork("active-work", HomeDestination.Chat), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 477d299c295b..ab245d99da10 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -1722,6 +1722,8 @@ class MainViewModel private constructor( clearLabel: Boolean = false, category: String? = null, clearCategory: Boolean = false, + snoozedUntil: Long? = null, + clearSnooze: Boolean = false, color: String? = null, clearColor: Boolean = false, pinned: Boolean? = null, @@ -1736,6 +1738,8 @@ class MainViewModel private constructor( clearLabel = clearLabel, category = category, clearCategory = clearCategory, + snoozedUntil = snoozedUntil, + clearSnooze = clearSnooze, color = color, clearColor = clearColor, pinned = pinned, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 9830542ce47a..b2bb6546c625 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -1233,6 +1233,8 @@ class ChatController internal constructor( clearLabel: Boolean = false, category: String? = null, clearCategory: Boolean = false, + snoozedUntil: Long? = null, + clearSnooze: Boolean = false, color: String? = null, clearColor: Boolean = false, pinned: Boolean? = null, @@ -1251,6 +1253,8 @@ class ChatController internal constructor( label != null || clearCategory || category != null || + clearSnooze || + snoozedUntil != null || clearColor || color != null || pinned != null || @@ -1258,7 +1262,7 @@ class ChatController internal constructor( unread != null if (!hasPatch) return false val lifecycleSessionId = expectedSessionId?.trim()?.takeIf { it.isNotEmpty() } - if (archived != null && lifecycleSessionId == null) { + if ((archived != null || snoozedUntil != null || clearSnooze) && lifecycleSessionId == null) { updateErrorText("Session lifecycle action requires a durable session identity.") return false } @@ -1278,6 +1282,11 @@ class ChatController internal constructor( } else if (category != null) { put("category", JsonPrimitive(category)) } + if (clearSnooze) { + put("snoozedUntil", JsonNull) + } else if (snoozedUntil != null) { + put("snoozedUntil", JsonPrimitive(snoozedUntil)) + } if (clearColor) { put("color", JsonNull) } else if (color != null) { @@ -7568,6 +7577,10 @@ class ChatController internal constructor( lastReadAt = obj["lastReadAt"].asLongOrNull(), markedUnreadAt = obj["markedUnreadAt"].asLongOrNull(), hasMarkedUnreadMetadata = "markedUnreadAt" in obj, + snoozedUntil = obj["snoozedUntil"].asLongOrNull(), + snoozedAt = obj["snoozedAt"].asLongOrNull(), + hasSnoozedUntilMetadata = "snoozedUntil" in obj, + hasSnoozedAtMetadata = "snoozedAt" in obj, agentStatus = parseSessionAgentStatus(obj["agentStatus"]), hasAgentStatusMetadata = "agentStatus" in obj, observerDigest = @@ -8857,6 +8870,10 @@ internal fun mergeChatSessionEntry( if (next.hasMarkedUnreadMetadata) next.markedUnreadAt else existing.markedUnreadAt, hasMarkedUnreadMetadata = existing.hasMarkedUnreadMetadata || next.hasMarkedUnreadMetadata, + snoozedUntil = if (next.hasSnoozedUntilMetadata) next.snoozedUntil else existing.snoozedUntil, + snoozedAt = if (next.hasSnoozedAtMetadata) next.snoozedAt else existing.snoozedAt, + hasSnoozedUntilMetadata = existing.hasSnoozedUntilMetadata || next.hasSnoozedUntilMetadata, + hasSnoozedAtMetadata = existing.hasSnoozedAtMetadata || next.hasSnoozedAtMetadata, agentStatus = if (next.hasAgentStatusMetadata) next.agentStatus else existing.agentStatus, hasAgentStatusMetadata = existing.hasAgentStatusMetadata || next.hasAgentStatusMetadata, observerDigest = observerDigest, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt index b7f5f61493eb..0bc711824149 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt @@ -471,6 +471,10 @@ data class ChatSessionEntry( val lastReadAt: Long? = null, val markedUnreadAt: Long? = null, val hasMarkedUnreadMetadata: Boolean = markedUnreadAt != null, + val snoozedUntil: Long? = null, + val snoozedAt: Long? = null, + val hasSnoozedUntilMetadata: Boolean = snoozedUntil != null, + val hasSnoozedAtMetadata: Boolean = snoozedAt != null, val agentStatus: ChatSessionAgentStatus? = null, val hasAgentStatusMetadata: Boolean = agentStatus != null, val observerDigest: SessionObserverDigest? = null, @@ -523,7 +527,9 @@ data class ChatSessionEntry( inputTokens != null || outputTokens != null || estimatedCostUsd != null, val hasRunMetadata: Boolean = status != null || startedAt != null || endedAt != null || runtimeMs != null || outputTokens != null, -) +) { + fun isSnoozed(nowMs: Long): Boolean = snoozedUntil?.let { it > nowMs } == true +} // Match Gateway precedence: terminal status wins; only missing live flags use historical status. internal fun isSessionRunActive( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/SessionSnooze.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/SessionSnooze.kt new file mode 100644 index 000000000000..f61a594a4bad --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/SessionSnooze.kt @@ -0,0 +1,101 @@ +package ai.openclaw.app.chat + +import ai.openclaw.app.i18n.nativeString +import java.time.DayOfWeek +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder +import java.time.format.FormatStyle +import java.time.format.TextStyle +import java.time.temporal.ChronoField +import java.time.temporal.TemporalAdjusters + +object SessionSnooze { + data class Preset( + val id: String, + val title: String, + val wakeAtMs: Long, + ) + + fun presets( + nowMs: Long, + zone: ZoneId = ZoneId.systemDefault(), + ): List { + val now = Instant.ofEpochMilli(nowMs).atZone(zone) + val today = now.toLocalDate() + val evening = + today + .atTime(18, 0) + .atZone(zone) + .toInstant() + .toEpochMilli() + val tomorrow = + today + .plusDays(1) + .atTime(9, 0) + .atZone(zone) + .toInstant() + .toEpochMilli() + val nextMonday = + today + .with(TemporalAdjusters.next(DayOfWeek.MONDAY)) + .atTime(9, 0) + .atZone(zone) + .toInstant() + .toEpochMilli() + return buildList { + add(Preset("hour", nativeString("In 1 hour"), now.plusHours(1).toInstant().toEpochMilli())) + add(Preset("three-hours", nativeString("In 3 hours"), now.plusHours(3).toInstant().toEpochMilli())) + if (evening > now.plusHours(1).toInstant().toEpochMilli()) { + add(Preset("evening", nativeString("This evening"), evening)) + } + add(Preset("tomorrow", nativeString("Tomorrow"), tomorrow)) + if (nextMonday != tomorrow) { + add(Preset("next-week", nativeString("Next week"), nextMonday)) + } + } + } + + fun wakeLabel( + wakeAtMs: Long, + nowMs: Long, + zone: ZoneId = ZoneId.systemDefault(), + ): String { + val now = Instant.ofEpochMilli(nowMs).atZone(zone) + val wake = Instant.ofEpochMilli(wakeAtMs).atZone(zone) + val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).format(wake) + return when { + wake.toLocalDate() == now.toLocalDate() -> { + time + } + + wake.toLocalDate() == now.toLocalDate().plusDays(1) -> { + nativeString("tomorrow \$time", time) + } + + wake.isAfter(now) && !wake.isAfter(now.plusDays(7)) -> { + DateTimeFormatterBuilder() + .appendText(ChronoField.DAY_OF_WEEK, TextStyle.SHORT) + .appendLiteral(' ') + .appendLocalized(null, FormatStyle.SHORT) + .toFormatter() + .format(wake) + } + + else -> { + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).format(wake) + } + } + } + + fun nextWakeMs( + entries: List, + nowMs: Long, + ): Long? = + entries + .asSequence() + .filter { it.isSnoozed(nowMs) } + .mapNotNull { it.snoozedUntil } + .minOrNull() +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt index 617f4bf036ae..812fc8fbc8e5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt @@ -2,6 +2,7 @@ package ai.openclaw.app.ui import ai.openclaw.app.MainViewModel import ai.openclaw.app.chat.ChatSessionEntry +import ai.openclaw.app.chat.SessionSnooze import ai.openclaw.app.chat.isSessionRunActive import ai.openclaw.app.i18n.nativeString import ai.openclaw.app.ui.design.ClawEmptyState @@ -84,6 +85,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle @Composable internal fun SessionsScreen( @@ -131,8 +136,9 @@ internal fun SessionsScreen( currentSessionKey = chatSessionKey, filter = filter, recentFirst = recentFirst, + nowMs = sessionStatusNowMs, ) - val nextAttentionExpiry = nextSessionStatusExpiry(visibleSessions, sessionStatusNowMs) + val nextAttentionExpiry = nextSessionStatusExpiry(searchState.entries, sessionStatusNowMs) val storedGroups by viewModel.sessionCustomGroups.collectAsState() val sections = buildSessionTreeSections( @@ -214,6 +220,7 @@ internal fun SessionsScreen( ) { FilterPill(text = nativeString("Recent"), icon = Icons.Outlined.AccessTime, active = filter == SessionFilter.Recent, onClick = { filter = SessionFilter.Recent }) FilterPill(text = nativeString("Current"), icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Current, showDot = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Current }) + FilterPill(text = nativeString("Snoozed"), icon = Icons.Outlined.AccessTime, active = filter == SessionFilter.Snoozed, onClick = { filter = SessionFilter.Snoozed }) FilterPill(text = nativeString("Archived"), icon = Icons.Outlined.Archive, active = filter == SessionFilter.Archived, onClick = { filter = SessionFilter.Archived }) FilterPill(text = nativeString("Automations"), icon = Icons.Outlined.Schedule, active = filter == SessionFilter.Automations, onClick = { filter = SessionFilter.Automations }) } @@ -373,7 +380,14 @@ internal fun SessionsScreen( fallback = if (active) nativeString("Current thread") else nativeString("OpenClaw thread"), nowMs = sessionStatusNowMs, ), - metadata = (session.lastActivityAt ?: session.updatedAtMs)?.let(::relativeSessionTime) ?: nativeString("now"), + metadata = + if (filter == SessionFilter.Snoozed && session.isSnoozed(sessionStatusNowMs)) { + nativeString("Wakes \$wakeLabel", SessionSnooze.wakeLabel(requireNotNull(session.snoozedUntil), sessionStatusNowMs)) + } else { + (session.lastActivityAt ?: session.updatedAtMs)?.let { relativeSessionTime(it, sessionStatusNowMs) } ?: nativeString("now") + }, + nowMs = sessionStatusNowMs, + showWakeTime = filter == SessionFilter.Snoozed, active = active, compact = compactLayout, archived = session.archived == true, @@ -399,6 +413,17 @@ internal fun SessionsScreen( viewModel.patchChatSession(key = session.key, ownerAgentId = session.ownerAgentId, pinned = pinned) } }, + onSetSnooze = { wakeAtMs -> + coroutineScope.launch { + viewModel.patchChatSession( + key = session.key, + ownerAgentId = session.ownerAgentId, + snoozedUntil = wakeAtMs, + clearSnooze = wakeAtMs == null, + expectedSessionId = session.sessionId, + ) + } + }, onSetUnread = { unread -> coroutineScope.launch { viewModel.patchChatSession(key = session.key, ownerAgentId = session.ownerAgentId, unread = unread) @@ -620,6 +645,8 @@ private fun SessionRow( title: String, subtitle: String, metadata: String, + nowMs: Long, + showWakeTime: Boolean, active: Boolean, compact: Boolean, archived: Boolean, @@ -631,6 +658,7 @@ private fun SessionRow( onToggleExpanded: () -> Unit, onClick: () -> Unit, onSetPinned: (Boolean) -> Unit, + onSetSnooze: (Long?) -> Unit, onSetUnread: (Boolean) -> Unit, onRename: () -> Unit, onSetColor: (String?) -> Unit, @@ -645,6 +673,7 @@ private fun SessionRow( var submenu by remember { mutableStateOf(null) } val selectedColor = session.color.takeIf { it in sessionColorNames } val canChangeArchived = !session.sessionId.isNullOrBlank() + val snoozePresets = remember(menuExpanded, submenu) { SessionSnooze.presets(System.currentTimeMillis()) } Surface(color = Color.Transparent, contentColor = ClawTheme.colors.text) { Box { @@ -734,6 +763,9 @@ private fun SessionRow( } SessionDescendantSignals(collapsedDescendantState, visible = compact) } + if (showWakeTime) { + Text(text = metadata, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 2) + } if (!compact) { Text(text = subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { @@ -745,7 +777,9 @@ private fun SessionRow( Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(ClawTheme.spacing.xxxs)) { Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textMuted) - Text(text = metadata, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1) + if (!showWakeTime) { + Text(text = metadata, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1) + } } } HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp) @@ -780,6 +814,16 @@ private fun SessionRow( }, ) } + } else if (submenu == SessionRowSubmenu.Snooze) { + SessionMenuItem(nativeString("← Back")) { submenu = null } + snoozePresets.forEach { preset -> + val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).format(Instant.ofEpochMilli(preset.wakeAtMs).atZone(ZoneId.systemDefault())) + SessionMenuItem(nativeString("\$title · \$time", preset.title, time)) { + menuExpanded = false + submenu = null + onSetSnooze(preset.wakeAtMs) + } + } } else if (archived) { if (canChangeArchived) { SessionMenuItem(nativeString("Unarchive")) { @@ -817,6 +861,16 @@ private fun SessionRow( menuExpanded = false onSetPinned(session.pinned != true) } + if (canSnoozeSession(session)) { + if (session.isSnoozed(nowMs)) { + SessionMenuItem(nativeString("Wake session · \$wakeLabel", SessionSnooze.wakeLabel(requireNotNull(session.snoozedUntil), nowMs))) { + menuExpanded = false + onSetSnooze(null) + } + } else { + SessionMenuItem(nativeString("Snooze")) { submenu = SessionRowSubmenu.Snooze } + } + } SessionMenuItem(if (session.unread == true) nativeString("Mark as read") else nativeString("Mark as unread")) { menuExpanded = false onSetUnread(session.unread != true) @@ -851,7 +905,7 @@ private fun SessionRow( } } -private enum class SessionRowSubmenu { Color, Group } +private enum class SessionRowSubmenu { Color, Group, Snooze } private fun sessionColorLabel(name: String?): String = when (name) { @@ -985,6 +1039,7 @@ private fun SessionMiniTag(text: String) { internal enum class SessionFilter { Recent, Current, + Snoozed, Archived, Automations, } @@ -1041,12 +1096,15 @@ internal fun resolveSessionBrowserEntries( currentSessionKey: String, filter: SessionFilter, recentFirst: Boolean, + nowMs: Long = System.currentTimeMillis(), ): List { val filtered = when (filter) { - SessionFilter.Recent -> entries.filter { isSessionVisibleInNavigation(it, currentSessionKey) } + SessionFilter.Recent -> entries.filter { isSessionVisibleInNavigation(it, currentSessionKey, nowMs) } - SessionFilter.Current -> entries.filter { it.key == currentSessionKey } + SessionFilter.Current -> entries.filter { it.key == currentSessionKey && !it.isSnoozed(nowMs) } + + SessionFilter.Snoozed -> entries.filter { it.archived != true && it.isSnoozed(nowMs) } SessionFilter.Automations -> entries.filter { it.archived != true && isAutomationSession(it) } @@ -1068,7 +1126,25 @@ private val cronSessionDisplayKey = Regex("^(?:cron:|agent::*[^:]+:+cron:+[^:])" internal fun isSessionVisibleInNavigation( session: ChatSessionEntry, currentSessionKey: String, -): Boolean = session.key == currentSessionKey || (session.archived != true && !isAutomationSession(session)) + nowMs: Long = System.currentTimeMillis(), +): Boolean = !session.isSnoozed(nowMs) && (session.key == currentSessionKey || (session.archived != true && !isAutomationSession(session))) + +internal fun canSnoozeSession(session: ChatSessionEntry): Boolean { + val key = session.key.trim().lowercase() + val agentKey = key.split(':', limit = 3).takeIf { it.size == 3 && it[0] == "agent" } + val agentRest = agentKey?.get(2) + val parentKey = session.parentSessionKey?.trim()?.takeIf(String::isNotEmpty) + // Dashboard rows can auto-parent to their agent's main root and remain pinnable. + val rootParent = agentKey?.let { "agent:${it[1]}:main" } + return !session.sessionId.isNullOrBlank() && + session.archived != true && + session.isMain != true && + key !in setOf("main", "global", "unknown") && + agentRest != "main" && + !(agentRest ?: key).startsWith("subagent:") && + (parentKey == null || parentKey == rootParent) && + session.spawnedBy.isNullOrBlank() +} private fun isAutomationSession(session: ChatSessionEntry): Boolean { if (cronSessionDisplayKey.containsMatchIn(session.key.trim().lowercase()) || session.createdActorType == "system") return true @@ -1184,7 +1260,11 @@ internal data class SessionDescendantSignal( internal fun nextSessionStatusExpiry( entries: List, nowMs: Long, -): Long? = entries.mapNotNull { it.agentStatus?.expiresAt }.filter { it > nowMs }.minOrNull() +): Long? = + listOfNotNull( + entries.mapNotNull { it.agentStatus?.expiresAt }.filter { it > nowMs }.minOrNull(), + SessionSnooze.nextWakeMs(entries, nowMs), + ).minOrNull() internal suspend fun awaitSessionStatusExpiry( expiry: Long, @@ -1433,6 +1513,7 @@ private fun emptySessionTitle(filter: SessionFilter): String = when (filter) { SessionFilter.Recent -> nativeString("No threads yet") SessionFilter.Current -> nativeString("No current thread") + SessionFilter.Snoozed -> nativeString("No snoozed threads") SessionFilter.Archived -> nativeString("No archived threads") SessionFilter.Automations -> nativeString("No automation threads") } @@ -1441,6 +1522,7 @@ private fun emptySessionBody(filter: SessionFilter): String = when (filter) { SessionFilter.Recent -> nativeString("Start a new conversation and it will show up here.") SessionFilter.Current -> nativeString("Open Chat to start or resume the current thread.") + SessionFilter.Snoozed -> nativeString("Snoozed threads will show up here.") SessionFilter.Archived -> nativeString("Archived threads will show up here.") SessionFilter.Automations -> nativeString("Automation and system conversations will show up here.") } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt index 8a99085a8b23..a1188e35feeb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt @@ -8,6 +8,7 @@ import ai.openclaw.app.SessionCatalog import ai.openclaw.app.SessionCatalogEntry import ai.openclaw.app.SessionCatalogState import ai.openclaw.app.chat.ChatSessionEntry +import ai.openclaw.app.chat.SessionSnooze import ai.openclaw.app.defaultSidebarPageOrder import ai.openclaw.app.defaultSidebarVisiblePages import ai.openclaw.app.i18n.nativeString @@ -197,10 +198,11 @@ internal data class SidebarSessionPresentation( internal fun sidebarRecentSessions( sessions: List, currentSessionKey: String = "", + nowMs: Long = System.currentTimeMillis(), ): List = sessions .asSequence() - .filter { isSessionVisibleInNavigation(it, currentSessionKey) } + .filter { isSessionVisibleInNavigation(it, currentSessionKey, nowMs) } .sortedWith( compareByDescending { it.pinned == true } .thenByDescending { it.lastActivityAt ?: it.updatedAtMs ?: 0L } @@ -213,8 +215,9 @@ internal fun sidebarSessionPresentation( expanded: Boolean, excludedSessionKeys: Set = emptySet(), currentSessionKey: String = "", + nowMs: Long = System.currentTimeMillis(), ): SidebarSessionPresentation { - val activeSessions = sidebarRecentSessions(sessions, currentSessionKey) + val activeSessions = sidebarRecentSessions(sessions, currentSessionKey, nowMs) val pinned = activeSessions.filter { it.pinned == true } val recent = activeSessions.filter { session -> @@ -426,6 +429,7 @@ internal fun OpenClawSidebar( rowHostBand: IntRect? = null, ) { val palette = sidebarPalette(ClawTheme.colors) + var sessionNowMs by remember { mutableLongStateOf(System.currentTimeMillis()) } val scope = rememberCoroutineScope() val lifecycle = LocalLifecycleOwner.current.lifecycle val scrollState = rememberScrollState() @@ -486,6 +490,7 @@ internal fun OpenClawSidebar( expanded = sessionsExpanded, excludedSessionKeys = catalogSessionKeys, currentSessionKey = activeSessionKey, + nowMs = sessionNowMs, ) val pinnedSessions = recentPresentation.pinned val recentSections = recentPresentation.recentSections @@ -580,12 +585,21 @@ internal fun OpenClawSidebar( query = query, archived = false, ) + val nextWakeMs = + listOfNotNull( + SessionSnooze.nextWakeMs(sessions, sessionNowMs), + SessionSnooze.nextWakeMs(searchState.entries, sessionNowMs), + ).minOrNull() + LaunchedEffect(nextWakeMs) { + nextWakeMs?.let { sessionNowMs = awaitSessionStatusExpiry(it) } + } val searchResults = resolveSessionBrowserEntries( entries = searchState.entries, currentSessionKey = activeSessionKey, filter = SessionFilter.Recent, recentFirst = true, + nowMs = sessionNowMs, ) Column( @@ -867,7 +881,7 @@ internal fun OpenClawSidebar( SidebarCollapsibleHeader( label = nativeString("Recent"), - attention = if (recentExpanded) null else attentionFor(sidebarRecentSessions(sessions, activeSessionKey).filter { it.pinned != true && it.key !in catalogSessionKeys }.map { it.key }), + attention = if (recentExpanded) null else attentionFor(sidebarRecentSessions(sessions, activeSessionKey, sessionNowMs).filter { it.pinned != true && it.key !in catalogSessionKeys }.map { it.key }), expanded = recentExpanded, palette = palette, onClick = { recentExpanded = !recentExpanded }, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionActionsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionActionsTest.kt index 4ac1bccdaf2a..320c8ff0aff5 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionActionsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerSessionActionsTest.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals @@ -51,6 +53,110 @@ class ChatControllerSessionActionsTest { ) } + @Test + fun snoozeAndWakeSendLifecycleIdentityAndRefreshSessions() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("sessions.patch", "{}") + val controller = controller(this, gateway) + val wakeAt = 1_800_003_600_000L + + assertTrue( + controller.patchSession( + key = "custom", + ownerAgentId = "main", + expectedSessionId = "session-custom", + snoozedUntil = wakeAt, + ), + ) + assertTrue( + controller.patchSession( + key = "custom", + ownerAgentId = "main", + expectedSessionId = "session-custom", + clearSnooze = true, + ), + ) + + val patches = + gateway.calls.filter { it.method == "sessions.patch" }.map { json.parseToJsonElement(it.paramsJson!!).jsonObject } + assertEquals(listOf(JsonPrimitive(wakeAt), JsonNull), patches.map { it["snoozedUntil"] }) + assertTrue(patches.all { it["key"] == JsonPrimitive("custom") }) + assertTrue(patches.all { it["agentId"] == JsonPrimitive("main") }) + assertTrue(patches.all { it["expectedSessionId"] == JsonPrimitive("session-custom") }) + assertEquals(2, gateway.callCount("sessions.list")) + assertEquals("main", controller.sessionKey.value) + } + + @Test + fun snoozeAndWakeRequireDurableSessionIdentity() = + runTest { + val gateway = ScriptedGateway(json) + val controller = controller(this, gateway) + + for (sessionId in listOf(null, " ")) { + for (clearSnooze in listOf(false, true)) { + assertFalse( + controller.patchSession( + key = "custom", + ownerAgentId = "main", + expectedSessionId = sessionId, + snoozedUntil = if (clearSnooze) null else 1_800_003_600_000L, + clearSnooze = clearSnooze, + ), + ) + assertEquals("Session lifecycle action requires a durable session identity.", controller.errorText.value) + } + } + assertEquals(0, gateway.callCount("sessions.patch")) + assertEquals(0, gateway.callCount("sessions.list")) + } + + @Test + fun sessionListParsesSnoozeAndEventsPreserveOmissionsButApplyNullClears() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith( + "sessions.list", + """{"sessions":[{"key":"custom","sessionId":"session-custom","snoozedUntil":1800003600000,"snoozedAt":1800000000000}]}""", + ) + val controller = controller(this, gateway) + + controller.refreshSessions() + runCurrent() + val initial = controller.sessions.value.single() + assertEquals(1_800_003_600_000L, initial.snoozedUntil) + assertEquals(1_800_000_000_000L, initial.snoozedAt) + + controller.handleGatewayEvent( + "sessions.changed", + """{"sessionKey":"custom","agentId":"main","session":{"key":"custom","label":"Updated title"}}""", + ) + runCurrent() + val omitted = controller.sessions.value.single() + assertEquals("Updated title", omitted.label) + assertEquals(initial.snoozedUntil, omitted.snoozedUntil) + assertEquals(initial.snoozedAt, omitted.snoozedAt) + + controller.handleGatewayEvent( + "sessions.changed", + """{"sessionKey":"custom","agentId":"main","session":{"key":"custom","snoozedUntil":null}}""", + ) + runCurrent() + val clearedUntil = controller.sessions.value.single() + assertNull(clearedUntil.snoozedUntil) + assertEquals(initial.snoozedAt, clearedUntil.snoozedAt) + + controller.handleGatewayEvent( + "sessions.changed", + """{"sessionKey":"custom","agentId":"main","session":{"key":"custom","snoozedAt":null}}""", + ) + runCurrent() + val cleared = controller.sessions.value.single() + assertNull(cleared.snoozedUntil) + assertNull(cleared.snoozedAt) + } + private inner class ArchiveFixture( private val scope: TestScope, ) { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/SessionSnoozeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/SessionSnoozeTest.kt new file mode 100644 index 000000000000..30364a5f0ec1 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/SessionSnoozeTest.kt @@ -0,0 +1,136 @@ +package ai.openclaw.app.chat + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.time.Instant +import java.time.ZoneId +import java.util.Locale + +class SessionSnoozeTest { + private val zone = ZoneId.of("America/Los_Angeles") + private val previousLocale = Locale.getDefault() + + @Before + fun setLocale() { + Locale.setDefault(Locale.US) + } + + @After + fun restoreLocale() { + Locale.setDefault(previousLocale) + } + + @Test + fun weekdayMorningOffersElapsedHoursAndLocalCalendarPresets() { + val presets = SessionSnooze.presets(instant("2026-09-30T17:00:00Z"), zone) + + assertEquals( + listOf( + SessionSnooze.Preset("hour", "In 1 hour", instant("2026-09-30T18:00:00Z")), + SessionSnooze.Preset("three-hours", "In 3 hours", instant("2026-09-30T20:00:00Z")), + SessionSnooze.Preset("evening", "This evening", instant("2026-10-01T01:00:00Z")), + SessionSnooze.Preset("tomorrow", "Tomorrow", instant("2026-10-01T16:00:00Z")), + SessionSnooze.Preset("next-week", "Next week", instant("2026-10-05T16:00:00Z")), + ), + presets, + ) + } + + @Test + fun eveningRequiresMoreThanOneHourOfNotice() { + val cases = + listOf( + "2026-09-30T23:59:59Z" to true, + "2026-10-01T00:00:00Z" to false, + "2026-10-01T00:30:00Z" to false, + ) + + for ((now, offersEvening) in cases) { + assertEquals(now, offersEvening, SessionSnooze.presets(instant(now), zone).any { it.id == "evening" }) + } + } + + @Test + fun sundayOmitsNextWeekWhenItIsTomorrowButMondayUsesTheFollowingMonday() { + val sunday = SessionSnooze.presets(instant("2026-10-04T17:00:00Z"), zone) + assertFalse(sunday.any { it.id == "next-week" }) + assertEquals(instant("2026-10-05T16:00:00Z"), sunday.single { it.id == "tomorrow" }.wakeAtMs) + + val monday = SessionSnooze.presets(instant("2026-10-05T17:00:00Z"), zone) + assertEquals(instant("2026-10-12T16:00:00Z"), monday.single { it.id == "next-week" }.wakeAtMs) + } + + @Test + fun daylightSavingChangesKeepCalendarPresetsAtLocalNineAndEighteen() { + val spring = SessionSnooze.presets(instant("2026-03-08T09:30:00Z"), zone) + assertEquals(instant("2026-03-08T10:30:00Z"), spring.single { it.id == "hour" }.wakeAtMs) + assertEquals(instant("2026-03-08T12:30:00Z"), spring.single { it.id == "three-hours" }.wakeAtMs) + assertEquals(instant("2026-03-09T01:00:00Z"), spring.single { it.id == "evening" }.wakeAtMs) + assertEquals(instant("2026-03-09T16:00:00Z"), spring.single { it.id == "tomorrow" }.wakeAtMs) + + val autumn = SessionSnooze.presets(instant("2026-11-01T07:30:00Z"), zone) + assertEquals(instant("2026-11-02T02:00:00Z"), autumn.single { it.id == "evening" }.wakeAtMs) + assertEquals(instant("2026-11-02T17:00:00Z"), autumn.single { it.id == "tomorrow" }.wakeAtMs) + } + + @Test + fun snoozeIsActiveOnlyBeforeItsDeadline() { + val now = instant("2026-09-30T17:00:00Z") + val entry = ChatSessionEntry(key = "session", updatedAtMs = null) + + assertFalse(entry.isSnoozed(now)) + assertFalse(entry.copy(snoozedUntil = now - 1).isSnoozed(now)) + assertFalse(entry.copy(snoozedUntil = now).isSnoozed(now)) + assertTrue(entry.copy(snoozedUntil = now + 1).isSnoozed(now)) + } + + @Test + fun wakeLabelsDistinguishTodayTomorrowNearWeekdaysAndDates() { + val now = instant("2026-09-30T17:00:00Z") + val cases = + listOf( + "2026-09-30T18:00:00Z" to "11:00 AM", + "2026-10-01T16:00:00Z" to "tomorrow 9:00 AM", + "2026-10-02T16:00:00Z" to "Fri 9:00 AM", + "2026-10-07T17:00:00Z" to "Wed 10:00 AM", + "2026-10-07T17:00:00.001Z" to "10/7/26, 10:00 AM", + "2026-09-29T16:00:00Z" to "9/29/26, 9:00 AM", + ) + + for ((wake, expected) in cases) { + assertEquals(wake, expected, normalizedLabel(SessionSnooze.wakeLabel(instant(wake), now, zone))) + } + } + + @Test + fun wakeLabelsUseCalendarDaysAcrossDaylightSavingChanges() { + val now = instant("2026-03-07T18:00:00Z") + + assertEquals("tomorrow 9:00 AM", normalizedLabel(SessionSnooze.wakeLabel(instant("2026-03-08T16:00:00Z"), now, zone))) + assertEquals("Sat 10:00 AM", normalizedLabel(SessionSnooze.wakeLabel(instant("2026-03-14T17:00:00Z"), now, zone))) + assertEquals("3/14/26, 10:00 AM", normalizedLabel(SessionSnooze.wakeLabel(instant("2026-03-14T17:00:00.001Z"), now, zone))) + } + + @Test + fun nextWakeSkipsExpiredAndAbsentDeadlinesAndChoosesTheEarliest() { + val now = instant("2026-09-30T17:00:00Z") + val entries = + listOf(null, now - 1, now, now + 3_000, now + 1_000, now + 2_000).mapIndexed { index, wake -> + ChatSessionEntry(key = "session-$index", updatedAtMs = null, snoozedUntil = wake) + } + + assertEquals(now + 1_000, SessionSnooze.nextWakeMs(entries, now)) + assertEquals(now + 2_000, SessionSnooze.nextWakeMs(entries, now + 1_000)) + assertNull(SessionSnooze.nextWakeMs(entries, now + 3_000)) + assertNull(SessionSnooze.nextWakeMs(emptyList(), now)) + } + + private fun instant(value: String): Long = Instant.parse(value).toEpochMilli() + + private fun normalizedLabel(value: String): String = value.replace('\u202f', ' ').replace('\u00a0', ' ') +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/SessionsScreenGroupingTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/SessionsScreenGroupingTest.kt index 24703f8735a1..253e49c0270c 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/SessionsScreenGroupingTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/SessionsScreenGroupingTest.kt @@ -7,6 +7,57 @@ import org.junit.Assert.assertEquals import org.junit.Test class SessionsScreenGroupingTest { + @Test + fun snoozedFilterSeparatesSleepingRowsUntilTheirDeadline() { + val nowMs = 1_800_000_000_000L + val entries = + listOf( + session("active"), + session("sleeping").copy(snoozedUntil = nowMs + 1_000), + session("expired").copy(snoozedUntil = nowMs), + session("archived").copy(archived = true, snoozedUntil = nowMs + 1_000), + ) + + fun keys( + filter: SessionFilter, + current: String = "active", + time: Long = nowMs, + ) = resolveSessionBrowserEntries(entries, current, filter, recentFirst = true, nowMs = time).map { it.key } + + assertEquals(listOf("active", "expired"), keys(SessionFilter.Recent)) + assertEquals(listOf("sleeping"), keys(SessionFilter.Snoozed)) + assertEquals(emptyList(), keys(SessionFilter.Current, current = "sleeping")) + assertEquals(listOf("expired"), keys(SessionFilter.Current, current = "expired")) + assertEquals(listOf("archived"), keys(SessionFilter.Archived)) + assertEquals(listOf("active", "sleeping", "expired"), keys(SessionFilter.Recent, time = nowMs + 1_000)) + assertEquals(emptyList(), keys(SessionFilter.Snoozed, time = nowMs + 1_000)) + assertEquals(listOf("active", "sleeping", "expired", "archived"), keys(SessionFilter.Recent, current = "archived", time = nowMs + 1_000)) + } + + @Test + fun snoozeMenuRequiresADurableNonArchivedNonChildUnprotectedSession() { + val eligible = session("agent:main:dashboard:work").copy(sessionId = "durable") + assertEquals(true, canSnoozeSession(eligible)) + assertEquals(true, canSnoozeSession(eligible.copy(pinned = true, hasActiveRun = true))) + assertEquals(true, canSnoozeSession(eligible.copy(parentSessionKey = "agent:main:main"))) + val ineligible = + listOf( + eligible.copy(sessionId = null), + eligible.copy(sessionId = " "), + eligible.copy(archived = true), + eligible.copy(isMain = true), + eligible.copy(key = "main"), + eligible.copy(key = "agent:main:main"), + eligible.copy(key = "global"), + eligible.copy(key = "unknown"), + eligible.copy(key = "agent:main:subagent:child"), + eligible.copy(key = "subagent:child"), + eligible.copy(parentSessionKey = "parent"), + eligible.copy(spawnedBy = "parent"), + ) + ineligible.forEach { assertEquals(it.toString(), false, canSnoozeSession(it)) } + } + @Test fun sessionPresentationTitlePrefersExplicitNamesAndKeepsDashboardPlaceholdersLocal() { val dashboardKey = "agent:main:dashboard:fresh" @@ -367,6 +418,7 @@ class SessionsScreenGroupingTest { listOf( session("first", attention = "question", attentionExpiresAt = 100L), session("second", attention = "approval", attentionExpiresAt = 200L), + session("sleeping").copy(snoozedUntil = 150L), ) var nowMs = 90L val waits = mutableListOf() @@ -384,7 +436,9 @@ class SessionsScreenGroupingTest { assertEquals(listOf(10L, 1L), waits) assertEquals(100L, reachedAt) - assertEquals(200L, nextSessionStatusExpiry(entries, reachedAt)) + assertEquals(150L, nextSessionStatusExpiry(entries, reachedAt)) + assertEquals(200L, nextSessionStatusExpiry(entries, 150L)) + assertEquals(null, nextSessionStatusExpiry(entries, 200L)) } @Test diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/SidebarShellLogicTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/SidebarShellLogicTest.kt index a26ff3039abc..41635fca44b5 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/SidebarShellLogicTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/SidebarShellLogicTest.kt @@ -150,10 +150,25 @@ class SidebarShellLogicTest { session("fresh", activity = 30), session("archived", activity = 50, archived = true), session("fresh-pinned", activity = 20, pinned = true), + session("sleeping-pinned", activity = 40, pinned = true).copy(snoozedUntil = 101L), + session("expired", activity = 10).copy(snoozedUntil = 100L), ), + nowMs = 100L, ) - assertEquals(listOf("fresh-pinned", "old-pinned", "fresh"), rows.map(ChatSessionEntry::key)) + assertEquals(listOf("fresh-pinned", "old-pinned", "fresh", "expired"), rows.map(ChatSessionEntry::key)) + } + + @Test + fun sidebarPresentationRestoresSnoozedPinsAtTheirDeadline() { + val rows = listOf(session("sleeping", activity = 20, pinned = true).copy(snoozedUntil = 100L), session("active", activity = 10)) + val before = sidebarSessionPresentation(rows, emptyList(), expanded = false, currentSessionKey = "sleeping", nowMs = 99L) + val after = sidebarSessionPresentation(rows, emptyList(), expanded = false, currentSessionKey = "sleeping", nowMs = 100L) + + assertEquals(emptyList(), before.pinned) + assertEquals(listOf("active"), before.recentSections.flatMap { it.entries }.map { it.key }) + assertEquals(listOf("sleeping"), after.pinned.map { it.key }) + assertEquals(before.recentSections, after.recentSections) } @Test diff --git a/apps/android/scripts/capture-session-snooze.py b/apps/android/scripts/capture-session-snooze.py new file mode 100644 index 000000000000..bd219b4cb19e --- /dev/null +++ b/apps/android/scripts/capture-session-snooze.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Capture synthetic snooze states on the emulator owned by android-screenshots.sh.""" + +import pathlib +import re +import subprocess +import sys +import time +import xml.etree.ElementTree as ET + + +adb, serial, phase, destination = sys.argv[1:] +output = pathlib.Path(destination) +package = "ai.openclaw.app.debug" + + +def run(*args): + return subprocess.check_output([adb, "-s", serial, *args]) + + +def tree(): + raw = run("exec-out", "uiautomator", "dump", "/dev/tty").decode() + (output / "last-ui-dump.txt").write_text(raw) + # During Activity startup UIAutomator can succeed before a root is available. + if "" not in raw: + return None, raw + xml = raw[raw.index("") + len("")] + return ET.fromstring(xml), xml + + +def find(root, label): + return next((node for node in root.iter("node") if label in (node.get("text"), node.get("content-desc"))), None) + + +def wait_for(label): + deadline = time.monotonic() + 45 + while time.monotonic() < deadline: + root, xml = tree() + node = find(root, label) if root is not None else None + if node is not None: + return root, xml, node + time.sleep(0.5) + raise RuntimeError(f"Timed out waiting for {label!r}") + + +def tap(label, long=False): + _, _, node = wait_for(label) + left, top, right, bottom = map(int, re.findall(r"\d+", node.get("bounds"))) + x, y = str((left + right) // 2), str((top + bottom) // 2) + if long: + run("shell", "input", "swipe", x, y, x, y, "800") + else: + run("shell", "input", "tap", x, y) + + +def capture(name, required=(), absent=()): + for label in required: + wait_for(label) + root, xml = tree() + if root is None: + raise RuntimeError(f"{name}: UIAutomator did not return a complete UI tree") + for label in absent: + if find(root, label) is not None: + raise AssertionError(f"{name}: unexpected {label!r}") + (output / f"{name}.xml").write_text(xml) + (output / f"{name}.png").write_bytes(run("exec-out", "screencap", "-p")) + print(f"Captured {output / (name + '.png')}", flush=True) + + +run("shell", "am", "force-stop", package) +run("shell", "am", "start", "-W", "-n", f"{package}/ai.openclaw.app.MainActivity", + "--ez", "openclaw.screenshotMode", "true", "--es", "openclaw.screenshotScene", "snooze") +tap("Show Sidebar") +tap("Threads") +wait_for("Recent") +if phase == "before": + capture("threads-recent", required=("Trip checklist", "Weekend reading"), absent=("Snoozed",)) + capture("threads-no-snoozed-pill", required=("Recent", "Current"), absent=("Snoozed",)) +else: + capture("threads-recent", required=("Trip checklist",), absent=("Weekend reading",)) +tap("Trip checklist", long=True) +capture("active-row-menu", required=("Pin",) + (("Snooze",) if phase == "after" else ()), + absent=("Snooze",) if phase == "before" else ()) +run("shell", "input", "keyevent", "4") +if phase == "after": + tap("Snoozed") + root, _, _ = wait_for("Weekend reading") + if not any((node.get("text") or "").startswith("Wakes ") for node in root.iter("node")): + raise AssertionError("Snoozed row does not show its wake time") + capture("threads-snoozed", required=("Weekend reading",), absent=("Trip checklist",)) +tap("Weekend reading", long=True) +root, _, _ = wait_for("Pin") +has_wake = any((node.get("text") or "").startswith("Wake session · ") for node in root.iter("node")) +if has_wake != (phase == "after"): + raise AssertionError("Wake session menu state does not match capture phase") +capture("snoozed-row-menu", required=("Pin",), absent=("Snooze",)) diff --git a/docs/platforms/android.md b/docs/platforms/android.md index 9620e892339f..7b43ce83aecf 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -22,6 +22,7 @@ The official Android app is available on [Google Play](https://play.google.com/s - Select an agent in the sidebar to view its credential status in **Settings → Providers & Models**. The page updates when the Gateway publishes model, credential, or config changes. Use **Refresh** to recheck model availability. - The sidebar marks sessions waiting for an answer or approval, including inactive sessions and collapsed groups. Tap the attention icon, hover over it, or focus it with a keyboard to read the oldest pending request and the count of additional requests of the same kind. The indicator clears when requests resolve, are canceled, or expire. Question previews never include answer drafts. - The sidebar and recent Threads view keep cron sessions and system-created probes out of ordinary chat lists, without changing saved pins. The selected conversation stays reachable; named work and human-created background conversations remain visible. Open **Threads → Automations** to find and reopen automation and system conversations, including previously pinned chats. This is a view filter, not a saved setting. **Sidebar → Pages pencil → Automations** shows scheduled jobs and their recent run summaries. Other sessions without creation metadata remain visible rather than being classified from their titles. +- Long-press an eligible thread and choose **Snooze**: **In 1 hour**, **In 3 hours**, **This evening** (18:00 local, when more than one hour away), **Tomorrow** (09:00 local), or **Next week** (next Monday at 09:00 local, omitted on Sundays). Snoozed sessions hide from Recent, Current, and sidebar recents. The **Snoozed** pill shows their wake times; **Wake session** brings one back early. Time expiry, a real inbound message, or a completed run wakes the session; pinning or archiving clears snooze too. Snooze syncs through the Gateway and never stops runs, messages, or automations. Archived, child, and protected sessions cannot be snoozed. - **Settings → OpenClaw** opens a dedicated Gateway settings assistant when the operator connection has `operator.admin` and the Gateway supports `openclaw.chat`. Its setup conversation stays separate from ordinary Chat, redacts secret replies locally, and moves to Chat only after you tap **Open Chat**. Its reply field switches to masked input for secret prompts. Tap it again if a prompt change closes the keyboard. Android sends sensitive replies without trimming them and clears unsent drafts when you leave this page or background the app. diff --git a/scripts/android-screenshots.sh b/scripts/android-screenshots.sh index 9f96f3367d30..6a3d2fae0e10 100644 --- a/scripts/android-screenshots.sh +++ b/scripts/android-screenshots.sh @@ -8,7 +8,7 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - scripts/android-screenshots.sh [--form-factor all|phone|wear] [--device ] [--avd ] [--locale en-US] [--skip-build] [--skip-install] [--keep-emulator] [--dry-run] + scripts/android-screenshots.sh [--form-factor all|phone|wear] [--device ] [--avd ] [--locale en-US] [--skip-build] [--skip-install] [--keep-emulator] [--snooze-proof before|after] [--dry-run] Builds and installs the phone and Wear OS debug apps on matching emulators, launches production screens with deterministic local fixture state, and writes @@ -22,6 +22,9 @@ Capture evidence is saved under: By default, the script captures both form factors using retained Pixel 2 and Wear OS Large Round AVDs. Use --form-factor with --avd or --device to capture one form factor on an explicitly selected emulator. + +--snooze-proof captures phone Threads and row menus as PNGs under +.artifacts/android-snooze-proof// without changing store screenshots. EOF } @@ -47,6 +50,7 @@ KEEP_EMULATOR="${ANDROID_SCREENSHOT_KEEP_EMULATOR:-0}" SKIP_BUILD=0 SKIP_INSTALL=0 DRY_RUN=0 +SNOOZE_PROOF="" SCENES=(home chat settings gateway voice-wake) OUTPUT_TYPE="phoneScreenshots" GRADLE_ASSEMBLE_TASK=":app:assemblePlayDebug" @@ -107,6 +111,11 @@ while [[ $# -gt 0 ]]; do DRY_RUN=1 shift ;; + --snooze-proof) + SNOOZE_PROOF="${2:-}" + FORM_FACTOR=phone + shift 2 + ;; -h|--help) usage exit 0 @@ -119,6 +128,19 @@ while [[ $# -gt 0 ]]; do esac done +if [[ -n "$SNOOZE_PROOF" && "$SNOOZE_PROOF" != "before" && "$SNOOZE_PROOF" != "after" ]]; then + echo "--snooze-proof must be before or after." >&2 + exit 1 +fi +if [[ -n "$SNOOZE_PROOF" ]]; then + if [[ "$FORM_FACTOR" != "phone" || "$DEVICE_EXPLICIT" == "1" ]]; then + echo "Snooze proof requires a script-owned phone emulator." >&2 + exit 1 + fi + ARTIFACT_DIR="${ROOT_DIR}/.artifacts/android-snooze-proof/${SNOOZE_PROOF}" + SCENES=(snooze) +fi + case "$FORM_FACTOR" in all) if [[ "$DEVICE_EXPLICIT" == "1" || "$AVD_EXPLICIT" == "1" ]]; then @@ -697,6 +719,9 @@ write_artifact_manifest() { } OUTPUT_DIR="${ANDROID_DIR}/fastlane/metadata/android/${LOCALE}/images/${OUTPUT_TYPE}" +if [[ -n "$SNOOZE_PROOF" ]]; then + OUTPUT_DIR="$ARTIFACT_DIR" +fi ADB_SERIAL="" ADB_DISPLAY="${DEVICE:-}" @@ -718,12 +743,23 @@ fi rm -rf "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR/screenshots" "$ARTIFACT_DIR/ui-dumps" "$ARTIFACT_DIR/activity-start" ADB_BIN="$(adb_bin)" +if [[ -n "$SNOOZE_PROOF" && -n "$("$ADB_BIN" devices | awk 'NR > 1 && NF { print $1 }')" ]]; then + echo "Snooze proof stopped: an Android device already exists and is not owned by this run." >&2 + "$ADB_BIN" devices >&2 + exit 1 +fi resolve_device "$ADB_BIN" +if [[ -n "$SNOOZE_PROOF" && "$STARTED_EMULATOR" != "1" ]]; then + echo "Snooze proof stopped: the selected emulator was not started by this run." >&2 + exit 1 +fi require_emulator_device "$ADB_BIN" "$ADB_SERIAL" stabilize_device_for_screenshots "$ADB_BIN" "$ADB_SERIAL" configure_screenshot_display "$ADB_BIN" "$ADB_SERIAL" -mkdir -p "$OUTPUT_DIR" -rm -f "$OUTPUT_DIR"/*.png "$OUTPUT_DIR"/*.jpg "$OUTPUT_DIR"/*.jpeg +if [[ -z "$SNOOZE_PROOF" ]]; then + mkdir -p "$OUTPUT_DIR" + rm -f "$OUTPUT_DIR"/*.png "$OUTPUT_DIR"/*.jpg "$OUTPUT_DIR"/*.jpeg +fi if [[ "$SKIP_INSTALL" != "1" ]]; then if [[ "$SKIP_BUILD" != "1" ]]; then @@ -749,6 +785,11 @@ fi "$ADB_BIN" -s "$ADB_SERIAL" shell pm grant "$APP_PACKAGE" android.permission.RECORD_AUDIO >/dev/null "$ADB_BIN" -s "$ADB_SERIAL" logcat -c >/dev/null 2>&1 || true +if [[ -n "$SNOOZE_PROOF" ]]; then + python3 "$ANDROID_DIR/scripts/capture-session-snooze.py" "$ADB_BIN" "$ADB_SERIAL" "$SNOOZE_PROOF" "$ARTIFACT_DIR" + exit 0 +fi + for scene in "${SCENES[@]}"; do output_path="${OUTPUT_DIR}/openclaw-${scene}.jpg" raw_path="${OUTPUT_DIR}/openclaw-${scene}.raw.png"