mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
7e43f459ce
commit
2fd8e321ba
17 changed files with 756 additions and 21 deletions
13
apps/.i18n/native-source.json
generated
13
apps/.i18n/native-source.json
generated
|
|
@ -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"}]},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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.")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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', ' ')
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
96
apps/android/scripts/capture-session-snooze.py
Normal file
96
apps/android/scripts/capture-session-snooze.py
Normal 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",))
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue