diff --git a/.kilo/skills/kilo-mcp/SKILL.md b/.kilo/skills/kilo-mcp/SKILL.md index 7317184403..9ed399f9a4 100644 --- a/.kilo/skills/kilo-mcp/SKILL.md +++ b/.kilo/skills/kilo-mcp/SKILL.md @@ -51,7 +51,7 @@ to send platform feedback and bug reports rather than a support channel. The first path segment names an area. Search to learn an area's vocabulary: each row's `summary` and `tags` are what the search matches. -**863 procedures** — **370 queries**, **493 mutations** — under **53 prefixes**. +**864 procedures** — **371 queries**, **493 mutations** — under **53 prefixes**. | Prefix | Procedures | Queries | Mutations | |---|---:|---:|---:| @@ -88,7 +88,7 @@ each row's `summary` and `tags` are what the search matches. | `modelPreferences` | 6 | 1 | 5 | | `usageAnalytics` | 6 | 6 | 0 | | `workspaceFolders` | 6 | 1 | 5 | -| `activeSessions` | 4 | 3 | 1 | +| `activeSessions` | 5 | 4 | 1 | | `debug` | 4 | 4 | 0 | | `discord` | 4 | 1 | 3 | | `securityAuditLog` | 4 | 3 | 1 | diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 30a968ce20..6f85db6b83 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -484,6 +484,8 @@ const config: ExpoConfig = { // Aggregate "Active Agents" glanceable surfaces: one Live Activity plus Home // Screen and Lock Screen widgets, rendered by src/glanceable-ios. The widget // target reuses the existing app group; no second group is created. + // Native Home refresh runs after expo-widgets' source generation (mods run in reverse). + './plugins/withHomeWidgetRefresh', [ 'expo-widgets', { diff --git a/apps/mobile/assets/images/logo-widget.png b/apps/mobile/assets/images/logo-widget.png deleted file mode 100644 index 8e72fff48d..0000000000 Binary files a/apps/mobile/assets/images/logo-widget.png and /dev/null differ diff --git a/apps/mobile/index.js b/apps/mobile/index.js index 9649fc1e9f..e89697e79f 100644 --- a/apps/mobile/index.js +++ b/apps/mobile/index.js @@ -1,18 +1,8 @@ // The app entry. // -// Android redraws a placed widget, and answers the ongoing notification's -// Approve action, from a headless JS task: the bundle loads with no Activity, -// no route and no notification handler runs first, so both tasks have to be -// registered here. The widget module loads only when its task fires — requiring -// it at entry would start the widget sink before `expo-router/entry` sets the -// app up. +// Android widget redraws, native refresh, and notification approval run without +// an Activity. Register their lazy task factories before the router entry. // -// The Approve action on the Live Update notification boots the same kind of -// headless run, so its task is registered here too, under the key its Kotlin -// worker starts (`KiloActiveAgentsApprove`). `registerApproveTask` is the second -// registration that action can take, through `ActiveAgentsApproveTaskService`: -// it requires the task module itself, which is why the literal above is only a -// factory. // // `require`, not `import`: ESM hoisting would run `expo-router/entry` first. const { AppRegistry, LogBox, Platform } = require('react-native'); @@ -32,12 +22,12 @@ if (Platform.OS === 'android') { await handleWidgetTask(task); }); - // `KiloActiveAgentsApprove` is `APPROVE_HEADLESS_TASK_KEY` - // (src/glanceable-android/approve-task.ts) and the Kotlin worker's - // `TASK_NAME`; only the string crosses the native boundary, so the three are - // asserted equal in approve-task.test.ts. The factory keeps this registration - // bodyless, like the widget handler above; the module itself arrives with the - // eager `registerApproveTask` require. + AppRegistry.registerHeadlessTask( + 'HomeWidgetRefresh', + () => require('./src/glanceable-android/register').handleHomeWidgetRefresh + ); + + // The Kotlin approval worker starts this key without a foreground Activity. AppRegistry.registerHeadlessTask( 'KiloActiveAgentsApprove', () => require('./src/glanceable-android/approve-task').handleApproveTask diff --git a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt index 69404a13a4..a62ba65d9b 100644 --- a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt +++ b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt @@ -5,13 +5,20 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.res.Configuration +import android.graphics.Color import android.graphics.drawable.Icon import android.net.Uri import android.os.Build +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan import android.util.Log import expo.modules.kotlin.exception.Exceptions import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import kotlinx.coroutines.CoroutineScope @@ -24,9 +31,10 @@ import kotlinx.coroutines.asCoroutineDispatcher * its creation), the alert decision, and the revision guard; this module owns * the fixed notification id, the posted-channel mirror, the API 36.1+ promotion * gate, and the content intent plus named actions: Open deep-links into the - * recorded session (the Agents tab when nothing waits), and Approve runs the - * headless approval when a cloud-agent permission waits. Both platforms answer - * through `src/lib/glanceable/approve-ask.ts`. + * recorded session (the Agents tab when nothing waits), Approve runs the + * headless approval when a cloud-agent permission waits, and New agent opens + * the composer on a working card. Both platforms answer through + * `src/lib/glanceable/approve-ask.ts`. * * This is the Android mechanism for the one shared kind model, not a second * behaviour: `@kilocode/notifications` maps each agent surface to `needs-input` @@ -58,8 +66,8 @@ class ActiveAgentsLiveUpdateModule : Module() { notificationManager.isNotificationPolicyAccessGranted } - // Group the Open label and URL so both entry points fit Expo's eight-argument - // Function limit while preserving the action and notification-kind fields. + // The card's content and actions travel as one record so both entry points + // fit Expo's eight-argument Function limit beside the notification-kind fields. // // `start`, `update` and `end` are the durable writes whose state the JS side // never reads back in the same turn: their bodies commit to SharedPreferences @@ -68,20 +76,18 @@ class ActiveAgentsLiveUpdateModule : Module() { // thread. The JS bridge (`src/glanceable-android/live-update.ts`) declares // them `void` and never consumes the promise, which is why `durable` logs a // failure instead of letting it become an unhandled rejection. - AsyncFunction("start") { title: String, text: String, openAction: Map, approveLabel: String?, compactText: String?, channelId: String, alerting: Boolean, promotion: Boolean -> + AsyncFunction("start") { card: LiveUpdateCard, channelId: String, alerting: Boolean, promotion: Boolean -> durable { - post(title, text, openAction.getValue("label"), openAction.getValue("url"), approveLabel, compactText, channelId, alerting, promotion, 0) + post(card, channelId, alerting, promotion, 0) } }.runOnQueue(moduleQueue) - // Expo's `Function` builder has one overload per arity and stops at eight - // arguments (expo-modules-core `ObjectDefinitionBuilder`), so `update` - // cannot carry `start`'s `promotion` flag on top of the terminal - // `timeoutMs`. The flag is redundant on this path: `post` gates promotion - // on `isPromotionCapable()` itself, which is the value the JS side passed. - AsyncFunction("update") { title: String, text: String, openAction: Map, approveLabel: String?, compactText: String?, channelId: String, alerting: Boolean, timeoutMs: Double -> + // `update` carries the terminal `timeoutMs` instead of `start`'s `promotion` + // flag. The flag is redundant on this path: `post` gates promotion on + // `isPromotionCapable()` itself, which is the value the JS side passes. + AsyncFunction("update") { card: LiveUpdateCard, channelId: String, alerting: Boolean, timeoutMs: Double -> durable { - post(title, text, openAction.getValue("label"), openAction.getValue("url"), approveLabel, compactText, channelId, alerting, isPromotionCapable(), timeoutMs.toLong()) + post(card, channelId, alerting, isPromotionCapable(), timeoutMs.toLong()) } }.runOnQueue(moduleQueue) @@ -279,6 +285,30 @@ class ActiveAgentsLiveUpdateModule : Module() { ) } + /** + * The New agent action: the app's fixed new-agent deep link. One constant URL, + * so the record under its request code never accumulates. + */ + private fun newAgentPendingIntent(url: String): PendingIntent { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + setPackage(context.packageName) + } + return PendingIntent.getActivity( + context, + NEW_AGENT_REQUEST_CODE, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + /** The app is drawing in its dark theme: the card's accent and retry red follow it. */ + private fun isNight(): Boolean = + (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES + + private fun action(label: String, intent: PendingIntent): Notification.Action = + Notification.Action.Builder(Icon.createWithResource(context, smallIconId()), label, intent).build() + /** * The channel a card still in the shade carries, or null when no card is * posted. The stored marker alone can outlive the card: a terminal timeout @@ -302,17 +332,13 @@ class ActiveAgentsLiveUpdateModule : Module() { } private fun post( - title: String, - text: String, - openLabel: String, - openUrl: String, - approveLabel: String?, - compactText: String?, + card: LiveUpdateCard, channelId: String, alerting: Boolean, promotion: Boolean, timeoutMs: Long ) { + val openUrl = card.openUrl // Read the URL the shade card carries before this call can change it: a // failed post must leave `OPEN_URL` naming the previous card's record. val previousOpenUrl = notificationState.getString(OPEN_URL, null) @@ -323,32 +349,38 @@ class ActiveAgentsLiveUpdateModule : Module() { // uses. A needs-input card takes both; a progress card keeps the status // category and the silent behaviour it had. val needsInput = channelId == NEEDS_INPUT_CHANNEL_ID + // The system template, coloured with the app's primary (olive by day, lime + // by night). The retry line after a failed approve draws in the destructive + // red; everything else is the template's own text colour. + val night = isNight() + val text: CharSequence = if (card.textIsError) { + SpannableString(card.text).apply { + setSpan( + ForegroundColorSpan(Color.parseColor(if (night) ERROR_NIGHT else ERROR_DAY)), + 0, + length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } else { + card.text + } val builder = newBuilder(channelId) .setSmallIcon(smallIconId()) - .setContentTitle(title) + .setColor(Color.parseColor(if (night) PRIMARY_NIGHT else PRIMARY_DAY)) + .setContentTitle(card.title) .setContentText(text) + .setSubText(card.subText) .setContentIntent(contentIntent) .setOngoing(true) .setCategory(if (needsInput) Notification.CATEGORY_MESSAGE else Notification.CATEGORY_STATUS) - .addAction( - Notification.Action.Builder( - Icon.createWithResource(context, smallIconId()), - openLabel, - contentIntent - ).build() - ) - - // No recorded approvable ask: the action is omitted, not disabled. The JS - // side drops the label once the wait is answered elsewhere. - if (approveLabel != null) { - builder.addAction( - Notification.Action.Builder( - Icon.createWithResource(context, smallIconId()), - approveLabel, - approvePendingIntent() - ).build() - ) - } + + // Approve first, then Open, then New agent. An action the card does not + // offer is omitted, not disabled: the JS side drops Approve once the wait is + // answered elsewhere or while an answer is in flight. + card.approveLabel?.let { builder.addAction(action(it, approvePendingIntent())) } + builder.addAction(action(card.openLabel, contentIntent)) + card.newAgentLabel?.let { builder.addAction(action(it, newAgentPendingIntent(card.newAgentUrl))) } if (needsInput) { // Only the first entry into the kind alerts; a later update in the same @@ -363,7 +395,7 @@ class ActiveAgentsLiveUpdateModule : Module() { // setRequestPromotedOngoing does not exist; use the documented flag setter. if (promotion && isPromotionCapable()) { builder.setFlag(Notification.FLAG_PROMOTED_ONGOING, true) - builder.setShortCriticalText(compactText) + builder.setShortCriticalText(card.compactText) builder.setStyle(Notification.ProgressStyle()) } @@ -462,6 +494,13 @@ class ActiveAgentsLiveUpdateModule : Module() { /** The kind marker in the channel id the JS side creates for needs-input. */ const val NEEDS_INPUT_CHANNEL_ID = "needs-input" const val APPROVE_REQUEST_CODE = 1003 + const val NEW_AGENT_REQUEST_CODE = 1004 + + /** The app's primary and destructive colours (`global.css`), day and night. */ + const val PRIMARY_DAY = "#4F5A10" + const val PRIMARY_NIGHT = "#E8F27A" + const val ERROR_DAY = "#B0483A" + const val ERROR_NIGHT = "#F28B7A" /** * The module's one serial queue for the durable write path, shared by every @@ -491,3 +530,17 @@ class ActiveAgentsLiveUpdateModule : Module() { Executors.newSingleThreadExecutor { Thread(it, "active-agents-live-update") } } } + +/** One card's content and actions, as the JS bridge (`live-update.ts`) sends it. */ +class LiveUpdateCard : Record { + @Field val title: String = "" + @Field val text: String = "" + @Field val textIsError: Boolean = false + @Field val subText: String? = null + @Field val compactText: String? = null + @Field val openLabel: String = "" + @Field val openUrl: String = "" + @Field val approveLabel: String? = null + @Field val newAgentLabel: String? = null + @Field val newAgentUrl: String = "" +} diff --git a/apps/mobile/modules/home-widget-refresh/android/build.gradle b/apps/mobile/modules/home-widget-refresh/android/build.gradle new file mode 100644 index 0000000000..36bcc56af6 --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/android/build.gradle @@ -0,0 +1,14 @@ +apply plugin: 'com.android.library' +group = 'com.kilocode.homewidgetrefresh' +version = '1.0.0' +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() +useDefaultAndroidSdkVersions() +android { namespace 'com.kilocode.homewidgetrefresh' } +dependencies { + implementation 'androidx.work:work-runtime:2.8.1' + implementation 'com.facebook.react:react-android' +} diff --git a/apps/mobile/modules/home-widget-refresh/android/src/main/AndroidManifest.xml b/apps/mobile/modules/home-widget-refresh/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..f79cac1ac1 --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshModule.kt b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshModule.kt new file mode 100644 index 0000000000..98f633b078 --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshModule.kt @@ -0,0 +1,54 @@ +package com.kilocode.homewidgetrefresh + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import org.json.JSONObject +import org.json.JSONArray + +class HomeWidgetRefreshModule : Module() { + private val operationLock = Any() + private var operationEpoch = 0L + private var destroyed = false + private val context get() = requireNotNull(appContext.reactContext) + override fun definition() = ModuleDefinition { + Name("HomeWidgetRefresh") + Function("getOperationEpoch") { synchronized(operationLock) { operationEpoch.toDouble() } } + AsyncFunction("configure") { payload: String -> + val json = JSONObject(payload) + val epoch = json.getLong("operationEpoch") + synchronized(operationLock) { + if (!destroyed && epoch > operationEpoch) { + operationEpoch = epoch + HomeWidgetStore.configure(context, json) + } + } + } + AsyncFunction("clear") { epoch: Long -> + synchronized(operationLock) { + if (!destroyed && epoch > operationEpoch) { + operationEpoch = epoch + HomeWidgetStore.clear(context) + } + } + } + AsyncFunction("setFixtureMode") { enabled: Boolean -> HomeWidgetStore.fixture(context, enabled) } + AsyncFunction("getData") { HomeWidgetStore.data(context)?.let { jsonMap(it) } } + AsyncFunction("getWidgetPushToken") { null as String? } + AsyncFunction("widgetsChanged") { HomeWidgetStore.schedule(context, replace = true) } + AsyncFunction("isCurrent") { scope: String, epoch: Int, generation: String -> HomeWidgetStore.current(context, scope, epoch, generation) } + OnDestroy { + synchronized(operationLock) { + destroyed = true + operationEpoch += 1 + } + } + } +} + +internal fun jsonValue(value: Any?): Any? = when (value) { + JSONObject.NULL -> null + is JSONObject -> jsonMap(value) + is JSONArray -> (0 until value.length()).map { jsonValue(value.get(it)) } + else -> value +} +internal fun jsonMap(value: JSONObject): Map = value.keys().asSequence().associateWith { jsonValue(value.get(it)) } diff --git a/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshWorker.kt b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshWorker.kt new file mode 100644 index 0000000000..4e65e857ec --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetRefreshWorker.kt @@ -0,0 +1,91 @@ +package com.kilocode.homewidgetrefresh + +import android.content.Context +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.facebook.react.ReactApplication +import com.facebook.react.ReactInstanceEventListener +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.jstasks.HeadlessJsTaskConfig +import com.facebook.react.jstasks.HeadlessJsTaskContext +import com.facebook.react.jstasks.HeadlessJsTaskEventListener +import org.json.JSONObject +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** WorkManager performs networking before booting JS; JS is only the widget renderer. */ +class HomeWidgetRefreshWorker(context: Context, params: WorkerParameters) : Worker(context, params) { + @Volatile private var stopped = false + private val finished = CountDownLatch(1) + private var listener: ReactInstanceEventListener? = null + private var taskContext: HeadlessJsTaskContext? = null + private var taskListener: HeadlessJsTaskEventListener? = null + private var taskId = -1 + + override fun doWork(): Result { + try { + val payload = try { HomeWidgetStore.fetch(applicationContext) } catch (_: Exception) { null } + if (payload != null && !isStopped) render(payload) + return Result.success() + } finally { + cleanup() + if (!isStopped) HomeWidgetStore.schedule(applicationContext) + } + } + + private fun render(payload: JSONObject) { + val host = (applicationContext as? ReactApplication)?.reactHost ?: return + UiThreadUtil.runOnUiThread { + if (stopped) return@runOnUiThread + val ready = host.currentReactContext + if (ready != null) startTask(ready, payload) else { + val pending = object : ReactInstanceEventListener { + override fun onReactContextInitialized(context: ReactContext) { + listener?.let { host.removeReactInstanceEventListener(it) } + listener = null + startTask(context, payload) + } + } + listener = pending + host.addReactInstanceEventListener(pending) + val initialized = host.currentReactContext + if (initialized != null) { + host.removeReactInstanceEventListener(pending) + listener = null + startTask(initialized, payload) + } else host.start() + } + } + finished.await(60, TimeUnit.SECONDS) + } + + private fun startTask(context: ReactContext, payload: JSONObject) { + if (stopped || !HomeWidgetStore.current(applicationContext, payload.getString("scopeKey"), payload.getInt("accountEpoch"), payload.getString("generation"))) { + finished.countDown() + return + } + val tasks = HeadlessJsTaskContext.getInstance(context) + val events = object : HeadlessJsTaskEventListener { + override fun onHeadlessJsTaskStart(id: Int) = Unit + override fun onHeadlessJsTaskFinish(id: Int) { if (id == taskId) finished.countDown() } + } + taskContext = tasks + taskListener = events + tasks.addTaskEventListener(events) + taskId = tasks.startTask(HeadlessJsTaskConfig("HomeWidgetRefresh", Arguments.makeNativeMap(jsonMap(payload)), 45_000, true)) + } + + private fun cleanup() { + stopped = true + UiThreadUtil.runOnUiThread { + val host = (applicationContext as? ReactApplication)?.reactHost + listener?.let { host?.removeReactInstanceEventListener(it) } + listener = null + taskListener?.let { taskContext?.removeTaskEventListener(it) } + taskListener = null + } + } + override fun onStopped() { super.onStopped(); cleanup(); finished.countDown() } +} diff --git a/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetStore.kt b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetStore.kt new file mode 100644 index 0000000000..517eee1659 --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/android/src/main/java/com/kilocode/homewidgetrefresh/HomeWidgetStore.kt @@ -0,0 +1,153 @@ +package com.kilocode.homewidgetrefresh + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.work.* +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.security.KeyStore +import java.util.UUID +import java.util.concurrent.TimeUnit +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +internal object HomeWidgetStore { + private const val ALIAS = "kilo-home-widget-context" + private const val WORK = "kilo-home-widget-refresh" + private fun prefs(context: Context) = context.getSharedPreferences("home-widget-refresh", Context.MODE_PRIVATE) + /** A missing key and a JSON null are both "no value"; `optString` would return the literal "null". */ + private fun JSONObject.text(key: String): String? = if (isNull(key)) null else optString(key, null) + private fun JSONObject.integer(key: String): Int? = if (isNull(key)) null else getInt(key) + private fun JSONObject.number(key: String): Long? = if (isNull(key)) null else getLong(key) + private fun key(): SecretKey { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (store.getKey(ALIAS, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { + init(KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).build()) + }.generateKey() + } + @Synchronized fun config(context: Context): JSONObject? { + val encrypted = prefs(context).getString("protectedContext", null) ?: return null + return try { + val bytes = Base64.decode(encrypted, Base64.NO_WRAP) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0, 12))) + JSONObject(String(cipher.doFinal(bytes, 12, bytes.size - 12), Charsets.UTF_8)) + } catch (_: Exception) { null } + } + @Synchronized fun configure(context: Context, config: JSONObject) { + val previous = HomeWidgetStore.config(context) + if (previous != null && (previous.text("scopeKey") != config.text("scopeKey") || previous.integer("accountEpoch") != config.integer("accountEpoch"))) clear(context) + val refreshAt = config.number("refreshAt") ?: (System.currentTimeMillis() + 1_800_000) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key()) + val encrypted = cipher.iv + cipher.doFinal(config.toString().toByteArray(Charsets.UTF_8)) + check(prefs(context).edit().putString("protectedContext", Base64.encodeToString(encrypted, Base64.NO_WRAP)) + .putString("generation", UUID.randomUUID().toString()).putString("data", config.getJSONObject("data").toString()) + .remove("terminalFence").putLong("refreshAt", refreshAt) + .putLong("refreshDelay", (refreshAt - System.currentTimeMillis()).coerceAtLeast(900_000)).commit()) + schedule(context, replace = true) + } + @Synchronized fun clear(context: Context, cancel: Boolean = true) { + prefs(context).edit().remove("protectedContext").remove("data").remove("terminalFence") + .remove("refreshAt").remove("refreshDelay").putString("generation", UUID.randomUUID().toString()).commit() + if (cancel) WorkManager.getInstance(context).cancelUniqueWork(WORK) + } + @Synchronized fun fixture(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean("fixture", enabled).putString("generation", UUID.randomUUID().toString()).commit() + if (enabled) WorkManager.getInstance(context).cancelUniqueWork(WORK) else schedule(context, replace = true) + } + fun hasWidgets(context: Context): Boolean = AppWidgetManager.getInstance(context) + .getAppWidgetIds(ComponentName(context.packageName, "${context.packageName}.widget.ActiveAgentsWidget")).isNotEmpty() + @Synchronized fun generation(context: Context) = prefs(context).getString("generation", "")!! + @Synchronized fun current(context: Context, scope: String, epoch: Int, generation: String): Boolean { + if (!hasWidgets(context) || prefs(context).getBoolean("fixture", false) || generation(context) != generation) return false + val config = config(context) ?: prefs(context).getString("terminalFence", null)?.let { JSONObject(it) } ?: return false + return config.text("scopeKey") == scope && config.integer("accountEpoch") == epoch + } + @Synchronized fun data(context: Context): JSONObject? = prefs(context).getString("data", null)?.let { JSONObject(it) } + @Synchronized fun schedule(context: Context, replace: Boolean = false) { + if (config(context) == null || !hasWidgets(context) || prefs(context).getBoolean("fixture", false)) { + WorkManager.getInstance(context).cancelUniqueWork(WORK) + return + } + val now = System.currentTimeMillis() + val wake = prefs(context).getLong("refreshAt", now + 1_800_000) + val requested = if (wake > now) wake - now else prefs(context).getLong("refreshDelay", 1_800_000) + val delay = requested.coerceAtLeast(900_000) + val request = OneTimeWorkRequest.Builder(HomeWidgetRefreshWorker::class.java) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setInitialDelay(delay, TimeUnit.MILLISECONDS).build() + WorkManager.getInstance(context).enqueueUniqueWork(WORK, + if (replace) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.APPEND_OR_REPLACE, request) + } + fun fetch(context: Context): JSONObject? { + val config: JSONObject + val generation: String + synchronized(this) { + if (!hasWidgets(context) || prefs(context).getBoolean("fixture", false)) return null + config = config(context) ?: return null + generation = generation(context) + } + val endpoint = config.text("endpoint")?.let { URL(it) } ?: return null + require(endpoint.protocol == "https" || endpoint.host == "localhost" || endpoint.host == "127.0.0.1" || endpoint.host == "10.0.2.2") + val token = config.text("token") ?: return null + val scopeKey = config.text("scopeKey") ?: return null + val accountEpoch = config.integer("accountEpoch") ?: return null + val connection = endpoint.openConnection() as HttpURLConnection + connection.connectTimeout = 15_000 + connection.readTimeout = 15_000 + connection.instanceFollowRedirects = false + connection.setRequestProperty("Authorization", "Bearer $token") + connection.setRequestProperty("Accept", "application/json") + try { + val status = connection.responseCode + // Only an authentication refusal is terminal; 403 and every other failure keep retained content. + if (status == 401) { + synchronized(this) { + if (!current(context, scopeKey, accountEpoch, generation)) return null + clear(context, cancel = false) + val fence = JSONObject().put("scopeKey", scopeKey) + .put("accountEpoch", accountEpoch).put("generation", generation(context)) + prefs(context).edit().putString("terminalFence", fence.toString()).commit() + return fence.put("terminal", "privacy") + } + } + if (status != 200) return null + val bytes = connection.inputStream.use { stream -> + val output = java.io.ByteArrayOutputStream() + val buffer = ByteArray(4096) + while (output.size() <= 262_144) { + val count = stream.read(buffer, 0, minOf(buffer.size, 262_145 - output.size())) + if (count < 0) break + output.write(buffer, 0, count) + } + output.toByteArray() + } + if (bytes.size > 262_144) return null + val response = JSONObject(String(bytes, Charsets.UTF_8)) + val snapshot = response.getJSONObject("snapshot") + if (snapshot.getString("scopeKey") != config.getString("scopeKey")) return null + response.getJSONObject("home") + val data = JSONObject().put("snapshot", snapshot).put("details", response.getJSONObject("details")) + synchronized(this) { + if (!current(context, scopeKey, accountEpoch, generation)) return null + val oldAt = HomeWidgetStore.data(context)?.getJSONObject("snapshot")?.optString("updatedAt") + if (oldAt != null && snapshot.getString("updatedAt") < oldAt) return null + prefs(context).edit().putString("data", data.toString()).putLong("refreshAt", response.getLong("refreshAt")) + .putLong("refreshDelay", (response.getLong("refreshAt") - System.currentTimeMillis()).coerceAtLeast(900_000)).commit() + } + return JSONObject().put("response", response).put("scopeKey", scopeKey) + .put("accountEpoch", accountEpoch).put("generation", generation) + .put("copy", config.optJSONObject("copy") ?: JSONObject()).put("locale", config.text("locale") ?: "") + } finally { connection.disconnect() } + } +} diff --git a/apps/mobile/modules/home-widget-refresh/expo-module.config.json b/apps/mobile/modules/home-widget-refresh/expo-module.config.json new file mode 100644 index 0000000000..83584302ab --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/expo-module.config.json @@ -0,0 +1,5 @@ +{ + "platforms": ["apple", "android"], + "apple": { "modules": ["HomeWidgetRefreshModule"] }, + "android": { "modules": ["com.kilocode.homewidgetrefresh.HomeWidgetRefreshModule"] } +} diff --git a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefresh.podspec b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefresh.podspec new file mode 100644 index 0000000000..c312ca7031 --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefresh.podspec @@ -0,0 +1,16 @@ +Pod::Spec.new do |s| + s.name = 'HomeWidgetRefresh' + s.version = '1.0.0' + s.summary = 'Authenticated background Home widget refresh' + s.description = 'Shared protected authentication and WidgetKit refresh transport.' + s.license = { :type => 'Proprietary' } + s.author = 'Kilo' + s.homepage = 'https://github.com/Kilo-Org/cloud' + s.source = { :git => 'https://github.com/Kilo-Org/cloud.git' } + s.platforms = { :ios => '16.4' } + s.swift_version = '5.9' + s.static_framework = true + s.dependency 'ExpoModulesCore' + s.source_files = 'HomeWidgetRefreshModule.swift', 'HomeWidgetRefreshStore.swift' + s.frameworks = 'Security', 'WidgetKit' +end diff --git a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift new file mode 100644 index 0000000000..276b2fb8c2 --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshModule.swift @@ -0,0 +1,98 @@ +import ExpoModulesCore +import WidgetKit +import Foundation + +public class HomeWidgetRefreshModule: Module { + private let operationLock = NSLock() + private var operationEpoch = 0 + private var destroyed = false + private var pendingClearEpoch: Int? + + private func performOperation(_ epoch: Int, _ body: () throws -> Void) rethrows -> Bool { + operationLock.lock() + defer { operationLock.unlock() } + guard !destroyed, epoch > operationEpoch else { return false } + operationEpoch = epoch + // A newer configure supersedes any in-flight clear; teardown must not erase the new scope. + pendingClearEpoch = nil + try body() + return true + } + + private func currentOperationEpoch() -> Int { + operationLock.lock() + defer { operationLock.unlock() } + return operationEpoch + } + + /// Admits a clear and captures the still-stored credential for unregister before it is removed. + private func admitClear(_ epoch: Int) -> (config: [String: Any]?, token: String?)? { + operationLock.lock() + defer { operationLock.unlock() } + guard !destroyed, epoch > operationEpoch else { return nil } + operationEpoch = epoch + pendingClearEpoch = epoch + // Without the app group there is no stored credential to unregister; the clear still completes. + return HomeWidgetRefreshStore.locked { + (HomeWidgetRefreshStore.context(), $0.string(forKey: "homeWidgetPushToken")) + } ?? (nil, nil) + } + + private func finishClear(_ epoch: Int) { + operationLock.lock() + defer { operationLock.unlock() } + guard pendingClearEpoch == epoch else { return } + pendingClearEpoch = nil + // A newer configure supersedes this clear; it already replaced (and fenced) the old scope. + guard !destroyed, operationEpoch == epoch else { return } + HomeWidgetRefreshStore.clear() + } + public func definition() -> ModuleDefinition { + Name("HomeWidgetRefresh") + Function("getOperationEpoch") { self.currentOperationEpoch() } + AsyncFunction("configure") { (payload: String) in + // The payload crosses the bridge as JSON text: a null field (a Personal + // scope's `organizationId`, a row without an approval key) makes Expo's + // Swift argument conversion fail before this body runs. + guard let config = (try? JSONSerialization.jsonObject(with: Data(payload.utf8))) as? [String: Any] else { + throw NSError(domain: HomeWidgetRefreshStore.service, code: 2, userInfo: [NSLocalizedDescriptionKey: "Widget configuration is not JSON"]) + } + guard let epoch = config["operationEpoch"] as? Int else { + throw NSError(domain: HomeWidgetRefreshStore.service, code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing widget operation epoch"]) + } + let applied = try self.performOperation(epoch) { try HomeWidgetRefreshStore.configure(config) } + if applied { await HomeWidgetRefreshStore.registerStoredPushToken() } + } + AsyncFunction("clear") { (epoch: Int) in + guard let captured = self.admitClear(epoch) else { return } + if let config = captured.config, let token = captured.token { + await HomeWidgetRefreshStore.unregisterPushToken(token, config: config) + } + self.finishClear(epoch) + } + AsyncFunction("setFixtureMode") { (enabled: Bool) in HomeWidgetRefreshStore.fixture(enabled) } + AsyncFunction("setFixtureLocale") { (locale: String) in HomeWidgetRefreshStore.fixtureLocale(locale) } + AsyncFunction("getData") { () -> [String: Any]? in + HomeWidgetRefreshStore.locked { HomeWidgetRefreshStore.readJSON($0, "homeWidgetData") } ?? nil + } + AsyncFunction("getWidgetPushToken") { () -> String? in + HomeWidgetRefreshStore.defaults?.string(forKey: "homeWidgetPushToken") + } + AsyncFunction("isCurrent") { (scope: String, epoch: Int, generation: String) -> Bool in + HomeWidgetRefreshStore.locked { + HomeWidgetRefreshStore.current($0, ["scopeKey": scope, "accountEpoch": epoch], generation) + } ?? false + } + AsyncFunction("widgetsChanged") { WidgetCenter.shared.reloadTimelines(ofKind: HomeWidgetRefreshStore.widgetName) } + OnDestroy { + self.operationLock.lock() + self.destroyed = true + self.operationEpoch += 1 + let clearPending = self.pendingClearEpoch != nil + self.pendingClearEpoch = nil + self.operationLock.unlock() + // An admitted sign-out clear must still complete when its unregister is interrupted by teardown. + if clearPending { HomeWidgetRefreshStore.clear() } + } + } +} diff --git a/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift new file mode 100644 index 0000000000..3e43108a5b --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/ios/HomeWidgetRefreshStore.swift @@ -0,0 +1,354 @@ +import Foundation +import Security +import WidgetKit +import SwiftUI +import Darwin + +// Compiled into both the app pod and widget target. JWT/context never enter UserDefaults. +enum HomeWidgetRefreshStore { + static let widgetName = "ActiveAgentsWidget" + static let service = "com.kilocode.home-widget-refresh" + static let group = Bundle.main.object(forInfoDictionaryKey: "ExpoWidgetsAppGroupIdentifier") as? String ?? "group.com.kilocode.kiloapp" + /// Nil when the app group is unusable; `locked` then skips the work instead of trapping. + static let defaults = UserDefaults(suiteName: group) + static let lockURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group)? + .appendingPathComponent("home-widget-refresh.lock") + static let accessGroup = Bundle.main.object(forInfoDictionaryKey: "HomeWidgetKeychainAccessGroup") as? String + static let session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 20 + return URLSession(configuration: config, delegate: HomeWidgetNetworkDelegate(), delegateQueue: nil) + }() + static var layoutDirection: LayoutDirection { + let locale = defaults?.string(forKey: "homeWidgetLocale") ?? Locale.current.identifier + let language = locale.replacingOccurrences(of: "_", with: "-").split(separator: "-").first.map(String.init) ?? "en" + return Locale.characterDirection(forLanguage: language) == .rightToLeft ? .rightToLeft : .leftToRight + } + static var keyQuery: [String: Any] { + var query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: "context"] + if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup } + return query + } + + static func saveJSON(_ defaults: UserDefaults, _ value: Any?, key: String) { + guard let value, let data = try? JSONSerialization.data(withJSONObject: value) else { return } + defaults.set(data, forKey: key) + } + static func readJSON(_ defaults: UserDefaults, _ key: String) -> [String: Any]? { + guard let bytes = defaults.data(forKey: key) else { return nil } + return (try? JSONSerialization.jsonObject(with: bytes)) as? [String: Any] + } + static func propertyList(_ value: Any) -> Any { + if let dictionary = value as? [String: Any] { + return dictionary.filter { !($0.value is NSNull) }.mapValues { propertyList($0) } + } + if let array = value as? [Any] { return array.filter { !($0 is NSNull) }.map { propertyList($0) } } + return value + } + + /// Runs `body` under the cross-process lock, or returns nil without running it when the + /// app group's defaults, container or lock file are unavailable (a misprovisioned build). + @discardableResult + static func locked(_ body: (UserDefaults) throws -> T) rethrows -> T? { + guard let defaults, let lockURL else { return nil } + let fd = open(lockURL.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard fd >= 0 else { return nil } + flock(fd, LOCK_EX) + defer { flock(fd, LOCK_UN); close(fd) } + // UserDefaults caches across processes; refresh under the cross-process lock. + defaults.synchronize() + return try body(defaults) + } + + static func context() -> [String: Any]? { + var query = keyQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } + + static func configure(_ config: [String: Any]) throws { + let stored: Void? = try locked { defaults in + let previous = context() + if previous?["scopeKey"] as? String != config["scopeKey"] as? String || + previous?["accountEpoch"] as? Int != config["accountEpoch"] as? Int { clearLocked(defaults) } + let data = try JSONSerialization.data(withJSONObject: config) + var query = keyQuery + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + SecItemDelete(keyQuery as CFDictionary) + guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { + throw NSError(domain: service, code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot protect widget authentication"]) + } + defaults.set(UUID().uuidString, forKey: "homeWidgetGeneration") + defaults.removeObject(forKey: "homeWidgetTerminalFence") + saveJSON(defaults, config["data"], key: "homeWidgetData") + saveJSON(defaults, config["home"], key: "homeWidgetPresentation") + defaults.set(config["refreshAt"], forKey: "homeWidgetRefreshAt") + defaults.set(config["locale"], forKey: "homeWidgetLocale") + defaults.set(max(900, (config["refreshAt"] as? Double ?? 0) / 1000 - Date().timeIntervalSince1970), forKey: "homeWidgetRefreshDelay") + defaults.synchronize() + } + guard stored != nil else { + throw NSError(domain: service, code: 3, userInfo: [NSLocalizedDescriptionKey: "Widget storage unavailable"]) + } + WidgetCenter.shared.reloadTimelines(ofKind: widgetName) + } + + static func clearLocked(_ defaults: UserDefaults) { + SecItemDelete(keyQuery as CFDictionary) + defaults.set(UUID().uuidString, forKey: "homeWidgetGeneration") + for key in ["homeWidgetData", "homeWidgetPresentation", "homeWidgetRefreshAt", "homeWidgetRefreshDelay", "homeWidgetTerminalFence", timelineKey] { + defaults.removeObject(forKey: key) + } + defaults.synchronize() + } + static func clear() { + // Without the group container there is nothing else to clear, but the credential still goes. + if locked({ clearLocked($0) }) == nil { SecItemDelete(keyQuery as CFDictionary) } + WidgetCenter.shared.reloadTimelines(ofKind: widgetName) + } + static func fixture(_ enabled: Bool) { + locked { defaults in + defaults.set(enabled, forKey: "homeWidgetFixture") + defaults.set(UUID().uuidString, forKey: "homeWidgetGeneration") + defaults.synchronize() + } + } + /// Fixture capture only: the fixture path never runs `configure`, so the app + /// mirrors its active language into the same key the widget chrome reads for + /// its layout direction. Without this the extension would draw a localized + /// surface inside stale chrome. + static func fixtureLocale(_ locale: String) { + locked { defaults in + defaults.set(locale, forKey: "homeWidgetLocale") + defaults.synchronize() + } + } + static func current(_ defaults: UserDefaults, _ config: [String: Any], _ generation: String) -> Bool { + guard !defaults.bool(forKey: "homeWidgetFixture"), + defaults.string(forKey: "homeWidgetGeneration") == generation, + let latest = context() ?? defaults.dictionary(forKey: "homeWidgetTerminalFence") else { return false } + return latest["scopeKey"] as? String == config["scopeKey"] as? String && + latest["accountEpoch"] as? Int == config["accountEpoch"] as? Int + } + static func request(_ config: [String: Any], input: [String: Any]? = nil) -> URLRequest? { + guard let endpoint = config["endpoint"] as? String, let token = config["token"] as? String, + var components = URLComponents(string: endpoint), + components.scheme == "https" || components.host == "localhost" || components.host == "127.0.0.1" else { return nil } + if input != nil { + components.path = "/api/mobile/widgets/push-token" + components.query = nil + } + guard let url = components.url else { return nil } + var request = URLRequest(url: url, timeoutInterval: 15) + request.httpMethod = input == nil ? "GET" : "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let input { request.httpBody = try? JSONSerialization.data(withJSONObject: input) } + return request + } + + /// How long a pressed marker may be carried forward across timeline rebuilds. + /// Long enough for a cold launch's JavaScript to boot and read it, short + /// enough that a press from a previous session can never fire. The app + /// mirrors it as `PENDING_ACTION_TTL_MS`. + static let pendingActionTTL: Double = 5 * 60 * 1000 + static var timelineKey: String { "__expo_widgets_\(widgetName)_timeline" } + + /// The press marker the stored timeline still carries, if any. + /// + /// Only a real press can put one there: the App Intent merges the button's + /// press patch — `pendingAction`/`pendingApprovalKey` and the `pendingActionAt` + /// it was pressed at — into the pressed entry's props. This reads the stored + /// timeline, never the server presentation, so a refresh can never manufacture + /// a press. A marker carrying no press time (an older patch) falls back to now. + static func storedPress(_ entries: [[String: Any]]?) -> [String: Any]? { + for entry in entries ?? [] { + guard let props = entry["props"] as? [String: Any], + let action = props["pendingAction"] as? String, + action == "approve" || action == "new-agent" else { continue } + var press: [String: Any] = ["action": action] + if let approvalKey = props["pendingApprovalKey"] as? String { press["approvalKey"] = approvalKey } + press["at"] = (props["pendingActionAt"] as? Double) ?? Date().timeIntervalSince1970 * 1000 + return press + } + return nil + } + + /// The marker to re-attach to the rebuilt timeline's first entry, or nil when + /// nothing waits or the recorded press is older than the TTL. + static func carriedPress(_ entries: [[String: Any]]?) -> [String: Any]? { + guard let press = storedPress(entries), let at = press["at"] as? Double, + Date().timeIntervalSince1970 * 1000 - at <= pendingActionTTL else { return nil } + return press + } + + static func refresh() async { + let captured = locked { defaults -> ([String: Any], String)? in + guard !defaults.bool(forKey: "homeWidgetFixture"), let config = context(), + let generation = defaults.string(forKey: "homeWidgetGeneration") else { return nil } + return (config, generation) + } ?? nil + guard let (config, generation) = captured, + let request = request(config) else { return } + do { + let (bytes, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { return } + // Only an authentication refusal is terminal; 403 and every other failure keep retained content. + if http.statusCode == 401 { + locked { defaults in + if current(defaults, config, generation) { + clearLocked(defaults) + defaults.set(["scopeKey": config["scopeKey"]!, "accountEpoch": config["accountEpoch"]!], forKey: "homeWidgetTerminalFence") + let copy = config["copy"] as? [String: String] ?? [:] + defaults.set([["timestamp": Int(Date().timeIntervalSince1970 * 1000), + "props": ["statusLine": copy["privacy"] ?? "", "countLines": [], "primaryCount": 0, + "actions": ["approve": false, "newAgent": false]]]], forKey: timelineKey) + defaults.synchronize() + } + } + return + } + guard http.statusCode == 200, bytes.count <= 262_144, + let payload = try JSONSerialization.jsonObject(with: bytes) as? [String: Any], + let snapshot = payload["snapshot"] as? [String: Any], + snapshot["scopeKey"] as? String == config["scopeKey"] as? String, + let details = payload["details"] as? [String: Any], + let home = payload["home"] as? [String: Any], + let refreshAt = payload["refreshAt"] as? Double else { return } + locked { defaults in + guard current(defaults, config, generation) else { return } + let oldSnapshot = readJSON(defaults, "homeWidgetData")?["snapshot"] as? [String: Any] + if let oldAt = oldSnapshot?["updatedAt"] as? String, let newAt = snapshot["updatedAt"] as? String, newAt < oldAt { return } + saveJSON(defaults, ["snapshot": snapshot, "details": details], key: "homeWidgetData") + saveJSON(defaults, home, key: "homeWidgetPresentation") + defaults.set(refreshAt, forKey: "homeWidgetRefreshAt") + defaults.set(max(900, refreshAt / 1000 - Date().timeIntervalSince1970), forKey: "homeWidgetRefreshDelay") + let copy = config["copy"] as? [String: String] ?? [:] + var phases = payload["presentationTimeline"] as? [[String: Any]] ?? [["at": Date().timeIntervalSince1970 * 1000, "home": home]] + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expires = (snapshot["expiresAt"] as? String).flatMap { formatter.date(from: $0) }.map { $0.timeIntervalSince1970 * 1000 } + if let expires, expires > Date().timeIntervalSince1970 * 1000 { + let retained = phases.last?["home"] as? [String: Any] ?? home + phases.append(["at": expires, "home": retained]) + phases.sort { ($0["at"] as? Double ?? 0) < ($1["at"] as? Double ?? 0) } + } + // Read the marker before the rebuild: the entries below are built from + // the server presentation and the stored base props, and the marker is + // re-attached from this carried press alone while it is still fresh. + let stored = defaults.array(forKey: timelineKey) as? [[String: Any]] + let carried = carriedPress(stored) + let baseProps = (stored?.first?["props"] as? [String: Any]) ?? [:] + var entries = phases.compactMap { phase -> [String: Any]? in + guard let at = phase["at"] as? Double, let presentation = phase["home"] as? [String: Any] else { return nil } + var props = baseProps + props["home"] = presentation + var counts = presentation["secondaryCounts"] as? [[String: Any]] ?? [] + if let kind = presentation["primaryKind"] as? String, let count = presentation["primaryCount"] as? Int, count > 0 { + counts.insert(["kind": kind, "count": count], at: 0) + } + props["countLines"] = counts.map { count -> [String: Any] in + var line = count + line["label"] = copy[count["kind"] as? String ?? ""] ?? "" + return line + } + props["primaryKind"] = presentation["primaryKind"] + props["primaryCount"] = presentation["primaryCount"] + props["primaryLabel"] = copy[presentation["primaryKind"] as? String ?? ""] + let status = presentation["status"] as? String ?? "waiting" + props["statusLine"] = status == "content" ? nil : copy[status] + if let expires, at >= expires { + props["countLines"] = [] + props["primaryKind"] = nil + props["primaryCount"] = 0 + props["primaryLabel"] = nil + props["statusLine"] = copy["expired"] + } + props["actions"] = ["approve": presentation["canApprove"] as? Bool ?? false, "newAgent": presentation["canCreate"] as? Bool ?? false] + props["needsInputSince"] = snapshot["needsInputSince"] + props["scheduledAt"] = presentation["scheduledAt"] + props["newestTitle"] = nil + props["actionFeedback"] = nil + props["accessibilityLabel"] = counts.map { "\($0["count"] ?? 0) \(copy[$0["kind"] as? String ?? ""] ?? "")" }.joined(separator: ", ") + // The rebuild owns the marker: strip every copy so only the carried + // press below can put one back on the first entry while it is fresh. + props.removeValue(forKey: "pendingAction") + props.removeValue(forKey: "pendingApprovalKey") + props.removeValue(forKey: "pendingActionAt") + // JSON nulls aren't valid property-list values; expo-widgets omits them too. + return ["timestamp": Int(at), "props": propertyList(props)] + } + if !entries.isEmpty { + if let carried, var props = entries[0]["props"] as? [String: Any] { + props["pendingAction"] = carried["action"] + if let approvalKey = carried["approvalKey"] { props["pendingApprovalKey"] = approvalKey } + props["pendingActionAt"] = carried["at"] + entries[0]["props"] = props + } + defaults.set(entries, forKey: timelineKey) + } + defaults.synchronize() + } + } catch { + // Keep the confirmed cache and its checkedAt on every transport/parse failure. + } + } + + static func registerStoredPushToken() async { + guard let token = locked({ $0.string(forKey: "homeWidgetPushToken") }) ?? nil else { return } + let widgets: [WidgetInfo]? = await withCheckedContinuation { continuation in + WidgetCenter.shared.getCurrentConfigurations { result in + continuation.resume(returning: try? result.get()) + } + } + guard let widgets else { return } + await uploadPushToken(token, enabled: widgets.contains(where: { $0.kind == widgetName })) + } + + static func pushToken(_ token: Data, widgets: [WidgetInfo]) async { + let hex = token.map { String(format: "%02x", $0) }.joined() + locked { defaults in defaults.set(hex, forKey: "homeWidgetPushToken"); defaults.synchronize() } + await uploadPushToken(hex, enabled: widgets.contains(where: { $0.kind == widgetName })) + } + + static func uploadPushToken(_ token: String, enabled: Bool) async { + let captured = locked { defaults -> ([String: Any], String)? in + guard !defaults.bool(forKey: "homeWidgetFixture"), let config = context(), + let generation = defaults.string(forKey: "homeWidgetGeneration") else { return nil } + return (config, generation) + } ?? nil + guard let (config, generation) = captured, + locked({ current($0, config, generation) }) == true, + let request = request(config, input: ["token": token, "enabled": enabled]) else { return } + _ = try? await session.data(for: request) + if locked({ current($0, config, generation) }) != true { + // A completed older upload cannot leave the current installation bound to its former scope. + await registerStoredPushToken() + } + } + + /// Best-effort unregister before explicit clear. Bounded so sign-out isolation is never held hostage by the network. + static func unregisterPushToken(_ token: String, config: [String: Any]) async { + guard let request = request(config, input: ["token": token, "enabled": false]) else { return } + await withTaskGroup(of: Void.self) { group in + group.addTask { _ = try? await session.data(for: request) } + group.addTask { try? await Task.sleep(nanoseconds: 3_000_000_000) } + await group.next() + group.cancelAll() + } + } +} + +private final class HomeWidgetNetworkDelegate: NSObject, URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + // Credentials are scoped to the configured origin; redirects aren't an authentication recovery path. + completionHandler(nil) + } +} diff --git a/apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift b/apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift new file mode 100644 index 0000000000..57810d1925 --- /dev/null +++ b/apps/mobile/modules/home-widget-refresh/ios/WidgetExtension/HomeWidgetTimelineProvider.swift @@ -0,0 +1,54 @@ +import Foundation +import WidgetKit +internal import ExpoWidgets + +struct HomeWidgetTimelineProvider: TimelineProvider { + let name: String + private var base: WidgetsTimelineProvider { WidgetsTimelineProvider(name: name) } + func placeholder(in context: Context) -> WidgetsTimelineEntry { base.placeholder(in: context) } + func getSnapshot(in context: Context, completion: @escaping @Sendable (WidgetsTimelineEntry) -> Void) { + guard context.isPreview else { + base.getSnapshot(in: context, completion: completion) + return + } + // The gallery draws the app-written sample (`WIDGET_GALLERY_PREVIEW_NAME` + // in active-agents-widget.tsx); before the app has written it, the placed + // widget's own entry. + let base = self.base + WidgetsTimelineProvider(name: "\(name)Preview").getSnapshot(in: context) { entry in + if entry.props == nil { + base.getSnapshot(in: context, completion: completion) + } else { + completion(entry) + } + } + } + func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline) -> Void) { + Task { + await HomeWidgetRefreshStore.refresh() + base.getTimeline(in: context) { timeline in + let next = HomeWidgetRefreshStore.locked { defaults -> Double? in + guard !defaults.bool(forKey: "homeWidgetFixture"), + HomeWidgetRefreshStore.context() != nil else { return nil } + return defaults.double(forKey: "homeWidgetRefreshAt") + } ?? nil + guard let next else { + completion(Timeline(entries: timeline.entries, policy: .never)) + return + } + // WidgetKit budgets requests; the requested wake is not an exact alarm. + let now = Date().timeIntervalSince1970 + let retryDelay = HomeWidgetRefreshStore.locked { $0.double(forKey: "homeWidgetRefreshDelay") } ?? 0 + let wake = Date(timeIntervalSince1970: next / 1000 > now ? max(now + 900, next / 1000) : now + max(900, retryDelay)) + completion(Timeline(entries: timeline.entries, policy: .after(wake))) + } + } + } +} + +@available(iOS 26.0, *) +struct HomeWidgetPushHandler: WidgetPushHandler { + func pushTokenDidChange(_ pushInfo: WidgetPushInfo, widgets: [WidgetInfo]) { + Task { await HomeWidgetRefreshStore.pushToken(pushInfo.token, widgets: widgets) } + } +} diff --git a/apps/mobile/plugins/backup/kilo_backup_rules.xml b/apps/mobile/plugins/backup/kilo_backup_rules.xml index 2b9cf22c90..54876847f3 100644 --- a/apps/mobile/plugins/backup/kilo_backup_rules.xml +++ b/apps/mobile/plugins/backup/kilo_backup_rules.xml @@ -5,13 +5,14 @@ Union of the exclusions that expo-secure-store and the AppsFlyer SDK would otherwise each try to declare on : - shared preferences are included wholesale, - - the SecureStore shared-preference file is excluded, + - the SecureStore and native Home widget shared-preference files are excluded, - the AppsFlyer capture files (appsflyer-data, appsflyer-purchase-data, afpurchases.db) are excluded. --> + \ No newline at end of file diff --git a/apps/mobile/plugins/backup/kilo_data_extraction_rules.xml b/apps/mobile/plugins/backup/kilo_data_extraction_rules.xml index 63f62ced48..32a6d5ed0b 100644 --- a/apps/mobile/plugins/backup/kilo_data_extraction_rules.xml +++ b/apps/mobile/plugins/backup/kilo_data_extraction_rules.xml @@ -4,18 +4,21 @@ Same union of exclusions as kilo_backup_rules.xml, declared once for each of cloud-backup and device-transfer: shared preferences are included, - SecureStore and the AppsFlyer capture files are excluded. + SecureStore, the native Home widget cache, and the AppsFlyer capture files + are excluded. --> + + diff --git a/apps/mobile/plugins/withHomeWidgetRefresh.js b/apps/mobile/plugins/withHomeWidgetRefresh.js new file mode 100644 index 0000000000..d7c7b4e5b2 --- /dev/null +++ b/apps/mobile/plugins/withHomeWidgetRefresh.js @@ -0,0 +1,131 @@ +const fs = require('fs'); +const path = require('path'); +const plist = require('@expo/plist').default; +const { + withDangerousMod, + withEntitlementsPlist, + withInfoPlist, + withXcodeProject, +} = require('expo/config-plugins'); + +const TARGET = 'ExpoWidgetsTarget'; +const GROUP = 'group.com.kilocode.kiloapp'; +const KEYCHAIN = '$(AppIdentifierPrefix)com.kilocode.kiloapp.home-widget-refresh'; +const FILES = ['HomeWidgetRefreshStore.swift', 'HomeWidgetTimelineProvider.swift']; + +function integrateProvider(source) { + if (source.includes('HomeWidgetTimelineProvider(name: name)')) return source; + const provider = 'WidgetsTimelineProvider(name: name)'; + const body = 'var body: some WidgetConfiguration {'; + if ( + !source.includes(provider) || + !source.includes(body) || + !source.includes('WidgetsEntryView(entry: entry)') + ) { + throw new Error('Home widget generator changed: cannot attach authenticated provider'); + } + source = source + .replace(provider, 'HomeWidgetTimelineProvider(name: name)') + .replace(body, 'var configuration: some WidgetConfiguration {') + .replace( + 'WidgetsEntryView(entry: entry)', + 'WidgetsEntryView(entry: entry).environment(\\.layoutDirection, HomeWidgetRefreshStore.layoutDirection)' + ); + const end = source.lastIndexOf('}'); + return ( + source.slice(0, end) + + ` + // Statement returns, not an if-expression: SE-0360 allows a different opaque type under #available. + var body: some WidgetConfiguration { + if #available(iOS 26.0, *) { + return configuration.pushHandler(HomeWidgetPushHandler.self) + } + return configuration + } +` + + source.slice(end) + ); +} + +// Register BEFORE expo-widgets: dangerous/Xcode mods run in reverse order. +module.exports = function withHomeWidgetRefresh(config) { + const extensions = config.extra?.eas?.build?.experimental?.ios?.appExtensions ?? []; + const previous = extensions.find(entry => entry.targetName === TARGET); + config.extra ??= {}; + config.extra.eas ??= {}; + config.extra.eas.build ??= {}; + config.extra.eas.build.experimental ??= {}; + config.extra.eas.build.experimental.ios ??= {}; + config.extra.eas.build.experimental.ios.appExtensions = [ + ...extensions.filter(entry => entry.targetName !== TARGET), + { + ...previous, + targetName: TARGET, + bundleIdentifier: `${config.ios.bundleIdentifier}.${TARGET}`, + entitlements: { + ...previous?.entitlements, + 'com.apple.security.application-groups': [GROUP], + 'keychain-access-groups': [KEYCHAIN], + 'aps-environment': 'production', + }, + }, + ]; + config = withEntitlementsPlist(config, cfg => { + const groups = cfg.modResults['keychain-access-groups'] ?? []; + // SecureStore's default must remain first; the extension may access only the widget group. + const defaultGroup = `$(AppIdentifierPrefix)${cfg.ios.bundleIdentifier}`; + cfg.modResults['keychain-access-groups'] = [...new Set([defaultGroup, ...groups, KEYCHAIN])]; + return cfg; + }); + config = withInfoPlist(config, cfg => { + cfg.modResults.HomeWidgetKeychainAccessGroup = KEYCHAIN; + return cfg; + }); + config = withDangerousMod(config, [ + 'ios', + cfg => { + const root = path.join(cfg.modRequest.platformProjectRoot, TARGET); + const module = path.join(cfg.modRequest.projectRoot, 'modules/home-widget-refresh/ios'); + if (!fs.existsSync(path.join(root, 'ActiveAgentsWidget.swift'))) + throw new Error('expo-widgets must generate its target before Home refresh'); + fs.copyFileSync(path.join(module, FILES[0]), path.join(root, FILES[0])); + fs.copyFileSync(path.join(module, 'WidgetExtension', FILES[1]), path.join(root, FILES[1])); + const widgetPath = path.join(root, 'ActiveAgentsWidget.swift'); + fs.writeFileSync(widgetPath, integrateProvider(fs.readFileSync(widgetPath, 'utf8'))); + const infoPath = path.join(root, 'Info.plist'); + const info = plist.parse(fs.readFileSync(infoPath, 'utf8')); + info.HomeWidgetKeychainAccessGroup = KEYCHAIN; + fs.writeFileSync(infoPath, plist.build(info)); + const entitlementPath = path.join(root, `${TARGET}.entitlements`); + const entitlements = plist.parse(fs.readFileSync(entitlementPath, 'utf8')); + entitlements['keychain-access-groups'] = [KEYCHAIN]; + entitlements['aps-environment'] = 'production'; + fs.writeFileSync(entitlementPath, plist.build(entitlements)); + return cfg; + }, + ]); + return withXcodeProject(config, cfg => { + const project = cfg.modResults; + const targets = project.pbxNativeTargetSection(); + const uuid = Object.keys(targets).find( + key => !key.endsWith('_comment') && targets[key].name === TARGET + ); + if (!uuid) throw new Error('Home widget target must exist before attaching refresh sources'); + // Xcode rejects a second Sources phase as duplicate tasks; join the target's existing phase. + const groups = project.hash.project.objects.PBXGroup; + const groupKey = Object.keys(groups).find( + key => + !key.endsWith('_comment') && + groups[key].name === TARGET && + String(groups[key].path).replaceAll('"', '') === TARGET + ); + if (!groupKey) throw new Error('Home widget group must exist before attaching refresh sources'); + for (const file of FILES) { + if (!project.hasFile(file)) { + project.addSourceFile(file, { target: uuid }, groupKey); + } + } + return cfg; + }); +}; +module.exports.integrateProvider = integrateProvider; diff --git a/apps/mobile/scripts/glanceable-fixtures.json b/apps/mobile/scripts/glanceable-fixtures.json index d7b2c09b68..f804029fc8 100644 --- a/apps/mobile/scripts/glanceable-fixtures.json +++ b/apps/mobile/scripts/glanceable-fixtures.json @@ -2,15 +2,30 @@ "url": "kiloapp:///dev/glanceable-fixture/{name}", "release": "kiloapp:///dev/glanceable-fixture/release", "fixtures": [ - { "name": "signed-out", "description": "Signed out: the sign-in copy, no counts, no actions." }, - { "name": "waiting", "description": "First load: no snapshot yet, the waiting copy." }, - { "name": "empty", "description": "Signed in, no agents: the empty copy with New agent." }, + { + "name": "signed-out", + "description": "Signed out: the sign-in copy, no counts, no actions." + }, + { + "name": "waiting", + "description": "First load: no snapshot yet, the waiting copy." + }, + { + "name": "empty", + "description": "Signed in, no agents: the empty copy with New agent." + }, { "name": "privacy", "description": "Locked (org switch/privacy): the open-Kilo copy, no counts." }, - { "name": "expired", "description": "Expired: counts dropped, the expired copy." }, - { "name": "stale", "description": "Delayed: the mixed counts under the updates-delayed copy." }, + { + "name": "expired", + "description": "Expired activity: the Home widget retains last-known mixed work." + }, + { + "name": "stale", + "description": "Delayed: mixed work retains its last successful confirmation time." + }, { "name": "needs-approval", "description": "Only needs input, 2 of 3 approvable (waiting 28 min): Approve shows." @@ -19,7 +34,10 @@ "name": "needs-input-question", "description": "Only needs input, questions only (waiting 12 min): no Approve." }, - { "name": "running-only", "description": "Only running: three agents working." }, + { + "name": "running-only", + "description": "Only running: three agents working." + }, { "name": "scheduled-only", "description": "Only scheduled: two agents, the soonest wake on a round clock time." @@ -36,7 +54,10 @@ "name": "large-counts", "description": "Width stress: 128 needs input, 1,234 running, 56 scheduled, 999 idle." }, - { "name": "long-title", "description": "Mixed counts with a very long newest-session title." }, + { + "name": "long-title", + "description": "Mixed counts with a very long newest-session title." + }, { "name": "approving", "description": "Needs approval while the in-place Approve runs: the Approving line." @@ -53,6 +74,77 @@ "name": "newest-scheduled", "description": "Mixed counts; newest change is scheduled (large footer)." }, - { "name": "newest-idle", "description": "Mixed counts; newest change is idle (large footer)." } + { + "name": "newest-idle", + "description": "Mixed counts; newest change is idle (large footer)." + }, + { + "name": "scheduled-no-time", + "description": "Scheduled agents without a usable wake time." + }, + { + "name": "scheduled-overdue", + "description": "The earliest scheduled wake passed without confirmation; never invent working." + }, + { + "name": "scheduled-tomorrow", + "description": "Three future runs show non-today local dates and times." + }, + { + "name": "retry-only", + "description": "Needs input from provider retry states, with no permission to approve." + }, + { + "name": "untitled", + "description": "Waiting and scheduled rows without titles use a localized generic label." + }, + { + "name": "stale-idle", + "description": "Old idle work remains useful without claiming current confirmation." + }, + { + "name": "stale-empty", + "description": "A last-known empty state retains creation and an honest timestamp." + }, + { + "name": "mix-input-running", + "description": "Needs input and working." + }, + { + "name": "mix-input-scheduled", + "description": "Needs input and scheduled." + }, + { + "name": "mix-input-idle", + "description": "Needs input and idle." + }, + { + "name": "mix-running-scheduled", + "description": "Working and scheduled." + }, + { + "name": "mix-running-idle", + "description": "Working and idle." + }, + { + "name": "mix-scheduled-idle", + "description": "Scheduled and idle; the wake time takes priority over idle details." + }, + { + "name": "mix-input-running-scheduled", + "description": "Needs input, working, and scheduled." + }, + { + "name": "mix-input-running-idle", + "description": "Needs input, working, and idle." + }, + { + "name": "mix-input-scheduled-idle", + "description": "Needs input, scheduled, and idle." + }, + { + "name": "mix-running-scheduled-idle", + "description": "Working, scheduled, and idle." + } ] } diff --git a/apps/mobile/src/components/profiles/kilo-command-form-sheet.tsx b/apps/mobile/src/components/profiles/kilo-command-form-sheet.tsx index 4a955a4a36..ba84c62bde 100644 --- a/apps/mobile/src/components/profiles/kilo-command-form-sheet.tsx +++ b/apps/mobile/src/components/profiles/kilo-command-form-sheet.tsx @@ -154,7 +154,7 @@ export function KiloCommandFormSheet({ }} /> {agent.length > 0 || model.length > 0 ? ( - {[agent.length > 0 ? `${t('profiles.slashCommands.agent')}: ${agent}` : '', model] + {[agent.length > 0 ? `${t('common.agent')}: ${agent}` : '', model] .filter(Boolean) .join(' ')} diff --git a/apps/mobile/src/glanceable-android/action-notice.ts b/apps/mobile/src/glanceable-android/action-notice.ts index 5a50aa1c23..2e8d1e960a 100644 --- a/apps/mobile/src/glanceable-android/action-notice.ts +++ b/apps/mobile/src/glanceable-android/action-notice.ts @@ -14,6 +14,8 @@ import { getWaitingAsk, type WaitingAsk } from '@/lib/glanceable/waiting-ask'; */ let actionNotice: string | null = null; let noticeAskKey: string | null = null; +/** An Approve tap is being answered: the card says "Approving…" and drops Approve. */ +let approving = false; /** The recorded ask identity the notice describes; '' means "no ask". */ function askKey(ask: WaitingAsk | null): string { @@ -29,13 +31,32 @@ export function setGlanceableActionNotice(notice: string | null): void { noticeAskKey = notice === null ? null : askKey(getWaitingAsk()); } +/** + * Mark (or clear) the in-flight answer; it ends with the ask like the notice does. + * A new answer supersedes the last failure: the notice outranks "Approving…" on + * the card, so a retry would otherwise keep showing the old failure line. + */ +export function setGlanceableActionApproving(value: boolean): void { + approving = value; + if (value) { + setGlanceableActionNotice(null); + } +} + +export function isActionApproving(): boolean { + return approving; +} + /** The notice waiting to reach the next notification text, or null. */ export function getActionNotice(): string | null { return actionNotice; } -/** Drop the notice once nothing needs input or the recorded ask has changed. */ +/** Drop the notice (and the in-flight mark) once nothing needs input; the notice also when the ask changed. */ export function pruneActionNotice(snapshot: GlanceableAgentsSnapshot): void { + if (snapshot.needsInput === 0) { + approving = false; + } if ( actionNotice !== null && (snapshot.needsInput === 0 || askKey(getWaitingAsk()) !== noticeAskKey) diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-actions.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-actions.tsx new file mode 100644 index 0000000000..17a7817284 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-actions.tsx @@ -0,0 +1,214 @@ +/* eslint-disable react-native/no-inline-styles -- the Android widget host requires native style objects */ +'use no memo'; + +import { type ReactNode } from 'react'; +import { FlexWidget, OverlapWidget, TextWidget } from 'react-native-android-widget'; + +import { LAUNCHER_NEW_AGENT_URL } from '@/lib/launcher-surfaces'; + +import { glyph, plusStrokes } from './active-agents-widget-glyphs'; +import { + estimateWidth, + type Frame, + place, + type Rect, + textAlign, +} from './active-agents-widget-parts'; + +/** Every action keeps a 48dp touch target, whatever size its glyph draws at. */ +const ACTION_TARGET = 48; + +/** An invisible 48dp-or-wider touch target over an action, placed apart from its glyph. */ +function target(f: Frame, key: 'create' | 'approve', rect: Rect) { + const approvalKey = f.props.home?.approvalKey ?? null; + return ( + + ); +} + +/** What the Approve slot shows: nothing, a ready Approve, or the in-flight state. */ +export function approveSlot(f: Frame): 'approve' | 'approving' | null { + if (f.copy.statusKind !== 'content') { + return null; + } + if (f.props.actionFeedback === 'approving') { + return 'approving'; + } + const ready = f.props.home?.canApprove === true && (f.props.home.approvalKey ?? null) !== null; + return ready ? 'approve' : null; +} + +export function canCreate(f: Frame): boolean { + const live = f.copy.statusKind === 'content' || f.copy.statusKind === 'empty'; + return live && (f.props.home?.canCreate ?? f.props.actions.newAgent); +} + +/** + * Approve then `+`, round glyphs at the trailing edge. Each owns a fixed 48dp + * target: `+` at `plusTarget`, Approve immediately before it. A hidden action + * leaves its slot empty, so `+` never moves. + */ +export function roundActions( + f: Frame, + spec: { + cy: number; + r: number; + plus: number; + approve: number; + plusTarget: number; + create: boolean; + } +) { + const y = Math.min(Math.max(0, spec.cy - ACTION_TARGET / 2), f.height - ACTION_TARGET); + const slot = approveSlot(f); + const nodes: ReactNode[] = []; + if (slot !== null) { + nodes.push(glyph(f, slot, { cx: spec.approve, cy: spec.cy, r: spec.r })); + } + if (slot === 'approve') { + const x = spec.plusTarget - ACTION_TARGET; + nodes.push(target(f, 'approve', { x, y, width: ACTION_TARGET, height: ACTION_TARGET })); + } + if (spec.create) { + nodes.push( + glyph(f, 'create', { cx: spec.plus, cy: spec.cy, r: spec.r }), + target(f, 'create', { x: spec.plusTarget, y, width: ACTION_TARGET, height: ACTION_TARGET }) + ); + } + return nodes; +} + +/** A filled pill with one centred label. */ +function pill( + f: Frame, + key: string, + spec: Rect & { value: string; fill: 'primary' | 'secondary' } +) { + const ink = spec.fill === 'primary' ? 'primaryForeground' : 'muted'; + return ( + + + + ); +} + +/** Medium/Large: the Approve pill (86x24, wider for long copy) ends where `+`'s target begins. */ +export function pillActions(f: Frame, create: boolean) { + const slot = approveSlot(f); + const nodes: ReactNode[] = []; + const end = f.width - ACTION_TARGET; + if (slot !== null) { + const value = slot === 'approve' ? f.props.actions.approveLabel : (f.copy.actionLine ?? ''); + const natural = Math.ceil(estimateWidth(value, 12, true)) + 28; + // Never into "Kilo": the brand ends at 78dp. + const width = Math.min(end - 78, Math.max(slot === 'approve' ? 86 : 96, natural)); + const x = end - width; + nodes.push( + pill(f, 'approve-pill', { + x, + y: 12, + width, + height: 24, + value, + fill: slot === 'approve' ? 'primary' : 'secondary', + }) + ); + if (slot === 'approve') { + nodes.push(target(f, 'approve', { x, y: 0, width, height: ACTION_TARGET })); + } + } + if (create) { + nodes.push( + glyph(f, 'create', { cx: f.width - 28, cy: 24, r: 12 }), + target(f, 'create', { x: end, y: 0, width: ACTION_TARGET, height: ACTION_TARGET }) + ); + } + return nodes; +} + +/** The secondary "New agent" pill that replaces the header `+` on an empty Medium/Large cell. */ +export function newAgentPill( + f: Frame, + spec: { x: number; y: number; height: number; size: number; arm: number; centred: boolean } +) { + const value = f.props.actions.newAgentLabel; + const lead = spec.height; + const natural = Math.ceil(estimateWidth(value, spec.size, true)) + lead + 12; + const width = Math.min(f.width - 32, Math.max(spec.centred ? 120 : 104, natural)); + const x = spec.centred ? (f.width - width) / 2 : spec.x; + const children = [ + + {plusStrokes('new', { + size: spec.height, + arm: spec.arm, + color: f.paint.palette.foreground, + width: 2, + })} + , + + + , + ]; + return [ + + {f.paint.rtl ? children.toReversed() : children} + , + target(f, 'create', { + x, + y: Math.max(0, spec.y + spec.height / 2 - ACTION_TARGET / 2), + width, + height: ACTION_TARGET, + }), + ]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-card.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-card.tsx new file mode 100644 index 0000000000..ebda29abbe --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-card.tsx @@ -0,0 +1,301 @@ +'use no memo'; + +import { type ReactNode } from 'react'; + +import { canCreate, roundActions } from './active-agents-widget-actions'; +import { lock, logo } from './active-agents-widget-glyphs'; +import { + bar, + boxTop, + type Copy, + dot, + dotInk, + estimateWidth, + type Frame, + type Ink, + label, + lineBox, +} from './active-agents-widget-parts'; +import { stackedStatus } from './active-agents-widget-status'; +import { fitRows, phaseOf, stack } from './active-agents-widget-stack'; + +/** + * Small (2x2, 2x3), plus what Medium and Large share. Cards draw at the design + * coordinates of a 170x170 / 364x170 / 364x382 widget. A taller cell keeps the + * layout: the extra height first buys agent rows (up to 3), then splits evenly + * across the gaps; the footer stays 18dp above the bottom edge. + */ + +/** Header ink ends at 36dp (the 24dp glyphs centred on y=24). */ +export const HEADER_BOTTOM = 36; + +export function brand(f: Frame) { + return [ + logo(f, { x: 16, y: 15, size: 18 }), + label(f, 'brand', { x: 40, baseline: 29, width: 40, value: 'Kilo', size: 13, weight: '600' }), + ]; +} + +/** The content edges: under the header, down to the top of the footer line 18dp above the bottom. */ +export function cardEdges(f: Frame) { + return { + top: HEADER_BOTTOM, + bottom: boxTop(f.copy.footer ?? f.copy.status ?? '', 11, f.height - 18), + }; +} + +/** "Checked …" / "Last known · …", "Updating agents", or (`long`) the failed-approve sentence. */ +export function footer(f: Frame, long: boolean) { + const phase = phaseOf(f.copy); + const failed = long && f.props.actionFeedback === 'couldNotApprove' && phase === 'content'; + let value = f.copy.footer ?? ''; + if (phase === 'updating') { + value = f.copy.status ?? ''; + } else if (failed) { + value = f.copy.approveFailed; + } + if (value === '') { + return null; + } + return label(f, 'footer', { + x: 16, + baseline: f.height - 18, + width: f.width - 32, + value, + size: 11, + ...(failed ? { weight: '600' as const, ink: 'warn' as const } : { ink: 'muted' as const }), + }); +} + +/** + * The locked composition: logo + Kilo, a centred lock and the copy under it; no + * actions, no footer. The copy may always wrap to a second line (an instruction + * never ellipsizes); `wrap` centres the block for two lines, as Small is drawn. + */ +export function locked( + f: Frame, + spec: { + height: number; + lockY: number; + lockSize: number; + baseline: number; + size: number; + wrap: boolean; + } +) { + const value = f.copy.status ?? ''; + const lines = spec.wrap || estimateWidth(value, spec.size, true) > f.width - 32 ? 2 : 1; + const { nodes } = stack({ + blocks: [ + { + top: spec.lockY - spec.lockSize / 2 - 2, + bottom: boxTop(value, spec.size, spec.baseline) + lineBox(value, spec.size) * lines, + shrink: 10, + draw: dy => [ + ...lock(f, { cx: f.width / 2, cy: spec.lockY + dy, size: spec.lockSize }), + label(f, 'status', { + x: 16, + baseline: spec.baseline + dy, + width: f.width - 32, + value, + size: spec.size, + weight: '600', + lines: 2, + align: 'center', + }), + ], + }, + ], + design: { top: HEADER_BOTTOM, bottom: spec.height - 15 }, + edges: { top: HEADER_BOTTOM, bottom: f.height - 15 }, + tailShrink: 10, + }); + return [...brand(f), ...nodes]; +} + +export type Entry = { title: string; sub: string | null; ink: 'warn' | 'info' }; + +/** The agents a card lists: earliest waits while input is needed, else the next wakes. */ +export function entries(copy: Copy): Entry[] { + if (copy.primaryKind === 'needsInput') { + return copy.waitingAgents.map(agent => ({ + title: agent.title, + sub: agent.reason, + ink: 'warn', + })); + } + if (copy.primaryKind === 'scheduled') { + return copy.scheduledAgents.map(agent => ({ + title: agent.title, + sub: agent.time, + ink: 'info', + })); + } + return []; +} + +/** Secondary counts as "3 Working" lines, each with its dot. */ +export function countLines( + f: Frame, + spec: { x: number; baseline: number; step: number; width: number } +) { + return f.copy.secondaryCounts.flatMap((line, index) => { + const baseline = spec.baseline + index * spec.step; + return [ + dot(f, `count-${line.kind}-dot`, { + cx: spec.x, + cy: baseline - 4, + r: 4, + fill: dotInk(line.kind), + }), + label(f, `count-${line.kind}`, { + x: spec.x + 12, + baseline, + width: spec.width, + value: `${line.count} ${line.label}`, + size: 13, + }), + ]; + }); +} + +type Line = { value: string; ink: Ink; weight?: '600' }; + +/** + * The Small cell's agent lines: a failed approve first, else the scheduled wake + * or the lead title; a taller cell adds the next waiting or scheduled titles. + */ +function smallLines(f: Frame): Line[] { + const { copy } = f; + const lines: Line[] = []; + if (f.props.actionFeedback === 'couldNotApprove') { + lines.push({ value: copy.actionLine ?? '', ink: 'warn', weight: '600' }); + } + if (copy.primaryKind === 'needsInput' && copy.waitingAgents.length > 0) { + lines.push( + ...copy.waitingAgents.map(agent => ({ value: agent.title, ink: 'foreground' as const })) + ); + } else if (copy.primaryKind === 'scheduled') { + if (copy.wake !== null) { + lines.push({ value: copy.wake, ink: copy.wakeOverdue ? 'muted' : 'foreground' }); + } + lines.push( + ...copy.scheduledAgents.map(agent => ({ value: agent.title, ink: 'foreground' as const })) + ); + } else if (copy.title !== null) { + lines.push({ value: copy.title, ink: 'foreground' }); + } + return lines.slice(0, 3); +} + +function smallBody(f: Frame): ReactNode[] { + const { width: W, copy } = f; + const design = { top: HEADER_BOTTOM, bottom: 140.4 }; + const edges = cardEdges(f); + const phase = phaseOf(copy); + if (phase === 'updating') { + return stack({ + blocks: [ + { + top: 62, + bottom: 114, + shrink: 10, + draw: dy => [ + bar(f, 'bar-count', { x: 16, y: 62 + dy, width: 44, height: 30 }), + bar(f, 'bar-label', { x: 16, y: 102 + dy, width: 96, height: 12 }), + ], + }, + ], + design, + edges, + tailShrink: 10, + }).nodes; + } + if (phase === 'empty') { + const value = copy.status ?? ''; + const status = { + x: 16, + baseline: 104, + width: W - 32, + value, + size: 17, + weight: '600', + lines: 2, + } as const; + return stack({ + blocks: [ + { + top: boxTop(value, 17, 104), + bottom: 128.5, + height: lineBox(value, 17) * 2, + shrink: 20, + draw: dy => [label(f, 'status', { ...status, baseline: 104 + dy })], + }, + ], + design, + edges, + tailShrink: 4, + }).nodes; + } + const lines = smallLines(f); + const layout = (count: number) => + stack({ + blocks: [ + { + top: 31.5, + bottom: 101.7, + shrink: 4, + draw: dy => + stackedStatus(f, { + x: 16, + countBaseline: 78 + dy, + countSize: 44, + labelBaseline: 98 + dy, + labelSize: 14, + r: 4, + width: W - 32, + }), + }, + { + top: 103.3, + bottom: 120.5, + height: 17.2 + Math.max(0, count - 1) * 19, + ...(count === 0 + ? {} + : { + draw: (dy: number) => + lines.slice(0, count).map((line, index) => + label(f, `line-${index}`, { + x: 16, + baseline: 117 + index * 19 + dy, + width: W - 32, + value: line.value, + size: 13, + ink: line.ink, + ...(line.weight === undefined ? {} : { weight: line.weight }), + }) + ), + }), + }, + ], + design, + edges, + tailShrink: 12, + }); + return layout(fitRows(lines.length, layout)).nodes; +} + +export function small(f: Frame): ReactNode[] { + if (phaseOf(f.copy) === 'locked') { + return locked(f, { height: 170, lockY: 76, lockSize: 26, baseline: 108, size: 13, wrap: true }); + } + const actions = roundActions(f, { + cy: 24, + r: 12, + plus: f.width - 28, + approve: f.width - 56, + plusTarget: f.width - 48, + create: canCreate(f), + }); + return [...brand(f), ...actions, ...smallBody(f), footer(f, false)]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-glyphs.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-glyphs.tsx new file mode 100644 index 0000000000..c017939638 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-glyphs.tsx @@ -0,0 +1,174 @@ +/* eslint-disable react-native/no-inline-styles -- the Android widget host requires native style objects */ +'use no memo'; + +import { type ReactNode } from 'react'; +import { FlexWidget, type HexColor, OverlapWidget } from 'react-native-android-widget'; + +import { type Frame, place, shape, tenth } from './active-agents-widget-parts'; + +/** The brand tile: a foreground square (22% corners) with a background inner square. */ +export function logo(f: Frame, spec: { x: number; y: number; size: number }) { + const inner = tenth(spec.size * 0.44); + return ( + + + + ); +} + +/** A round-capped stroke inside a glyph box: centre, length and angle in degrees. */ +function stroke( + key: string, + spec: { cx: number; cy: number; length: number; angle: number; color: HexColor; width?: number } +) { + const width = spec.width ?? 2.2; + const long = spec.length + width; + return ( + + ); +} + +/** A `+` drawn from two strokes, centred in a box of `size`. */ +export function plusStrokes( + key: string, + spec: { size: number; arm: number; color: HexColor; width?: number } +) { + const centre = spec.size / 2; + const base = { + cx: centre, + cy: centre, + length: spec.arm * 2, + color: spec.color, + width: spec.width, + }; + return [stroke(`${key}-h`, { ...base, angle: 0 }), stroke(`${key}-v`, { ...base, angle: 90 })]; +} + +export type GlyphKind = 'create' | 'approve' | 'approving'; + +function glyphStrokes(f: Frame, kind: GlyphKind, r: number): ReactNode[] { + const { palette } = f.paint; + if (kind === 'create') { + return plusStrokes('plus', { size: r * 2, arm: r / 2, color: palette.foreground }); + } + if (kind === 'approve') { + // The design path: from (-a, 0) down to (-0.2a, 0.8a), then up to (1.2a, -0.8a). + const a = r * 0.42; + const color = palette.primaryForeground; + return [ + stroke('check-short', { + cx: r - 0.6 * a, + cy: r + 0.4 * a, + length: Math.hypot(0.8 * a, 0.8 * a), + angle: 45, + color, + }), + stroke('check-long', { + cx: r + 0.5 * a, + cy: r, + length: Math.hypot(1.4 * a, 1.6 * a), + angle: -49, + color, + }), + ]; + } + return [-5, 0, 5].map(dx => ( + + )); +} + +/** + * The round action glyphs: `+` on the secondary fill, a checkmark on primary, + * and three muted dots while an approve is in flight. Glyph strokes are not + * mirrored in RTL; only the circle's position is. + */ +export function glyph(f: Frame, kind: GlyphKind, spec: { cx: number; cy: number; r: number }) { + const { cx, cy, r } = spec; + const fill = kind === 'approve' ? f.paint.palette.primary : f.paint.palette.secondary; + return ( + + {glyphStrokes(f, kind, r)} + + ); +} + +/** A padlock from two shapes: a stroked shackle sitting on a rounded body. */ +export function lock(f: Frame, spec: { cx: number; cy: number; size: number }) { + const { cx, cy, size } = spec; + const body = size * 0.62; + const weight = Math.max(2, size * 0.13); + const shackle = size * 0.56; + const bodyTop = cy - size / 2 + size - body; + const rise = (size - body) * 0.45 + (shackle - weight) / 2 + weight / 2; + return [ + , + shape(f, 'lock-body', { + x: cx - size * 0.43, + y: bodyTop, + width: size * 0.86, + height: body, + fill: 'foreground', + radius: tenth(size * 0.14), + }), + ]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-large-rows.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-large-rows.tsx new file mode 100644 index 0000000000..9fba59e91f --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-large-rows.tsx @@ -0,0 +1,114 @@ +'use no memo'; + +import { countLines, type Entry } from './active-agents-widget-card'; +import { + dot, + estimateWidth, + type Frame, + label, + shape, + textRow, +} from './active-agents-widget-parts'; +import { stackedStatus } from './active-agents-widget-status'; + +/** The Large cell's building blocks: its status with the other counts, headings and list rows. */ + +export type Status = { + countBaseline: number; + countSize: number; + labelBaseline: number; + labelSize: number; + r: number; +}; + +/** The count, with the other counts top-right (from 226dp of 364) when they clear a wide count. */ +export function largeStatus( + f: Frame, + spec: Status & { countsBaseline: number; step: number }, + dy: number +) { + const clear = Math.ceil(16 + estimateWidth(f.copy.primaryCount, spec.countSize, true) + 16); + const x = Math.max(Math.round(f.width * 0.621), clear); + const counts = f.copy.secondaryCounts.length > 0 && f.width - 16 - (x + 12) >= 48; + return [ + ...stackedStatus(f, { + x: 16, + countBaseline: spec.countBaseline + dy, + countSize: spec.countSize, + labelBaseline: spec.labelBaseline + dy, + labelSize: spec.labelSize, + r: spec.r, + width: (counts ? x - 10 : f.width - 16) - 16, + }), + ...(counts + ? countLines(f, { + x, + baseline: spec.countsBaseline + dy, + step: spec.step, + width: f.width - 16 - (x + 12), + }) + : []), + ]; +} + +export function divider(f: Frame, y: number) { + return shape(f, 'divider', { x: 16, y, width: f.width - 32, height: 1, fill: 'divider' }); +} + +export function heading(f: Frame, key: string, spec: { value: string; baseline: number }) { + return label(f, key, { + ...spec, + x: 16, + width: f.width - 32, + size: 12, + weight: '600', + ink: 'muted', + }); +} + +/** A waiting row: dot, 14dp title, 12dp reason, 46dp apart. */ +export function waitRow(f: Frame, entry: Entry, spec: { index: number; baseline: number }) { + const { index, baseline } = spec; + return [ + dot(f, `row-${index}-dot`, { cx: 21, cy: baseline - 5, r: 4, fill: entry.ink }), + label(f, `row-${index}-title`, { + x: 33, + baseline, + width: f.width - 49, + value: entry.title, + size: 14, + }), + ...(entry.sub === null + ? [] + : [ + label(f, `row-${index}-sub`, { + x: 33, + baseline: baseline + 17, + width: f.width - 49, + value: entry.sub, + size: 12, + ink: 'muted', + }), + ]), + ]; +} + +/** A scheduled row: dot, title, and its time at the trailing edge, 34dp apart. */ +export function runRow(f: Frame, entry: Entry, spec: { index: number; baseline: number }) { + const { index, baseline } = spec; + return [ + dot(f, `row-${index}-dot`, { cx: 21, cy: baseline - 5, r: 4, fill: 'info' }), + textRow(f, `row-${index}`, { + x: 33, + baseline, + width: f.width - 49, + size: 14, + gap: 8, + fill: true, + parts: [ + { value: entry.title, flex: true }, + ...(entry.sub === null ? [] : [{ value: entry.sub, ink: 'muted' as const }]), + ], + }), + ]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-large.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-large.tsx new file mode 100644 index 0000000000..f776fbed27 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-large.tsx @@ -0,0 +1,269 @@ +'use no memo'; + +import { type ReactNode } from 'react'; + +import { canCreate, newAgentPill, pillActions } from './active-agents-widget-actions'; +import { + brand, + cardEdges, + entries, + footer, + HEADER_BOTTOM, + locked, +} from './active-agents-widget-card'; +import { + divider, + heading, + largeStatus, + runRow, + type Status, + waitRow, +} from './active-agents-widget-large-rows'; +import { bar, boxTop, estimateWidth, type Frame, label } from './active-agents-widget-parts'; +import { stackedStatus } from './active-agents-widget-status'; +import { type Block, fitRows, phaseOf, stack } from './active-agents-widget-stack'; + +/** Large (3x3, 4x3, 4x4) at the 364x382 design. */ + +const DESIGN = { top: HEADER_BOTTOM, bottom: 352.4 }; + +/** Needs input (rounds 2/6) and scheduled (round 4): the count block, then a titled list. */ +function listed(f: Frame) { + const { copy } = f; + const rows = entries(copy); + const scheduled = copy.primaryKind === 'scheduled'; + const step = scheduled ? 34 : 46; + const status: Status = scheduled + ? { countBaseline: 128, countSize: 80, labelBaseline: 158, labelSize: 19, r: 5 } + : { countBaseline: 106, countSize: 64, labelBaseline: 131, labelSize: 17, r: 4.5 }; + const short = scheduled ? (copy.wake ?? copy.title) : null; + const list = scheduled + ? { top: 206, bottom: 329.7, heading: 232, first: 258, value: copy.headings.nextScheduled } + : { top: 152, bottom: 314.1, heading: 176, first: 202, value: copy.headings.waitingForYou }; + const layout = (count: number) => + stack({ + blocks: [ + { + top: scheduled ? 43.5 : 38.4, + bottom: scheduled ? 185.4 : 135.5, + shrink: 4, + draw: dy => [ + ...largeStatus(f, { ...status, countsBaseline: 74, step: 22 }, dy), + ...(short === null + ? [] + : [ + label(f, 'short', { + x: 16, + baseline: 182 + dy, + width: f.width - 32, + value: short, + size: 13, + ink: 'muted', + }), + ]), + ], + }, + { + top: list.top, + bottom: list.bottom, + height: list.bottom - list.top - (3 - count) * step, + shrink: 8, + ...(count === 0 + ? {} + : { + draw: (dy: number) => [ + divider(f, list.top + dy), + heading(f, 'section', { value: list.value, baseline: list.heading + dy }), + ...rows.slice(0, count).flatMap((entry, index) => { + const row = { index, baseline: list.first + index * step + dy }; + return scheduled ? runRow(f, entry, row) : waitRow(f, entry, row); + }), + ], + }), + }, + ], + design: DESIGN, + edges: cardEdges(f), + tailShrink: 20, + }); + return layout(fitRows(Math.min(3, rows.length), layout)).nodes; +} + +/** Working or idle (round 3): the latest title, and the next wake when one is scheduled. */ +function latest(f: Frame) { + const { copy } = f; + const mixed = copy.secondaryCounts.length > 0; + const next = copy.scheduledAgents[0]; + const recent = (top: number, headingBaseline: number, titleBaseline: number): Block => ({ + top, + bottom: titleBaseline + 4, + shrink: 10, + ...(copy.title === null + ? {} + : { + draw: (dy: number) => [ + divider(f, top + dy), + heading(f, 'recent-heading', { + value: copy.headings.recent, + baseline: headingBaseline + dy, + }), + label(f, 'recent-title', { + x: 16, + baseline: titleBaseline + dy, + width: f.width - 32, + value: copy.title ?? '', + size: 15, + }), + ], + }), + }); + const blocks: Block[] = mixed + ? [ + { + top: 52.4, + bottom: 149.5, + shrink: 8, + draw: dy => + largeStatus( + f, + { + countBaseline: 120, + countSize: 64, + labelBaseline: 145, + labelSize: 17, + r: 4.5, + countsBaseline: 92, + step: 24, + }, + dy + ), + }, + recent(178, 210, 234), + { + top: 271.3, + bottom: 329.1, + shrink: 12, + ...(next === undefined + ? {} + : { + draw: (dy: number) => [ + heading(f, 'next-heading', { + value: copy.headings.nextScheduled, + baseline: 284 + dy, + }), + ...waitRow( + f, + { title: next.title, sub: next.time, ink: 'info' }, + { index: 0, baseline: 308 + dy } + ), + ], + }), + }, + ] + : [ + { + top: 75.1, + bottom: 208.3, + shrink: 14, + draw: dy => + stackedStatus(f, { + x: 16, + countBaseline: 168 + dy, + countSize: 88, + labelBaseline: 203 + dy, + labelSize: 20, + r: 5, + width: f.width - 32, + }), + }, + recent(238, 268, 292), + ]; + return stack({ blocks, design: DESIGN, edges: cardEdges(f), tailShrink: mixed ? 10 : 20 }).nodes; +} + +function placeholders(f: Frame) { + const bars = [ + ['bar-count', 80, 70, 60], + ['bar-label', 152, 140, 16], + ['bar-row-0', 210, 300, 14], + ['bar-row-0-sub', 232, 180, 11], + ['bar-row-1', 262, 260, 14], + ['bar-row-1-sub', 284, 160, 11], + ] as const; + return stack({ + blocks: [ + { + top: 80, + bottom: 295, + shrink: 20, + draw: dy => + bars.map(([key, y, width, height]) => bar(f, key, { x: 16, y: y + dy, width, height })), + }, + ], + design: DESIGN, + edges: cardEdges(f), + tailShrink: 20, + }).nodes; +} + +function empty(f: Frame) { + const value = f.copy.status ?? ''; + const lines = estimateWidth(value, 22, true) > f.width - 32 ? 2 : 1; + const extra = (lines - 1) * 26; + return stack({ + blocks: [ + { + top: boxTop(value, 22, 176), + bottom: 234 + extra, + shrink: 40, + draw: dy => [ + label(f, 'status', { + x: 16, + baseline: 176 + dy, + width: f.width - 32, + value, + size: 22, + weight: '600', + lines, + align: 'center', + }), + ...newAgentPill(f, { + x: 0, + y: 198 + extra + dy, + height: 36, + size: 14, + arm: 7, + centred: true, + }), + ], + }, + ], + design: DESIGN, + edges: cardEdges(f), + tailShrink: 40, + }).nodes; +} + +export function large(f: Frame): ReactNode[] { + const { copy } = f; + const phase = phaseOf(copy); + if (phase === 'locked') { + return locked(f, { + height: 382, + lockY: 168, + lockSize: 40, + baseline: 236, + size: 20, + wrap: false, + }); + } + if (phase === 'updating') { + return [...brand(f), ...placeholders(f), footer(f, true)]; + } + if (phase === 'empty') { + return [...brand(f), ...empty(f), footer(f, true)]; + } + const body = + copy.primaryKind === 'needsInput' || copy.primaryKind === 'scheduled' ? listed(f) : latest(f); + return [...brand(f), ...pillActions(f, canCreate(f)), ...body, footer(f, true)]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-medium.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-medium.tsx new file mode 100644 index 0000000000..770bdc45e7 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-medium.tsx @@ -0,0 +1,183 @@ +'use no memo'; + +import { type ReactNode } from 'react'; + +import { canCreate, newAgentPill, pillActions } from './active-agents-widget-actions'; +import { + brand, + cardEdges, + countLines, + entries, + type Entry, + footer, + HEADER_BOTTOM, + locked, +} from './active-agents-widget-card'; +import { bar, boxTop, dot, type Frame, label } from './active-agents-widget-parts'; +import { stackedStatus } from './active-agents-widget-status'; +import { fitRows, phaseOf, stack } from './active-agents-widget-stack'; + +/** Medium (3x2, 4x2) at the 364x170 design; its right column starts at 172dp and scales. */ + +const DESIGN = { top: HEADER_BOTTOM, bottom: 140.4 }; + +/** A Medium row: dot, 13dp title, 11dp reason or time, 42dp apart. */ +function mediumRow(f: Frame, entry: Entry, spec: { index: number; x: number; baseline: number }) { + const { index, baseline } = spec; + const x = spec.x + 12; + const width = f.width - 16 - x; + return [ + dot(f, `row-${index}-dot`, { cx: spec.x, cy: baseline - 4, r: 4, fill: entry.ink }), + label(f, `row-${index}-title`, { x, baseline, width, value: entry.title, size: 13 }), + ...(entry.sub === null || entry.sub === '' + ? [] + : [ + label(f, `row-${index}-sub`, { + x, + baseline: baseline + 16, + width, + value: entry.sub, + size: 11, + ink: 'muted', + }), + ]), + ]; +} + +function placeholders(f: Frame, x: number) { + return stack({ + blocks: [ + { + top: 62, + bottom: 138, + shrink: 10, + draw: dy => [ + bar(f, 'bar-count', { x: 16, y: 62 + dy, width: 44, height: 34 }), + bar(f, 'bar-label', { x: 16, y: 104 + dy, width: 96, height: 12 }), + bar(f, 'bar-row-0', { x: x + 4, y: 66 + dy, width: 150, height: 12 }), + bar(f, 'bar-row-0-sub', { x: x + 4, y: 86 + dy, width: 100, height: 10 }), + bar(f, 'bar-row-1', { x: x + 4, y: 108 + dy, width: 140, height: 12 }), + bar(f, 'bar-row-1-sub', { x: x + 4, y: 128 + dy, width: 90, height: 10 }), + ], + }, + ], + design: DESIGN, + edges: cardEdges(f), + }).nodes; +} + +function empty(f: Frame) { + const value = f.copy.status ?? ''; + return stack({ + blocks: [ + { + top: boxTop(value, 20, 92), + bottom: 134, + shrink: 12, + draw: dy => [ + label(f, 'status', { + x: 16, + baseline: 92 + dy, + width: f.width - 32, + value, + size: 20, + weight: '600', + }), + ...newAgentPill(f, { x: 16, y: 106 + dy, height: 28, size: 13, arm: 6, centred: false }), + ], + }, + ], + design: DESIGN, + edges: cardEdges(f), + tailShrink: 2, + }).nodes; +} + +/** The right column: waiting/scheduled rows, else the other counts, else the latest title. */ +function column( + f: Frame, + spec: { x: number; rows: Entry[]; count: number }, + dy: number +): ReactNode[] { + const { copy } = f; + const { x } = spec; + if (spec.rows.length > 0) { + return spec.rows + .slice(0, spec.count) + .flatMap((entry, index) => mediumRow(f, entry, { index, x, baseline: 74 + index * 42 + dy })); + } + if (copy.secondaryCounts.length > 0) { + return countLines(f, { x: x + 4, baseline: 78 + dy, step: 24, width: f.width - 16 - (x + 16) }); + } + if (copy.title === null) { + return []; + } + const width = f.width - 16 - (x + 4); + return [ + label(f, 'recent-heading', { + x: x + 4, + baseline: 88 + dy, + width, + value: copy.headings.recent, + size: 11, + weight: '600', + ink: 'muted', + }), + label(f, 'recent-title', { x: x + 4, baseline: 106 + dy, width, value: copy.title, size: 13 }), + ]; +} + +export function medium(f: Frame): ReactNode[] { + const phase = phaseOf(f.copy); + if (phase === 'locked') { + return locked(f, { + height: 170, + lockY: 83, + lockSize: 28, + baseline: 118, + size: 15, + wrap: false, + }); + } + const x = Math.round(f.width * 0.4725); + if (phase === 'updating') { + return [...brand(f), ...placeholders(f, x), footer(f, true)]; + } + if (phase === 'empty') { + // The pill is the one create control here: the header `+` is hidden. + return [...brand(f), ...empty(f), footer(f, true)]; + } + const rows = entries(f.copy); + let bottom = 115.7; + if (rows.length === 0 && f.copy.secondaryCounts.length > 0) { + bottom = 130.4; + } + const band = (count: number) => + stack({ + blocks: [ + { + top: 45.5, + bottom: 134.9, + height: (rows.length > 0 ? Math.max(bottom, 92.9 + (count - 1) * 42) : bottom) - 45.5, + shrink: 8, + draw: dy => [ + ...stackedStatus(f, { + x: 16, + countBaseline: 92 + dy, + countSize: 44, + labelBaseline: 112 + dy, + labelSize: 14, + r: 4, + width: x - 28, + }), + ...column(f, { x, rows, count }, dy), + ], + }, + ], + design: DESIGN, + edges: cardEdges(f), + tailShrink: 4, + }); + const count = fitRows(Math.min(3, rows.length), band); + return [...brand(f), ...pillActions(f, canCreate(f)), ...band(count).nodes, footer(f, true)]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx new file mode 100644 index 0000000000..b1b77b3ace --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-parts.tsx @@ -0,0 +1,290 @@ +/* eslint-disable react-native/no-inline-styles -- the Android widget host requires native style objects */ +'use no memo'; + +import { FlexWidget, type HexColor, TextWidget } from 'react-native-android-widget'; + +import { type AndroidWidgetHomeCopy } from './home-copy'; +import { type AndroidWidgetProps } from './widget-props'; + +/** + * Every layout here draws on one absolute canvas: the library renders the whole + * tree to a bitmap and overlays the clickable views by their bounds, so each + * element is placed by its design coordinate (dp from the top-left, LTR) and RTL + * mirrors that rectangle about the vertical axis. + */ + +export type Palette = { + background: HexColor; + foreground: HexColor; + muted: HexColor; + warn: HexColor; + good: HexColor; + info: HexColor; + idle: HexColor; + primary: HexColor; + primaryForeground: HexColor; + secondary: HexColor; + divider: HexColor; +}; + +export type Paint = { palette: Palette; rtl: boolean }; + +export type Copy = AndroidWidgetHomeCopy; + +/** One placed widget in one palette. */ +export type Frame = { + props: AndroidWidgetProps; + copy: Copy; + paint: Paint; + width: number; + height: number; +}; + +export type Rect = { x: number; y: number; width: number; height: number }; + +export type Ink = 'foreground' | 'muted' | 'warn' | 'primaryForeground'; + +export type Weight = 'normal' | '600' | 'bold'; + +const DOT_INK = { + needsInput: 'warn', + running: 'good', + scheduled: 'info', + idle: 'idle', +} as const satisfies Record, keyof Palette>; + +export function dotInk(kind: Copy['primaryKind']): keyof Palette { + return kind === null ? 'idle' : DOT_INK[kind]; +} + +const TALL_SCRIPT = /[\u0600-\u08FF\u0900-\u0DFF]/u; + +/** Roboto's ascent plus font padding above the baseline, per unit of font size. */ +const BASELINE = 1.056; + +const LATIN_LINE = 1.32; + +/** + * The line box a TextView draws one line in: Roboto with font padding (1.32em), + * or the taller fallback box Arabic-Indic and Devanagari need (1.62em). + */ +export function lineBox(value: string, size: number): number { + return Math.ceil(size * (TALL_SCRIPT.test(value) ? 1.62 : LATIN_LINE)); +} + +/** Top of the box whose Latin baseline sits at `baseline`; a taller script keeps the same centre. */ +export function boxTop(value: string, size: number, baseline: number): number { + return baseline - BASELINE * size - (lineBox(value, size) - Math.ceil(size * LATIN_LINE)) / 2; +} + +/** Rough advance width, only for choices with slack (pill widths, one line or two). */ +export function estimateWidth(value: string, size: number, bold = false): number { + let ems = 0; + for (const char of value) { + if (/[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFF00-\uFF60]/u.test(char)) { + ems += 1; + } else if (/[\s.,:;'|!ilI]/u.test(char)) { + ems += 0.28; + } else if (/\p{Lu}/u.test(char)) { + ems += 0.66; + } else { + ems += 0.56; + } + } + return ems * size * (bold ? 1.06 : 1); +} + +export const tenth = (value: number) => Math.round(value * 10) / 10; + +/** The native placement of an LTR design rectangle. */ +export function place(f: Frame, rect: Rect) { + return { + marginLeft: tenth(f.paint.rtl ? f.width - rect.x - rect.width : rect.x), + marginTop: tenth(rect.y), + width: tenth(rect.width), + height: tenth(rect.height), + }; +} + +export function textAlign(f: Frame, align: 'start' | 'center' | 'end') { + if (align === 'center') { + return 'center'; + } + return (align === 'end') === f.paint.rtl ? 'left' : 'right'; +} + +export function shape( + f: Frame, + key: string, + spec: Rect & { fill: keyof Palette; radius?: number } +) { + return ( + + ); +} + +export function dot( + f: Frame, + key: string, + spec: { cx: number; cy: number; r: number; fill: keyof Palette } +) { + const { cx, cy, r } = spec; + return shape(f, key, { + x: cx - r, + y: cy - r, + width: r * 2, + height: r * 2, + fill: spec.fill, + radius: r, + }); +} + +/** A grey placeholder bar, its width clamped to the content edge. */ +export function bar(f: Frame, key: string, rect: Rect) { + const width = Math.max(8, Math.min(rect.width, f.width - rect.x - 16)); + return shape(f, key, { ...rect, width, fill: 'secondary', radius: rect.height / 2 }); +} + +export type LabelSpec = { + x: number; + baseline: number; + width: number; + value: string; + size: number; + weight?: Weight; + ink?: Ink; + lines?: number; + align?: 'start' | 'center' | 'end'; +}; + +/** One text, ellipsized at its width; `lines` only lets copy that must read whole wrap. */ +export function label(f: Frame, key: string, spec: LabelSpec) { + const lines = spec.lines ?? 1; + const rect = { + x: spec.x, + y: boxTop(spec.value, spec.size, spec.baseline), + width: spec.width, + height: lineBox(spec.value, spec.size) * lines, + }; + return ( + + ); +} + +type Part = { value: string; weight?: Weight; ink?: Ink; flex?: boolean }; + +/** A one-line text inside a weighted slot: it takes the room left and ellipsizes. */ +export function flexText( + f: Frame, + key: string, + spec: { value: string; size: number; weight?: Weight; ink?: Ink } +) { + return ( + + + + ); +} + +/** + * Texts sharing one baseline row; the one `flex` part ellipsizes. `fill` lets it + * take every spare dp (a trailing time at the content edge); otherwise the row + * hugs its copy, and the flex part only shrinks when the row would overflow. + * Reversed in RTL so reading order holds. + */ +export function textRow( + f: Frame, + key: string, + spec: { + x: number; + baseline: number; + width: number; + size: number; + gap: number; + fill: boolean; + parts: Part[]; + } +) { + const height = Math.max(...spec.parts.map(part => lineBox(part.value, spec.size))); + const tallest = spec.parts.find(part => lineBox(part.value, spec.size) === height)?.value ?? ''; + const children = spec.parts.map((part, index) => + part.flex === true ? ( + flexText(f, `${key}-${index}`, { ...part, size: spec.size }) + ) : ( + + ) + ); + const ordered = f.paint.rtl ? children.toReversed() : children; + const row = { flexDirection: 'row', alignItems: 'center', flexGap: spec.gap } as const; + const rect = place(f, { + x: spec.x, + y: boxTop(tallest, spec.size, spec.baseline), + width: spec.width, + height, + }); + if (spec.fill) { + return ( + + {ordered} + + ); + } + // A wrap-width row measured against the fixed slot: LinearLayout gives the weighted + // part its natural width, and takes only the overflow back from it. + return ( + + {ordered} + + ); +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-row.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-row.tsx new file mode 100644 index 0000000000..aa800b3f1e --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-row.tsx @@ -0,0 +1,208 @@ +'use no memo'; + +import { type ReactNode } from 'react'; + +import { approveSlot, canCreate, roundActions } from './active-agents-widget-actions'; +import { lock, logo } from './active-agents-widget-glyphs'; +import { + bar, + boxTop, + estimateWidth, + type Frame, + label, + lineBox, +} from './active-agents-widget-parts'; +import { statusRow } from './active-agents-widget-status'; +import { type Block, phaseOf, stack } from './active-agents-widget-stack'; + +/** Row (3x1, 4x1) at the 360x104 design; taller bands split the extra height across the gaps. */ + +const DESIGN = { top: 27.2, bottom: 90 }; + +export const approveFailed = (f: Frame) => + f.props.actionFeedback === 'couldNotApprove' && phaseOf(f.copy) === 'content'; + +/** The agent line a short cell shows: the lead title, with the scheduled wake after it. */ +function agentLine(f: Frame): string | null { + const { copy } = f; + if (copy.primaryKind === 'scheduled') { + return [copy.title, copy.wake].filter(Boolean).join(' · ') || null; + } + return copy.title; +} + +function header(f: Frame) { + const { copy } = f; + const phase = phaseOf(copy); + const nodes = [ + logo(f, { x: 14, y: 13, size: 14 }), + label(f, 'brand', { x: 34, baseline: 24, width: 26, value: 'Kilo', size: 12, weight: '600' }), + ]; + if ((phase === 'content' || phase === 'empty') && copy.footer !== null) { + const value = `· ${copy.footer}`; + nodes.push( + label(f, 'footer', { + x: 62, + baseline: 24, + width: f.width - 76, + value, + size: 11, + ink: 'muted', + }) + ); + } + return nodes; +} + +function lockedBlocks(f: Frame): Block[] { + const value = f.copy.status ?? ''; + const lines = estimateWidth(value, 15, true) > f.width - 62 ? 2 : 1; + const baseline = 72 - (lines - 1) * 9; + return [ + { + top: Math.min(55, boxTop(value, 15, baseline)), + bottom: boxTop(value, 15, baseline) + lineBox(value, 15) * lines, + shrink: 14, + draw: dy => [ + ...lock(f, { cx: 28, cy: 66 + dy, size: 20 }), + // Always room for a second line: an instruction never ellipsizes. + label(f, 'status', { + x: 48, + baseline: baseline + dy, + width: f.width - 62, + value, + size: 15, + weight: '600', + lines: 2, + }), + ], + }, + ]; +} + +function blocksFor(f: Frame): Block[] { + const { width: W, copy } = f; + const phase = phaseOf(copy); + const actions = (dy: number) => + roundActions(f, { + cy: 52 + dy, + r: 18, + plus: W - 32, + approve: W - 76, + plusTarget: W - 50, + create: canCreate(f), + }); + // The label ends before the Approve slot only while that slot draws. + const end = approveSlot(f) === null ? W - 58 : W - 102; + if (phase === 'locked') { + return lockedBlocks(f); + } + if (phase === 'updating') { + return [ + { + top: 40, + bottom: 66, + shrink: 8, + draw: dy => [ + bar(f, 'bar-count', { x: 14, y: 40 + dy, width: 30, height: 26 }), + bar(f, 'bar-label', { x: 54, y: 46 + dy, width: 110, height: 14 }), + ], + }, + { + top: 78, + bottom: 88, + shrink: 6, + draw: dy => [bar(f, 'bar-line', { x: 14, y: 78 + dy, width: 180, height: 10 })], + }, + ]; + } + if (phase === 'empty') { + const value = copy.status ?? ''; + const hint = f.props.actions.newAgentLabel; + return [ + { + top: boxTop(value, 17, 62), + bottom: 66.5, + shrink: 10, + draw: dy => [ + label(f, 'status', { + x: 14, + baseline: 62 + dy, + width: end - 14, + value, + size: 17, + weight: '600', + }), + ...actions(dy), + ], + }, + { + top: 73.3, + bottom: 89.1, + shrink: 4, + draw: dy => [ + label(f, 'hint', { + x: 14, + baseline: 86 + dy, + width: end - 14, + value: hint, + size: 12, + ink: 'muted', + }), + ], + }, + ]; + } + const failed = approveFailed(f); + const line = failed ? copy.actionLine : agentLine(f); + return [ + { + top: 32.3, + bottom: 72, + shrink: 12, + draw: dy => [ + statusRow(f, { + x: 14, + baseline: 64 + dy, + width: end - 14, + countSize: 30, + labelSize: 15, + r: 4, + }), + ...actions(dy), + ], + }, + { + top: 74.3, + bottom: 90.1, + shrink: 8, + ...(line === null + ? {} + : { + draw: (dy: number) => [ + label(f, 'line', { + x: 14, + baseline: 87 + dy, + width: W - 28, + value: line, + size: 12, + ...(failed + ? { ink: 'warn' as const, weight: '600' as const } + : { ink: 'muted' as const }), + }), + ], + }), + }, + ]; +} + +export function row(f: Frame): ReactNode[] { + const phase = phaseOf(f.copy); + const { nodes } = stack({ + blocks: blocksFor(f), + design: DESIGN, + edges: { top: DESIGN.top, bottom: f.height - 14 }, + tailShrink: { locked: 8, updating: 2, empty: 6, content: 10 }[phase], + }); + return [...header(f), ...nodes]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-short.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-short.tsx new file mode 100644 index 0000000000..799b98c19d --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-short.tsx @@ -0,0 +1,278 @@ +'use no memo'; + +import { type ReactNode } from 'react'; + +import { approveSlot, canCreate, roundActions } from './active-agents-widget-actions'; +import { lock, logo } from './active-agents-widget-glyphs'; +import { bar, boxTop, type Frame, type Ink, label, textRow } from './active-agents-widget-parts'; +import { statusRow } from './active-agents-widget-status'; +import { approveFailed } from './active-agents-widget-row'; +import { type Block, phaseOf, stack } from './active-agents-widget-stack'; + +/** Narrow (2x1: 172x104, rounds 5b/5c) and Landscape (~62dp rows, round 2b) cells. */ + +/** The 2x1 cell's last line: a failed approve, the scheduled wake, or the checked time. */ +function narrowFoot(f: Frame): { value: string; ink: Ink } | null { + const { copy } = f; + const phase = phaseOf(copy); + if (phase === 'updating') { + return { value: copy.status ?? '', ink: 'muted' }; + } + if (approveFailed(f)) { + return { value: copy.actionLine ?? '', ink: 'warn' }; + } + const value = (phase === 'content' ? copy.wake : null) ?? copy.footer; + return value === null ? null : { value, ink: 'muted' }; +} + +function narrowFirst(f: Frame): Block { + const { width: W, copy } = f; + const phase = phaseOf(copy); + if (phase === 'updating') { + return { + top: 50, + bottom: 70, + draw: dy => [ + bar(f, 'bar-count', { x: 14, y: 50 + dy, width: 24, height: 20 }), + bar(f, 'bar-label', { x: 46, y: 54 + dy, width: 80, height: 12 }), + ], + }; + } + if (phase === 'empty') { + const value = copy.emptyShort; + return { + top: boxTop(value, 14, 66), + bottom: 69.7, + draw: dy => [ + label(f, 'status', { + x: 14, + baseline: 66 + dy, + width: W - 28, + value, + size: 14, + weight: '600', + }), + ], + }; + } + return { + top: 40.5, + bottom: 74.9, + draw: dy => [ + statusRow(f, { + x: 14, + baseline: 68 + dy, + width: W - 28, + countSize: 26, + labelSize: 13, + r: 3.5, + }), + ], + }; +} + +export function narrow(f: Frame): ReactNode[] { + const { width: W, height: H, copy } = f; + const phase = phaseOf(copy); + const brand = [ + logo(f, { x: 14, y: 14, size: 16 }), + label(f, 'brand', { x: 36, baseline: 27, width: 40, value: 'Kilo', size: 12, weight: '600' }), + ]; + const design = { top: 36, bottom: 90 }; + const edges = { top: 36, bottom: H - 14 }; + if (phase === 'locked') { + const value = copy.status ?? ''; + const status = { + x: 42, + baseline: 62, + width: W - 56, + value, + size: 12, + weight: '600', + lines: 2, + } as const; + const block: Block = { + top: boxTop(value, 12, 62), + bottom: 80, + shrink: 10, + draw: dy => [ + ...lock(f, { cx: 24, cy: 66 + dy, size: 18 }), + label(f, 'status', { ...status, baseline: 62 + dy }), + ], + }; + return [...brand, ...stack({ blocks: [block], design, edges, tailShrink: 8 }).nodes]; + } + const actions = roundActions(f, { + cy: 22, + r: 14, + plus: W - 28, + approve: W - 60, + plusTarget: W - 48, + create: canCreate(f), + }); + const foot = narrowFoot(f); + const footBlock: Block = { + top: 78.4, + bottom: 92.9, + shrink: 4, + ...(foot === null + ? {} + : { + draw: (dy: number) => [ + label(f, 'footer', { + x: 14, + baseline: 90 + dy, + width: W - 28, + value: foot.value, + size: 11, + ink: foot.ink, + ...(foot.ink === 'warn' ? { weight: '600' as const } : {}), + }), + ], + }), + }; + const blocks = [{ ...narrowFirst(f), shrink: 10 }, footBlock]; + return [...brand, ...actions, ...stack({ blocks, design, edges, tailShrink: 6 }).nodes]; +} + +function landscapeFirst(f: Frame, end: number): Block { + const { copy } = f; + const phase = phaseOf(copy); + if (phase === 'updating') { + return { + top: 13, + bottom: 33, + draw: dy => [ + bar(f, 'bar-count', { x: 14, y: 13 + dy, width: 22, height: 20 }), + bar(f, 'bar-label', { x: 46, y: 17 + dy, width: 90, height: 12 }), + ], + }; + } + if (phase === 'empty') { + const value = copy.status ?? ''; + return { + top: boxTop(value, 14, 28), + bottom: 31.7, + draw: dy => [ + label(f, 'status', { + x: 14, + baseline: 28 + dy, + width: end - 14, + value, + size: 14, + weight: '600', + }), + ], + }; + } + return { + top: 7.8, + bottom: 36.9, + draw: dy => [ + statusRow(f, { + x: 14, + baseline: 31 + dy, + width: end - 14, + countSize: 22, + labelSize: 14, + r: 3.5, + }), + ], + }; +} + +/** The second line: logo + checked time (with the title first from 400dp), or the failure. */ +function landscapeSecond(f: Frame, end: number): Block { + const { copy } = f; + const phase = phaseOf(copy); + const tail = + phase === 'updating' ? copy.status : ((phase === 'content' ? copy.wake : null) ?? copy.footer); + const title = phase === 'content' && f.width >= 400 ? copy.title : null; + const block = { top: 37.4, bottom: 51.9 }; + if (approveFailed(f)) { + const value = copy.actionLine ?? ''; + return { + ...block, + draw: dy => [ + label(f, 'line', { + x: 14, + baseline: 49 + dy, + width: end - 14, + value, + size: 11, + weight: '600', + ink: 'warn', + }), + ], + }; + } + if (tail === null) { + return block; + } + const muted = 'muted' as const; + const parts = [ + ...(title === null + ? [] + : [ + { value: title, ink: muted, flex: true }, + { value: '·', ink: muted }, + ]), + { value: tail, ink: muted, ...(title === null ? { flex: true } : {}) }, + ]; + return { + ...block, + draw: dy => [ + logo(f, { x: 14, y: 40 + dy, size: 11 }), + textRow(f, 'line', { + x: 33, + baseline: 49 + dy, + width: end - 33, + size: 11, + gap: 8, + fill: false, + parts, + }), + ], + }; +} + +export function landscape(f: Frame): ReactNode[] { + const { width: W, height: H, copy } = f; + const phase = phaseOf(copy); + const design = { top: 0, bottom: 62 }; + const edges = { top: 0, bottom: H }; + if (phase === 'locked') { + const value = copy.status ?? ''; + const block: Block = { + top: 21, + bottom: 40, + shrink: 8, + draw: dy => [ + ...lock(f, { cx: 26, cy: 31 + dy, size: 18 }), + label(f, 'status', { + x: 46, + baseline: 36 + dy, + width: W - 60, + value, + size: 14, + weight: '600', + }), + ], + }; + return stack({ blocks: [block], design, edges, tailShrink: 8 }).nodes; + } + const end = approveSlot(f) === null ? W - 60 : W - 102; + const actions = + phase === 'updating' + ? [] + : roundActions(f, { + cy: H / 2, + r: 18, + plus: W - 32, + approve: W - 74, + plusTarget: W - 50, + create: canCreate(f), + }); + const blocks = [{ ...landscapeFirst(f, end), shrink: 4 }, landscapeSecond(f, end)]; + return [...actions, ...stack({ blocks, design, edges, tailShrink: 4 }).nodes]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-stack.ts b/apps/mobile/src/glanceable-android/active-agents-widget-stack.ts new file mode 100644 index 0000000000..34faca53fb --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-stack.ts @@ -0,0 +1,77 @@ +import { type ReactNode } from 'react'; + +import { type Copy, tenth } from './active-agents-widget-parts'; + +export type Phase = 'locked' | 'updating' | 'empty' | 'content'; + +const PHASES = { + privacy: 'locked', + unavailable: 'locked', + signed_out: 'locked', + waiting: 'updating', + empty: 'empty', + content: 'content', +} as const satisfies Record; + +export function phaseOf(copy: Copy): Phase { + return PHASES[copy.statusKind]; +} + +/** + * A vertical band of the design: `top`/`bottom` are its design extent (with + * every optional block present), `height` what it draws now. `draw` gets the + * offset from its design position; an absent `draw` omits the block. + */ +export type Block = { + top: number; + bottom: number; + height?: number; + shrink?: number; + draw?: (dy: number) => ReactNode[]; +}; + +type Edges = { top: number; bottom: number }; + +/** + * Lay blocks between two edges. Gaps keep their design size; free height (also + * the room an omitted block leaves) splits evenly across every gap so it never + * pools in one hole, and a short cell takes the shortfall from each gap's + * shrinkable share. `slack` is the free height before it is spread. + */ +export function stack(spec: { blocks: Block[]; design: Edges; edges: Edges; tailShrink?: number }) { + const { blocks, design, edges } = spec; + const gaps = blocks.map((block, index) => block.top - (blocks[index - 1]?.bottom ?? design.top)); + const tail = design.bottom - (blocks.at(-1)?.bottom ?? design.top); + const present = blocks.flatMap((block, index) => + block.draw === undefined ? [] : [{ block, gap: gaps[index] ?? 0 }] + ); + const natural = + present.reduce( + (sum, { block, gap }) => sum + gap + (block.height ?? block.bottom - block.top), + 0 + ) + tail; + // Tenths absorb float noise, so an exact fit is not read as a shortfall. + const slack = tenth(edges.bottom - edges.top - natural); + const shrinks = [...present.map(({ block }) => block.shrink ?? 0), spec.tailShrink ?? 0]; + const shrinkable = shrinks.reduce((sum, value) => sum + value, 0); + const deltas = shrinks.map(value => + slack >= 0 || shrinkable === 0 ? slack / shrinks.length : (slack * value) / shrinkable + ); + let y = edges.top; + const nodes = present.flatMap(({ block, gap }, index) => { + y += gap + (deltas[index] ?? 0); + const drawn = block.draw?.(y - block.top) ?? []; + y += block.height ?? block.bottom - block.top; + return drawn; + }); + return { slack, nodes }; +} + +/** The most rows (from `most` down to one) whose layout still fits without shrinking. */ +export function fitRows(most: number, layout: (rows: number) => { slack: number }): number { + let rows = most; + while (rows > 1 && layout(rows).slack < 0) { + rows -= 1; + } + return rows; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-states.test.ts b/apps/mobile/src/glanceable-android/active-agents-widget-states.test.ts new file mode 100644 index 0000000000..09a4d7f835 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-states.test.ts @@ -0,0 +1,232 @@ +import { buildHomeWidgetData } from '@kilocode/app-shared/home-widget'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { _resetHomeWidgetDataForTests } from '@/lib/glanceable/home-widget-data'; +import { setSurfaceExtras } from '@/lib/glanceable/surface-extras'; +import { darkColors, lightColors } from '@/lib/hooks/theme-colors.generated'; + +import { + byKey, + CELLS, + DESIGN, + hasKey, + nodes, + NOW, + placed, + rectOf, + render, + texts, + translate, +} from './active-agents-widget.test-helpers'; +import { + close, + LONG, + PERMISSION, + type State, + stateProps, + STATES, + targets, +} from './active-agents-widget.test-fixtures'; +import { buildCurrentWidgetProps } from './widget-props'; + +vi.mock('react-native-android-widget', () => ({ + FlexWidget: () => null, + OverlapWidget: () => null, + TextWidget: () => null, +})); + +beforeEach(() => { + _resetHomeWidgetDataForTests(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); +afterEach(() => { + vi.useRealTimers(); + setSurfaceExtras({ newestSessionTitle: null, actionFeedback: null }); +}); + +describe('Home widget extra height', () => { + it('spends a taller Small cell on agent lines, then splits the rest evenly', () => { + const props = stateProps(STATES['needs input with Approve']); + const short = render(props, [170, 170]).light; + const tall = render(props, [172, 224]).light; + expect(placed(short).filter(node => node.key?.startsWith('line-'))).toHaveLength(1); + expect(placed(tall).filter(node => node.key?.startsWith('line-'))).toHaveLength(3); + close(rectOf(byKey(tall, 'footer')).y, 224 - 18 - 1.056 * 11); + }); + + it('adds Medium and Large rows only when they fit', () => { + const props = stateProps(STATES['needs input with Approve']); + const rows = (size: readonly [number, number]) => + placed(render(props, size).light).filter(node => /^row-\d-title$/u.test(node.key ?? '')) + .length; + expect(rows([467, 208])).toBe(2); + expect(rows([360, 224])).toBe(3); + expect(rows([360, 344])).toBe(2); + expect(rows([360, 464])).toBe(3); + }); + + it('splits a taller narrow cell evenly across its three gaps', () => { + const props = stateProps(STATES.working); + const y = (height: number, key: string) => + rectOf(byKey(render(props, [172, height]).light, key)).y; + const extra = 31; + close(y(135, 'status') - y(104, 'status'), extra / 3); + close(y(135, 'footer') - y(104, 'footer'), (extra * 2) / 3); + }); +}); + +describe('Home widget states', () => { + const at = (state: State, size: readonly [number, number]) => + render(stateProps(state), size).light; + + it('shows approve progress in the Approve slot and the failure in place of the detail', () => { + const approving = at(STATES.approving, DESIGN.small); + expect(hasKey(approving, 'approving-glyph')).toBe(true); + expect(hasKey(approving, 'approve-target')).toBe(false); + expect(hasKey(approving, 'create-target')).toBe(true); + expect(texts(at(STATES.approving, DESIGN.medium))).toContain('Approving…'); + const failed = at(STATES['could not approve'], DESIGN.small); + expect(byKey(failed, 'line-0').props.text).toBe('Could not approve'); + expect(hasKey(failed, 'approve-target')).toBe(true); + expect(byKey(at(STATES['could not approve'], DESIGN.medium), 'footer').props.text).toBe( + "Couldn't approve. Tap Approve to try again." + ); + expect(byKey(at(STATES['could not approve'], DESIGN.narrow), 'footer').props.text).toBe( + 'Could not approve' + ); + }); + + it('keeps the Approve slot empty without moving `+` when no approval is possible', () => { + for (const size of Object.values(DESIGN)) { + const withApprove = at(STATES['needs input with Approve'], size); + const without = at(STATES['needs input without Approve'], size); + expect(hasKey(without, 'approve-target')).toBe(false); + expect(rectOf(byKey(without, 'create-target'))).toEqual( + rectOf(byKey(withApprove, 'create-target')) + ); + } + }); + + it('hides the header `+` on an empty Medium/Large cell behind one New agent pill', () => { + for (const size of [DESIGN.medium, DESIGN.large, [266, 344] as const]) { + const root = at(STATES.empty, size); + expect(hasKey(root, 'create-glyph')).toBe(false); + expect(hasKey(root, 'new-agent-pill')).toBe(true); + expect(targets(root)).toHaveLength(1); + expect(texts(root)).toEqual( + expect.arrayContaining(['Nothing running right now', 'New agent']) + ); + } + expect(hasKey(at(STATES.empty, DESIGN.small), 'create-glyph')).toBe(true); + expect(texts(at(STATES.empty, DESIGN.narrow))).toContain('No work in progress'); + }); + + it('draws updating placeholders with no actions', () => { + for (const size of Object.values(DESIGN)) { + const root = at(STATES.updating, size); + expect(targets(root)).toEqual([]); + expect(placed(root).some(node => node.key?.startsWith('bar-'))).toBe(true); + // The 4x1 row draws bars only (round 5): its header has no footer slot while updating. + if (size !== DESIGN.row) { + expect(texts(root)).toContain('Updating agents'); + } + } + }); + + it.each(['privacy', 'signed out'] as const)( + 'locks %s with a two-shape lock and no actions or titles', + name => { + for (const size of Object.values(DESIGN)) { + const root = at(STATES[name], size); + expect(hasKey(root, 'lock-shackle')).toBe(true); + expect(hasKey(root, 'lock-body')).toBe(true); + expect(targets(root)).toEqual([]); + const shown = texts(root).join(' '); + expect(shown).not.toContain('Review the release'); + expect(shown).not.toContain('Checked'); + } + } + ); + + it('labels scheduled time today, overdue and unknown without relative words', () => { + const today = texts(at(STATES['scheduled today'], DESIGN.small)); + expect(today).toContain('Next run 8:00 PM'); + expect(texts(at(STATES['scheduled today'], DESIGN.large))).toEqual( + expect.arrayContaining(['Usage report', 'Dependency audit', '8:00 PM']) + ); + expect(texts(at(STATES['scheduled today'], DESIGN.narrow))).toContain('Next run 8:00 PM'); + const overdue = at(STATES['scheduled overdue'], DESIGN.small); + expect(byKey(overdue, 'line-0').props.style?.textAlign).toBe('left'); + expect(byKey(overdue, 'line-0').props.text).toBe('Awaiting update'); + expect(texts(at(STATES['scheduled unknown time'], DESIGN.small))).toContain('Dependency audit'); + }); + + it('shows the latest title for working and idle, and counts for mixed', () => { + expect(texts(at(STATES.working, DESIGN.medium))).toEqual( + expect.arrayContaining(['Recent', 'Fix the flaky login test']) + ); + expect(texts(at(STATES.mixed, DESIGN.medium))).toEqual( + expect.arrayContaining(['1 Scheduled', '1 Idle']) + ); + expect(texts(at(STATES.mixed, DESIGN.large))).toEqual( + expect.arrayContaining(['Recent', 'Build the app', 'Next scheduled', 'Morning checks']) + ); + expect(hasKey(at(STATES.idle, DESIGN.small), 'status')).toBe(true); + }); + + it('keeps a stress count at full size and an untitled agent generic', () => { + for (const size of Object.values(DESIGN)) { + const root = at(STATES.stress, size); + const count = nodes(root).find(node => node.props.text === '9999'); + expect(count?.props.style?.fontSize).toBeGreaterThanOrEqual(22); + } + expect(texts(at(STATES.stress, DESIGN.large))).toEqual(expect.arrayContaining([LONG, 'Agent'])); + }); + + it.each(CELLS)('budgets German and Arabic/RTL copy at %dx%d', (width, height) => { + for (const language of ['de', 'ar']) { + const translated = (key: string) => + language === 'ar' ? `العربية ${translate(key)}` : `Deutsch ${translate(key)}`; + const props = stateProps(STATES['needs input with Approve'], translated); + const root = render(props, [width, height], language === 'ar').light; + for (const node of placed(root)) { + const rect = rectOf(node); + expect(rect.y + rect.height, String(node.key)).toBeLessThanOrEqual(height + 0.05); + expect(rect.y, String(node.key)).toBeGreaterThanOrEqual(-0.05); + } + const label = nodes(root).find(node => node.props.text === props.homeCopy?.primaryLabel); + expect(label?.props.style?.textAlign).toBe(language === 'ar' ? 'right' : 'left'); + } + }); + + it('uses the approved palettes', () => { + const { light, dark } = render(stateProps(STATES.working), DESIGN.small); + expect(light.props.style?.backgroundColor).toBe(lightColors.background); + expect(dark.props.style?.backgroundColor).toBe(darkColors.card); + }); + + it('retains last-known data after activity expiry without renewing the checked time', () => { + const data = buildHomeWidgetData({ + sessions: PERMISSION, + userId: 'u1', + organizationId: null, + now: NOW, + }); + vi.setSystemTime(Date.parse(data.snapshot.expiresAt) + 1); + const props = buildCurrentWidgetProps( + data.snapshot, + translate, + String, + String, + () => '8:00 PM', + data + ); + expect(props.home?.checkedAt).toBe(data.snapshot.updatedAt); + const root = render(props, DESIGN.narrow).light; + expect(texts(root)).toEqual( + expect.arrayContaining(['3', 'Needs input', 'Last known · 8:00 PM']) + ); + expect(hasKey(root, 'create-target')).toBe(true); + }); +}); diff --git a/apps/mobile/src/glanceable-android/active-agents-widget-status.tsx b/apps/mobile/src/glanceable-android/active-agents-widget-status.tsx new file mode 100644 index 0000000000..cf94cac1ab --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget-status.tsx @@ -0,0 +1,120 @@ +/* eslint-disable react-native/no-inline-styles -- the Android widget host requires native style objects */ +'use no memo'; + +import { FlexWidget, TextWidget } from 'react-native-android-widget'; + +import { + boxTop, + dotInk, + flexText, + type Frame, + label, + lineBox, + place, +} from './active-agents-widget-parts'; + +/** Spec: count, dot and label share one row with fixed 6dp gaps; only the label ellipsizes. */ +const STATUS_GAP = 6; + +function statusDot(f: Frame, r: number) { + return ( + + ); +} + +/** + * Count → dot → label on one row, centred on the count. The count keeps its + * natural width; the label takes what is left up to `width` and ellipsizes. + */ +export function statusRow( + f: Frame, + spec: { + x: number; + baseline: number; + width: number; + countSize: number; + labelSize: number; + r: number; + } +) { + const count = f.copy.primaryCount; + const value = f.copy.primaryLabel ?? ''; + const countBox = lineBox(count, spec.countSize); + const height = Math.max(countBox, lineBox(value, spec.labelSize)); + const top = boxTop(count, spec.countSize, spec.baseline) - (height - countBox) / 2; + const children = [ + , + statusDot(f, spec.r), + flexText(f, 'label', { value, size: spec.labelSize, weight: '600' }), + ]; + return ( + + {f.paint.rtl ? children.toReversed() : children} + + ); +} + +/** The stacked count (Small/Medium/Large): the number on its own line, then dot → label. */ +export function stackedStatus( + f: Frame, + spec: { + x: number; + countBaseline: number; + countSize: number; + labelBaseline: number; + labelSize: number; + r: number; + width: number; + } +) { + const value = f.copy.primaryLabel ?? ''; + const children = [ + statusDot(f, spec.r), + flexText(f, 'label', { value, size: spec.labelSize, weight: '600' }), + ]; + // The dot's centre sits 5dp in from the count's leading edge. + const rect = { + x: spec.x + 5 - spec.r, + y: boxTop(value, spec.labelSize, spec.labelBaseline), + width: spec.width - 5 + spec.r, + height: lineBox(value, spec.labelSize), + }; + return [ + label(f, 'count', { + x: spec.x, + baseline: spec.countBaseline, + width: spec.width, + value: f.copy.primaryCount, + size: spec.countSize, + weight: 'bold', + }), + + {f.paint.rtl ? children.toReversed() : children} + , + ]; +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.test-fixtures.ts b/apps/mobile/src/glanceable-android/active-agents-widget.test-fixtures.ts new file mode 100644 index 0000000000..f013699e2d --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget.test-fixtures.ts @@ -0,0 +1,100 @@ +import { + type buildHomeWidgetData, + type HomeWidgetSessionRow, +} from '@kilocode/app-shared/home-widget'; +import { expect } from 'vitest'; + +import { type GlanceableActionFeedback, setSurfaceExtras } from '@/lib/glanceable/surface-extras'; + +import { + type Element, + NOW, + placed, + propsFor, + type Rect, + translate, + WAKE, +} from './active-agents-widget.test-helpers'; + +/** Home widget states every renderer test walks. */ + +const KEY = 'a'.repeat(64); +export const LONG = 'Untangle the payment reconciliation job that keeps timing out every night'; +export const PERMISSION: HomeWidgetSessionRow[] = [ + { status: 'permission', title: 'Review the release', approvalKey: KEY }, + { status: 'question', title: 'Pick a color for the badge' }, + { status: 'question', title: 'Choose the migration strategy' }, + { status: 'busy', title: 'Build the app' }, + { status: 'scheduled', title: 'Morning checks', scheduledAt: WAKE }, + { status: 'idle', title: 'Connected agent' }, +]; +const MIXED: HomeWidgetSessionRow[] = [ + { status: 'busy', title: 'Build the app' }, + { status: 'scheduled', title: 'Morning checks', scheduledAt: WAKE }, + { status: 'idle', title: 'Connected agent' }, +]; +export type State = { + sessions: HomeWidgetSessionRow[]; + status?: Parameters[0]['status']; + feedback?: GlanceableActionFeedback; +}; +export const STATES = { + 'needs input with Approve': { sessions: PERMISSION }, + 'needs input without Approve': { + sessions: [ + { status: 'question', title: 'Pick a color for the badge' }, + { status: 'retry', title: 'Recover the interrupted build' }, + ], + }, + approving: { sessions: PERMISSION, feedback: 'approving' }, + 'could not approve': { sessions: PERMISSION, feedback: 'couldNotApprove' }, + working: { sessions: [{ status: 'busy', title: 'Fix the flaky login test' }] }, + idle: { sessions: [{ status: 'idle', title: 'Review the onboarding copy' }] }, + mixed: { sessions: MIXED }, + 'scheduled today': { + sessions: [ + { status: 'scheduled', title: 'Usage report', scheduledAt: WAKE }, + { + status: 'scheduled', + title: 'Dependency audit', + scheduledAt: new Date(NOW + 9e6).toISOString(), + }, + ], + }, + 'scheduled overdue': { + sessions: [ + { + status: 'scheduled', + title: 'Dependency audit', + scheduledAt: new Date(NOW - 60_000).toISOString(), + }, + ], + }, + 'scheduled unknown time': { sessions: [{ status: 'scheduled', title: 'Dependency audit' }] }, + empty: { sessions: [], status: 'empty' }, + updating: { sessions: [], status: 'waiting' }, + privacy: { sessions: PERMISSION, status: 'privacy' }, + 'signed out': { sessions: PERMISSION, status: 'signed_out' }, + stress: { + sessions: [ + { status: 'permission', title: LONG, approvalKey: KEY }, + { status: 'question' }, + ...Array.from({ length: 9997 }, () => ({ status: 'question' as const })), + ], + }, +} satisfies Record; +export function stateProps(state: State, copy = translate) { + setSurfaceExtras({ newestSessionTitle: null, actionFeedback: state.feedback ?? null }); + return propsFor(state.sessions, state.status, copy); +} + +export function close(actual: number, expected: number) { + expect(actual).toBeCloseTo(expected, 0); +} +/** A placed rect read back in LTR design coordinates. */ +export function ltr(rect: Rect, frame: { width: number; rtl: boolean }): Rect { + return frame.rtl ? { ...rect, x: frame.width - rect.x - rect.width } : rect; +} +export function targets(root: Element) { + return placed(root).filter(node => node.key === 'create-target' || node.key === 'approve-target'); +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.test-helpers.ts b/apps/mobile/src/glanceable-android/active-agents-widget.test-helpers.ts new file mode 100644 index 0000000000..b3ae0890f5 --- /dev/null +++ b/apps/mobile/src/glanceable-android/active-agents-widget.test-helpers.ts @@ -0,0 +1,169 @@ +import { buildHomeWidgetData, type HomeWidgetSessionRow } from '@kilocode/app-shared/home-widget'; + +import { renderActiveAgentsWidget } from './active-agents-widget'; +import { type AndroidWidgetProps, buildAndroidWidgetProps } from './widget-props'; + +/** Shared fixtures and tree walkers for the widget layout suite. Mocks stay in the test file. */ + +export const NOW = 1_750_000_000_000; +export const WAKE = new Date(NOW + 7_200_000).toISOString(); +const COPY: Record = { + 'glanceable.needsInput': 'Needs input', + 'common.working': 'Working', + 'common.scheduled': 'Scheduled', + 'common.idle': 'Idle', + 'common.agent': 'Agent', + 'common.recent': 'Recent', + 'glanceable.empty': 'No work in progress', + 'home.noLiveSessions': 'Nothing running right now', + 'glanceable.waiting': 'Updating agents', + 'glanceable.signedOut': 'Sign in to see agents', + 'glanceable.privacy': 'Open Kilo to see agents', + 'glanceable.checked': 'Checked', + 'glanceable.lastKnown': 'Last known', + 'glanceable.awaitingUpdate': 'Awaiting update', + 'glanceable.nextRun': 'Next run', + 'glanceable.newAgent': 'New agent', + 'glanceable.waitingForYou': 'Waiting for you', + 'glanceable.nextScheduled': 'Next scheduled', + 'common.approve': 'Approve', + 'glanceable.approving': 'Approving…', + 'glanceable.couldNotApprove': 'Could not approve', + 'glanceable.approveFailed': "Couldn't approve. Tap Approve to try again.", + 'agentChat.permissionCard.title': 'Permission required', + 'glanceable.answerNeeded': 'Answer needed', + 'glanceable.waitingToRetry': 'Waiting to retry', + 'glanceable.openAgents': 'Open agents', +}; +export const translate = (key: string) => COPY[key] ?? key; + +/** The design frames, then launcher-reported dp cells including the landscape bands. */ +export const DESIGN = { + small: [170, 170], + medium: [364, 170], + large: [364, 382], + row: [360, 104], + narrow: [172, 104], + landscape: [627, 62], +} as const; +export const CELLS = [ + [172, 104], + [266, 104], + [360, 104], + [172, 135], + [172, 224], + [266, 224], + [360, 224], + [172, 344], + [266, 344], + [360, 344], + [360, 464], + [307, 62], + [467, 62], + [627, 62], + [307, 135], + [467, 135], + [627, 135], + [307, 208], + [467, 208], + [627, 208], + [627, 281], + ...Object.values(DESIGN), +] as const; + +type Style = { + height?: string | number; + width?: string | number; + marginLeft?: number; + marginTop?: number; + flex?: number; + flexDirection?: string; + flexGap?: number; + fontSize?: number; + fontWeight?: string; + backgroundColor?: string; + textAlign?: string; + rotation?: number; +}; +export type Element = { + key?: string | null; + props: { + children?: unknown; + text?: string; + maxLines?: number; + truncate?: string; + clickAction?: string; + clickActionData?: { uri?: string; approvalKey?: string | null }; + accessibilityLabel?: string; + style?: Style; + }; +}; +export function nodes(root: unknown): Element[] { + if (root === null || typeof root !== 'object') { + return []; + } + if (Array.isArray(root)) { + return root.flatMap(child => nodes(child)); + } + const element = root as Element; + return [element, ...nodes(element.props.children)]; +} +/** The canvas's absolutely placed children. */ +export function placed(root: Element): Element[] { + return [root.props.children] + .flat(Infinity) + .filter((child): child is Element => child !== null && typeof child === 'object'); +} +export type Rect = { x: number; y: number; width: number; height: number }; +export function rectOf(node: Element): Rect { + const { marginLeft = 0, marginTop = 0, width, height } = node.props.style ?? {}; + return { + x: marginLeft, + y: marginTop, + width: typeof width === 'number' ? width : Number.NaN, + height: typeof height === 'number' ? height : Number.NaN, + }; +} +export function hasKey(root: Element, key: string): boolean { + return placed(root).some(node => node.key === key); +} +/** The placed child with `key`; a missing one fails the test that asked for it. */ +export function byKey(root: Element, key: string): Element { + const found = placed(root).find(node => node.key === key); + if (found === undefined) { + throw new Error(`no placed ${key}`); + } + return found; +} +export function propsFor( + sessions: HomeWidgetSessionRow[], + status?: Parameters[0]['status'], + copy = translate +) { + const data = buildHomeWidgetData({ + sessions, + userId: 'u1', + organizationId: null, + now: NOW, + ...(status ? { status } : {}), + }); + return buildAndroidWidgetProps(data.snapshot, {}, copy, String, String, () => '8:00 PM', data); +} +/** `size` is a launcher-reported `[width, height]` dp frame. */ +export function render(props: AndroidWidgetProps, size: readonly [number, number], rtl = false) { + const [width, height] = size; + return renderActiveAgentsWidget( + props, + { + widgetName: 'ActiveAgentsWidget', + widgetId: 1, + width, + height, + screenInfo: { screenWidthDp: 400, screenHeightDp: 800, density: 2, densityDpi: 320 }, + }, + rtl + ) as { light: Element; dark: Element }; +} +export function texts(root: Element) { + return nodes(root).flatMap(node => (node.props.text ? [node.props.text] : [])); +} diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts index 559d21b2b8..141b31ddfe 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts +++ b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts @@ -1,707 +1,268 @@ -/* eslint-disable max-lines -- one suite covering every composition and the state matrix through a shared mock-element tree harness */ -import { - buildGlanceableSnapshot, - type GlanceableAgentsSnapshot, -} from '@kilocode/app-shared/glanceable-agents-snapshot'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _resetHomeWidgetDataForTests } from '@/lib/glanceable/home-widget-data'; import { setSurfaceExtras } from '@/lib/glanceable/surface-extras'; -import { darkColors, lightColors } from '@/lib/hooks/theme-colors.generated'; +import { lightColors } from '@/lib/hooks/theme-colors.generated'; -import { renderActiveAgentsWidget } from './active-agents-widget'; +import { type SizeClass, sizeClassFor } from './active-agents-widget'; +import { + byKey, + CELLS, + DESIGN, + type Element, + hasKey, + nodes, + NOW, + placed, + type Rect, + rectOf, + render, + texts, +} from './active-agents-widget.test-helpers'; import { - type AndroidWidgetProps, - buildAndroidWidgetProps, - buildCurrentWidgetProps, -} from './widget-props'; + close, + ltr, + type State, + stateProps, + STATES, + targets, +} from './active-agents-widget.test-fixtures'; -// Stub the widget primitives so the layout functions return inspectable trees -// without loading react-native. The real components are exercised by prebuild. vi.mock('react-native-android-widget', () => ({ - FlexWidget: (props: Record) => ({ kind: 'FlexWidget', props }), - TextWidget: (props: Record) => ({ kind: 'TextWidget', props }), - ImageWidget: (props: Record) => ({ kind: 'ImageWidget', props }), - requestWidgetUpdate: () => undefined, + FlexWidget: () => null, + OverlapWidget: () => null, + TextWidget: () => null, })); -const NOW = 1_750_000_000_000; - -/** The newest result's timestamp, forwarded to the age formatter below. */ -const NEWEST_AT = new Date(NOW - 180_000).toISOString(); -/** Two hours ahead of the suite's clock: the soonest scheduled wake. */ -const WAKE = new Date(NOW + 7_200_000).toISOString(); - -type MockElement = { - type?: { name?: string } | string; - props: { - text?: string; - clickAction?: string; - clickActionData?: { uri?: string }; - accessibilityLabel?: string; - allowFontScaling?: boolean; - maxLines?: number; - style?: { - backgroundColor?: string; - borderColor?: string; - justifyContent?: string; - alignItems?: string; - height?: number | string; - }; - children?: unknown; - }; -}; - -const COPY: Record = { - 'glanceable.needsInput': 'Needs input', - 'common.idle': 'Idle', - 'common.working': 'Working', - 'common.scheduled': 'Scheduled', - 'glanceable.waiting': 'Waiting for agents', - 'glanceable.empty': 'No work in progress', - 'glanceable.expired': 'Status expired', - 'glanceable.signedOut': 'Sign in to see agents', - 'glanceable.privacy': 'Open Kilo to see agents', - 'glanceable.stale': 'Updates delayed', - 'glanceable.openAgents': 'Open agents', - 'glanceable.newestResult': 'Newest result', - 'glanceable.noneWaiting': 'No agents waiting', - 'glanceable.newAgent': 'New agent', - 'glanceable.approving': 'Approving…', - 'glanceable.couldNotApprove': 'Could not approve', - 'glanceable.newestSession': 'Newest: {{title}}', - 'common.approve': 'Approve', -}; - +beforeEach(() => { + _resetHomeWidgetDataForTests(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); afterEach(() => { - setSurfaceExtras({ newestSessionTitle: null, actionFeedback: null }); vi.useRealTimers(); + setSurfaceExtras({ newestSessionTitle: null, actionFeedback: null }); }); -function translate(key: string): string { - return COPY[key] ?? key; -} - -/** The formatters the app injects, stubbed to short, realistic strings. */ -const formatAgo = (): string => '3 min ago'; -const formatClock = (): string => '8:00 PM'; - -/** - * The cell sizes (w x h dp) the Pixel 6 launcher reports for every span from - * 2x1 to 4x4, portrait and landscape: the sizes the widget is drawn at. - */ -const LAUNCHER_CELLS = [ - { span: '2x1', width: 172, height: 104 }, - { span: '3x1', width: 266, height: 104 }, - { span: '4x1', width: 360, height: 104 }, - { span: '2x2', width: 172, height: 224 }, - { span: '3x2', width: 266, height: 224 }, - { span: '4x2', width: 360, height: 224 }, - { span: '2x3', width: 172, height: 344 }, - { span: '3x3', width: 266, height: 344 }, - { span: '4x3', width: 360, height: 344 }, - { span: '4x4', width: 360, height: 464 }, - { span: '2x1 landscape', width: 307, height: 62 }, - { span: '3x1 landscape', width: 467, height: 62 }, - { span: '4x1 landscape', width: 627, height: 62 }, - { span: '2x2 landscape', width: 307, height: 135 }, - { span: '3x2 landscape', width: 467, height: 135 }, - { span: '4x2 landscape', width: 627, height: 135 }, - { span: '2x3 landscape', width: 307, height: 208 }, - { span: '3x3 landscape', width: 467, height: 208 }, - { span: '4x3 landscape', width: 627, height: 208 }, - { span: '4x4 landscape', width: 627, height: 281 }, -]; - -function snapshotFor( - sessions: { status: string; statusUpdatedAt?: string; scheduledAt?: string }[], - revision = 0, - status?: GlanceableAgentsSnapshot['status'] -): GlanceableAgentsSnapshot { - return buildGlanceableSnapshot({ - sessions, - userId: 'u1', - organizationId: null, - now: NOW, - previousRevision: revision, - ...(status === undefined ? {} : { status }), - }); -} - -function propsFor(snapshot: GlanceableAgentsSnapshot): AndroidWidgetProps { - return buildAndroidWidgetProps(snapshot, {}, translate, String, formatAgo, formatClock); -} - -/** Every state at once: two approvable waits, work, a scheduled wake, idle, and a newest result. */ -function mixedProps(): AndroidWidgetProps { - return propsFor( - snapshotFor([ - { status: 'permission' }, - { status: 'permission' }, - { status: 'busy', statusUpdatedAt: NEWEST_AT }, - { status: 'scheduled', scheduledAt: WAKE }, - { status: 'idle' }, - ]) - ); -} - -function children(element: MockElement): unknown[] { - const kids = element.props.children; - if (kids == null) { - return []; - } - return Array.isArray(kids) ? kids.flat(Infinity) : [kids]; -} - -function walk(node: unknown, visit: (element: MockElement) => void): void { - if (node == null || typeof node !== 'object') { - return; - } - if (Array.isArray(node)) { - for (const item of node) { - walk(item, visit); - } - return; - } - const element = node as MockElement; - visit(element); - for (const child of children(element)) { - walk(child, visit); - } -} - -function collectText(node: unknown): string[] { - const text: string[] = []; - walk(node, element => { - if (typeof element.props.text === 'string') { - text.push(element.props.text); - } - }); - return text; -} - -function findAll(node: unknown, match: (element: MockElement) => boolean): MockElement[] { - const found: MockElement[] = []; - walk(node, element => { - if (match(element)) { - found.push(element); - } - }); - return found; -} - -const NEW_AGENT_URI = 'kiloapp:///cloud/sessions/new'; -const isApprove = (element: MockElement): boolean => element.props.clickAction === 'approve'; -const isNewAgent = (element: MockElement): boolean => - element.props.clickActionData?.uri === NEW_AGENT_URI; -const isMark = (element: MockElement): boolean => - typeof element.props === 'object' && 'imageWidth' in element.props; - -type Cell = { width: number; height: number; rtl?: boolean }; - -function render(props: AndroidWidgetProps, cell: Cell) { - const { width, height, rtl = false } = cell; - return renderActiveAgentsWidget( - props, - { - widgetName: 'ActiveAgentsWidget', - widgetId: 1, - width, - height, - screenInfo: { screenWidthDp: 400, screenHeightDp: 800, density: 2, densityDpi: 320 }, - }, - rtl - ) as unknown as { light: MockElement; dark: MockElement }; -} - -/** The visible numbers, in order. */ -function counts(node: unknown): string[] { - return collectText(node).filter(text => /^\d+$/.test(text)); -} - -describe('renderActiveAgentsWidget', () => { - it('returns distinct light and dark layouts in the app palette', () => { - const rep = render(mixedProps(), { width: 360, height: 344 }); - - expect(rep.light).not.toBe(rep.dark); - expect(rep.light.props.style?.backgroundColor).toBe(lightColors.background); - expect(rep.dark.props.style?.backgroundColor).toBe(darkColors.background); - // One plan draws both themes, so they never differ in what they show. - expect(collectText(rep.dark)).toEqual(collectText(rep.light)); - }); - - it('opens Kilo from the whole widget, in every composition', () => { - for (const cell of LAUNCHER_CELLS) { - for (const props of [mixedProps(), propsFor(snapshotFor([], 0, 'empty'))]) { - const { light } = render(props, cell); - expect(light.props.clickAction, cell.span).toBe('OPEN_URI'); - expect(light.props.clickActionData, cell.span).toEqual({ - uri: 'kiloapp:///cloud/sessions', - }); - expect(light.props.accessibilityLabel, cell.span).toBe(props.accessibilityLabel); - } - } - }); - - // Rule: the action is always reachable. Every launcher cell can hold one line - // of content plus the 48 dp action, so every one of them offers it. - it('offers Approve at every launcher size, and never a count less', () => { - const props = mixedProps(); - for (const cell of LAUNCHER_CELLS) { - const { light } = render(props, cell); - const approve = findAll(light, isApprove); - expect(approve, cell.span).toHaveLength(1); - expect(approve[0]?.props.accessibilityLabel).toBe('Approve'); - expect(approve[0]?.props.style?.height, cell.span).toBe(48); - expect(counts(light), cell.span).toEqual(['2', '1', '1', '1']); - } - }); - - it('offers New agent at every launcher size for the empty state', () => { - const props = propsFor(snapshotFor([], 0, 'empty')); - for (const cell of LAUNCHER_CELLS) { - const { light } = render(props, cell); - const chip = findAll(light, isNewAgent); - expect(chip, cell.span).toHaveLength(1); - // Starting an agent needs the composer: a plain deep link, no headless task. - expect(chip[0]?.props.clickAction).toBe('OPEN_URI'); - expect(collectText(light), cell.span).toEqual(['No agents waiting', 'New agent']); - } - }); - - it('offers New agent when every agent is idle', () => { - const props = propsFor(snapshotFor([{ status: 'idle' }, { status: 'idle' }])); - for (const cell of LAUNCHER_CELLS) { - expect(findAll(render(props, cell).light, isNewAgent), cell.span).toHaveLength(1); - } - }); - - // A retry or a question needs the app: no Approve whose press would only open it. - it('draws no Approve for a wait the action cannot answer', () => { - const props = propsFor(snapshotFor([{ status: 'retry' }])); - for (const cell of LAUNCHER_CELLS) { - expect(findAll(render(props, cell).light, isApprove), cell.span).toEqual([]); - } - }); - - it('offers no action for a state with nothing to act on', () => { - for (const status of ['waiting', 'signed_out', 'privacy', 'expired'] as const) { - const { light } = render(propsFor(snapshotFor([], 0, status)), { width: 360, height: 344 }); - expect( - findAll(light, element => element !== light && element.props.clickAction !== undefined), - status - ).toEqual([]); - } - }); - - it('drops the action rather than clipping it in a cell too short for its target', () => { - const { light } = render(propsFor(snapshotFor([], 0, 'empty')), { width: 172, height: 40 }); - - expect(findAll(light, isNewAgent)).toEqual([]); - expect(collectText(light)).toEqual(['No agents waiting']); - }); - - // Rule: overflow order in narrow cells — the wake first, then the secondary - // labels, then the primary label, then the mark. Never a count, and never the - // action while the cell can hold it beside the bare counts. - it('drops the wake, then the labels, then the mark as a one-row cell narrows', () => { - const props = mixedProps(); - const widths = [627, 520, 467, 400, 360, 307, 266]; - const seen = widths.map(width => { - const { light } = render(props, { width, height: 62 }); - const text = collectText(light); - return { - width, - time: text.includes('8:00 PM'), - secondary: text.includes('Working'), - primary: text.includes('Needs input'), - mark: findAll(light, isMark).length > 0, - approve: findAll(light, isApprove).length, - counts: counts(light), - }; - }); - - for (const step of seen) { - expect(step.counts, `${step.width}`).toEqual(['2', '1', '1', '1']); - expect(step.approve, `${step.width}`).toBe(1); - } - // Once a piece is gone it stays gone at every narrower width. - for (const key of ['time', 'secondary', 'primary', 'mark'] as const) { - const firstGone = seen.findIndex(step => !step[key]); - if (firstGone !== -1) { - expect( - seen.slice(firstGone).every(step => !step[key]), - key - ).toBe(true); - } - } - // And a piece never outlives one ranked below it. - for (const step of seen) { - if (step.time) { - expect(step.secondary, `${step.width}`).toBe(true); - } - if (step.secondary) { - expect(step.primary, `${step.width}`).toBe(true); - } - if (step.primary) { - expect(step.mark, `${step.width}`).toBe(true); - } - } - expect(seen[0]).toMatchObject({ time: true, secondary: true, primary: true, mark: true }); - }); - - it('mirrors every row for a right-to-left language', () => { - const props = propsFor(snapshotFor([{ status: 'permission' }, { status: 'busy' }])); - const ltr = collectText(render(props, { width: 627, height: 62 }).light); - const rtl = collectText(render(props, { width: 627, height: 62, rtl: true }).light); - - // Each count row reads label-then-number, and the rows run right to left. - expect(rtl.filter(text => text !== 'Approve')).toEqual( - ltr.filter(text => text !== 'Approve').toReversed() - ); - }); - - // Arabic falls back to a font with taller lines than Roboto: four stacked - // rows that fit a one-row cell in English cut the last one in Arabic. - it('budgets taller lines for Arabic copy, so a one-row cell never stacks four rows', () => { - const arabic: Record = { - 'glanceable.needsInput': 'بانتظار تدخلك', - 'common.working': 'جارٍ العمل', - 'common.scheduled': 'مجدول', - 'common.idle': 'خامل', - }; - const props = buildAndroidWidgetProps( - snapshotFor([{ status: 'permission' }, { status: 'busy' }]), - {}, - key => arabic[key] ?? translate(key), - String, - formatAgo, - formatClock - ); - const stacks = (cell: Cell, input: AndroidWidgetProps) => - findAll( - render(input, cell).light, - element => - (element.props.style as { flexDirection?: string } | undefined)?.flexDirection === - 'column' && children(element).filter(child => counts(child).length === 1).length === 4 - ).length; - - expect(stacks({ width: 266, height: 104 }, mixedProps())).toBe(1); - expect(stacks({ width: 266, height: 104, rtl: true }, props)).toBe(0); - expect(counts(render(props, { width: 266, height: 104, rtl: true }).light)).toHaveLength(4); - }); - - it('hides counts and shows the expired copy for an expired snapshot', () => { - const props = propsFor({ - ...snapshotFor([{ status: 'busy' }], 0), - status: 'expired', - running: 0, - needsInput: 0, - idle: 0, - }); - - expect(collectText(render(props, { width: 266, height: 104 }).light)).toEqual([ - 'Status expired', - ]); - }); +const PADDING: Record = { + small: 16, + medium: 16, + large: 16, + row: 14, + narrow: 14, + landscape: 14, +}; +/** The `+` target's trailing edge inset per class; Approve's target sits directly before it. */ +const PLUS_INSET: Record = { + small: 0, + medium: 0, + large: 0, + row: 2, + narrow: 0, + landscape: 2, +}; - // A widget cell is a fixed frame with no scrolling and no reflow, so text that - // scaled with the system font size pushed the action out of it at Large text. - it('pins every label to its dp size in every composition and theme', () => { - setSurfaceExtras({ newestSessionTitle: 'Fix the flaky test', actionFeedback: null }); - for (const cell of LAUNCHER_CELLS) { - for (const [theme, surface] of Object.entries(render(mixedProps(), cell))) { - const labels = findAll(surface, element => typeof element.props.text === 'string'); - expect(labels.length).toBeGreaterThan(0); - for (const label of labels) { - expect( - label.props.allowFontScaling, - `${theme} \`${label.props.text}\` at ${cell.span}` - ).toBe(false); +describe('Home widget geometry per cell and state', () => { + it.each(CELLS)('places every element inside the %dx%d cell', (width, height) => { + const sizeClass = sizeClassFor(width, height); + const pad = PADDING[sizeClass]; + const slots: Record = {}; + for (const [name, state] of Object.entries(STATES) as [string, State][]) { + const props = stateProps(state); + for (const rtl of [false, true]) { + const { light, dark } = render(props, [width, height], rtl); + expect(texts(light), name).toEqual(texts(dark)); + expect(light.props.clickActionData, name).toEqual({ uri: 'kiloapp:///cloud/sessions' }); + expect(light.props.accessibilityLabel, name).toBe(props.homeCopy?.accessibilityLabel); + for (const node of placed(light)) { + const rect = rectOf(node); + const where = `${name} ${rtl ? 'rtl' : 'ltr'} ${String(node.key)} ${JSON.stringify(rect)}`; + expect(rect.width, where).toBeGreaterThan(0); + expect(rect.height, where).toBeGreaterThan(0); + expect(rect.x, where).toBeGreaterThanOrEqual(-0.05); + expect(rect.y, where).toBeGreaterThanOrEqual(-0.05); + expect(rect.x + rect.width, where).toBeLessThanOrEqual(width + 0.05); + expect(rect.y + rect.height, where).toBeLessThanOrEqual(height + 0.05); + if (node.props.text !== undefined || node.key === 'status' || node.key === 'line') { + // Copy stays inside the side padding. + expect(rect.x, where).toBeGreaterThanOrEqual(pad - 0.05); + expect(rect.x + rect.width, where).toBeLessThanOrEqual(width - pad + 0.05); + } + } + for (const node of nodes(light).filter(child => child.props.text !== undefined)) { + expect(node.props.maxLines, name).toBeGreaterThanOrEqual(1); + if (node.props.text !== props.homeCopy?.primaryCount) { + expect(node.props.truncate, `${name} ${node.props.text}`).toBe('END'); + } + } + const controls = targets(light); + expect(controls.length, name).toBe( + (['content', 'empty'].includes(props.home?.status ?? '') && props.home?.canCreate + ? 1 + : 0) + + (props.home?.status === 'content' && + props.home.canApprove && + state.feedback !== 'approving' + ? 1 + : 0) + ); + for (const control of controls) { + const rect = ltr(rectOf(control), { width: width, rtl: rtl }); + expect(rect.width, name).toBeGreaterThanOrEqual(48); + expect(rect.height, name).toBeGreaterThanOrEqual(48); + expect(control.props.accessibilityLabel, name).toBeTruthy(); + // Action slots never move between states of one cell (the empty-state pill aside). + const slot = `${String(control.key)}-${rtl}`; + if (props.home?.status === 'content' && !['medium', 'large'].includes(sizeClass)) { + slots[slot] ??= rect; + expect(rect, `${name} ${slot}`).toEqual(slots[slot]); + } + } + const [first, second] = controls.map(control => + ltr(rectOf(control), { width: width, rtl: rtl }) + ); + if (first !== undefined && second !== undefined) { + const [left, right] = first.x < second.x ? [first, second] : [second, first]; + expect(left.x + left.width, name).toBeLessThanOrEqual(right.x + 0.05); } } } }); -}); - -describe('the tall counts card', () => { - // Rule: a header row holds the mark at the leading edge and the action at the - // trailing edge, and the count rows sit below it. - it('heads the card with the mark and the action', () => { - const { light } = render(mixedProps(), { width: 360, height: 344 }); - const header = findAll( - light, - element => findAll(element, isMark).length === 1 && findAll(element, isApprove).length === 1 - ).at(-1); - expect(header).toBeDefined(); - expect(counts(header)).toEqual([]); - }); - - // Rule: the footer is pinned only when its caption and its line both fit; - // the caption never draws alone. - it('draws the newest-result footer only whole', () => { - const props = mixedProps(); - for (const cell of LAUNCHER_CELLS) { - const text = collectText(render(props, cell).light); - if (text.includes('Newest result')) { - expect(text, cell.span).toContain('Working'); - expect(text.indexOf('Newest result'), cell.span).toBeLessThan(text.lastIndexOf('Working')); + it.each(CELLS)( + 'pins the `+` target to the trailing edge with Approve before it at %dx%d', + (width, height) => { + const sizeClass = sizeClassFor(width, height); + for (const rtl of [false, true]) { + const root = render( + stateProps(STATES['needs input with Approve']), + [width, height], + rtl + ).light; + const plus = ltr(rectOf(byKey(root, 'create-target')), { width: width, rtl: rtl }); + const approve = ltr(rectOf(byKey(root, 'approve-target')), { width: width, rtl: rtl }); + close(plus.x + plus.width, width - PLUS_INSET[sizeClass]); + close(approve.x + approve.width, plus.x); } } - expect(collectText(render(props, { width: 360, height: 344 }).light)).toContain( - 'Newest result' - ); - expect(collectText(render(props, { width: 360, height: 224 }).light)).not.toContain( - 'Newest result' - ); - }); - - it('drops the footer age whole when the row would not fit with it', () => { - const props = mixedProps(); - - expect(collectText(render(props, { width: 360, height: 344 }).light)).toContain('3 min ago'); - const narrow = collectText( - render( - { ...props, newestResultAgo: '12 minutes ago' }, - { - width: 140, - height: 344, - } - ).light - ); - expect(narrow).toContain('Newest result'); - expect(narrow).not.toContain('12 minutes ago'); - }); - - // Rule: tall cards scale the count rows up with the height. - it('sets the count rows larger in a taller card', () => { - const fontOf = (cell: Cell) => - findAll(render(mixedProps(), cell).light, element => element.props.text === 'Needs input')[0] - ?.props.style as { fontSize?: number } | undefined; - - const short = fontOf({ width: 360, height: 224 })?.fontSize ?? 0; - const tall = fontOf({ width: 360, height: 464 })?.fontSize ?? 0; - expect(tall).toBeGreaterThan(short); - // Numbers and labels in one row share one size. - const number = findAll( - render(mixedProps(), { width: 360, height: 464 }).light, - element => element.props.text === '2' - )[0]?.props.style as { fontSize?: number } | undefined; - expect(number?.fontSize).toBe(tall); - }); - - it('keeps the rows and states the delayed copy under the caption when stale', () => { - const props = propsFor({ - ...snapshotFor([{ status: 'busy', statusUpdatedAt: NEWEST_AT }], 0, 'stale'), - needsInput: 2, - idle: 3, - running: 4, - }); - const text = collectText(render(props, { width: 360, height: 344 }).light); - - expect(counts(render(props, { width: 360, height: 344 }).light)).toEqual(['2', '4', '0', '3']); - expect(text.slice(-2)).toEqual(['Newest result', 'Updates delayed']); - }); - - it('draws the delayed copy once the data lapses', () => { - vi.useFakeTimers(); - vi.setSystemTime(NOW + 31 * 60_000); - const props = buildCurrentWidgetProps( - { - ...snapshotFor([{ status: 'busy', statusUpdatedAt: NEWEST_AT }], 0), - needsInput: 2, - idle: 3, - running: 4, - }, - translate, - String, - formatAgo, - formatClock - ); - vi.useRealTimers(); - const { light } = render(props, { width: 360, height: 344 }); - - expect(collectText(light).slice(-2)).toEqual(['Newest result', 'Updates delayed']); - expect(light.props.accessibilityLabel).toBe( - 'Updates delayed, 2 Needs input, 4 Working, 3 Idle, Open agents' - ); - }); - - it('draws the scheduled wake as a clock time, dropped whole when the row is too wide', () => { - const props = mixedProps(); - // The deepest element that holds both the scheduled label and its count is its row. - const scheduledRow = (cell: Cell) => - findAll(render(props, cell).light, element => { - const text = collectText(element); - return text.includes('Scheduled') && text.includes('1'); - }).at(-1); - - expect(collectText(scheduledRow({ width: 360, height: 344 }))).toEqual([ - '1', - 'Scheduled', - '8:00 PM', - ]); - expect(collectText(scheduledRow({ width: 172, height: 224 }))).toEqual(['1', 'Scheduled']); - }); - - it('gives the scheduled marker its own color instead of the idle outline', () => { - const rep = render(mixedProps(), { width: 360, height: 344 }); - const styles = (node: unknown) => - findAll(node, element => element.props.style !== undefined).map( - element => element.props.style ?? {} - ); - - expect(styles(rep.light).some(style => style.backgroundColor === lightColors.mutedSoft)).toBe( - true - ); - expect(styles(rep.dark).some(style => style.backgroundColor === darkColors.mutedSoft)).toBe( - true - ); - expect(styles(rep.light).some(style => style.borderColor === lightColors.foreground)).toBe( - true - ); - }); - - const fontOf = (node: unknown, text: string): number | undefined => - ( - findAll(node, element => element.props.text === text)[0]?.props.style as - | { fontSize?: number } - | undefined - )?.fontSize; - - // The footer follows the rows: large rows over a footnote read as a mistake. - it('scales the footer with the count rows, never below its base sizes', () => { - const tall = render(mixedProps(), { width: 360, height: 464 }).light; - const rowFont = fontOf(tall, 'Needs input') ?? 0; - const lineFont = fontOf(tall, '3 min ago') ?? 0; - const captionFont = fontOf(tall, 'Newest result') ?? 0; - - expect(rowFont).toBeGreaterThan(20); - expect(lineFont).toBe(Math.round(rowFont * 0.7)); - expect(captionFont).toBe(Math.round(rowFont * 0.6)); - - const short = render(mixedProps(), { width: 360, height: 344 }).light; - expect(fontOf(short, '3 min ago')).toBeGreaterThanOrEqual(13); - expect(fontOf(short, 'Newest result')).toBeGreaterThanOrEqual(11); - }); - - // Zero rows keep the grid still but must not compete with the real counts. - it('draws a zero row muted: glyph, number, and label, the number not bold', () => { - const props = propsFor(snapshotFor([{ status: 'permission' }])); - const { light } = render(props, { width: 360, height: 344 }); - const working = findAll(light, element => { - const text = collectText(element); - return text.includes('Working') && text.includes('0'); - }).at(-1); - const count = findAll(working, element => element.props.text === '0')[0]?.props.style as - | { color?: string; fontWeight?: string } - | undefined; - const needs = findAll(light, element => element.props.text === '1')[0]?.props.style as - | { color?: string; fontWeight?: string } - | undefined; + ); - expect(count).toMatchObject({ color: lightColors.mutedForeground, fontWeight: 'normal' }); - expect(needs).toMatchObject({ color: lightColors.foreground, fontWeight: 'bold' }); + it('maps every cell to one size class', () => { expect( - findAll(working, element => element.props.style?.backgroundColor === lightColors.good) - ).toEqual([]); - }); - - // A chip too wide to sit beside the mark takes its own row at the bottom; - // the mark keeps the header. - it('moves a wide action under the counts and keeps the mark', () => { - const props = { - ...mixedProps(), - actions: { ...mixedProps().actions, approveLabel: 'Genehmigen' }, - }; - const { light } = render(props, { width: 172, height: 344 }); - const top = children(light).filter(child => child !== null && child !== undefined); - - expect(findAll(top[0], isMark)).toHaveLength(1); - expect(findAll(top[0], isApprove)).toEqual([]); - expect(findAll(top.at(-1), isApprove)).toHaveLength(1); - }); - - // One size for every row: a label too long for the cell shrinks the whole - // column, down to the minimum, before any label ends in an ellipsis. - it('shrinks the whole column to fit a long label, down to 12 dp', () => { - const translateDe = (key: string) => - ({ 'glanceable.needsInput': 'Eingabe erforderlich' })[key] ?? translate(key); - const props = buildAndroidWidgetProps( - snapshotFor([{ status: 'permission' }, { status: 'busy' }]), - {}, - translateDe, - String, - formatAgo, - formatClock - ); - const { light } = render(props, { width: 172, height: 344 }); - const sizes = ['Eingabe erforderlich', 'Working', 'Scheduled', 'Idle'].map(text => - fontOf(light, text) - ); - - expect(new Set(sizes).size).toBe(1); - expect(sizes[0]).toBeLessThan(15); - expect(sizes[0]).toBeGreaterThanOrEqual(12); + CELLS.map(([width, height]) => `${width}x${height}:${sizeClassFor(width, height)}`) + ).toEqual([ + '172x104:narrow', + '266x104:row', + '360x104:row', + '172x135:narrow', + '172x224:small', + '266x224:medium', + '360x224:medium', + '172x344:small', + '266x344:large', + '360x344:large', + '360x464:large', + '307x62:landscape', + '467x62:landscape', + '627x62:landscape', + '307x135:row', + '467x135:row', + '627x135:row', + '307x208:medium', + '467x208:medium', + '627x208:medium', + '627x281:medium', + '170x170:small', + '364x170:medium', + '364x382:large', + '360x104:row', + '172x104:narrow', + '627x62:landscape', + ]); }); }); -describe('the in-flight and failed Approve', () => { - it('draws the progress line where the chip was while an Approve runs', () => { - setSurfaceExtras({ newestSessionTitle: 'Fix the flaky test', actionFeedback: 'approving' }); - const props = mixedProps(); - for (const cell of LAUNCHER_CELLS) { - const { light } = render(props, cell); - expect(findAll(light, isApprove), cell.span).toEqual([]); - expect(collectText(light), cell.span).toContain('Approving…'); +describe('Home widget design coordinates at the design frames', () => { + const at = (size: readonly [number, number], state = STATES['needs input with Approve']) => + render(stateProps(state), size).light; + const rect = (root: Element, key: string) => rectOf(byKey(root, key)); + + it('draws Small at the round 2 coordinates', () => { + const root = at(DESIGN.small); + expect(rect(root, 'logo')).toEqual({ x: 16, y: 15, width: 18, height: 18 }); + expect(rect(root, 'create-glyph')).toEqual({ x: 130, y: 12, width: 24, height: 24 }); + expect(rect(root, 'approve-glyph')).toEqual({ x: 102, y: 12, width: 24, height: 24 }); + expect(rect(root, 'count').x).toBe(16); + close(rect(root, 'count').y, 78 - 1.056 * 44); + close(rect(root, 'footer').y, 152 - 1.056 * 11); + close(rect(root, 'line-0').y, 117 - 1.056 * 13); + expect(byKey(root, 'line-0').props.text).toBe('Review the release'); + }); + + it('draws Medium with the Approve pill ending at the `+` slot and two rows', () => { + const root = at(DESIGN.medium); + expect(rect(root, 'approve-pill')).toEqual({ x: 230, y: 12, width: 86, height: 24 }); + expect(rect(root, 'create-glyph')).toEqual({ x: 324, y: 12, width: 24, height: 24 }); + expect(rect(root, 'row-0-dot')).toEqual({ x: 168, y: 66, width: 8, height: 8 }); + expect(rect(root, 'row-1-title').x).toBe(184); + expect(hasKey(root, 'row-2-title')).toBe(false); + close(rect(root, 'count').y, 92 - 1.056 * 44); + }); + + it('draws Large with secondary counts, the divider and three waiting rows', () => { + const root = at(DESIGN.large); + expect(rect(root, 'divider')).toEqual({ x: 16, y: 152, width: 332, height: 1 }); + expect(rect(root, 'count-running-dot')).toEqual({ x: 222, y: 66, width: 8, height: 8 }); + for (const index of [0, 1, 2]) { + close(rect(root, `row-${index}-title`).y, 202 + index * 46 - 1.056 * 14); } - }); - - it('keeps the chip to retry, with the failure over it, at every launcher size', () => { - setSurfaceExtras({ - newestSessionTitle: 'Fix the flaky test', - actionFeedback: 'couldNotApprove', - }); - const props = mixedProps(); - for (const cell of LAUNCHER_CELLS) { - const { light } = render(props, cell); - expect(findAll(light, isApprove), cell.span).toHaveLength(1); - expect(counts(light), cell.span).toEqual(['2', '1', '1', '1']); + expect(byKey(root, 'section').props.text).toBe('Waiting for you'); + }); + + it('draws Row, Narrow and Landscape glyphs at their design centres', () => { + const rowRoot = at(DESIGN.row); + expect(rect(rowRoot, 'create-glyph')).toEqual({ x: 310, y: 34, width: 36, height: 36 }); + expect(rect(rowRoot, 'approve-glyph')).toEqual({ x: 266, y: 34, width: 36, height: 36 }); + expect(byKey(rowRoot, 'footer').props.text).toBe('· Checked 8:00 PM'); + const narrowRoot = at(DESIGN.narrow); + expect(rect(narrowRoot, 'create-glyph')).toEqual({ x: 130, y: 8, width: 28, height: 28 }); + expect(rect(narrowRoot, 'approve-glyph')).toEqual({ x: 98, y: 8, width: 28, height: 28 }); + expect(rect(narrowRoot, 'logo')).toEqual({ x: 14, y: 14, width: 16, height: 16 }); + const land = at(DESIGN.landscape); + expect(rect(land, 'create-glyph')).toEqual({ x: 577, y: 13, width: 36, height: 36 }); + expect(rect(land, 'approve-glyph')).toEqual({ x: 535, y: 13, width: 36, height: 36 }); + expect(texts(land)).toContain('Review the release'); + expect(texts(at([307, 62]))).not.toContain('Review the release'); + }); + + it('keeps the count, dot and label in one 6dp row whose label alone ellipsizes', () => { + for (const size of [DESIGN.row, DESIGN.narrow, DESIGN.landscape]) { + const row = byKey(at(size), 'status'); + expect(row.props.style?.flexDirection).toBe('row'); + expect(row.props.style?.flexGap).toBe(6); + const [count, dotNode, labelSlot] = [row.props.children].flat() as Element[]; + expect(count?.props.text).toBe('3'); + expect(count?.props.truncate).toBeUndefined(); + expect(count?.props.style?.width).toBeUndefined(); + expect(dotNode?.props.style?.backgroundColor).toBe(lightColors.warn); + expect(labelSlot?.props.style).toMatchObject({ width: 0, flex: 1 }); + const labelNode = nodes(labelSlot).find(node => node.props.text === 'Needs input'); + expect(labelNode?.props).toMatchObject({ maxLines: 1, truncate: 'END' }); } - const tall = collectText(render(props, { width: 360, height: 344 }).light); - expect(tall).toContain('Could not approve'); - // The failure belongs to the chip, so the footer keeps the newest result. - expect(tall).toContain('Newest result'); }); - it('names the newest session over the newest result, and only once', () => { - setSurfaceExtras({ newestSessionTitle: 'Fix the flaky test', actionFeedback: null }); - const text = collectText(render(mixedProps(), { width: 360, height: 344 }).light); - - expect(text.slice(-3)).toEqual(['Newest: Fix the flaky test', 'Working', '3 min ago']); - expect(text).not.toContain('Newest result'); - }); -}); - -describe('a state with no counts', () => { - // Rule: count-less states in tall cards are one centered composition. - it.each([ - ['waiting', 'Waiting for agents'], - ['empty', 'No agents waiting'], - ['expired', 'Status expired'], - ['signed_out', 'Sign in to see agents'], - ['privacy', 'Open Kilo to see agents'], - ] as const)('centers the mark and the %s copy in a tall cell', (status, copy) => { - const { light, dark } = render(propsFor(snapshotFor([], 0, status)), { - width: 360, - height: 344, - }); - - expect(light.props.style?.justifyContent).toBe('center'); - expect(light.props.style?.alignItems).toBe('center'); - expect(findAll(light, isMark)).toHaveLength(1); - expect(collectText(light)[0]).toBe(copy); - expect(collectText(dark)[0]).toBe(copy); - expect(collectText(light)).not.toContain('Newest result'); + it('mirrors every placed rectangle in RTL and aligns copy to the right', () => { + for (const size of Object.values(DESIGN)) { + const props = stateProps(STATES['needs input with Approve']); + const left = placed(render(props, size).light); + const right = placed(render(props, size, true).light); + expect(right.map(node => node.key)).toEqual(left.map(node => node.key)); + expect(right.map(node => ltr(rectOf(node), { width: size[0], rtl: true }))).toEqual( + left.map(node => rectOf(node)) + ); + for (const node of right.filter(child => child.props.text !== undefined)) { + expect(['right', 'center'], String(node.key)).toContain(node.props.style?.textAlign); + } + } }); }); diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.tsx b/apps/mobile/src/glanceable-android/active-agents-widget.tsx index c86756e381..5b39ec73b9 100644 --- a/apps/mobile/src/glanceable-android/active-agents-widget.tsx +++ b/apps/mobile/src/glanceable-android/active-agents-widget.tsx @@ -1,1462 +1,133 @@ -/* eslint-disable max-lines -- every composition and its height and width budget share this one widget layout module */ -/* eslint-disable react-native/no-inline-styles -- react-native-android-widget primitives take style objects; NativeWind className is unavailable in the widget host */ - +/* eslint-disable react-native/no-inline-styles -- the Android widget host requires native style objects */ 'use no memo'; -// Metro turns a static image import into the asset id the widget host resolves, -// the same value `require` would give. Imported rather than required so vitest -// can stand in for the binary. -import LOGO from '../../assets/images/logo-widget.png'; import { - FlexWidget, - type FlexWidgetStyle, - type HexColor, - ImageWidget, - TextWidget, + OverlapWidget, type WidgetInfo, type WidgetRepresentation, } from 'react-native-android-widget'; -import { type GlanceableCountKind } from '@/lib/glanceable/presentation'; import { darkColors, lightColors } from '@/lib/hooks/theme-colors.generated'; -import { LAUNCHER_NEW_AGENT_URL } from '@/lib/launcher-surfaces'; +import { small } from './active-agents-widget-card'; +import { large } from './active-agents-widget-large'; +import { medium } from './active-agents-widget-medium'; +import { type Copy, type Frame, type Paint, type Palette } from './active-agents-widget-parts'; +import { row } from './active-agents-widget-row'; +import { landscape, narrow } from './active-agents-widget-short'; import { type AndroidWidgetProps } from './widget-props'; export const WIDGET_NAME = 'ActiveAgentsWidget'; -/* - * How the layout works. - * - * The library draws the tree with native Android views into one bitmap the - * size the host reports (`info.width` x `info.height`, in dp). Nothing scrolls - * and nothing reflows, so a piece that does not fit is cut at the cell edge. - * The layout therefore budgets every composition against the cell before it - * draws, with an estimate of each text's width and height, and picks the - * richest composition that fits: - * - * - card: a header row (the mark leading, the action trailing), the count rows - * scaled with the height, and the newest-result footer pinned to the bottom - * when its caption and its line both fit; - * - side: the mark, the count rows as a column (set tight in a one-row cell), - * and the action trailing — or no action, the fallback that keeps the rows; - * - line: the mark and the counts run as one row, the action beside them or - * under them. - * - * A state with no counts (signed out, waiting, empty, locked, expired) centers - * the mark, its status line, and the New agent action when it offers one; a - * one-row cell runs them beside the mark instead. - * - * An Approve in flight draws its progress line where the chip was; a failed one - * draws its line over the chip, which stays as the retry. - * - * What each composition drops when the width runs out, in order: the wake - * time, the labels of the secondary rows, the primary label, then the mark. A - * count is never dropped, and the action wins over all of them. - * - * Every label is pinned to its dp size (`allowFontScaling={false}`): the cell is - * a fixed frame, so scaled text would push the action out of it. A user gets a - * larger surface by resizing the widget. - */ - -/** Below this height (dp) a cell is one launcher row: its content runs beside the mark. */ -const TALL_MIN_HEIGHT_DP = 130; -/** At or above this height (dp) the counts card has room for its footer and the larger type. */ -const LARGE_MIN_HEIGHT_DP = 220; - -type Palette = { - background: HexColor; - foreground: HexColor; - muted: HexColor; - primary: HexColor; - primaryForeground: HexColor; - /** Four states, four colors — the same vocabulary the iOS surfaces draw. */ - needsInput: HexColor; - running: HexColor; - scheduled: HexColor; - /** The marker of a state no agent is in: the row stays, the ink steps back. */ - zero: HexColor; -}; - -// The app's own palette, not a widget-local one: a Home Screen card that does -// not match the app it opens reads as a different product. +/** The approved widget palettes; three surfaces have no app theme token. */ const LIGHT: Palette = { background: lightColors.background, foreground: lightColors.foreground, muted: lightColors.mutedForeground, + warn: lightColors.warn, + good: lightColors.good, + info: lightColors.info, + idle: lightColors.mutedSoft, primary: lightColors.primary, primaryForeground: lightColors.primaryForeground, - needsInput: lightColors.warn, - running: lightColors.good, - // The muted-soft tone the session list's scheduled clock glyph uses, so the - // widget's scheduled marker matches the row the user taps to get here. - scheduled: lightColors.mutedSoft, - zero: lightColors.mutedSoft, + secondary: lightColors.secondary, + divider: '#E6E3DA', }; - const DARK: Palette = { - background: darkColors.background, + background: darkColors.card, foreground: darkColors.foreground, muted: darkColors.mutedForeground, + warn: darkColors.warn, + good: darkColors.good, + info: darkColors.info, + idle: darkColors.mutedSoft, primary: darkColors.primary, primaryForeground: darkColors.primaryForeground, - needsInput: darkColors.warn, - running: darkColors.good, - scheduled: darkColors.mutedSoft, - zero: darkColors.mutedSoft, + secondary: '#26262B', + divider: '#2A2A30', }; -// This function is evaluated only through `renderActiveAgentsWidget` and the -// library's `buildWidgetTree`. Everything it references is explicit so the -// React Compiler is disabled ("use no memo") and the widget host can re-evaluate -// the source. Translated copy arrives through `props`. - -// --------------------------------------------------------------------------- -// Metrics -// --------------------------------------------------------------------------- - -/** The padding inside a one-row cell and inside a taller one. */ -const SHORT_PAD_DP = 12; -const TALL_PAD_DP = 16; -/** The gap between the mark and what sits beside it. */ -const MARK_GAP_DP = 12; -/** The gap between the content and a trailing action. */ -const TRAILING_GAP_DP = 14; -/** The minimum gap between the blocks of a tall composition. */ -const BLOCK_GAP_DP = 12; -/** The gap between a body line and the line or action under it. */ -const BODY_GAP_DP = 8; - -/** - * The action's tap target and its drawn pill. The target holds Android's 48 dp - * minimum; the pill inside it is smaller, so the target may reach into the - * cell padding while the drawn part keeps its distance from the edge. - */ -const ACTION_TARGET_DP = 48; -const ACTION_PILL_DP = 36; -const ACTION_FONT_DP = 13; -const ACTION_PAD_X_DP = 16; - -/** The type sizes. Count numbers and labels in one row share one size. */ -const SHORT_COUNT_FONT_DP = 13; -const TALL_COUNT_FONT_DP = 15; -const MAX_COUNT_FONT_DP = 28; -/** - * No count row is set below this size (dp). A column that would need less - * gives way to the one-line row, which drops parts instead of shrinking them. - */ -const MIN_COUNT_FONT_DP = 12; -const STATUS_FONT_DP = { short: 13, tall: 14, large: 16, nightstand: 18 } as const; -/** At or above this height (dp) a count-less card sets its mark and copy a size up. */ -const NIGHTSTAND_MIN_HEIGHT_DP = 400; -const SLOT_FONT_DP = 12; -const FOOTER_CAPTION_FONT_DP = 11; -const FOOTER_FONT_DP = 13; - -/** - * One pinned dp text line is about 1.32 times its font size in Roboto with - * font padding. Arabic, the Indic scripts, Thai, and Myanmar fall back to fonts - * with taller ascenders and descenders, and a column budgeted at Roboto's - * height cut their last row at the cell edge. - */ -const ROBOTO_LINE_FACTOR = 1.32; -const TALL_SCRIPT_LINE_FACTOR = 1.62; -const TALL_SCRIPT = - /[\u0600-\u08FF\u0900-\u0DFF\u0E00-\u0E7F\u1000-\u109F\uFB50-\uFDFF\uFE70-\uFEFF]/u; - -/** The line height factor the copy in `props` draws at. */ -function lineFactorOf(props: AndroidWidgetProps): number { - const copy = [ - props.statusLine, - props.newestLine, - props.newestResultLabel, - props.actions.approveLabel, - props.actions.newAgentLabel, - ...props.countLines.map(line => line.label), - ]; - return copy.some(text => text !== null && TALL_SCRIPT.test(text)) - ? TALL_SCRIPT_LINE_FACTOR - : ROBOTO_LINE_FACTOR; -} - -function lineHeight(fontSize: number, factor: number): number { - return Math.ceil(fontSize * factor); -} +export type SizeClass = 'small' | 'medium' | 'large' | 'row' | 'narrow' | 'landscape'; -/** - * Roboto advance widths per em, by character class. The host reports no - * measured text width, so the layout estimates it to decide what to drop - * before it draws; the estimate errs wide (the factor below) so a near miss - * drops a piece rather than cutting it. - */ -function charAdvance(char: string): number { - if (char >= '0' && char <= '9') { - return 0.562; +/** Every launcher cell maps to one size class; its layout then fills the cell. */ +export function sizeClassFor(width: number, height: number): SizeClass { + if (height < 84) { + return 'landscape'; } - if (char === ' ' || char === '\u00A0' || char === '\u202F') { - return 0.25; + if (height < 152) { + return width < 220 ? 'narrow' : 'row'; } - if ("ijlI!|.,:;'’".includes(char)) { - return 0.26; + if (width < 240) { + return 'small'; } - if ('frt()[]-/'.includes(char)) { - return 0.35; - } - if ('mwMW…%'.includes(char)) { - return 0.88; - } - if (/[\u0600-\u06FF]/u.test(char)) { - return 0.5; - } - // CJK, kana, hangul, and everything after them: a full em. - if (/[\u2E80-\u{10FFFF}]/u.test(char)) { - return 1; - } - const code = char.codePointAt(0) ?? 0; - if (char !== char.toLowerCase()) { - return 0.66; - } - return code < 0x02_50 ? 0.54 : 0.6; + return height < 300 ? 'medium' : 'large'; } -/** - * The estimated width of `text` in dp. The table is close to bold Roboto and a - * little over regular, and the factor adds a margin on top, so an estimate errs - * wide: a near miss drops a piece rather than cutting it. - */ -function textWidth(text: string, fontSize: number): number { - let em = 0; - for (const char of text) { - em += charAdvance(char); - } - return Math.ceil(em * fontSize * 1.04); -} +const LAYOUTS = { small, medium, large, row, narrow, landscape } as const; -/** How many lines `text` takes at `width`, word-wrapped. */ -function lineCount(text: string, fontSize: number, width: number): number { - let lines = 1; - let used = 0; - const space = textWidth(' ', fontSize); - for (const word of text.split(' ')) { - const w = textWidth(word, fontSize); - if (used > 0 && used + space + w > width) { - lines += 1; - used = w; - } else { - used += (used > 0 ? space : 0) + w; - } - if (w > width) { - lines += Math.floor(w / width); - } +// Old persisted props have no Home presentation. They get the same hierarchy; +// fresh producers always provide shared Home policy and translated Home copy. +function copyFor(props: AndroidWidgetProps): Copy { + if (props.homeCopy !== undefined) { + return props.homeCopy; } - return lines; -} - -// --------------------------------------------------------------------------- -// Pieces -// --------------------------------------------------------------------------- - -/** The edge a column's content starts from. */ -function startEdge(rtl: boolean): 'flex-start' | 'flex-end' { - return rtl ? 'flex-end' : 'flex-start'; -} - -/** Lay a row's children out in reading order. */ -function inReadingOrder(children: React.ReactNode[], rtl: boolean): React.ReactNode[] { - return rtl ? children.toReversed() : children; -} - -function dotColor(kind: GlanceableCountKind, palette: Palette): HexColor { - if (kind === 'needsInput') { - return palette.needsInput; - } - if (kind === 'running') { - return palette.running; - } - if (kind === 'scheduled') { - return palette.scheduled; - } - return palette.foreground; -} - -/** - * The state marker. Needs-input, working, and scheduled are filled, idle is an - * outline — the shapes differ as well as the colors, so the four states stay - * apart for a user who cannot tell orange from green or from grey. - */ -function stateDot(kind: GlanceableCountKind, color: HexColor, size: number) { - return ( - - ); -} - -function logo(size: number) { - return ( - - ); -} - -/** A flexible gap: at least `min` dp, plus an even share of the free space. */ -function spacer(key: string, min = 0) { - return ; -} - -type CountLine = AndroidWidgetProps['countLines'][number]; - -/** Which parts of a count row draw: the label, and the wake text (scheduled row only). */ -type RowParts = { label: boolean; time: string | null }; - -const dotSize = (fontSize: number): number => - fontSize < 14 ? 9 : Math.min(14, Math.round(fontSize * 0.62)); -function innerGap(fontSize: number): number { - if (fontSize >= 18) { - return 8; - } - return fontSize >= 14 ? 6 : 5; -} -const timeFont = (fontSize: number): number => Math.max(11, Math.round(fontSize * 0.8)); -const columnGap = (fontSize: number): number => Math.round(fontSize * 0.5); - -function rowWidth(line: CountLine, fontSize: number, parts: RowParts) { - const gap = innerGap(fontSize); - let width = dotSize(fontSize) + gap + textWidth(line.count, fontSize); - if (parts.label) { - width += gap + textWidth(line.label, fontSize); - } - if (parts.time !== null && line.kind === 'scheduled') { - width += gap + textWidth(parts.time, timeFont(fontSize)); - } - return width; -} - -type RowStyle = { - palette: Palette; - fontSize: number; - rtl: boolean; - parts: RowParts; - isPrimary: boolean; - /** No agent is in this state: the row keeps its place but draws muted. */ - zero: boolean; - /** In a column the label may shrink (and end in an ellipsis) as a last resort. */ - shrinkLabel: boolean; -}; - -/** - * One count line: marker, count, label, and the scheduled row's wake. Only the - * label color ranks the rows; number and label share one size. - */ -function countRow(line: CountLine, style: RowStyle) { - const { palette, fontSize, rtl, parts, isPrimary, shrinkLabel, zero } = style; - let label: React.ReactNode = null; - if (parts.label) { - label = ( - - ); - if (shrinkLabel) { - label = ( - - {label} - - ); - } - } - const time = - parts.time !== null && line.kind === 'scheduled' ? ( - - ) : null; - return ( - - {inReadingOrder( - [ - stateDot( - line.kind, - zero ? palette.zero : dotColor(line.kind, palette), - dotSize(fontSize) - ), - , - label, - time, - ], - rtl - )} - - ); -} - -/** A count column's height at `fontSize`. */ -function columnHeight( - lines: number, - fontSize: number, - { factor, gap = columnGap(fontSize) }: { factor: number; gap?: number } -): number { - return lines * lineHeight(fontSize, factor) + Math.max(0, lines - 1) * gap; -} - -/** - * The count rows as a column. The rows keep their labels unless `labels` is - * false; the wake drops from a row only when the row would not fit the width - * with it. - */ -function countColumn(props: AndroidWidgetProps, palette: Palette, layout: ColumnLayout) { - const { fontSize, gap, width, rtl, labels } = layout; - return ( - - {props.countLines.map(line => { - const withTime = { label: labels, time: props.scheduledTime }; - return countRow(line, { - palette, - fontSize, - rtl, - parts: - rowWidth(line, fontSize, withTime) <= width ? withTime : { label: labels, time: null }, - isPrimary: line.label === props.primaryLabel, - zero: props.zeroKinds.includes(line.kind), - shrinkLabel: true, - }); - })} - - ); -} - -type ColumnLayout = { - fontSize: number; - gap: number; - /** The width the wake must fit in beside its row. */ - width: number; - rtl: boolean; - labels: boolean; -}; - -/** - * How much of a count row a one-line layout keeps, richest first. Each level - * keeps what the next one drops: the wake goes first, then the secondary - * labels, then the primary label. - */ -type LineLevel = { labels: 'all' | 'primary' | 'none'; time: boolean }; -/** Counts only: what a line keeps when nothing else fits. */ -const BARE_LINE: LineLevel = { labels: 'none', time: false }; -const LINE_LEVELS: LineLevel[] = [ - { labels: 'all', time: true }, - { labels: 'all', time: false }, - { labels: 'primary', time: false }, - BARE_LINE, -]; -const LINE_ITEM_GAP_DP = 10; - -function lineParts(props: AndroidWidgetProps, line: CountLine, level: LineLevel): RowParts { + const primary = props.countLines.find(line => line.label === props.primaryLabel); return { - label: - level.labels === 'all' || (level.labels === 'primary' && line.label === props.primaryLabel), - time: level.time ? props.scheduledTime : null, + statusKind: primary === undefined ? 'signed_out' : 'content', + primaryKind: primary?.kind ?? null, + primaryCount: primary?.count ?? '', + primaryLabel: primary?.label ?? null, + status: props.statusLine, + emptyShort: props.statusLine ?? '', + title: null, + wake: null, + wakeOverdue: false, + footer: null, + actionLine: null, + approveFailed: '', + headings: { recent: '', waitingForYou: '', nextScheduled: '' }, + secondaryCounts: props.countLines.filter(line => line !== primary && line.count !== '0'), + waitingAgents: [], + scheduledAgents: [], + accessibilityLabel: props.accessibilityLabel, }; } -function lineWidth(props: AndroidWidgetProps, level: LineLevel): number { - return ( - props.countLines.reduce( - (sum, line) => sum + rowWidth(line, SHORT_COUNT_FONT_DP, lineParts(props, line, level)), - 0 - ) + - Math.max(0, props.countLines.length - 1) * LINE_ITEM_GAP_DP - ); -} - -/** The richest line level that fits `width`, or null. */ -function fitLine(props: AndroidWidgetProps, width: number): LineLevel | null { - return LINE_LEVELS.find(level => lineWidth(props, level) <= width) ?? null; -} - -/** The palette and the reading direction a piece draws with. */ -type Paint = { palette: Palette; rtl: boolean }; - -/** The counts run as one row, at the one-row cell's type size. */ -function countLine(props: AndroidWidgetProps, level: LineLevel, { palette, rtl }: Paint) { - const rows = props.countLines.map(line => - countRow(line, { - palette, - fontSize: SHORT_COUNT_FONT_DP, - rtl, - parts: lineParts(props, line, level), - isPrimary: line.label === props.primaryLabel, - zero: props.zeroKinds.includes(line.kind), - shrinkLabel: false, - }) - ); - return ( - - {inReadingOrder(rows, rtl)} - - ); -} - -/** A single muted line: the newest session, or an action's progress or failure. */ -function slotLine(text: string, palette: Palette, fontSize = SLOT_FONT_DP) { - return ( - - ); -} - -/** The action the state offers, if any. Approve and New agent never coexist. */ -type Action = { kind: 'approve' | 'new-agent'; label: string }; - -function actionOf(props: AndroidWidgetProps): Action | null { - if (props.actions.approve) { - return { kind: 'approve', label: props.actions.approveLabel }; - } - if (props.actions.newAgent) { - return { kind: 'new-agent', label: props.actions.newAgentLabel }; - } - return null; -} - -function actionWidth(action: Action): number { - return Math.max(64, 2 * ACTION_PAD_X_DP + textWidth(action.label, ACTION_FONT_DP)); -} - -/** - * The action chip. Approve is a custom `clickAction`, which makes the library - * launch a headless task (`register.ts`) that answers in place. New agent is an - * `OPEN_URI` deep link: starting an agent needs the composer, so the tap opens - * the app on the new-session screen. The body keeps its own `OPEN_URI` deep - * link, so a tap beside the chip still opens Kilo. - * - * The whole 48 dp box is the tap target; the filled pill inside it is what draws. - */ -function actionChip(action: Action, palette: Palette) { - const click = - action.kind === 'approve' - ? { clickAction: 'approve' } - : { clickAction: 'OPEN_URI', clickActionData: { uri: LAUNCHER_NEW_AGENT_URL } }; - return ( - - - - - - ); -} - -/** - * What sits where the action would: the chip, the chip under a failed - * Approve's note (the chip is the retry), or — while an Approve is in flight — - * its progress line, so the chip is not offered twice. - */ -type Trailing = - | { kind: 'action'; action: Action; note: string | null } - | { kind: 'progress'; text: string }; - -/** The trailing piece; `withNote` false leaves a failed Approve's note out. */ -function trailingOf(props: AndroidWidgetProps, withNote = true): Trailing | null { - if (props.actionFeedback === 'approving' && props.newestLine !== null) { - return { kind: 'progress', text: props.newestLine }; - } - const action = actionOf(props); - if (action === null) { - return null; - } - const note = - withNote && props.actionFeedback === 'couldNotApprove' && action.kind === 'approve' - ? props.newestLine - : null; - return { kind: 'action', action, note }; -} - -const NOTE_GAP_DP = 2; - -function trailingSize(trailing: Trailing, factor: number) { - if (trailing.kind === 'progress') { - return { - width: textWidth(trailing.text, SLOT_FONT_DP + 1), - height: lineHeight(SLOT_FONT_DP + 1, factor), - }; - } - const chip = actionWidth(trailing.action); - return trailing.note === null - ? { width: chip, height: ACTION_TARGET_DP } - : { - width: Math.max(chip, textWidth(trailing.note, SLOT_FONT_DP)), - height: lineHeight(SLOT_FONT_DP, factor) + NOTE_GAP_DP + ACTION_TARGET_DP, - }; -} - -function renderTrailing(trailing: Trailing, palette: Palette, rtl: boolean) { - if (trailing.kind === 'progress') { - return slotLine(trailing.text, palette, SLOT_FONT_DP + 1); - } - if (trailing.note === null) { - return actionChip(trailing.action, palette); - } - return ( - - {slotLine(trailing.note, palette)} - {actionChip(trailing.action, palette)} - - ); -} - -/** The line under the counts: the newest session, never an action's line (that trails). */ -function slotTextOf(props: AndroidWidgetProps): string | null { - return props.actionFeedback === null ? props.newestLine : null; -} - -/** - * The tall card's footer: the newest result under its caption — the newest - * session's own line when one is known, so the card names it without saying - * "newest" twice — or the delayed copy under the caption when stale. Its type - * follows the count rows, so a tall card's footer is not a footnote under - * large rows: the line at about 0.7x the row size, the caption at about 0.6x, - * never below the base sizes. - */ -type Footer = { - caption: string | null; - body: 'result' | 'text'; - text: string; - lines: number; - /** The result's age, dropped whole when the row would not fit with it. */ - showAgo: boolean; - captionFont: number; - lineFont: number; -}; - -function footerOf(props: AndroidWidgetProps, width: number, rowFont: number): Footer | null { - if (props.countLines.length === 0) { - return null; - } - const captionFont = Math.max(FOOTER_CAPTION_FONT_DP, Math.round(rowFont * 0.6)); - const lineFont = Math.max(FOOTER_FONT_DP, Math.round(rowFont * 0.7)); - if (props.statusLine !== null) { - return { - caption: props.newestResultTitle, - body: 'text', - text: props.statusLine, - lines: Math.min(2, lineCount(props.statusLine, lineFont, width)), - showAgo: false, - captionFont, - lineFont, - }; - } - if ( - props.newestResultKind === null || - props.newestResultLabel === null || - props.newestResultAgo === null - ) { - return null; - } - const labelPart = dotSize(lineFont) + 6 + textWidth(props.newestResultLabel, lineFont); - return { - caption: - props.actionFeedback === null && props.newestLine !== null - ? props.newestLine - : props.newestResultTitle, - body: 'result', - text: props.newestResultLabel, - lines: 1, - showAgo: labelPart + 6 + textWidth(props.newestResultAgo, lineFont) <= width, - captionFont, - lineFont, +function draw(props: AndroidWidgetProps, info: WidgetInfo, paint: Paint) { + const { palette } = paint; + const frame: Frame = { + props, + copy: copyFor(props), + paint, + width: info.width, + height: info.height, }; -} - -function footerHeight(footer: Footer, factor: number): number { - return ( - (footer.caption === null ? 0 : lineHeight(footer.captionFont, factor) + 4) + - footer.lines * lineHeight(footer.lineFont, factor) - ); -} - -function renderFooter(props: AndroidWidgetProps, footer: Footer, { palette, rtl }: Paint) { - const body = - footer.body === 'result' && - props.newestResultKind !== null && - props.newestResultLabel !== null && - props.newestResultAgo !== null ? ( - - {inReadingOrder( - [ - stateDot( - props.newestResultKind, - dotColor(props.newestResultKind, palette), - dotSize(footer.lineFont) - ), - , - footer.showAgo ? ( - - ) : null, - ], - rtl - )} - - ) : ( - - ); - return ( - - {footer.caption === null ? null : ( - - )} - {body} - - ); -} - -// --------------------------------------------------------------------------- -// Compositions -// --------------------------------------------------------------------------- - -/** The cell's dp size, reading direction, and the line height factor of its copy. */ -type Cell = { width: number; height: number; rtl: boolean; lineFactor: number }; - -/** - * A composition the cell can hold, with what it keeps. `score` ranks the - * candidates: the action first, then the richest content. - */ -type Plan = { score: number; draw: (palette: Palette) => React.ReactNode }; - -/** What each line level costs a plan: the labels it drops. */ -const LABELS_PENALTY = { all: 0, primary: 8, none: 20 } satisfies Record< - LineLevel['labels'], - number ->; - -/** What a plan loses, as a penalty; the action outweighs everything else. */ -function penalty(dropped: { - action: boolean; - time: boolean; - labels: LineLevel['labels']; - mark: boolean; -}) { - return ( - (dropped.action ? 1000 : 0) + - (dropped.mark ? 40 : 0) + - LABELS_PENALTY[dropped.labels] + - (dropped.time ? 2 : 0) - ); -} - -/** The whole cell: the background, and the deep link a tap anywhere opens. */ -function root( - props: AndroidWidgetProps, - palette: Palette, - { style, children }: { style: FlexWidgetStyle; children: React.ReactNode } -) { + const sizeClass = sizeClassFor(info.width, info.height); return ( - - {children} - - ); -} - -/** A row whose start and end sit at its two edges, mirrored for RTL. */ -function spreadRow(key: string, [start, end]: [React.ReactNode, React.ReactNode], rtl: boolean) { - return ( - - {inReadingOrder([start, spacer(`${key}-gap`), end], rtl)} - - ); -} - -/** The type sizes a card's count column may take, largest first. */ -function countSizes(large: boolean): number[] { - const sizes: number[] = []; - for ( - let size = large ? MAX_COUNT_FONT_DP : TALL_COUNT_FONT_DP; - size >= MIN_COUNT_FONT_DP; - size -= 1 - ) { - sizes.push(size); - } - return sizes; -} - -/** - * The counts card: header, scaled count rows, and the footer when it fits. - * - * The header holds the mark and the action. When the two do not fit side by - * side the mark keeps the header and the action takes its own row at the - * bottom of the card, so neither sits alone at an edge. Only a card too short - * for that row gives the header to the action alone (`chipOnly`), which ranks - * below every composition that keeps the mark. - */ -function cardPlan( - props: AndroidWidgetProps, - cell: Cell, - { note, chipOnly }: { note: boolean; chipOnly: boolean } -): Plan | null { - const { rtl } = cell; - const pad = TALL_PAD_DP; - const aw = cell.width - 2 * pad; - const ah = cell.height - 2 * pad; - const large = cell.height >= LARGE_MIN_HEIGHT_DP; - const markSize = large ? 30 : 26; - const lines = props.countLines.length; - const factor = cell.lineFactor; - const trailing = trailingOf(props, note); - const trailingBox = trailing === null ? null : trailingSize(trailing, factor); - const showTrailing = trailingBox !== null && trailingBox.width <= aw; - if (chipOnly && !showTrailing) { - return null; - } - const actionBelow = !chipOnly && showTrailing && markSize + MARK_GAP_DP + trailingBox.width > aw; - let headerHeight = markSize; - if (chipOnly) { - headerHeight = trailingBox?.height ?? 0; - } else if (showTrailing && !actionBelow) { - headerHeight = Math.max(markSize, trailingBox.height); - } - const bottomHeight = actionBelow ? BLOCK_GAP_DP + trailingBox.height : 0; - - const footerHeightAt = (fontSize: number): number | null => { - const footer = footerOf(props, aw, fontSize); - return footer === null ? null : BLOCK_GAP_DP + footerHeight(footer, factor); - }; - const freeAt = (fontSize: number, withFooter: boolean): number => - ah - headerHeight - bottomHeight - (withFooter ? (footerHeightAt(fontSize) ?? 0) : 0); - const fits = (fontSize: number, withFooter: boolean): boolean => - BLOCK_GAP_DP + columnHeight(lines, fontSize, { factor }) <= freeAt(fontSize, withFooter); - // The rows share one size: the largest that fits the height, leaves the card - // room to breathe, and fits the longest label; the minimum when no size fits - // that label, which then ends in an ellipsis. - const pickSize = (withFooter: boolean): number | null => { - const fitting = countSizes(large).filter(size => fits(size, withFooter)); - if (fitting.length === 0) { - return null; - } - const roomy = fitting.filter( - size => columnHeight(lines, size, { factor }) <= freeAt(size, withFooter) * 0.75 - ); - const pool = roomy.length > 0 ? roomy : fitting.slice(-1); - return ( - pool.find(size => - props.countLines.every(line => rowWidth(line, size, { label: true, time: null }) <= aw) - ) ?? - pool.at(-1) ?? - null - ); - }; - // The footer is the third fact: it may not shrink the rows below the tall - // card's base size (or below what the longest label allows anyway). - const bare = pickSize(false); - if (bare === null) { - return null; - } - const footed = footerHeightAt(TALL_COUNT_FONT_DP) === null ? null : pickSize(true); - const withFooter = footed !== null && footed >= Math.min(bare, TALL_COUNT_FONT_DP); - const fontSize = withFooter ? footed : bare; - const footer = withFooter ? footerOf(props, aw, fontSize) : null; - const keepsTime = - props.scheduledTime === null || - props.countLines.every( - line => rowWidth(line, fontSize, { label: true, time: props.scheduledTime }) <= aw - ); - return { - score: - -penalty({ - action: trailing !== null && !showTrailing, - time: !keepsTime, - labels: 'all', - mark: chipOnly, - }) + (withFooter ? 1 : 0), - draw: palette => { - const action = - showTrailing && trailing !== null ? renderTrailing(trailing, palette, rtl) : null; - const header = chipOnly - ? action - : spreadRow('header', [logo(markSize), actionBelow ? null : action], rtl); - const counts = countColumn(props, palette, { - fontSize, - gap: columnGap(fontSize), - width: aw, - rtl, - labels: true, - }); - return root(props, palette, { - style: { flexDirection: 'column', alignItems: startEdge(rtl), padding: pad }, - children: [ - header, - spacer('above-counts', BLOCK_GAP_DP), - counts, - spacer('below-counts', footer === null ? 0 : BLOCK_GAP_DP), - footer === null ? null : renderFooter(props, footer, { palette, rtl }), - actionBelow ? : null, - actionBelow ? action : null, - ], - }); - }, - }; -} - -/** - * The mark, the count rows as a column, and the action trailing. Without the - * action (`withAction` false) it is the short narrow cell's fallback that keeps - * every label when the counts cannot run as one line beside the action. - */ -function sidePlan( - props: AndroidWidgetProps, - cell: Cell, - { short, withAction, note }: { short: boolean; withAction: boolean; note: boolean } -): Plan | null { - const { rtl } = cell; - const pad = short ? SHORT_PAD_DP : TALL_PAD_DP - 2; - const aw = cell.width - 2 * pad; - const ah = cell.height - 2 * pad; - const trailing = withAction ? trailingOf(props, note) : null; - const trailingBox = trailing === null ? null : trailingSize(trailing, cell.lineFactor); - const baseFont = short ? SHORT_COUNT_FONT_DP : 14; - const gapFor = (size: number) => (short ? 2 : columnGap(size)); - const lines = props.countLines.length; - const heightAt = (size: number) => - columnHeight(lines, size, { factor: cell.lineFactor, gap: gapFor(size) }); - if (trailingBox !== null && trailingBox.height > cell.height - 2 * (pad - 6)) { - return null; - } - const colWidth = (option: { labels: boolean; time: boolean; size: number }) => - Math.max( - ...props.countLines.map(line => - rowWidth(line, option.size, { - label: option.labels, - time: option.time ? props.scheduledTime : null, - }) - ) - ); - const trailingPart = trailingBox === null ? 0 : TRAILING_GAP_DP + trailingBox.width; - const markSize = short ? 24 : 26; - const room = (mark: boolean) => aw - trailingPart - (mark ? markSize + MARK_GAP_DP : 0); - // Richest first, in the overflow order: the wake, then the labels (after the - // whole column shrinks to the minimum size for them), then the mark. - const chosen = [ - { labels: true, time: true, mark: true, size: baseFont }, - { labels: true, time: false, mark: true, size: baseFont }, - { labels: true, time: false, mark: true, size: MIN_COUNT_FONT_DP }, - { labels: false, time: false, mark: true, size: baseFont }, - { labels: false, time: false, mark: false, size: baseFont }, - ].find(option => heightAt(option.size) <= ah && colWidth(option) <= room(option.mark)); - if (chosen === undefined) { - return null; - } - const { labels, time, mark: showMark, size: fontSize } = chosen; - const gap = gapFor(fontSize); - const colHeight = heightAt(fontSize); - const slot = slotTextOf(props); - const showSlot = - slot !== null && - colHeight + BODY_GAP_DP + lineHeight(SLOT_FONT_DP, cell.lineFactor) <= ah && - !short; - return { - score: - -penalty({ - action: !withAction && trailingOf(props) !== null, - time: !time && props.scheduledTime !== null, - labels: labels ? 'all' : 'none', - mark: !showMark, - }) - 3, - draw: palette => { - const body = ( - - {countColumn(props, palette, { - fontSize, - gap, - width: time ? Number.POSITIVE_INFINITY : 0, - rtl, - labels, - })} - {showSlot ? slotLine(slot, palette) : null} - - ); - const start = ( - - {inReadingOrder([showMark ? logo(markSize) : null, body], rtl)} - - ); - return root(props, palette, { - style: { flexDirection: 'row', alignItems: 'center', padding: pad }, - children: inReadingOrder( - [ - start, - spacer('gap', TRAILING_GAP_DP), - trailing === null ? null : renderTrailing(trailing, palette, rtl), - ], - rtl - ), - }); - }, - }; -} - -/** - * The mark and the counts as one row, with the action beside them, under them, - * or (`alone`) not at all. - */ -function linePlan( - props: AndroidWidgetProps, - cell: Cell, - { layout, note }: { layout: 'beside' | 'below' | 'alone'; note: boolean } -): Plan | null { - const { rtl } = cell; - const pad = cell.height < TALL_MIN_HEIGHT_DP ? SHORT_PAD_DP : TALL_PAD_DP - 2; - const aw = cell.width - 2 * pad; - const ah = cell.height - 2 * pad; - const countHeight = lineHeight(SHORT_COUNT_FONT_DP, cell.lineFactor); - const wanted = trailingOf(props, note); - const trailing = layout === 'alone' ? null : wanted; - if (layout !== 'alone' && trailing === null) { - return null; - } - const beside = layout === 'beside'; - const trailingBox = trailing === null ? null : trailingSize(trailing, cell.lineFactor); - const markSize = ah >= 40 ? 28 : 24; - const slot = slotTextOf(props); - const slotHeight = BODY_GAP_DP + lineHeight(SLOT_FONT_DP, cell.lineFactor); - let bodyHeight = countHeight; - if (layout === 'below' && trailingBox !== null) { - bodyHeight += BODY_GAP_DP + trailingBox.height; - } - if (bodyHeight > ah) { - return null; - } - // The 48 dp target may reach into the padding; its drawn pill may not. - if (beside && trailingBox !== null && trailingBox.height > cell.height - 2 * (pad - 6)) { - return null; - } - const showSlot = slot !== null && bodyHeight + slotHeight <= ah; - const trailingWidth = trailingBox?.width ?? 0; - const room = (mark: boolean): number => - aw - - (mark && markSize <= ah ? markSize + MARK_GAP_DP : 0) - - (beside ? TRAILING_GAP_DP + trailingWidth : 0); - let showMark = markSize <= ah; - let level = fitLine(props, room(showMark)); - if (level === null && showMark) { - showMark = false; - level = fitLine(props, room(false)); - } - if (level === null || (layout === 'below' && trailingWidth > room(showMark))) { - return null; - } - const chosen = level; - return { - score: - -penalty({ - action: layout === 'alone' && wanted !== null, - time: !chosen.time && props.scheduledTime !== null, - labels: chosen.labels, - mark: !showMark, - }) - (beside ? 6 : 5), - draw: palette => { - const body = ( - - {countLine(props, chosen, { palette, rtl })} - {showSlot ? slotLine(slot, palette) : null} - {layout === 'below' && trailing !== null ? renderTrailing(trailing, palette, rtl) : null} - - ); - return root(props, palette, { - style: { flexDirection: 'row', alignItems: 'center', padding: pad }, - children: inReadingOrder( - [ - showMark ? logo(markSize) : null, - showMark ? : null, - body, - beside && trailing !== null ? spacer('gap', TRAILING_GAP_DP) : null, - beside && trailing !== null ? renderTrailing(trailing, palette, rtl) : null, - ], - rtl - ), - }); - }, - }; -} - -/** - * Every counts composition the cell can hold. A failed Approve's note rides - * above the retry chip when there is room; the variants without it rank lower. - */ -function countPlans(props: AndroidWidgetProps, cell: Cell): Plan[] { - const tall = cell.height >= TALL_MIN_HEIGHT_DP; - const short = !tall; - const failed = props.actionFeedback === 'couldNotApprove'; - return (failed ? [true, false] : [true]).flatMap(note => - [ - tall ? cardPlan(props, cell, { note, chipOnly: false }) : null, - tall ? cardPlan(props, cell, { note, chipOnly: true }) : null, - sidePlan(props, cell, { short, withAction: true, note }), - sidePlan(props, cell, { short, withAction: false, note }), - linePlan(props, cell, { layout: 'below', note }), - linePlan(props, cell, { layout: 'beside', note }), - linePlan(props, cell, { layout: 'alone', note }), - ] - .filter((plan): plan is Plan => plan !== null) - // Without the failure's note a plan ranks below the same plan with it. - .map(plan => (failed && !note ? { score: plan.score - 3, draw: plan.draw } : plan)) + {LAYOUTS[sizeClass](frame)} + ); } -/** The centered composition's type and mark sizes, a step up for each taller bound. */ -function centeredSizes(height: number) { - if (height >= NIGHTSTAND_MIN_HEIGHT_DP) { - return { fontSize: STATUS_FONT_DP.nightstand, markSize: 48 }; - } - if (height >= LARGE_MIN_HEIGHT_DP) { - return { fontSize: STATUS_FONT_DP.large, markSize: 40 }; - } - return { fontSize: STATUS_FONT_DP.tall, markSize: 30 }; -} - -/** A tall cell's count-less composition: mark, copy, and action, centered on both axes. */ -function centeredStatusPlan(props: AndroidWidgetProps, cell: Cell): Plan | null { - const text = props.statusLine ?? ''; - const action = actionOf(props); - const pad = TALL_PAD_DP; - const aw = cell.width - 2 * pad; - const ah = cell.height - 2 * pad; - const { fontSize, markSize } = centeredSizes(cell.height); - const lines = Math.min(3, lineCount(text, fontSize, Math.min(aw, 260))); - const textHeight = lines * lineHeight(fontSize, cell.lineFactor); - const showAction = action !== null && actionWidth(action) <= aw; - const actionPart = showAction ? BLOCK_GAP_DP + ACTION_TARGET_DP : 0; - const showMark = markSize + BLOCK_GAP_DP + textHeight + actionPart <= ah; - if (!showMark && textHeight + actionPart > ah) { - return null; - } - return { - score: -penalty({ - action: action !== null && !showAction, - time: false, - labels: 'all', - mark: !showMark, - }), - draw: palette => - root(props, palette, { - style: { - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - flexGap: BLOCK_GAP_DP, - padding: pad, - }, - children: [ - showMark ? logo(markSize) : null, - , - showAction ? actionChip(action, palette) : null, - ], - }), - }; -} - -/** - * A one-row count-less composition (or a tall cell's fallback): the mark, the - * copy, and the action beside the copy, under it, or (`alone`) not at all. - */ -function rowStatusPlan( - props: AndroidWidgetProps, - cell: Cell, - layout: 'beside' | 'below' | 'alone' -): Plan | null { - const { rtl } = cell; - const text = props.statusLine ?? ''; - const action = layout === 'alone' ? null : actionOf(props); - if (layout !== 'alone' && action === null) { - return null; - } - const pad = cell.height >= TALL_MIN_HEIGHT_DP ? TALL_PAD_DP - 2 : SHORT_PAD_DP; - // The 48 dp target may reach into the padding; its drawn pill may not. - if (layout === 'beside' && ACTION_TARGET_DP > cell.height - 2 * (pad - 6)) { - return null; - } - const aw = cell.width - 2 * pad; - const ah = cell.height - 2 * pad; - const fontSize = STATUS_FONT_DP.short; - const markSize = ah >= 40 ? 28 : 24; - const actionWide = action === null ? 0 : actionWidth(action); - const roomFor = (mark: boolean): number => - aw - - (mark ? markSize + MARK_GAP_DP : 0) - - (layout === 'beside' ? TRAILING_GAP_DP + actionWide : 0); - const fitsWith = (mark: boolean): boolean => - roomFor(mark) >= 48 && (layout !== 'below' || actionWide <= roomFor(mark)); - const showMark = fitsWith(true); - if (!showMark && !fitsWith(false)) { - return null; - } - const room = roomFor(showMark); - const reserve = layout === 'below' ? BODY_GAP_DP + ACTION_TARGET_DP : 0; - const maxLines = Math.floor((ah - reserve) / lineHeight(fontSize, cell.lineFactor)); - if (maxLines < 1) { - return null; - } - const wanted = lineCount(text, fontSize, room); - const lines = Math.min(maxLines, 2, wanted); - return { - score: - -penalty({ - action: layout === 'alone' && actionOf(props) !== null, - time: false, - labels: 'all', - mark: !showMark, - }) - - (wanted > lines ? 4 : 0) - - // One line beside the action reads better than two squeezed beside it. - (layout === 'beside' ? 2 * (lines - 1) : 0) - - (layout === 'below' ? 1 : 0) - - 10, - draw: palette => { - const copy = ( - - ); - let body: React.ReactNode = copy; - if (layout === 'beside') { - body = ( - - {copy} - - ); - } else if (layout === 'below' && action !== null) { - body = ( - - {copy} - {actionChip(action, palette)} - - ); - } - return root(props, palette, { - style: { flexDirection: 'row', alignItems: 'center', padding: pad }, - children: inReadingOrder( - [ - showMark ? logo(markSize) : null, - showMark ? : null, - body, - layout === 'beside' ? ( - - ) : null, - layout === 'beside' && action !== null ? actionChip(action, palette) : null, - ], - rtl - ), - }); - }, - }; -} - -/** Every count-less composition the cell can hold. */ -function statusPlans(props: AndroidWidgetProps, cell: Cell): Plan[] { - return [ - cell.height >= TALL_MIN_HEIGHT_DP ? centeredStatusPlan(props, cell) : null, - rowStatusPlan(props, cell, 'beside'), - rowStatusPlan(props, cell, 'below'), - rowStatusPlan(props, cell, 'alone'), - ].filter((plan): plan is Plan => plan !== null); -} - -/** The fallback when no composition fits: the counts as a tight line, nothing else. */ -function fallbackPlan(props: AndroidWidgetProps, cell: Cell): Plan { - return { - score: Number.NEGATIVE_INFINITY, - draw: palette => - root(props, palette, { - style: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - padding: SHORT_PAD_DP, - }, - children: - props.countLines.length === 0 - ? slotLine(props.statusLine ?? '', palette, STATUS_FONT_DP.short) - : countLine(props, BARE_LINE, { palette, rtl: cell.rtl }), - }), - }; -} - -function bestPlan(props: AndroidWidgetProps, cell: Cell): Plan { - const plans = props.countLines.length === 0 ? statusPlans(props, cell) : countPlans(props, cell); - let best = fallbackPlan(props, cell); - for (const plan of plans) { - if (plan.score > best.score) { - best = plan; - } - } - return best; -} - -/** - * Distinct light and dark layouts through the library's theme callback, drawn - * from one plan so both themes keep the same composition. - */ export function renderActiveAgentsWidget( props: AndroidWidgetProps, info: WidgetInfo, rtl = false ): WidgetRepresentation { - const plan = bestPlan(props, { - width: info.width, - height: info.height, - rtl, - lineFactor: lineFactorOf(props), - }); return { - light: plan.draw(LIGHT) as React.JSX.Element, - dark: plan.draw(DARK) as React.JSX.Element, + light: draw(props, info, { palette: LIGHT, rtl }), + dark: draw(props, info, { palette: DARK, rtl }), }; } diff --git a/apps/mobile/src/glanceable-android/android-sink.test.ts b/apps/mobile/src/glanceable-android/android-sink.test.ts index 40d582a7bd..179c82b9b7 100644 --- a/apps/mobile/src/glanceable-android/android-sink.test.ts +++ b/apps/mobile/src/glanceable-android/android-sink.test.ts @@ -6,6 +6,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GlanceablePublisher } from '@/lib/glanceable/publisher'; +import { _resetHomeWidgetDataForTests } from '@/lib/glanceable/home-widget-data'; import { setGlanceableDelivery } from '@/lib/glanceable/sink-registry'; import { _resetWaitingAskForTests, @@ -20,22 +21,30 @@ import { getCurrentWidgetProps, handleAppStateActive, renderStoredSnapshotWithNotice, + setGlanceableActionApproving, setGlanceableActionNotice, } from './android-sink'; import { _setPermissionReaderForTests, type NotificationPermissionStatus } from './permission'; +vi.mock('expo-localization', () => ({ + getLocales: () => [{ languageTag: 'en-US' }], + getCalendars: () => [{ uses24hourClock: false }], +})); const mocks = vi.hoisted(() => { - let notification: { + type Card = { title: string; text: string; + textIsError: boolean; + subText: string | null; + compactText: string | null; openLabel: string; openUrl: string; approveLabel: string | null; - compactText: string | null; - channelId: string; - alerting: boolean; - promotion: boolean; - } | null = null; + newAgentLabel: string | null; + newAgentUrl: string; + }; + let notification: (Card & { channelId: string; alerting: boolean; promotion: boolean }) | null = + null; // Capture the requested bridge timeout, not Android's alarm cancellation behavior. let notificationDeadline: number | null = null; @@ -47,58 +56,24 @@ const mocks = vi.hoisted(() => { // eslint-disable-next-line max-params -- the fake models the native bridge arguments function post( - title: string, - text: string, - openAction: { label: string; url: string }, - approveLabel: string | null, - compactText: string | null, + card: Card, channelId: string, alerting: boolean, promotion: boolean, timeoutMs = 0 ): void { - notification = { - title, - text, - openLabel: openAction.label, - openUrl: openAction.url, - approveLabel, - compactText, - channelId, - alerting, - promotion, - }; + notification = { ...card, channelId, alerting, promotion }; notificationDeadline = timeoutMs > 0 ? Date.now() + timeoutMs : null; postedChannel = channelId; } const isPromotionCapable = vi.fn(() => true); - // The native `update` spends its eighth bridge slot on the terminal timeout, - // so it applies the promotion gate from the capability check it re-runs - // instead of a JS flag. + // The native `update` carries the terminal timeout and applies the promotion + // gate from the capability check it re-runs instead of a JS flag. // eslint-disable-next-line max-params -- the fake models the native update bridge arguments - function postUpdate( - title: string, - text: string, - openAction: { label: string; url: string }, - approveLabel: string | null, - compactText: string | null, - channelId: string, - alerting: boolean, - timeoutMs = 0 - ): void { - post( - title, - text, - openAction, - approveLabel, - compactText, - channelId, - alerting, - isPromotionCapable(), - timeoutMs - ); + function postUpdate(card: Card, channelId: string, alerting: boolean, timeoutMs = 0): void { + post(card, channelId, alerting, isPromotionCapable(), timeoutMs); } return { @@ -155,6 +130,7 @@ vi.mock('react-native', () => ({ // vitest; stub them so only the sink logic runs. vi.mock('react-native-android-widget', () => ({ FlexWidget: () => null, + OverlapWidget: () => null, TextWidget: () => null, ImageWidget: () => null, requestWidgetUpdate: (...args: unknown[]) => mocks.requestWidgetUpdate(...args), @@ -260,6 +236,7 @@ beforeEach(() => { vi.setSystemTime(NOW); mocks.native.setWidgetSnapshot('', 0); _resetAndroidSinkForTests(); + _resetHomeWidgetDataForTests(); _resetWaitingAskForTests(); // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules _setPermissionReaderForTests(() => Promise.resolve('granted')); @@ -295,7 +272,7 @@ describe('androidSink start and update', () => { publisher.applySnapshot(snapshotFor([{ status: 'busy' }], 1), CTX); await flushAsync(); - expect(mocks.getNotification()?.text).toBe('1 Working'); + expect(mocks.getNotification()).toMatchObject({ title: '1 Working', text: '' }); publisher.handleSessions([], CTX); await vi.advanceTimersByTimeAsync(8000); @@ -307,9 +284,9 @@ describe('androidSink start and update', () => { it('forwards the ranked compact number and all counts on start and update', async () => { androidSink.startOrUpdate(MIXED, CTX); await flushAsync(); - expect(mocks.getNotification()).toEqual({ - title: 'Active agents', - text: '2 Needs input, 4 Working, 3 Idle', + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: '4 Working · 3 Idle', ...noAskActions(), compactText: '2', channelId: 'needs-input', @@ -319,9 +296,9 @@ describe('androidSink start and update', () => { androidSink.startOrUpdate({ ...MIXED, revision: 2, needsInput: 0 }, CTX); await flushAsync(); - expect(mocks.getNotification()).toEqual({ - title: 'Active agents', - text: '4 Working, 3 Idle', + expect(mocks.getNotification()).toMatchObject({ + title: '4 Working', + text: '3 Idle', ...noAskActions(), compactText: '4', channelId: 'agent-progress', @@ -331,9 +308,9 @@ describe('androidSink start and update', () => { androidSink.startOrUpdate({ ...MIXED, revision: 3, needsInput: 0, idle: 0 }, CTX); await flushAsync(); - expect(mocks.getNotification()).toEqual({ - title: 'Active agents', - text: '4 Working', + expect(mocks.getNotification()).toMatchObject({ + title: '4 Working', + text: '', ...noAskActions(), compactText: '4', channelId: 'agent-progress', @@ -343,21 +320,27 @@ describe('androidSink start and update', () => { expect(mocks.native.start).toHaveBeenCalledTimes(1); expect(mocks.native.update).toHaveBeenCalledTimes(2); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - '2 Needs input, 4 Working, 3 Idle', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - '2', + expect.objectContaining({ + title: '2 Needs input', + text: '4 Working · 3 Idle', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: '2', + }), 'needs-input', true, true ); expect(mocks.native.update).toHaveBeenLastCalledWith( - 'Active agents', - '4 Working', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - '4', + expect.objectContaining({ + title: '4 Working', + text: '', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: '4', + }), 'agent-progress', false, 0 @@ -373,11 +356,12 @@ describe('androidSink start and update', () => { androidSink.startOrUpdate({ ...needsInput, revision: 2 }, CTX); await flushAsync(); expect(mocks.native.update).toHaveBeenLastCalledWith( - 'Active agents', - expect.any(String), - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - expect.any(String), + expect.objectContaining({ + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: expect.any(String), + }), 'needs-input', false, 0 @@ -390,11 +374,12 @@ describe('androidSink start and update', () => { expect(mocks.getNotification()).toMatchObject({ channelId: 'agent-progress', alerting: false }); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - expect.any(String), - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - expect.any(String), + expect.objectContaining({ + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: expect.any(String), + }), 'agent-progress', false, true @@ -423,11 +408,12 @@ describe('androidSink start and update', () => { await flushAsync(); expect(mocks.native.update).toHaveBeenLastCalledWith( - 'Active agents', - expect.any(String), - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - expect.any(String), + expect.objectContaining({ + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: expect.any(String), + }), 'needs-input', true, 0 @@ -448,11 +434,12 @@ describe('androidSink start and update', () => { await flushAsync(); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - expect.any(String), - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - expect.any(String), + expect.objectContaining({ + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: expect.any(String), + }), 'needs-input', false, true @@ -471,11 +458,12 @@ describe('androidSink start and update', () => { await flushAsync(); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - expect.any(String), - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - expect.any(String), + expect.objectContaining({ + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: expect.any(String), + }), 'needs-input', true, true @@ -502,11 +490,12 @@ describe('androidSink start and update', () => { await handleAppStateActive(); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - expect.any(String), - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - expect.any(String), + expect.objectContaining({ + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: expect.any(String), + }), 'needs-input', true, true @@ -528,11 +517,12 @@ describe('androidSink start and update', () => { await flushAsync(); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - expect.any(String), - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - expect.any(String), + expect.objectContaining({ + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: expect.any(String), + }), 'needs-input', true, true @@ -568,7 +558,8 @@ describe('androidSink start and update', () => { expect(mocks.native.start).toHaveBeenCalledTimes(1); expect(mocks.native.update).toHaveBeenCalledTimes(1); expect(mocks.getNotification()).toMatchObject({ - text: '4 Working, 3 Idle', + title: '4 Working', + text: '3 Idle', channelId: 'agent-progress', alerting: false, }); @@ -579,9 +570,9 @@ describe('androidSink start and update', () => { androidSink.startOrUpdate(MIXED, CTX); await flushAsync(); - expect(mocks.getNotification()).toEqual({ - title: 'Active agents', - text: '2 Needs input, 4 Working, 3 Idle', + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: '4 Working · 3 Idle', ...noAskActions(), compactText: '2', channelId: 'needs-input', @@ -599,9 +590,9 @@ describe('androidSink start and update', () => { deferred.resolve('granted'); await flushAsync(); - expect(mocks.getNotification()).toEqual({ - title: 'Active agents', - text: '4 Working, 3 Idle', + expect(mocks.getNotification()).toMatchObject({ + title: '4 Working', + text: '3 Idle', ...noAskActions(), compactText: '4', channelId: 'agent-progress', @@ -711,11 +702,14 @@ describe('androidSink approve action', () => { await flushAsync(); expect(mocks.getNotification()?.approveLabel).toBe(i18n.t('common.approve')); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - '1 Needs input', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions/ses_approve' }, - i18n.t('common.approve'), - '1', + expect.objectContaining({ + title: '1 Needs input', + text: '', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions/ses_approve', + approveLabel: i18n.t('common.approve'), + compactText: '1', + }), 'needs-input', true, true @@ -727,11 +721,14 @@ describe('androidSink approve action', () => { await flushAsync(); expect(mocks.getNotification()?.approveLabel).toBeNull(); expect(mocks.native.update).toHaveBeenLastCalledWith( - 'Active agents', - '1 Working', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions' }, - null, - '1', + expect.objectContaining({ + title: '1 Working', + text: '', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions', + approveLabel: null, + compactText: '1', + }), 'agent-progress', false, 0 @@ -751,11 +748,14 @@ describe('androidSink approve action', () => { await handleAppStateActive(); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - '1 Needs input', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions/ses_retry' }, - i18n.t('common.approve'), - '1', + expect.objectContaining({ + title: '1 Needs input', + text: '', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions/ses_retry', + approveLabel: i18n.t('common.approve'), + compactText: '1', + }), 'needs-input', true, true @@ -768,11 +768,14 @@ describe('androidSink approve action', () => { await flushAsync(); expect(mocks.getNotification()?.approveLabel).toBeNull(); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - '1 Needs input', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions/ses_question' }, - null, - '1', + expect.objectContaining({ + title: '1 Needs input', + text: '', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions/ses_question', + approveLabel: null, + compactText: '1', + }), 'needs-input', true, true @@ -793,11 +796,14 @@ describe('androidSink approve action', () => { androidSink.publish(snapshotFor([{ status: 'permission' }], 1)); expect(mocks.getNotification()?.approveLabel).toBe(i18n.t('common.approve')); expect(mocks.native.update).toHaveBeenLastCalledWith( - 'Active agents', - '1 Needs input', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions/ses_late' }, - i18n.t('common.approve'), - '1', + expect.objectContaining({ + title: '1 Needs input', + text: '', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions/ses_late', + approveLabel: i18n.t('common.approve'), + compactText: '1', + }), 'needs-input', true, 0 @@ -818,11 +824,14 @@ describe('androidSink approve action', () => { expect(mocks.getNotification()?.approveLabel).toBeNull(); expect(mocks.native.update).toHaveBeenLastCalledWith( - 'Active agents', - 'No agents waiting', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions/ses_done' }, - null, - null, + expect.objectContaining({ + title: 'No agents waiting', + text: '', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions/ses_done', + approveLabel: null, + compactText: null, + }), 'agent-progress', false, expect.any(Number) @@ -938,7 +947,10 @@ describe('androidSink action notice', () => { androidSink.startOrUpdate({ ...MIXED, revision: 2, running: 5 }, CTX); await flushAsync(); - expect(mocks.getNotification()?.text).toBe('Approval failed 2 Needs input, 5 Working, 3 Idle'); + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: 'Approval failed', + }); }); it('drops the notice once the counts reach zero', async () => { @@ -948,10 +960,13 @@ describe('androidSink action notice', () => { setGlanceableActionNotice('Approval failed'); androidSink.publish({ ...MIXED, revision: 2, needsInput: 0 }); - expect(mocks.getNotification()?.text).toBe('4 Working, 3 Idle'); + expect(mocks.getNotification()).toMatchObject({ title: '4 Working', text: '3 Idle' }); androidSink.publish({ ...MIXED, revision: 3, needsInput: 3 }); - expect(mocks.getNotification()?.text).toBe('3 Needs input, 4 Working, 3 Idle'); + expect(mocks.getNotification()).toMatchObject({ + title: '3 Needs input', + text: '4 Working · 3 Idle', + }); }); it('drops the notice when the recorded ask changes', async () => { @@ -964,14 +979,20 @@ describe('androidSink action notice', () => { androidSink.startOrUpdate({ ...MIXED, revision: 2, running: 5 }, CTX); await flushAsync(); - expect(mocks.getNotification()?.text).toBe('2 Needs input, 5 Working, 3 Idle'); + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: '5 Working · 3 Idle', + }); }); it('prefixes the notice only after it is set', async () => { androidSink.startOrUpdate(MIXED, CTX); await flushAsync(); - expect(mocks.getNotification()?.text).toBe('2 Needs input, 4 Working, 3 Idle'); + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: '4 Working · 3 Idle', + }); }); }); @@ -990,11 +1011,14 @@ describe('renderStoredSnapshotWithNotice', () => { expect(mocks.native.getWidgetSnapshot).toHaveBeenCalled(); expect(mocks.native.start).toHaveBeenCalledTimes(1); expect(mocks.native.start).toHaveBeenCalledWith( - 'Active agents', - 'Approval failed 2 Needs input, 4 Working, 3 Idle', - { label: i18n.t('glanceable.openSession'), url: 'kiloapp:///cloud/sessions/ses_retry' }, - i18n.t('common.approve'), - '2', + expect.objectContaining({ + title: '2 Needs input', + text: 'Approval failed', + openLabel: i18n.t('glanceable.openSession'), + openUrl: 'kiloapp:///cloud/sessions/ses_retry', + approveLabel: i18n.t('common.approve'), + compactText: '2', + }), 'needs-input', true, true @@ -1015,7 +1039,8 @@ describe('renderStoredSnapshotWithNotice', () => { expect(mocks.native.start).toHaveBeenCalledTimes(1); expect(mocks.getNotification()).toMatchObject({ - text: 'Approval failed 2 Needs input, 4 Working, 3 Idle', + title: '2 Needs input', + text: 'Approval failed', approveLabel: i18n.t('common.approve'), }); }); @@ -1034,7 +1059,8 @@ describe('renderStoredSnapshotWithNotice', () => { expect(mocks.native.start).not.toHaveBeenCalled(); expect(mocks.native.update).toHaveBeenCalledTimes(1); expect(mocks.getNotification()).toMatchObject({ - text: 'Approval failed 2 Needs input, 4 Working, 3 Idle', + title: '2 Needs input', + text: 'Approval failed', approveLabel: i18n.t('common.approve'), }); }); @@ -1054,11 +1080,27 @@ describe('renderStoredSnapshotWithNotice', () => { expect(mocks.native.start).not.toHaveBeenCalled(); expect(mocks.native.update).toHaveBeenCalledTimes(1); expect(mocks.getNotification()).toMatchObject({ - text: 'Approval failed 2 Needs input, 4 Working, 3 Idle', + title: '2 Needs input', + text: 'Approval failed', approveLabel: i18n.t('common.approve'), }); }); + it('reads Approving… on a retry, not the previous failure line', async () => { + recordWaitingAsk(waitingAsk({ kiloSessionId: 'ses_retry' })); + mocks.native.setWidgetSnapshot(JSON.stringify(MIXED), 0); + setGlanceableActionNotice('Approval failed'); + + setGlanceableActionApproving(true); + await renderStoredSnapshotWithNotice(CTX); + + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: i18n.t('glanceable.approving'), + textIsError: false, + }); + }); + it('makes no native call when nothing is stored', async () => { setGlanceableActionNotice('Approval failed'); @@ -1082,14 +1124,14 @@ describe('androidSink widget publish and end', () => { expect(runningCount()).toBe('1'); }); - it('publishes the stale warning and retained counts through the native bridge', async () => { + it('publishes the last-known time and retained counts through the native bridge', async () => { androidSink.startOrUpdate(MIXED, CTX); await flushAsync(); androidSink.publish({ ...MIXED, revision: 2, status: 'stale' }); const notification = mocks.getNotification(); - expect(notification?.text).toContain(i18n.t('glanceable.stale')); - expect(notification?.text).toContain('2 Needs input, 4 Working, 3 Idle'); + expect(notification).toMatchObject({ title: '2 Needs input', text: '4 Working · 3 Idle' }); + expect(notification?.subText).toMatch(new RegExp(`^${i18n.t('glanceable.lastKnown')} · `, 'u')); expect(notification?.compactText).toBe('2'); expect(getCurrentWidgetProps()?.accessibilityLabel).toContain( '2 Needs input, 4 Working, 3 Idle, Open agents' @@ -1145,7 +1187,8 @@ describe('androidSink widget publish and end', () => { androidSink.publish(snapshotFor([{ status: 'idle' }], MIXED.revision)); expect(mocks.getNotification()).toMatchObject({ - text: i18n.t('glanceable.empty'), + title: i18n.t('glanceable.empty'), + text: '', compactText: null, }); expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000); @@ -1190,6 +1233,13 @@ describe('androidSink widget publish and end', () => { vi.setSystemTime(NOW + 28_800_000); expect(getCurrentWidgetProps()?.statusLine).toBe('Status expired'); expect(getCurrentWidgetProps()?.countLines).toEqual([]); + expect(getCurrentWidgetProps()?.home).toMatchObject({ + primaryKind: 'running', + primaryCount: 1, + stale: true, + checkedAt: snapshot.updatedAt, + canCreate: true, + }); } ); @@ -1227,7 +1277,8 @@ describe('androidSink widget publish and end', () => { _resetAndroidSinkForTests(); expect(mocks.getNotification()).toMatchObject({ - text: 'No agents waiting', + title: 'No agents waiting', + text: '', compactText: null, }); expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000); @@ -1258,13 +1309,17 @@ describe('androidSink widget publish and end', () => { expect(() => { androidSink.publish(empty); }).toThrow('Cannot persist the active agents notification timeout'); - expect(mocks.getNotification()?.text).toBe('2 Needs input, 4 Working, 3 Idle'); + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: '4 Working · 3 Idle', + }); expect(mocks.getRequestedNotificationDeadline()).toBeNull(); vi.setSystemTime(NOW + 3000); androidSink.publish(empty); expect(mocks.getNotification()).toMatchObject({ - text: 'No agents waiting', + title: 'No agents waiting', + text: '', compactText: null, }); expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000); @@ -1292,7 +1347,8 @@ describe('androidSink widget publish and end', () => { expect(mocks.getRequestedNotificationDeadline()).toBeNull(); expect(mocks.getNotification()).toMatchObject({ - text: '2 Needs input, 4 Working, 3 Idle', + title: '2 Needs input', + text: '4 Working · 3 Idle', compactText: '2', }); expect(mocks.getWidgetDeadline()).toBe(method === 'publish' ? NOW + 28_800_000 : 0); @@ -1351,9 +1407,9 @@ describe('handleAppStateActive', () => { // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules _setPermissionReaderForTests(() => Promise.resolve('granted')); await handleAppStateActive(); - expect(mocks.getNotification()).toEqual({ - title: 'Active agents', - text: '2 Needs input, 4 Working, 3 Idle', + expect(mocks.getNotification()).toMatchObject({ + title: '2 Needs input', + text: '4 Working · 3 Idle', ...noAskActions(), compactText: '2', channelId: 'needs-input', diff --git a/apps/mobile/src/glanceable-android/android-sink.ts b/apps/mobile/src/glanceable-android/android-sink.ts index 411db2a207..871971283a 100644 --- a/apps/mobile/src/glanceable-android/android-sink.ts +++ b/apps/mobile/src/glanceable-android/android-sink.ts @@ -12,15 +12,24 @@ import { import { requestWidgetUpdate } from 'react-native-android-widget'; import { i18n } from '@/i18n'; +import { + getHomeWidgetDataForSnapshot, + getLastHomeWidgetData, +} from '@/lib/glanceable/home-widget-data'; import { getLiveActivityEnabled } from '@/lib/glanceable/live-activity-switch'; import { getGlanceableDelivery, type GlanceableSink, type GlanceableSinkContext, } from '@/lib/glanceable/sink-registry'; -import { getWaitingAsk } from '@/lib/glanceable/waiting-ask'; -import { getActionNotice, pruneActionNotice, setGlanceableActionNotice } from './action-notice'; +import { + getActionNotice, + isActionApproving, + pruneActionNotice, + setGlanceableActionApproving, + setGlanceableActionNotice, +} from './action-notice'; import { renderActiveAgentsWidget, WIDGET_NAME } from './active-agents-widget'; import { formatGlanceableAgo, @@ -30,7 +39,6 @@ import { } from './count-format'; import { ensureAndroidNotificationChannels } from './ensure-notification-channels'; import { - buildNotificationActions, end as endLiveUpdate, getPostedNotificationChannel, getStoredWidgetSnapshot, @@ -38,25 +46,19 @@ import { start as startLiveUpdate, update as updateLiveUpdate, } from './live-update'; +import { cardFor } from './notification-card'; import { isNotificationPermissionGranted } from './permission'; -import { - type AndroidWidgetProps, - buildCompactNotificationText, - buildCurrentWidgetProps, - buildOngoingNotificationText, -} from './widget-props'; +import { type AndroidWidgetProps, buildCurrentWidgetProps } from './widget-props'; -// Re-exported because the approve task and the widget suite import it from the -// sink; the notice state itself now lives in `./action-notice`. -export { setGlanceableActionNotice }; +// Re-exported because the approve task and the widget suite import them from the +// sink; the notice state itself lives in `./action-notice`. +export { setGlanceableActionApproving, setGlanceableActionNotice }; /** * Android owns the widget expiry and notification timeout. The sink supplies * translated copy, persists the latest snapshot, and fences pending starts. * Ending the ongoing notification never cancels a still-eligible widget expiry. */ -const NOTIFICATION_TITLE_KEY = 'glanceable.channelName'; - function translate(key: string): string { return i18n.t(key); } @@ -88,19 +90,8 @@ let terminalExpiresAt: number | null = null; * task for an OS retry. */ let inflightStart: Promise | null = null; - -/** The ongoing notification line, carrying the pending notice when one waits. */ -function notificationText(snapshot: GlanceableAgentsSnapshot): string { - pruneActionNotice(snapshot); - return buildOngoingNotificationText( - snapshot, - {}, - translate, - formatGlanceableCount, - getActionNotice(), - formatGlanceableAgo - ); -} +/** Whether the posted card reads "Approving…", so settling the answer redraws it. */ +let postedApproving = false; /** * A needs-input card is the kind that asks the user a question, so its first @@ -117,33 +108,21 @@ function postNotification( method: 'start' | 'update', terminalText?: string ): void { - const actions = buildNotificationActions(getWaitingAsk(), translate); const kind = agentNotificationKindForGlanceableSnapshot(snapshot); - const args = [ - translate(NOTIFICATION_TITLE_KEY), - terminalText ?? notificationText(snapshot), - actions.openLabel, - actions.openUrl, - // A terminal card has nothing to answer, even if a background delivery left - // an ask recorded. Open remains the route back; Approve must disappear. - terminalText === undefined ? actions.approveLabel : null, - terminalText === undefined - ? buildCompactNotificationText(snapshot, {}, formatGlanceableCount) - : null, - androidChannelIdForAgentKind(kind), - shouldAlert(kind), - ] as const; + const card = cardFor(snapshot, terminalText); + const channelId = androidChannelIdForAgentKind(kind); if (method === 'start') { - startLiveUpdate(...args); + startLiveUpdate(card, channelId, shouldAlert(kind)); } else { const timeoutMs = terminalText === undefined || terminalExpiresAt === null ? 0 : Math.max(1, terminalExpiresAt - Date.now()); - updateLiveUpdate(...args, timeoutMs); + updateLiveUpdate(card, channelId, shouldAlert(kind), timeoutMs); } notificationKind = kind; revision = snapshot.revision; + postedApproving = terminalText === undefined && isActionApproving(); } /** The widget props for `snapshot`, with the deadline and staleness checks every redraw runs. */ @@ -153,13 +132,15 @@ function widgetPropsFor(snapshot: GlanceableAgentsSnapshot): AndroidWidgetProps translate, formatGlanceableCount, formatGlanceableAgo, - formatGlanceableClock + formatGlanceableClock, + getHomeWidgetDataForSnapshot(snapshot) ); } /** A delayed render must check the current snapshot and its deadline, not cached props. */ export function getCurrentWidgetProps(): AndroidWidgetProps | null { - return lastWidgetSnapshot === null ? null : widgetPropsFor(lastWidgetSnapshot); + const snapshot = lastWidgetSnapshot ?? getLastHomeWidgetData()?.snapshot ?? null; + return snapshot === null ? null : widgetPropsFor(snapshot); } function renderWidgetNow(props: AndroidWidgetProps): void { @@ -193,6 +174,7 @@ function endNotification(): void { pending = null; startEpoch += 1; terminalExpiresAt = null; + postedApproving = false; } /** @@ -221,7 +203,12 @@ async function tryStartOrUpdate( // A pending notice must reach the surface even when the counts did not // change: it is the only carrier of the retryable failure, and the republish // that carries it can arrive with the same counts (or not arrive at all). - if (notificationActive && snapshot.revision <= revision && getActionNotice() === null) { + if ( + notificationActive && + snapshot.revision <= revision && + getActionNotice() === null && + postedApproving === isActionApproving() + ) { return; } if (notificationActive) { @@ -375,7 +362,10 @@ export const androidSink: GlanceableSink = { return; } } - if (notificationActive && snapshot.revision > revision) { + if ( + notificationActive && + (snapshot.revision > revision || postedApproving !== isActionApproving()) + ) { postNotification( snapshot, 'update', @@ -409,5 +399,7 @@ export function _resetAndroidSinkForTests(): void { startEpoch += 1; terminalExpiresAt = null; inflightStart = null; + postedApproving = false; setGlanceableActionNotice(null); + setGlanceableActionApproving(false); } diff --git a/apps/mobile/src/glanceable-android/approve-task.test.ts b/apps/mobile/src/glanceable-android/approve-task.test.ts index 2c459aa47e..a397213dc5 100644 --- a/apps/mobile/src/glanceable-android/approve-task.test.ts +++ b/apps/mobile/src/glanceable-android/approve-task.test.ts @@ -1,7 +1,6 @@ -/* eslint-disable max-lines, eslint-plugin-import/no-nodejs-modules, eslint-plugin-unicorn/prefer-module -- one cohesive approve-task suite, and the entry, the Kotlin worker and the Kotlin service are sources: running/reading them from disk is the only way to see the registered keys */ +/* eslint-disable max-lines, eslint-plugin-import/no-nodejs-modules, eslint-plugin-unicorn/prefer-module -- cohesive approval lifecycle suite; native watchdog contracts read the worker source */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { runInNewContext } from 'node:vm'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -11,7 +10,7 @@ import { type GlanceableApproveResult } from '@/lib/glanceable/approve-ask'; import { getGlanceableSinks } from '@/lib/glanceable/sink-registry'; import { type WaitingAsk } from '@/lib/glanceable/waiting-ask'; -import { type ApproveRunner } from './approve-task'; +import { type ApproveRunner, handleApproveTask, runApproveTask } from './approve-task'; const ASK: WaitingAsk = { kiloSessionId: 'ses_1', @@ -25,7 +24,6 @@ const ASK: WaitingAsk = { const mocks = vi.hoisted(() => { const order: string[] = []; - const tasks = new Map Promise>(); return { readWaitingAsk: vi.fn<() => Promise>(), recordWaitingAsk: vi.fn<(ask: WaitingAsk | null) => void>(), @@ -52,7 +50,6 @@ const mocks = vi.hoisted(() => { startOrUpdate: vi.fn(), }, order, - tasks, applyWidgetLanguage: vi.fn(async () => { await Promise.resolve(); order.push('language'); @@ -61,9 +58,7 @@ const mocks = vi.hoisted(() => { await Promise.resolve(); order.push('approve'); }), - registerHeadlessTask: vi.fn((key: string, provider: () => () => Promise) => { - tasks.set(key, provider()); - }), + registerHeadlessTask: vi.fn(), }; }); @@ -94,6 +89,7 @@ vi.mock('@/lib/hooks/use-language-preference', () => ({ vi.mock('./android-sink', () => ({ androidSink: mocks.sink, setGlanceableActionNotice: mocks.setGlanceableActionNotice, + setGlanceableActionApproving: (): void => undefined, renderStoredSnapshotWithNotice: mocks.renderStoredSnapshotWithNotice, })); @@ -115,17 +111,8 @@ vi.mock('@/lib/glanceable/approve-front-agent', () => ({ approveFrontAgent: mocks.approveFrontAgent, })); -const { - APPROVE_AGENT_TASK_KEY, - APPROVE_HEADLESS_TASK_KEY, - handleApproveTask, - registerApproveTask, - runApproveTask, -} = await import('./approve-task'); - -/** The catalog key for the retryable line, and the copy this slice must show. */ const APPROVE_FAILED_KEY = 'glanceable.approveFailed'; -const APPROVE_FAILED = "Couldn't approve. Tap Approve to try again."; +let APPROVE_FAILED = ''; const NATIVE_SOURCE_DIR = join( __dirname, '..', @@ -141,16 +128,10 @@ const NATIVE_SOURCE_DIR = join( 'activeagentsliveupdate' ); const WORKER_SOURCE = readFileSync(join(NATIVE_SOURCE_DIR, 'ActiveAgentsApproveWorker.kt'), 'utf8'); -const SERVICE_SOURCE = readFileSync( - join(NATIVE_SOURCE_DIR, 'ActiveAgentsApproveTaskService.kt'), - 'utf8' -); -const ENTRY_SOURCE = readFileSync(join(__dirname, '..', '..', 'index.js'), 'utf8'); beforeEach(async () => { vi.clearAllMocks(); mocks.order.length = 0; - mocks.tasks.clear(); mocks.language.whenLanguagePreferenceLoaded.mockResolvedValue(undefined); mocks.language.getResolvedLanguage.mockReturnValue('en'); mocks.readWaitingAsk.mockResolvedValue(ASK); @@ -159,6 +140,7 @@ beforeEach(async () => { mocks.restorePersistedGlanceable.mockResolvedValue(undefined); // The language case switches the shared instance; every other case is English. await i18n.changeLanguage('en'); + APPROVE_FAILED = i18n.t(APPROVE_FAILED_KEY); }); /** A promise a case releases by hand, so a task's await on it is observable. */ @@ -186,7 +168,8 @@ describe('handleApproveTask', () => { // clear it again, and an approval is not a failure to report. expect(mocks.recordWaitingAsk).not.toHaveBeenCalled(); expect(mocks.setGlanceableActionNotice).not.toHaveBeenCalled(); - expect(mocks.renderStoredSnapshotWithNotice).not.toHaveBeenCalled(); + // One render: the Approving… draw before the answer. + expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(1); expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledTimes(1); expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledWith({ userId: 'u1', @@ -199,10 +182,6 @@ describe('handleApproveTask', () => { expect(mocks.dismissNeedsInputNotification).toHaveBeenCalledWith('ses_1'); }); - it('shows the catalog copy for the retryable key', () => { - expect(i18n.t(APPROVE_FAILED_KEY)).toBe(APPROVE_FAILED); - }); - it('keeps the ask, shows the failure line and republishes on a retryable failure', async () => { mocks.runGlanceableApprove.mockResolvedValue({ kind: 'retryable' }); @@ -214,7 +193,7 @@ describe('handleApproveTask', () => { expect(mocks.setGlanceableActionNotice).toHaveBeenCalledTimes(2); expect(mocks.setGlanceableActionNotice).toHaveBeenNthCalledWith(1, APPROVE_FAILED); expect(mocks.setGlanceableActionNotice).toHaveBeenNthCalledWith(2, APPROVE_FAILED); - expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(2); + expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(3); expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenNthCalledWith(1, { userId: 'u1', organizationId: 'org_1', @@ -228,10 +207,11 @@ describe('handleApproveTask', () => { // republish, which writes the notification again and re-selects the ask from // the tray — a line drawn only first could be pruned by that render. const firstNotice = mocks.setGlanceableActionNotice.mock.invocationCallOrder[0]; - const firstRender = mocks.renderStoredSnapshotWithNotice.mock.invocationCallOrder[0]; + // Render 0 is the Approving… draw before the answer. + const firstRender = mocks.renderStoredSnapshotWithNotice.mock.invocationCallOrder[1]; const refreshOrder = mocks.refreshGlanceableSnapshot.mock.invocationCallOrder[0]; const lastNotice = mocks.setGlanceableActionNotice.mock.invocationCallOrder[1]; - const lastRender = mocks.renderStoredSnapshotWithNotice.mock.invocationCallOrder[1]; + const lastRender = mocks.renderStoredSnapshotWithNotice.mock.invocationCallOrder[2]; expect(firstNotice).toBeLessThan(firstRender ?? Number.POSITIVE_INFINITY); expect(firstRender).toBeLessThan(refreshOrder ?? Number.POSITIVE_INFINITY); expect(refreshOrder).toBeLessThan(lastNotice ?? Number.POSITIVE_INFINITY); @@ -250,15 +230,20 @@ describe('handleApproveTask', () => { it('waits for the failure line before it republishes and finishes', async () => { mocks.runGlanceableApprove.mockResolvedValue({ kind: 'retryable' }); const firstRender = deferredRender(); - mocks.renderStoredSnapshotWithNotice.mockImplementationOnce(async () => { - await firstRender.promise; - }); + // The Approving… draw resolves; the failure draw after it stays in flight. + mocks.renderStoredSnapshotWithNotice + .mockImplementationOnce(async () => { + await Promise.resolve(); + }) + .mockImplementationOnce(async () => { + await firstRender.promise; + }); const pending = handleApproveTask(); // The task must not republish, let alone resolve, while the first draw is // still in flight: the headless process would exit with the line unshown. await vi.waitFor(() => { - expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(1); + expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(2); }); expect(mocks.refreshGlanceableSnapshot).not.toHaveBeenCalled(); @@ -266,7 +251,7 @@ describe('handleApproveTask', () => { await pending; expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledTimes(1); - expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(2); + expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(3); }); it('renders the failure line from the stored snapshot when the republish rejects', async () => { @@ -318,7 +303,8 @@ describe('handleApproveTask', () => { expect(mocks.recordWaitingAsk).toHaveBeenCalledTimes(1); expect(mocks.recordWaitingAsk).toHaveBeenCalledWith(null); expect(mocks.setGlanceableActionNotice).not.toHaveBeenCalled(); - expect(mocks.renderStoredSnapshotWithNotice).not.toHaveBeenCalled(); + // One render: the Approving… draw before the answer. + expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(1); expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledTimes(1); // The ask is gone, so its stale tray row is skipped like an answered one. expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledWith({ @@ -336,7 +322,8 @@ describe('handleApproveTask', () => { expect(mocks.recordWaitingAsk).not.toHaveBeenCalled(); expect(mocks.setGlanceableActionNotice).not.toHaveBeenCalled(); - expect(mocks.renderStoredSnapshotWithNotice).not.toHaveBeenCalled(); + // One render: the Approving… draw before the answer. + expect(mocks.renderStoredSnapshotWithNotice).toHaveBeenCalledTimes(1); expect(mocks.refreshGlanceableSnapshot).toHaveBeenCalledTimes(1); // The ask is neither answered nor gone: it is still there, so the republish // re-selects it and the notification keeps the Open it names. @@ -430,137 +417,6 @@ describe('runApproveTask', () => { }); }); -describe('registerApproveTask', () => { - it('registers the task under the key the Kotlin service starts', async () => { - registerApproveTask(); - - expect(mocks.registerHeadlessTask).toHaveBeenCalledTimes(1); - expect(mocks.registerHeadlessTask.mock.calls[0]?.[0]).toBe(APPROVE_AGENT_TASK_KEY); - - await mocks.tasks.get(APPROVE_AGENT_TASK_KEY)?.(); - - expect(mocks.order).toEqual(['language', 'approve']); - expect(mocks.approveFrontAgent).toHaveBeenCalledTimes(1); - }); -}); - -type Registration = { key: string; factory: () => unknown }; - -/** - * Run the app entry with a stub `require`, so what it registered and when it - * loaded the task module are observable without loading the native graph. - */ -function evaluateEntry(platform: string): { - registrations: Registration[]; - required: string[]; -} { - const required: string[] = []; - const registrations: Registration[] = []; - const requireFn = (id: string): unknown => { - required.push(id); - switch (id) { - case 'react-native': { - return { - Platform: { OS: platform }, - AppRegistry: { - registerHeadlessTask: (key: string, factory: () => unknown): void => { - registrations.push({ key, factory }); - }, - }, - }; - } - case './src/lib/dev-logbox': { - // The entry drops expo-iap's developer copy for a failed - // available-purchases query before the router entry loads; the stub - // answers with the one call it makes. - return { applyDevLogBoxFilters: (): void => undefined }; - } - case 'react-native-android-widget': { - return { registerWidgetTaskHandler: (): void => undefined }; - } - case './src/glanceable-android/register': { - return { handleWidgetTask: (): void => undefined }; - } - case './src/glanceable-android/approve-task': { - return { - APPROVE_HEADLESS_TASK_KEY, - APPROVE_AGENT_TASK_KEY, - handleApproveTask, - // The entry registers the task-service chain through this call; the - // stub records the key it would register. - registerApproveTask: (): void => { - registrations.push({ key: APPROVE_AGENT_TASK_KEY, factory: () => undefined }); - }, - }; - } - case 'expo-router/entry': { - return {}; - } - case './src/lib/app-actions/app-action-dispatch': { - // The entry registers the OS-action dispatcher after the router entry; - // the stub answers with the one call it makes. - return { registerAppActionDispatcher: (): void => undefined }; - } - case './src/lib/notification-background-task': { - // The entry defines and registers the background-notification task last, - // for the same headless-context reason; the stub answers with a resolved - // registration so the entry's `.catch` has a promise to attach to. - return { - registerNotificationBackgroundTask: async (): Promise => { - await Promise.resolve(); - }, - }; - } - case './src/lib/glanceable-refresh-task': { - return { - registerGlanceableRefreshTask: async (): Promise => { - await Promise.resolve(); - }, - }; - } - default: { - throw new Error(`The entry required an unexpected module: ${id}`); - } - } - }; - runInNewContext(ENTRY_SOURCE, { require: requireFn }); - return { registrations, required }; -} - -describe('the headless task keys', () => { - it('registers both Approve tasks when the platform is android', () => { - expect(APPROVE_HEADLESS_TASK_KEY).toBe('KiloActiveAgentsApprove'); - // Only the string crosses into Kotlin; the worker's `TASK_NAME` and the - // entry's literal must name the same task. - expect(WORKER_SOURCE).toContain(`TASK_NAME = "${APPROVE_HEADLESS_TASK_KEY}"`); - - const android = evaluateEntry('android'); - - expect(android.registrations.map(registration => registration.key)).toEqual([ - APPROVE_HEADLESS_TASK_KEY, - APPROVE_AGENT_TASK_KEY, - ]); - const [workerRegistration] = android.registrations; - expect(workerRegistration?.factory()).toBe(handleApproveTask); - // `registerApproveTask` requires the module at entry, so the widget-style - // `require` inside the factory above is not what loads it; the entry always - // has it loaded by the time a task fires. - expect(android.required).toContain('./src/glanceable-android/approve-task'); - }); - - it('names the key the headless task service starts', () => { - expect(APPROVE_AGENT_TASK_KEY).toBe('ActiveAgentsApprove'); - expect(SERVICE_SOURCE).toContain(`TASK_KEY = "${APPROVE_AGENT_TASK_KEY}"`); - }); - - it('registers no headless task off Android', () => { - const ios = evaluateEntry('ios'); - - expect(ios.registrations).toEqual([]); - expect(ios.required).not.toContain('./src/glanceable-android/approve-task'); - }); -}); - /** One member's source, so a lifecycle claim is asserted where it lives. */ function workerMember(header: string): string { const escaped = header.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); diff --git a/apps/mobile/src/glanceable-android/approve-task.ts b/apps/mobile/src/glanceable-android/approve-task.ts index 6202247cc5..00402fac19 100644 --- a/apps/mobile/src/glanceable-android/approve-task.ts +++ b/apps/mobile/src/glanceable-android/approve-task.ts @@ -12,6 +12,7 @@ import { dismissNeedsInputNotification } from '@/lib/needs-input-notification'; import { androidSink, renderStoredSnapshotWithNotice, + setGlanceableActionApproving, setGlanceableActionNotice, } from './android-sink'; @@ -19,24 +20,21 @@ import { * The JS bodies the ongoing notification's Approve action runs headless. * * Two registrations share this module. `handleApproveTask` answers the ask the - * app recorded, and the entry registers it under `APPROVE_HEADLESS_TASK_KEY` - * via the Kotlin worker; `runApproveTask` runs the front-approval service and - * is registered under `APPROVE_AGENT_TASK_KEY` by `registerApproveTask`, the - * key the `ActiveAgentsApproveTaskService` chain starts. Only the strings cross - * the native boundary, so they are asserted equal in `approve-task.test.ts`. + * app recorded, and the entry registers it under `KiloActiveAgentsApprove`, + * the Kotlin worker's `TASK_NAME`; `runApproveTask` runs the front-approval + * service and is registered under `APPROVE_AGENT_TASK_KEY` by + * `registerApproveTask`, the key the `ActiveAgentsApproveTaskService` chain + * starts. Only the strings cross the native boundary. * * The recorded waiting ask is the only thing `handleApproveTask` answers — * never the snapshot's counts — and it also names the ids the republish below * needs. - */ -export const APPROVE_HEADLESS_TASK_KEY = 'KiloActiveAgentsApprove'; - -/** + * * The key the `ActiveAgentsApproveTaskService` chain starts. One literal, * shared by the Kotlin service and this registration: a mismatch would leave * that notification action with no task. */ -export const APPROVE_AGENT_TASK_KEY = 'ActiveAgentsApprove'; +const APPROVE_AGENT_TASK_KEY = 'ActiveAgentsApprove'; /** The approval the task runs. Injected so the flow is unit-testable. */ export type ApproveRunner = () => Promise; @@ -75,6 +73,22 @@ async function showApproveFailed(ask: WaitingAsk): Promise { } } +/** + * Show the answer in flight: the card reads "Approving…" and drops Approve until + * the answer settles. Best-effort: the republish after the answer redraws it. + */ +async function showApproving(ask: WaitingAsk): Promise { + setGlanceableActionApproving(true); + try { + await renderStoredSnapshotWithNotice({ + userId: ask.userId, + organizationId: ask.organizationId, + }); + } catch { + // The answer still runs; the republish below corrects the surface. + } +} + /** * Answer the recorded ask and update the notification in place. Never throws: * the worker completes from the headless task's finish, so a rejection would @@ -111,6 +125,7 @@ export async function handleApproveTask(): Promise { // Nothing is recorded, so there is no ask to answer and no action to drop. return; } + await showApproving(ask); const result = await runGlanceableApprove({ now: () => Date.now() }); askEnded = result.kind === 'approved' || result.kind === 'gone'; if (result.kind === 'gone') { @@ -124,6 +139,8 @@ export async function handleApproveTask(): Promise { // Keep the recorded ask and its Approve; only the failure line is needed. failed = ask !== null; } + // Settled either way: the next draw shows the answer, the failure, or Approve again. + setGlanceableActionApproving(false); if (ask === null) { return; } diff --git a/apps/mobile/src/glanceable-android/count-format.test.ts b/apps/mobile/src/glanceable-android/count-format.test.ts new file mode 100644 index 0000000000..d9a0046bf5 --- /dev/null +++ b/apps/mobile/src/glanceable-android/count-format.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { formatGlanceableClock } from './count-format'; + +const settings = vi.hoisted(() => ({ + language: 'en-US', + uses24hourClock: false as boolean | null, +})); +vi.mock('@/i18n', () => ({ + i18n: { + get language() { + return settings.language; + }, + }, +})); +vi.mock('expo-localization', () => ({ + getCalendars: () => [{ uses24hourClock: settings.uses24hourClock }], +})); +vi.mock('@/lib/utils', () => ({ parseTimestamp: (at: string) => new Date(at), timeAgo: () => '' })); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 9, 9, 12, 0)); + settings.language = 'en-US'; + settings.uses24hourClock = false; +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe('Home wake and checked clock formatting', () => { + it('honors the device 24-hour setting even when the app language normally uses 12-hour time', () => { + settings.uses24hourClock = true; + expect(formatGlanceableClock(new Date(2026, 9, 9, 18, 30).toISOString())).toBe('18:30'); + expect(formatGlanceableClock(new Date(2026, 9, 9, 0, 30).toISOString())).toBe('00:30'); + }); + + it('honors the device 12-hour setting even when the app language normally uses 24-hour time', () => { + settings.language = 'de'; + expect(formatGlanceableClock(new Date(2026, 9, 9, 18, 30).toISOString())).toMatch(/6:30.*PM/u); + }); + + it('uses language defaults only when the native clock preference is unavailable', () => { + settings.language = 'de'; + settings.uses24hourClock = null; + expect(formatGlanceableClock(new Date(2026, 9, 9, 18, 30).toISOString())).toBe('18:30'); + }); + + it('includes a local calendar day for non-today wakes and old checked timestamps', () => { + settings.uses24hourClock = true; + const tomorrow = new Date(2026, 9, 10, 18, 30); + const yesterday = new Date(2026, 9, 8, 18, 30); + expect(formatGlanceableClock(tomorrow.toISOString())).toBe( + new Intl.DateTimeFormat('en-US', { + dateStyle: 'short', + timeStyle: 'short', + hourCycle: 'h23', + }).format(tomorrow) + ); + expect(formatGlanceableClock(yesterday.toISOString())).toBe( + new Intl.DateTimeFormat('en-US', { + dateStyle: 'short', + timeStyle: 'short', + hourCycle: 'h23', + }).format(yesterday) + ); + expect(formatGlanceableClock(tomorrow.toISOString())).not.toBe('18:30'); + }); + + it('reads changes to the device clock setting on the next headless redraw', () => { + const at = new Date(2026, 9, 9, 18, 30).toISOString(); + expect(formatGlanceableClock(at)).toMatch(/6:30.*PM/u); + settings.uses24hourClock = true; + expect(formatGlanceableClock(at)).toBe('18:30'); + }); +}); diff --git a/apps/mobile/src/glanceable-android/count-format.ts b/apps/mobile/src/glanceable-android/count-format.ts index e98dfcc715..77fb2a3fa9 100644 --- a/apps/mobile/src/glanceable-android/count-format.ts +++ b/apps/mobile/src/glanceable-android/count-format.ts @@ -1,3 +1,4 @@ +import { getCalendars } from 'expo-localization'; import { i18n } from '@/i18n'; import { RTL_LANGUAGES, type SupportedLanguage } from '@/i18n/languages'; import { dateTimeFormat, numberFormat } from '@/lib/intl-cache'; @@ -12,7 +13,7 @@ import { parseTimestamp, timeAgo } from '@/lib/utils'; * separator in a two-character number is only noise. */ export function formatGlanceableCount(value: number): string { - return numberFormat(i18n.language, { useGrouping: false }).format(value); + return numberFormat(i18n.language, { useGrouping: true }).format(value); } /** @@ -41,11 +42,21 @@ export function formatGlanceableAgo(at: string): string { return timeAgo(parseTimestamp(at)); } -/** - * The clock time a scheduled count row draws beside its count ("8:00 PM", or - * "20:00" where the language reads a 24-hour clock): the row says when the - * agent wakes, the way the iOS widget's scheduled row does. - */ +/** Clock time for today, date and time otherwise: a widget bitmap cannot advance a relative age offline. */ export function formatGlanceableClock(at: string): string { - return dateTimeFormat(i18n.language, { timeStyle: 'short' }).format(parseTimestamp(at)); + const date = parseTimestamp(at); + const now = new Date(); + const today = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + const uses24hourClock = getCalendars().at(0)?.uses24hourClock ?? null; + return dateTimeFormat(i18n.language, { + ...(today + ? { timeStyle: 'short' as const } + : { dateStyle: 'short' as const, timeStyle: 'short' as const }), + ...(uses24hourClock === null + ? {} + : { hourCycle: uses24hourClock ? ('h23' as const) : ('h12' as const) }), + }).format(date); } diff --git a/apps/mobile/src/glanceable-android/home-copy.ts b/apps/mobile/src/glanceable-android/home-copy.ts new file mode 100644 index 0000000000..98bd19fbe9 --- /dev/null +++ b/apps/mobile/src/glanceable-android/home-copy.ts @@ -0,0 +1,179 @@ +import { type HomeWidgetPresentation } from '@kilocode/app-shared/home-widget'; + +import { type GlanceableCountKind } from '@/lib/glanceable/presentation'; +import { type GlanceableActionFeedback } from '@/lib/glanceable/surface-extras'; + +export type AndroidWidgetCount = { label: string; kind: GlanceableCountKind; count: string }; +export type GlanceableCountFormat = (value: number) => string; +/** A clock time, with the date added when `at` is not today. */ +export type GlanceableClockFormat = (at: string) => string; + +/** Translated Home widget copy; the layout never translates or formats on its own. */ +export type AndroidWidgetHomeCopy = { + /** The presented state: privacy/signed_out/unavailable centre a locked composition. */ + statusKind: HomeWidgetPresentation['status']; + /** Picks the status dot colour. */ + primaryKind: GlanceableCountKind | null; + primaryCount: string; + primaryLabel: string | null; + /** Locked, empty ("Nothing running right now") and updating copy; null while content shows. */ + status: string | null; + /** The 2x1 cell's shorter empty wording. */ + emptyShort: string; + /** The agent the count is about; an untitled agent reads `common.agent`. */ + title: string | null; + /** "Next run