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.
This commit is contained in:
Peter Steinberger 2026-09-30 20:38:08 -07:00 • committed by GitHub
parent 7e43f459ce
commit 2fd8e321ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 756 additions and 21 deletions

View file

@ -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"}]},

View file

@ -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

View file

@ -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") {

View file

@ -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),

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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<Preset> {
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<ChatSessionEntry>,
nowMs: Long,
): Long? =
entries
.asSequence()
.filter { it.isSnoozed(nowMs) }
.mapNotNull { it.snoozedUntil }
.minOrNull()
}

View file

@ -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<SessionRowSubmenu?>(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<ChatSessionEntry> {
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<ChatSessionEntry>,
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.")
}

View file

@ -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<ChatSessionEntry>,
currentSessionKey: String = "",
nowMs: Long = System.currentTimeMillis(),
): List<ChatSessionEntry> =
sessions
.asSequence()
.filter { isSessionVisibleInNavigation(it, currentSessionKey) }
.filter { isSessionVisibleInNavigation(it, currentSessionKey, nowMs) }
.sortedWith(
compareByDescending<ChatSessionEntry> { it.pinned == true }
.thenByDescending { it.lastActivityAt ?: it.updatedAtMs ?: 0L }
@ -213,8 +215,9 @@ internal fun sidebarSessionPresentation(
expanded: Boolean,
excludedSessionKeys: Set<String> = 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 },

View file

@ -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,
) {

View file

@ -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', ' ')
}

View file

@ -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<String>(), 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<String>(), 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<Long>()
@ -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

View file

@ -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<ChatSessionEntry>(), 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

View file

@ -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 "<?xml" not in raw or "</hierarchy>" not in raw:
return None, raw
xml = raw[raw.index("<?xml"):raw.rindex("</hierarchy>") + len("</hierarchy>")]
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",))

View file

@ -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.

View file

@ -8,7 +8,7 @@ set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/android-screenshots.sh [--form-factor all|phone|wear] [--device <adb-serial>] [--avd <name>] [--locale en-US] [--skip-build] [--skip-install] [--keep-emulator] [--dry-run]
scripts/android-screenshots.sh [--form-factor all|phone|wear] [--device <adb-serial>] [--avd <name>] [--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/<before|after>/ 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:-<auto>}"
@ -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"