git.iko.soy/opencode-android
on master help sign in
Implement 14 improvements: notifications, attachments, caches, search, pin/archive, TTS, tests, refactor, translations, security bump
51544d9iko
app/src/main/AndroidManifest.xml+6
@@ -65,6 +65,12 @@
65 android:exported="false"
65 android:exported="false"
66 android:foregroundServiceType="dataSync" />
66 android:foregroundServiceType="dataSync" />
67
67
68+ <!-- Handles notification action buttons (permission responses, inline reply). Not
69+ exported: only the app's own PendingIntents target it. -->
70+ <receiver
71+ android:name=".notification.NotificationActionReceiver"
72+ android:exported="false" />
73+
68 <!-- Serves camera-capture temp files (cache/captures) to the system camera app. -->
74 <!-- Serves camera-capture temp files (cache/captures) to the system camera app. -->
69 <provider
75 <provider
70 android:name="androidx.core.content.FileProvider"
76 android:name="androidx.core.content.FileProvider"
app/src/main/java/soy/iko/opencode/MainActivity.kt+14
@@ -90,6 +90,20 @@ class MainActivity : FragmentActivity() {
90 handleIntent(intent)
90 handleIntent(intent)
91 }
91 }
92
92
93+ // Track real foreground state so background notifications (permission requests,
94+ // completions, errors) still fire for a session the user "has open" but has walked
95+ // away from — the chat screen isn't disposed when the phone is locked, so
96+ // container.currentSession stays set and can't be used as a foreground signal.
97+ override fun onStart() {
98+ super.onStart()
99+ (application as OpencodeApp).container.setForeground(true)
100+ }
101+
102+ override fun onStop() {
103+ super.onStop()
104+ (application as OpencodeApp).container.setForeground(false)
105+ }
106+
93 /** Capture text shared from another app so it can be prefilled into a session draft,
107 /** Capture text shared from another app so it can be prefilled into a session draft,
94 * and session ids from notification taps / deep links so we can open them. */
108 * and session ids from notification taps / deep links so we can open them. */
95 private fun handleIntent(intent: Intent?) {
109 private fun handleIntent(intent: Intent?) {
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+20
@@ -62,6 +62,26 @@ object NetworkConfig {
62 * triggers a full list filter + sort + recomposition, causing scroll jank. */
62 * triggers a full list filter + sort + recomposition, causing scroll jank. */
63 const val sessionUpdateDebounceMs = 300L
63 const val sessionUpdateDebounceMs = 300L
64
64
65+ // --- Global search (GlobalSearchViewModel) ---
66+
67+ /** Max sessions whose message history is scanned in one global-search pass. Global
68+ * search downloads each session's messages, so this bounds the work; if there are more
69+ * sessions, the result set notes that the search was truncated. */
70+ const val maxSearchSessions = 50
71+ /** Debounce before a global search fires, so typing doesn't launch a fetch per keystroke. */
72+ const val searchDebounceMs = 350L
73+ /** Minimum query length before a global search runs (shorter queries match too much). */
74+ const val minSearchQueryLength = 2
75+ /** Characters of context to show around a global-search match. */
76+ const val searchSnippetLength = 160
77+
78+ // --- Message cache (MessageCacheStore) ---
79+
80+ /** Minimum interval between on-disk writes of a session's message snapshot, so a fast
81+ * token stream doesn't hammer the disk. The cache only needs to be recent enough for an
82+ * instant/offline first paint; the network corrects it on the next open. */
83+ const val messageCacheWriteThrottleMs = 2_000L
84+
65 /** Grace period before a deferred session delete actually fires, during which an
85 /** Grace period before a deferred session delete actually fires, during which an
66 * Undo snackbar lets the user cancel it. */
86 * Undo snackbar lets the user cancel it. */
67 const val undoDeleteDelayMs = 5_000L
87 const val undoDeleteDelayMs = 5_000L
app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.ktnew file+80
@@ -0,0 +1,80 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import android.util.Log
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.withContext
7+import kotlinx.serialization.Serializable
8+import kotlinx.serialization.decodeFromString
9+import kotlinx.serialization.encodeToString
10+import kotlinx.serialization.json.Json
11+import soy.iko.opencode.util.runCatchingCancellable
12+import java.io.File
13+
14+/**
15+ * One staged (not-yet-sent) attachment, in the minimal form needed to re-send and preview
16+ * it after a process restart. Only the self-contained wire payload is kept — the base64
17+ * `data:` [url] — because the original content Uri's read permission does NOT survive a
18+ * process restart, whereas the data URL is self-sufficient (and Coil re-renders it as the
19+ * thumbnail for images).
20+ */
21+@Serializable
22+data class PersistedAttachment(
23+ val id: String,
24+ val name: String,
25+ val mime: String,
26+ val url: String,
27+ val filename: String? = null,
28+)
29+
30+/**
31+ * Persists attachments staged for each session so an interrupted compose survives process
32+ * death, mirroring how [DraftStore] persists the text draft.
33+ *
34+ * Backed by one small JSON file per session under filesDir (not cacheDir, which the OS may
35+ * evict) rather than SharedPreferences: a base64 payload can be several megabytes and
36+ * SharedPreferences loads its whole file into memory on first access.
37+ */
38+open class AttachmentDraftStore private constructor(
39+ private val appContext: Context?,
40+ @Suppress("unused") private val testMode: Boolean,
41+) {
42+ constructor(context: Context) : this(context.applicationContext, false)
43+ protected constructor() : this(null, true)
44+
45+ private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
46+
47+ /** Session ids are validated tokens upstream, but sanitize anyway so a stray id can't
48+ * escape the directory. */
49+ private val idRegex = Regex("[^A-Za-z0-9_-]")
50+
51+ private val dir: File? by lazy {
52+ appContext?.let { File(it.filesDir, "attachment_drafts").apply { mkdirs() } }
53+ }
54+
55+ private fun fileFor(sessionId: String): File? =
56+ dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
57+
58+ /** Load the staged attachments for [sessionId], or empty if none / unreadable. */
59+ open suspend fun load(sessionId: String): List<PersistedAttachment> = withContext(Dispatchers.IO) {
60+ val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
61+ runCatchingCancellable { json.decodeFromString<List<PersistedAttachment>>(file.readText()) }
62+ .getOrDefault(emptyList())
63+ }
64+
65+ /** Persist [attachments] for [sessionId] (an empty list deletes the file). */
66+ open suspend fun save(sessionId: String, attachments: List<PersistedAttachment>): Unit = withContext(Dispatchers.IO) {
67+ val file = fileFor(sessionId) ?: return@withContext
68+ runCatchingCancellable {
69+ if (attachments.isEmpty()) file.delete()
70+ else file.writeText(json.encodeToString(attachments))
71+ }.onFailure { Log.w("AttachmentDraftStore", "Failed to persist attachments for $sessionId", it) }
72+ Unit
73+ }
74+
75+ /** Remove staged attachments for a session (call on session deletion). */
76+ open suspend fun remove(sessionId: String): Unit = withContext(Dispatchers.IO) {
77+ runCatchingCancellable { fileFor(sessionId)?.delete() }
78+ Unit
79+ }
80+}
app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.ktnew file+66
@@ -0,0 +1,66 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import android.util.Log
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.withContext
7+import kotlinx.serialization.builtins.ListSerializer
8+import soy.iko.opencode.data.model.MessageWithParts
9+import soy.iko.opencode.data.network.OpencodeJson
10+import soy.iko.opencode.util.runCatchingCancellable
11+import java.io.File
12+
13+/**
14+ * On-disk cache of a session's reduced message list, so a conversation shows instantly on
15+ * open (and remains readable while offline / before the network responds) instead of a blank
16+ * screen until `GET /session/:id/message` returns.
17+ *
18+ * One JSON file per session under filesDir, encoded with [OpencodeJson] so the same sealed
19+ * polymorphism used on the wire round-trips (unknown parts/messages are concrete
20+ * `__unknown` variants, so they encode too). Every operation is best-effort — a cache
21+ * miss or a write failure just falls back to the network — so problems never surface to
22+ * the user. No annotation processing (Room/KSP) per the project's manual-DI convention.
23+ */
24+open class MessageCacheStore private constructor(
25+ private val appContext: Context?,
26+ @Suppress("unused") private val testMode: Boolean,
27+) {
28+ constructor(context: Context) : this(context.applicationContext, false)
29+ protected constructor() : this(null, true)
30+
31+ private val serializer = ListSerializer(MessageWithParts.serializer())
32+
33+ /** Session ids are validated tokens upstream, but sanitize anyway so a stray id can't
34+ * escape the directory. */
35+ private val idRegex = Regex("[^A-Za-z0-9_-]")
36+
37+ private val dir: File? by lazy {
38+ appContext?.let { File(it.filesDir, "message_cache").apply { mkdirs() } }
39+ }
40+
41+ private fun fileFor(sessionId: String): File? =
42+ dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
43+
44+ /** Load the cached messages for [sessionId], or empty if none / unreadable. */
45+ open suspend fun load(sessionId: String): List<MessageWithParts> = withContext(Dispatchers.IO) {
46+ val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
47+ runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
48+ .getOrDefault(emptyList())
49+ }
50+
51+ /** Persist [messages] for [sessionId] (an empty list deletes the file). */
52+ open suspend fun save(sessionId: String, messages: List<MessageWithParts>): Unit = withContext(Dispatchers.IO) {
53+ val file = fileFor(sessionId) ?: return@withContext
54+ runCatchingCancellable {
55+ if (messages.isEmpty()) file.delete()
56+ else file.writeText(OpencodeJson.encodeToString(serializer, messages))
57+ }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
58+ Unit
59+ }
60+
61+ /** Remove a session's cached messages (call on deletion). */
62+ open suspend fun remove(sessionId: String): Unit = withContext(Dispatchers.IO) {
63+ runCatchingCancellable { fileFor(sessionId)?.delete() }
64+ Unit
65+ }
66+}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+6
@@ -1,3 +1,9 @@
1+// Jetpack Security (androidx.security:security-crypto) was deprecated wholesale in 1.1.0 with
2+// no first-party drop-in replacement; we intentionally keep using EncryptedSharedPreferences /
3+// MasterKey for the encrypted password store (on-disk-compatible, still functional). Suppress
4+// the expected deprecation warnings here so the build log stays clean until a future migration.
5+@file:Suppress("DEPRECATION")
6+
1package soy.iko.opencode.data.repo
7package soy.iko.opencode.data.repo
2
8
3import android.content.Context
9import android.content.Context
app/src/main/java/soy/iko/opencode/data/repo/SessionPrefsStore.ktnew file+64
@@ -0,0 +1,64 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import androidx.datastore.preferences.core.edit
5+import androidx.datastore.preferences.core.stringSetPreferencesKey
6+import androidx.datastore.preferences.preferencesDataStore
7+import kotlinx.coroutines.flow.Flow
8+import kotlinx.coroutines.flow.flowOf
9+import kotlinx.coroutines.flow.map
10+
11+private val Context.sessionPrefsDataStore by preferencesDataStore(name = "session_prefs")
12+
13+/**
14+ * Local, per-session organization flags the opencode server doesn't track: **pinned**
15+ * (sorted to the top of the session list) and **archived** (hidden from the list unless the
16+ * user opts to show them). Keyed by opencode session id.
17+ *
18+ * `open` with a `protected` no-arg constructor so tests can subclass and override the flows
19+ * without a real DataStore (mirrors the other stores). The flow accessors are getters (not
20+ * eager initializers) and null-guard the context so the test constructor never touches disk.
21+ */
22+open class SessionPrefsStore private constructor(
23+ private val appContext: Context?,
24+ @Suppress("unused") private val testMode: Boolean,
25+) {
26+ constructor(context: Context) : this(context.applicationContext, false)
27+ protected constructor() : this(null, true)
28+
29+ private val pinnedKey = stringSetPreferencesKey("pinned")
30+ private val archivedKey = stringSetPreferencesKey("archived")
31+
32+ open val pinned: Flow<Set<String>>
33+ get() = appContext?.sessionPrefsDataStore?.data?.map { it[pinnedKey] ?: emptySet() }
34+ ?: flowOf(emptySet())
35+
36+ open val archived: Flow<Set<String>>
37+ get() = appContext?.sessionPrefsDataStore?.data?.map { it[archivedKey] ?: emptySet() }
38+ ?: flowOf(emptySet())
39+
40+ open suspend fun setPinned(sessionId: String, pinned: Boolean) = toggle(pinnedKey, sessionId, pinned)
41+
42+ open suspend fun setArchived(sessionId: String, archived: Boolean) = toggle(archivedKey, sessionId, archived)
43+
44+ /** Drop a session from both sets — call on deletion so ids don't accumulate forever. */
45+ open suspend fun forget(sessionId: String) {
46+ val ctx = appContext ?: return
47+ ctx.sessionPrefsDataStore.edit { prefs ->
48+ prefs[pinnedKey] = (prefs[pinnedKey] ?: emptySet()) - sessionId
49+ prefs[archivedKey] = (prefs[archivedKey] ?: emptySet()) - sessionId
50+ }
51+ }
52+
53+ private suspend fun toggle(
54+ key: androidx.datastore.preferences.core.Preferences.Key<Set<String>>,
55+ sessionId: String,
56+ on: Boolean,
57+ ) {
58+ val ctx = appContext ?: return
59+ ctx.sessionPrefsDataStore.edit { prefs ->
60+ val current = prefs[key] ?: emptySet()
61+ prefs[key] = if (on) current + sessionId else current - sessionId
62+ }
63+ }
64+}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+33−6
@@ -38,6 +38,9 @@ import kotlinx.coroutines.sync.withLock
38open class SessionRepository(
38open class SessionRepository(
39 private val api: OpencodeApiClient,
39 private val api: OpencodeApiClient,
40 private val eventStream: EventStreamClient,
40 private val eventStream: EventStreamClient,
41+ // Optional on-disk cache so a conversation renders instantly on open and stays readable
42+ // offline. Null in tests (and any path that doesn't wire it), where caching is skipped.
43+ private val messageCache: MessageCacheStore? = null,
41) {
44) {
42 open suspend fun listSessions() = api.listSessions()
45 open suspend fun listSessions() = api.listSessions()
43 open suspend fun createSession(title: String? = null, directory: String? = null) =
46 open suspend fun createSession(title: String? = null, directory: String? = null) =
@@ -95,6 +98,18 @@ open class SessionRepository(
95 // initial load) clobbering a newer seed. Only mutated/read under [lock].
98 // initial load) clobbering a newer seed. Only mutated/read under [lock].
96 var seedGeneration = 0
99 var seedGeneration = 0
97
100
101+ // Seed from the on-disk cache first so the conversation renders instantly — and stays
102+ // readable offline — before the network responds. Guarded on generation 0 so a
103+ // reconnect re-seed that somehow raced ahead isn't clobbered; the initial REST seed
104+ // (also generation 0) then overrides this with authoritative data when it succeeds.
105+ messageCache?.let { cache ->
106+ val cached = cache.load(sessionId)
107+ if (cached.isNotEmpty()) {
108+ val changed = lock.withLock { if (seedGeneration == 0) store.seed(cached) else false }
109+ if (changed) publish()
110+ }
111+ }
112+
98 // Re-seed from REST when the SSE stream reconnects after a drop. The REST fetch
113 // Re-seed from REST when the SSE stream reconnects after a drop. The REST fetch
99 // is performed *outside* the lock so events arriving via SSE during the fetch are
114 // is performed *outside* the lock so events arriving via SSE during the fetch are
100 // not blocked. The seed itself is under the lock; a seed generation counter ensures
115 // not blocked. The seed itself is under the lock; a seed generation counter ensures
@@ -144,17 +159,18 @@ open class SessionRepository(
144 }
159 }
145 }
160 }
146
161
147− val initial = runCatchingCancellable { api.listMessages(sessionId) }
162+ val initialResult = runCatchingCancellable { api.listMessages(sessionId) }
148− .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
163+ .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on cache/SSE: ${safeExceptionSummary(it)}") }
149− .getOrDefault(emptyList())
150 // Apply the initial seed only if no reconnect re-seed has run yet (generation still 0).
164 // Apply the initial seed only if no reconnect re-seed has run yet (generation still 0).
151 // A re-seed is always a fresher full snapshot, so if the (possibly slow) initial fetch
165 // A re-seed is always a fresher full snapshot, so if the (possibly slow) initial fetch
152 // returns after one has applied, seeding this older REST data would clobber the newer
166 // returns after one has applied, seeding this older REST data would clobber the newer
153 // state — reverting a completed run to "running", regressing cost/token/part state —
167 // state — reverting a completed run to "running", regressing cost/token/part state —
154− // until the next SSE event or reconnect healed it.
168+ // until the next SSE event or reconnect healed it. Only seed on SUCCESS: on failure
169+ // (e.g. offline) keep whatever's shown (the cache seed above) rather than clearing it.
155 val seeded = lock.withLock {
170 val seeded = lock.withLock {
171+ val list = initialResult.getOrNull()
156− if (seedGeneration == 0) {
172+ if (seedGeneration == 0 && list != null) {
157− store.seed(initial)
173+ store.seed(list)
158 true
174 true
159 } else false
175 } else false
160 }
176 }
@@ -165,9 +181,20 @@ open class SessionRepository(
165 // outside the lock so a slow downstream collector can't stall the drain. This
181 // outside the lock so a slow downstream collector can't stall the drain. This
166 // loop also keeps the flow alive until the collector cancels; the launched jobs
182 // loop also keeps the flow alive until the collector cancels; the launched jobs
167 // above are children of this scope and are torn down with it.
183 // above are children of this scope and are torn down with it.
184+ var lastCacheWrite = 0L
168 dirty.consumeAsFlow().collect {
185 dirty.consumeAsFlow().collect {
169 val snapshot = lock.withLock { store.snapshot() }
186 val snapshot = lock.withLock { store.snapshot() }
170 send(snapshot)
187 send(snapshot)
188+ // Persist the snapshot to the on-disk cache, throttled so a fast token stream
189+ // doesn't hammer the disk — the cache only needs to be "recent enough" for an
190+ // instant/offline first paint; the network corrects it on the next open.
191+ if (messageCache != null && snapshot.isNotEmpty()) {
192+ val now = System.currentTimeMillis()
193+ if (now - lastCacheWrite >= NetworkConfig.messageCacheWriteThrottleMs) {
194+ lastCacheWrite = now
195+ launch { messageCache.save(sessionId, snapshot) }
196+ }
197+ }
171 }
198 }
172 // Run the whole reducer pipeline — event reduction, list rebuilds, and the O(N)
199 // Run the whole reducer pipeline — event reduction, list rebuilds, and the O(N)
173 // snapshot copy — on Dispatchers.Default rather than the collector's context. The
200 // snapshot copy — on Dispatchers.Default rather than the collector's context. The
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+113−7
@@ -10,11 +10,19 @@ import soy.iko.opencode.data.model.AssistantMessage
10import soy.iko.opencode.data.model.BusEvent
10import soy.iko.opencode.data.model.BusEvent
11import soy.iko.opencode.data.model.MessagePartUpdated
11import soy.iko.opencode.data.model.MessagePartUpdated
12import soy.iko.opencode.data.model.MessageUpdated
12import soy.iko.opencode.data.model.MessageUpdated
13+import soy.iko.opencode.data.model.Permission
14+import soy.iko.opencode.data.model.PermissionReplied
15+import soy.iko.opencode.data.model.PermissionResponse
16+import soy.iko.opencode.data.model.PermissionUpdated
13import soy.iko.opencode.data.model.StepFinishPart
17import soy.iko.opencode.data.model.StepFinishPart
14import soy.iko.opencode.data.model.ServerProfile
18import soy.iko.opencode.data.model.ServerProfile
19+import soy.iko.opencode.data.model.SessionError
15import soy.iko.opencode.data.model.SessionIdle
20import soy.iko.opencode.data.model.SessionIdle
21+import soy.iko.opencode.data.repo.AttachmentDraftStore
16import soy.iko.opencode.data.repo.DraftStore
22import soy.iko.opencode.data.repo.DraftStore
17import soy.iko.opencode.data.repo.ErrorKind
23import soy.iko.opencode.data.repo.ErrorKind
24+import soy.iko.opencode.data.repo.MessageCacheStore
25+import soy.iko.opencode.data.repo.SessionPrefsStore
18import soy.iko.opencode.data.repo.ProfileStore
26import soy.iko.opencode.data.repo.ProfileStore
19import soy.iko.opencode.data.repo.SettingsStore
27import soy.iko.opencode.data.repo.SettingsStore
20import soy.iko.opencode.data.repo.classifyError
28import soy.iko.opencode.data.repo.classifyError
@@ -79,6 +87,9 @@ open class AppContainer private constructor(
79 open val profileStore: ProfileStore by lazy { ProfileStore(appContext!!) }
87 open val profileStore: ProfileStore by lazy { ProfileStore(appContext!!) }
80 open val settingsStore: SettingsStore by lazy { SettingsStore(appContext!!) }
88 open val settingsStore: SettingsStore by lazy { SettingsStore(appContext!!) }
81 open val draftStore: DraftStore by lazy { DraftStore(appContext!!, appScope) }
89 open val draftStore: DraftStore by lazy { DraftStore(appContext!!, appScope) }
90+ open val attachmentDraftStore: AttachmentDraftStore by lazy { AttachmentDraftStore(appContext!!) }
91+ open val sessionPrefsStore: SessionPrefsStore by lazy { SessionPrefsStore(appContext!!) }
92+ open val messageCacheStore: MessageCacheStore by lazy { MessageCacheStore(appContext!!) }
82
93
83 private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
94 private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
84 open val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
95 open val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
@@ -155,12 +166,31 @@ open class AppContainer private constructor(
155 private val _isOnline = MutableStateFlow(true)
166 private val _isOnline = MutableStateFlow(true)
156 open val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
167 open val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
157
168
169+ /** Whether a foreground Activity is currently showing. Distinct from [currentSession],
170+ * which stays set while the app is merely backgrounded (the chat screen isn't disposed
171+ * when the user locks their phone). Permission/completion notifications need the real
172+ * foreground signal so they still fire for a session the user "has open" but has walked
173+ * away from — the core of the run-in-the-background use case. Set from MainActivity's
174+ * onStart/onStop. */
175+ private val _isForeground = MutableStateFlow(false)
176+ open val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
177+ open fun setForeground(foreground: Boolean) { _isForeground.value = foreground }
178+
179+ /** True when the user is actively looking at [sessionId] (app foregrounded AND that
180+ * session is the one on screen), so an in-app affordance (the permission dialog) is
181+ * handling it and a notification would be redundant. */
182+ private fun isActivelyViewing(sessionId: String): Boolean =
183+ _isForeground.value && sessionId == _currentSession.value
184+
158 open fun setCurrentSession(id: String?) {
185 open fun setCurrentSession(id: String?) {
159 _currentSession.value = id
186 _currentSession.value = id
160 if (id != null) {
187 if (id != null) {
161 _unread.update { it - id }
188 _unread.update { it - id }
162 unreadMessageIds.remove(id)
189 unreadMessageIds.remove(id)
190+ appContext?.let {
163− appContext?.let { SessionNotifications.cancel(it, id) }
191+ SessionNotifications.cancel(it, id)
192+ SessionNotifications.cancelPermission(it, id)
193+ }
164 }
194 }
165 }
195 }
166
196
@@ -260,6 +290,35 @@ open class AppContainer private constructor(
260 activeConnection
290 activeConnection
261 .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
291 .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
262 .collect { event ->
292 .collect { event ->
293+ // A permission request needs the user's approval. Post a heads-up
294+ // notification (with Allow/Reject actions) unless the user is actively
295+ // viewing this session, where the in-app permission dialog handles it.
296+ if (event is PermissionUpdated) {
297+ val psid = event.properties.sessionID.takeIf { it.isNotBlank() } ?: return@collect
298+ if (!isActivelyViewing(psid)) appScope.launch { notifyPermission(event.properties) }
299+ return@collect
300+ }
301+ // The permission was answered (in-app, from the notification, or
302+ // auto-resolved): clear its heads-up notification.
303+ if (event is PermissionReplied) {
304+ val psid = event.properties.sessionID?.takeIf { it.isNotBlank() } ?: return@collect
305+ appContext?.let { SessionNotifications.cancelPermission(it, psid) }
306+ return@collect
307+ }
308+ // A run failed. Clear its active-run tracking (an error isn't always
309+ // followed by a SessionIdle) and notify unless it's being viewed.
310+ if (event is SessionError) {
311+ val esid = event.properties.sessionID ?: return@collect
312+ val wasRunning = synchronized(activeRuns) {
313+ activeRuns.remove(esid).also { removed ->
314+ if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
315+ }
316+ }
317+ if (wasRunning && !isActivelyViewing(esid)) {
318+ appScope.launch { notifySessionError(esid) }
319+ }
320+ return@collect
321+ }
263 // SessionIdle is not "message activity" (don't badge as unread)
322 // SessionIdle is not "message activity" (don't badge as unread)
264 // but signals a run finished — fire a completion notification if the
323 // but signals a run finished — fire a completion notification if the
265 // session isn't currently being viewed and was actively streaming.
324 // session isn't currently being viewed and was actively streaming.
@@ -275,7 +334,7 @@ open class AppContainer private constructor(
275 if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
334 if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
276 }
335 }
277 }
336 }
278− if (wasRunning && idleSid != _currentSession.value) {
337+ if (wasRunning && !isActivelyViewing(idleSid)) {
279 // Fire this off the collector's coroutine: notifySessionCompleted
338 // Fire this off the collector's coroutine: notifySessionCompleted
280 // does a network listSessions() to resolve the title, and blocking
339 // does a network listSessions() to resolve the title, and blocking
281 // the shared-event collector here would stall the SharedFlow,
340 // the shared-event collector here would stall the SharedFlow,
@@ -370,15 +429,59 @@ open class AppContainer private constructor(
370 const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
429 const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
371 }
430 }
372
431
432+ /** Resolve the human-readable title for [sessionId] (best-effort), falling back to the id. */
433+ private suspend fun resolveSessionTitle(sessionId: String): String =
434+ runCatchingCancellable {
435+ activeConnection.value?.repository?.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
436+ }.getOrNull() ?: sessionId
437+
373 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
438 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
374 private suspend fun notifySessionCompleted(sessionId: String) {
439 private suspend fun notifySessionCompleted(sessionId: String) {
375− val conn = activeConnection.value ?: return
440+ val title = resolveSessionTitle(sessionId)
376− val title = runCatchingCancellable {
377− conn.repository.listSessions().firstOrNull { it.id == sessionId }?.displayTitle
378− }.getOrNull() ?: sessionId
379 appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
441 appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
380 }
442 }
381
443
444+ /** Post a heads-up permission notification for [permission], resolving its session title. */
445+ private suspend fun notifyPermission(permission: Permission) {
446+ val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
447+ val title = resolveSessionTitle(sessionId)
448+ appContext?.let { SessionNotifications.postPermission(it, permission, title) }
449+ }
450+
451+ /** Post an error notification for a failed background run. */
452+ private suspend fun notifySessionError(sessionId: String) {
453+ val title = resolveSessionTitle(sessionId)
454+ appContext?.let { SessionNotifications.postError(it, sessionId, title) }
455+ }
456+
457+ /** Respond to a permission from a notification action (Allow once / Always / Reject),
458+ * off any UI. Runs on the process-lived app scope; [onDone] fires when the call resolves
459+ * so the receiver's goAsync() result can finish. No-ops if the connection is gone. */
460+ open fun respondToPermissionFromNotification(
461+ sessionId: String,
462+ permissionId: String,
463+ response: PermissionResponse,
464+ onDone: () -> Unit,
465+ ) {
466+ appScope.launch {
467+ runCatchingCancellable {
468+ activeConnection.value?.api?.respondPermission(sessionId, permissionId, response)
469+ }.onFailure { Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}") }
470+ onDone()
471+ }
472+ }
473+
474+ /** Send a follow-up prompt from a notification's inline reply, off any UI. Uses the
475+ * server's default model/agent. [onDone] fires when the send resolves. */
476+ open fun sendPromptFromNotification(sessionId: String, text: String, onDone: () -> Unit) {
477+ appScope.launch {
478+ runCatchingCancellable {
479+ activeConnection.value?.repository?.sendPrompt(sessionId, text, model = null)
480+ }.onFailure { Log.w("AppContainer", "notif reply send failed: ${safeExceptionSummary(it)}") }
481+ onDone()
482+ }
483+ }
484+
382 /** Extract the session id an event pertains to, for message-activity events. */
485 /** Extract the session id an event pertains to, for message-activity events. */
383 private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
486 private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
384
487
@@ -443,7 +546,7 @@ open class AppContainer private constructor(
443 val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
546 val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
444 val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
547 val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
445 if (needsSave) profileStore.save(finalProfile)
548 if (needsSave) profileStore.save(finalProfile)
446− OpencodeConnection(finalProfile).also { _activeConnection.value = it }
549+ OpencodeConnection(finalProfile, messageCacheStore).also { _activeConnection.value = it }
447 }
550 }
448
551
449 open suspend fun disconnect() {
552 open suspend fun disconnect() {
@@ -548,6 +651,9 @@ open class AppContainer private constructor(
548 runCatchingCancellable { conn.repository.deleteSession(id) }
651 runCatchingCancellable { conn.repository.deleteSession(id) }
549 .onSuccess {
652 .onSuccess {
550 draftStore.remove(id)
653 draftStore.remove(id)
654+ attachmentDraftStore.remove(id)
655+ sessionPrefsStore.forget(id)
656+ messageCacheStore.remove(id)
551 onDeleted()
657 onDeleted()
552 }
658 }
553 .onFailure { onError(it) }
659 .onFailure { onError(it) }
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+9−3
@@ -4,6 +4,7 @@ import soy.iko.opencode.data.model.ServerProfile
4import soy.iko.opencode.data.network.EventStreamClient
4import soy.iko.opencode.data.network.EventStreamClient
5import soy.iko.opencode.data.network.HttpClientFactory
5import soy.iko.opencode.data.network.HttpClientFactory
6import soy.iko.opencode.data.network.OpencodeApiClient
6import soy.iko.opencode.data.network.OpencodeApiClient
7+import soy.iko.opencode.data.repo.MessageCacheStore
7import soy.iko.opencode.data.repo.SessionRepository
8import soy.iko.opencode.data.repo.SessionRepository
8import kotlinx.coroutines.CoroutineScope
9import kotlinx.coroutines.CoroutineScope
9import kotlinx.coroutines.Dispatchers
10import kotlinx.coroutines.Dispatchers
@@ -15,9 +16,14 @@ import kotlinx.coroutines.cancel
15 * SSE event stream, and the reducing repository, all sharing a connection-scoped
16 * SSE event stream, and the reducing repository, all sharing a connection-scoped
16 * coroutine scope that is cancelled on [close].
17 * coroutine scope that is cancelled on [close].
17 */
18 */
18−open class OpencodeConnection(val profile: ServerProfile) {
19+open class OpencodeConnection(
20+ val profile: ServerProfile,
21+ // Optional on-disk message cache, supplied by AppContainer, so conversations render
22+ // instantly and stay readable offline. Null in the test constructor.
23+ private val messageCache: MessageCacheStore? = null,
24+) {
19
25
20− protected constructor() : this(ServerProfile(id = "", label = "", baseUrl = "http://localhost"))
26+ protected constructor() : this(ServerProfile(id = "", label = "", baseUrl = "http://localhost"), null)
21
27
22 private val scopeJob = SupervisorJob()
28 private val scopeJob = SupervisorJob()
23 private val scope by lazy { CoroutineScope(scopeJob + Dispatchers.IO) }
29 private val scope by lazy { CoroutineScope(scopeJob + Dispatchers.IO) }
@@ -25,7 +31,7 @@ open class OpencodeConnection(val profile: ServerProfile) {
25
31
26 open val api: OpencodeApiClient by lazy { OpencodeApiClient(client) }
32 open val api: OpencodeApiClient by lazy { OpencodeApiClient(client) }
27 open val events: EventStreamClient by lazy { EventStreamClient(client, scope) }
33 open val events: EventStreamClient by lazy { EventStreamClient(client, scope) }
28− open val repository: SessionRepository by lazy { SessionRepository(api, events) }
34+ open val repository: SessionRepository by lazy { SessionRepository(api, events, messageCache) }
29
35
30 /**
36 /**
31 * Cancel the coroutine scope and wait for in-flight work (including the SSE reader)
37 * Cancel the coroutine scope and wait for in-flight work (including the SSE reader)
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.ktnew file+65
@@ -0,0 +1,65 @@
1+package soy.iko.opencode.notification
2+
3+import android.content.BroadcastReceiver
4+import android.content.Context
5+import android.content.Intent
6+import androidx.core.app.RemoteInput
7+import soy.iko.opencode.OpencodeApp
8+import soy.iko.opencode.data.model.PermissionResponse
9+
10+/**
11+ * Handles the action buttons on the app's notifications so the user can respond without
12+ * opening the app — the core of the "kick it off and walk away" flow:
13+ *
14+ * - [ACTION_PERMISSION] with a [PermissionResponse] → replies to a paused tool's permission
15+ * request (Allow once / Always / Reject) via the active connection.
16+ * - [ACTION_REPLY] with a [RemoteInput] text payload → sends a follow-up prompt to a session
17+ * that just finished a run.
18+ *
19+ * The receiver is not exported (see the manifest); the [PendingIntent]s that target it are
20+ * created by the app itself. Work is done on the process-lived app scope via [OpencodeApp]'s
21+ * container, and [goAsync] keeps the receiver alive until the (network) call resolves.
22+ */
23+class NotificationActionReceiver : BroadcastReceiver() {
24+
25+ override fun onReceive(context: Context, intent: Intent) {
26+ val container = (context.applicationContext as? OpencodeApp)?.container ?: return
27+ val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
28+ when (intent.action) {
29+ ACTION_PERMISSION -> {
30+ val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
31+ val response = PermissionResponse.entries.firstOrNull {
32+ it.wire == intent.getStringExtra(EXTRA_RESPONSE)
33+ } ?: return
34+ // Dismiss the notification immediately for responsive feedback; the reply
35+ // itself continues in the background.
36+ SessionNotifications.cancelPermission(context, sessionId)
37+ val pending = goAsync()
38+ container.respondToPermissionFromNotification(sessionId, permissionId, response) {
39+ pending.finish()
40+ }
41+ }
42+ ACTION_REPLY -> {
43+ val text = RemoteInput.getResultsFromIntent(intent)
44+ ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
45+ ?.takeIf { it.isNotEmpty() } ?: return
46+ val pending = goAsync()
47+ container.sendPromptFromNotification(sessionId, text) {
48+ // The follow-up is on its way; clear the "session ready" notification.
49+ // A fresh run (and its foreground indicator) now signals progress.
50+ SessionNotifications.cancel(context, sessionId)
51+ pending.finish()
52+ }
53+ }
54+ }
55+ }
56+
57+ companion object {
58+ const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
59+ const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
60+ const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
61+ const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
62+ const val EXTRA_RESPONSE = "soy.iko.opencode.extra.RESPONSE"
63+ const val KEY_REPLY_TEXT = "soy.iko.opencode.key.REPLY_TEXT"
64+ }
65+}
app/src/main/java/soy/iko/opencode/notification/NotificationChannels.kt+16
@@ -12,10 +12,16 @@ import soy.iko.opencode.R
12 * - [STATUS]: low-importance, no sound. Used by the foreground service's persistent
12 * - [STATUS]: low-importance, no sound. Used by the foreground service's persistent
13 * "agent running" indicator.
13 * "agent running" indicator.
14 * - [COMPLETED]: default importance. Used when a background session finishes a run.
14 * - [COMPLETED]: default importance. Used when a background session finishes a run.
15+ * - [PERMISSION]: high importance (heads-up + sound). Used when a paused tool run needs
16+ * the user's approval — the whole point is to reach the user who has walked away, so it
17+ * interrupts rather than sitting silently in the shade.
18+ * - [ERROR]: default importance. Used when a background run fails.
15 */
19 */
16object NotificationChannels {
20object NotificationChannels {
17 const val STATUS = "run_status"
21 const val STATUS = "run_status"
18 const val COMPLETED = "session_completed"
22 const val COMPLETED = "session_completed"
23+ const val PERMISSION = "permission_request"
24+ const val ERROR = "session_error"
19
25
20 fun create(context: Context) {
26 fun create(context: Context) {
21 val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
27 val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
@@ -30,5 +36,15 @@ object NotificationChannels {
30 description = context.getString(R.string.notif_channel_completed_desc)
36 description = context.getString(R.string.notif_channel_completed_desc)
31 },
37 },
32 )
38 )
39+ nm.createNotificationChannel(
40+ NotificationChannel(PERMISSION, context.getString(R.string.notif_channel_permission), NotificationManager.IMPORTANCE_HIGH).apply {
41+ description = context.getString(R.string.notif_channel_permission_desc)
42+ },
43+ )
44+ nm.createNotificationChannel(
45+ NotificationChannel(ERROR, context.getString(R.string.notif_channel_error), NotificationManager.IMPORTANCE_DEFAULT).apply {
46+ description = context.getString(R.string.notif_channel_error_desc)
47+ },
48+ )
33 }
49 }
34}
50}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+5
@@ -65,6 +65,11 @@ class RunForegroundService : Service() {
65 .setContentText(getString(R.string.notif_running_text))
65 .setContentText(getString(R.string.notif_running_text))
66 .setContentIntent(pendingIntent)
66 .setContentIntent(pendingIntent)
67 .setOngoing(true)
67 .setOngoing(true)
68+ // Show a live elapsed timer so the user can see how long the current run has
69+ // been going — no per-tick plumbing needed; the system renders the chronometer.
70+ .setWhen(System.currentTimeMillis())
71+ .setUsesChronometer(true)
72+ .setShowWhen(true)
68 .setPriority(NotificationCompat.PRIORITY_LOW)
73 .setPriority(NotificationCompat.PRIORITY_LOW)
69 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
74 .setCategory(NotificationCompat.CATEGORY_PROGRESS)
70 .build()
75 .build()
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+148−28
@@ -1,5 +1,6 @@
1package soy.iko.opencode.notification
1package soy.iko.opencode.notification
2
2
3+import android.annotation.SuppressLint
3import android.app.PendingIntent
4import android.app.PendingIntent
4import android.content.Context
5import android.content.Context
5import android.content.Intent
6import android.content.Intent
@@ -7,31 +8,47 @@ import android.os.Build
7import android.util.Log
8import android.util.Log
8import androidx.core.app.NotificationCompat
9import androidx.core.app.NotificationCompat
9import androidx.core.app.NotificationManagerCompat
10import androidx.core.app.NotificationManagerCompat
11+import androidx.core.app.RemoteInput
10import soy.iko.opencode.MainActivity
12import soy.iko.opencode.MainActivity
11import soy.iko.opencode.R
13import soy.iko.opencode.R
14+import soy.iko.opencode.data.model.Permission
15+import soy.iko.opencode.data.model.PermissionResponse
12import java.security.MessageDigest
16import java.security.MessageDigest
13
17
14/**
18/**
15− * Builds and posts the "session completed" notification when a background session
19+ * Builds and posts the app's session notifications:
16− * finishes a run. Tapping it opens the session via [MainActivity] with an
17− * [MainActivity.EXTRA_SESSION_ID] extra.
18 *
20 *
21+ * - [postCompleted]: a "session ready" notification when a background run finishes, with an
22+ * inline [RemoteInput] Reply action so a follow-up can be sent without opening the app.
23+ * - [postPermission]: a heads-up notification when a paused tool needs approval, with
24+ * Allow once / Always / Reject action buttons handled by [NotificationActionReceiver].
25+ * - [postError]: a notification when a background run fails.
26+ *
27+ * Tapping a notification body opens the session via [MainActivity] with an
19− * Notification posting is guarded for Android 13+ runtime permission: if the user
28+ * [MainActivity.EXTRA_SESSION_ID] extra. Notification posting is guarded for Android 13+
20− * hasn't granted POST_NOTIFICATIONS, the post is silently skipped rather than throwing.
29+ * runtime permission: if the user hasn't granted POST_NOTIFICATIONS, the post is silently
30+ * skipped rather than throwing.
21 */
31 */
22object SessionNotifications {
32object SessionNotifications {
23
33
24 private const val NOTIF_ID_PREFIX = 4000
34 private const val NOTIF_ID_PREFIX = 4000
25 private const val TAG = "SessionNotifications"
35 private const val TAG = "SessionNotifications"
26
36
37+ // Distinct namespaces so a session can have a completion, a permission, and an error
38+ // notification outstanding at once without their ids colliding.
39+ private const val NS_COMPLETED = "done"
40+ private const val NS_PERMISSION = "perm"
41+ private const val NS_ERROR = "err"
42+
27 private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
43 private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
28
44
29− /** Derive a stable, collision-resistant notification id from a session id.
45+ /** Derive a stable, collision-resistant notification id from a namespace + session id.
30 * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
46 * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
31− * bits makes accidental collisions astronomically unlikely. */
47+ * bits makes accidental collisions astronomically unlikely. The namespace keeps the
48+ * three notification kinds for one session in separate id spaces. */
32− private fun notifIdFor(sessionId: String): Int {
49+ private fun notifId(namespace: String, sessionId: String): Int {
33 val digest = runCatching { MessageDigest.getInstance("SHA-256") }
50 val digest = runCatching { MessageDigest.getInstance("SHA-256") }
34− .getOrNull()?.digest(sessionId.toByteArray()) ?: return NOTIF_ID_PREFIX
51+ .getOrNull()?.digest("$namespace:$sessionId".toByteArray()) ?: return NOTIF_ID_PREFIX
35 // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
52 // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
36 // Kotlin's shl/or/and are equal-precedence infix functions evaluated strictly
53 // Kotlin's shl/or/and are equal-precedence infix functions evaluated strictly
37 // left-to-right, so the shifts MUST be parenthesized individually — otherwise
54 // left-to-right, so the shifts MUST be parenthesized individually — otherwise
@@ -46,30 +63,59 @@ object SessionNotifications {
46 return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
63 return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
47 }
64 }
48
65
49− fun postCompleted(context: Context, sessionId: String, title: String) {
50− // Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user
66+ /** Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user denied
51− // denied it, skip the post instead of throwing. Foreground-service notifications
67+ * it, callers should skip posting instead of throwing. Foreground-service notifications
52− // are exempt, so the running indicator still shows during a run.
68+ * are exempt, so the running indicator still shows during a run. */
69+ private fun canPost(context: Context): Boolean =
53− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
70+ Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
54 androidx.core.content.ContextCompat.checkSelfPermission(
71 androidx.core.content.ContextCompat.checkSelfPermission(
55 context, android.Manifest.permission.POST_NOTIFICATIONS,
72 context, android.Manifest.permission.POST_NOTIFICATIONS,
56− ) != android.content.pm.PackageManager.PERMISSION_GRANTED
73+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
57− ) {
74+
58− return
59− }
60− val notifId = notifIdFor(sessionId)
61− // Sanitize the session id before embedding it in the URI so characters like
75+ /** Sanitize a session id before embedding it in the deep-link URI so characters like
62− // /, ?, # can't inject path segments or query parameters.
76+ * /, ?, # can't inject path segments or query parameters. */
63− val safeSessionId = notifIdRegex.replace(sessionId, "")
77+ private fun safeId(sessionId: String): String = notifIdRegex.replace(sessionId, "")
78+
79+ /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped. */
80+ private fun openSessionIntent(context: Context, sessionId: String, requestCode: Int): PendingIntent {
64 val openIntent = Intent(context, MainActivity::class.java).apply {
81 val openIntent = Intent(context, MainActivity::class.java).apply {
65 action = Intent.ACTION_VIEW
82 action = Intent.ACTION_VIEW
66− data = android.net.Uri.parse("opencode://session/$safeSessionId")
83+ data = android.net.Uri.parse("opencode://session/${safeId(sessionId)}")
67 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
84 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
68 }
85 }
69− val pendingIntent = PendingIntent.getActivity(
86+ return PendingIntent.getActivity(
70− context, notifId, openIntent,
87+ context, requestCode, openIntent,
71 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
88 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
72 )
89 )
90+ }
91+
92+ // canPost() checks POST_NOTIFICATIONS before every notify(); lint can't follow the
93+ // check through the helper, so suppress its MissingPermission flag on these posts.
94+ @SuppressLint("MissingPermission")
95+ fun postCompleted(context: Context, sessionId: String, title: String) {
96+ if (!canPost(context)) return
97+ val notifId = notifId(NS_COMPLETED, sessionId)
98+
99+ // Inline reply: a RemoteInput-backed action that broadcasts the typed follow-up to
100+ // NotificationActionReceiver. The PendingIntent MUST be mutable so the system can
101+ // fill in the RemoteInput results before delivering it.
102+ val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_REPLY_TEXT)
103+ .setLabel(context.getString(R.string.notif_reply_hint))
104+ .build()
105+ val replyIntent = Intent(context, NotificationActionReceiver::class.java).apply {
106+ action = NotificationActionReceiver.ACTION_REPLY
107+ putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
108+ }
109+ val replyPending = PendingIntent.getBroadcast(
110+ context, notifId, replyIntent,
111+ PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
112+ )
113+ val replyAction = NotificationCompat.Action.Builder(
114+ R.drawable.ic_launcher_foreground,
115+ context.getString(R.string.notif_action_reply),
116+ replyPending,
117+ ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
118+
73 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
119 val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
74 .setSmallIcon(R.drawable.ic_launcher_foreground)
120 .setSmallIcon(R.drawable.ic_launcher_foreground)
75 .setContentTitle(context.getString(R.string.notif_completed_title))
121 .setContentTitle(context.getString(R.string.notif_completed_title))
@@ -78,7 +124,8 @@ object SessionNotifications {
78 // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
124 // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
79 .setContentText(context.getString(R.string.notif_completed_text, title))
125 .setContentText(context.getString(R.string.notif_completed_text, title))
80 .setAutoCancel(true)
126 .setAutoCancel(true)
81− .setContentIntent(pendingIntent)
127+ .setContentIntent(openSessionIntent(context, sessionId, notifId))
128+ .addAction(replyAction)
82 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
129 .setCategory(NotificationCompat.CATEGORY_MESSAGE)
83 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
130 .setPriority(NotificationCompat.PRIORITY_DEFAULT)
84 .build()
131 .build()
@@ -87,9 +134,82 @@ object SessionNotifications {
87 .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
134 .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
88 }
135 }
89
136
137+ /** Post a heads-up notification for a permission request with Allow once / Always /
138+ * Reject action buttons. [sessionTitle] labels the notification; [permission]'s title/
139+ * pattern/type supplies the detail line. */
140+ @SuppressLint("MissingPermission")
141+ fun postPermission(context: Context, permission: Permission, sessionTitle: String) {
142+ if (!canPost(context)) return
143+ val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
144+ val notifId = notifId(NS_PERMISSION, sessionId)
145+ val detail = permission.title?.takeIf { it.isNotBlank() }
146+ ?: permission.patternText?.takeIf { it.isNotBlank() }
147+ ?: permission.type?.takeIf { it.isNotBlank() }
148+ ?: context.getString(R.string.notif_permission_fallback)
149+
150+ val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
151+ .setSmallIcon(R.drawable.ic_launcher_foreground)
152+ .setContentTitle(context.getString(R.string.notif_permission_title, sessionTitle))
153+ .setContentText(detail)
154+ .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
155+ .setAutoCancel(true)
156+ .setContentIntent(openSessionIntent(context, sessionId, notifId))
157+ .setCategory(NotificationCompat.CATEGORY_CALL)
158+ .setPriority(NotificationCompat.PRIORITY_HIGH)
159+
160+ // One action per response. Each PendingIntent needs a distinct request code or
161+ // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
162+ listOf(
163+ PermissionResponse.ONCE to R.string.notif_action_allow_once,
164+ PermissionResponse.ALWAYS to R.string.notif_action_always,
165+ PermissionResponse.REJECT to R.string.notif_action_reject,
166+ ).forEach { (response, labelRes) ->
167+ val intent = Intent(context, NotificationActionReceiver::class.java).apply {
168+ action = NotificationActionReceiver.ACTION_PERMISSION
169+ putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
170+ putExtra(NotificationActionReceiver.EXTRA_PERMISSION_ID, permission.id)
171+ putExtra(NotificationActionReceiver.EXTRA_RESPONSE, response.wire)
172+ }
173+ val pending = PendingIntent.getBroadcast(
174+ context, notifId + response.ordinal + 1, intent,
175+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
176+ )
177+ builder.addAction(R.drawable.ic_launcher_foreground, context.getString(labelRes), pending)
178+ }
179+
180+ runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
181+ .onFailure { Log.w(TAG, "Failed to post permission notification", it) }
182+ }
183+
184+ /** Post a notification when a background run fails. */
185+ @SuppressLint("MissingPermission")
186+ fun postError(context: Context, sessionId: String, title: String) {
187+ if (!canPost(context)) return
188+ val notifId = notifId(NS_ERROR, sessionId)
189+ val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
190+ .setSmallIcon(R.drawable.ic_launcher_foreground)
191+ .setContentTitle(context.getString(R.string.notif_error_title))
192+ .setContentText(context.getString(R.string.notif_error_text, title))
193+ .setAutoCancel(true)
194+ .setContentIntent(openSessionIntent(context, sessionId, notifId))
195+ .setCategory(NotificationCompat.CATEGORY_ERROR)
196+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
197+ .build()
198+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
199+ .onFailure { Log.w(TAG, "Failed to post error notification", it) }
200+ }
201+
202+ /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
203+ private fun mutableFlag(): Int =
204+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
205+
90 /** Cancel a session's completion notification (e.g. when the user opens it). */
206 /** Cancel a session's completion notification (e.g. when the user opens it). */
91 fun cancel(context: Context, sessionId: String) {
207 fun cancel(context: Context, sessionId: String) {
92− val notifId = notifIdFor(sessionId)
208+ NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
209+ }
210+
211+ /** Cancel a session's permission notification (on reply, or when the user opens it). */
212+ fun cancelPermission(context: Context, sessionId: String) {
93− NotificationManagerCompat.from(context).cancel(notifId)
213+ NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
94 }
214 }
95}
215}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11
@@ -28,6 +28,7 @@ import soy.iko.opencode.ui.file.FileBrowserScreen
28import soy.iko.opencode.ui.file.FileViewScreen
28import soy.iko.opencode.ui.file.FileViewScreen
29import soy.iko.opencode.ui.server.ServerEditScreen
29import soy.iko.opencode.ui.server.ServerEditScreen
30import soy.iko.opencode.ui.server.ServerListScreen
30import soy.iko.opencode.ui.server.ServerListScreen
31+import soy.iko.opencode.ui.search.GlobalSearchScreen
31import soy.iko.opencode.ui.session.SessionListScreen
32import soy.iko.opencode.ui.session.SessionListScreen
32import soy.iko.opencode.ui.session.TwoPaneSessionChat
33import soy.iko.opencode.ui.session.TwoPaneSessionChat
33import soy.iko.opencode.ui.settings.DiagnosticsScreen
34import soy.iko.opencode.ui.settings.DiagnosticsScreen
@@ -164,6 +165,7 @@ fun OpencodeApp(container: AppContainer) {
164 onOpenFiles = { navController.navigate(Routes.FILES) },
165 onOpenFiles = { navController.navigate(Routes.FILES) },
165 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
166 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
166 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
167 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
168+ onOpenSearch = { navController.navigate(Routes.SEARCH) },
167 onDisconnect = {
169 onDisconnect = {
168 scope.launch { runCatchingCancellable { container.disconnect() } }
170 scope.launch { runCatchingCancellable { container.disconnect() } }
169 navController.popBackStack(Routes.SERVERS, inclusive = false)
171 navController.popBackStack(Routes.SERVERS, inclusive = false)
@@ -189,6 +191,7 @@ fun OpencodeApp(container: AppContainer) {
189 },
191 },
190 onOpenFiles = { navController.navigate(Routes.FILES) },
192 onOpenFiles = { navController.navigate(Routes.FILES) },
191 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
193 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
194+ onOpenSearch = { navController.navigate(Routes.SEARCH) },
192 onAddServer = { navController.navigate(Routes.serverEdit()) },
195 onAddServer = { navController.navigate(Routes.serverEdit()) },
193 )
196 )
194 }
197 }
@@ -254,6 +257,14 @@ fun OpencodeApp(container: AppContainer) {
254 composable(Routes.DIAGNOSTICS) {
257 composable(Routes.DIAGNOSTICS) {
255 DiagnosticsScreen(onBack = { navController.popBackStack() })
258 DiagnosticsScreen(onBack = { navController.popBackStack() })
256 }
259 }
260+
261+ composable(Routes.SEARCH) {
262+ GlobalSearchScreen(
263+ container = container,
264+ onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
265+ onBack = { navController.popBackStack() },
266+ )
267+ }
257 }
268 }
258 }
269 }
259}
270}
app/src/main/java/soy/iko/opencode/ui/Routes.kt+1
@@ -7,6 +7,7 @@ object Routes {
7 const val SETTINGS = "settings"
7 const val SETTINGS = "settings"
8 const val DIAGNOSTICS = "diagnostics"
8 const val DIAGNOSTICS = "diagnostics"
9 const val FILES = "files"
9 const val FILES = "files"
10+ const val SEARCH = "search"
10
11
11 const val SERVER_EDIT = "server_edit"
12 const val SERVER_EDIT = "server_edit"
12 fun serverEdit(id: String? = null) =
13 fun serverEdit(id: String? = null) =
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.ktnew file+92
@@ -0,0 +1,92 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Row
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.padding
7+import androidx.compose.foundation.layout.size
8+import androidx.compose.foundation.layout.widthIn
9+import androidx.compose.foundation.horizontalScroll
10+import androidx.compose.foundation.rememberScrollState
11+import androidx.compose.material.icons.Icons
12+import androidx.compose.material.icons.filled.Close
13+import androidx.compose.material.icons.filled.Description
14+import androidx.compose.material3.Icon
15+import androidx.compose.material3.IconButton
16+import androidx.compose.material3.MaterialTheme
17+import androidx.compose.material3.Surface
18+import androidx.compose.material3.Text
19+import androidx.compose.runtime.Composable
20+import androidx.compose.ui.Alignment
21+import androidx.compose.ui.Modifier
22+import androidx.compose.ui.draw.clip
23+import androidx.compose.ui.layout.ContentScale
24+import androidx.compose.ui.res.stringResource
25+import androidx.compose.ui.text.style.TextOverflow
26+import androidx.compose.ui.unit.dp
27+import coil.compose.AsyncImage
28+import soy.iko.opencode.R
29+
30+/**
31+ * The staged-attachment UI for the chat composer — extracted from ChatScreen so that (large)
32+ * file stays focused on the conversation surface. `internal` so ChatScreen can call it.
33+ */
34+@Composable
35+internal fun AttachmentStrip(attachments: List<PendingAttachment>, onRemove: (String) -> Unit) {
36+ if (attachments.isEmpty()) return
37+ // Horizontally-scrollable strip of staged attachment thumbnails/chips, each removable.
38+ Row(
39+ modifier = Modifier
40+ .fillMaxWidth()
41+ .horizontalScroll(rememberScrollState())
42+ .padding(start = 8.dp, end = 8.dp, top = 8.dp),
43+ horizontalArrangement = Arrangement.spacedBy(8.dp),
44+ ) {
45+ attachments.forEach { att ->
46+ AttachmentChip(att, onRemove = { onRemove(att.id) })
47+ }
48+ }
49+}
50+
51+/** A single staged attachment: an image thumbnail (or a generic file icon) with its name
52+ * and a remove button. */
53+@Composable
54+private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
55+ Surface(
56+ shape = MaterialTheme.shapes.small,
57+ color = MaterialTheme.colorScheme.surfaceVariant,
58+ tonalElevation = 1.dp,
59+ ) {
60+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 6.dp)) {
61+ if (attachment.previewModel != null) {
62+ AsyncImage(
63+ model = attachment.previewModel,
64+ contentDescription = attachment.name,
65+ contentScale = ContentScale.Crop,
66+ modifier = Modifier.size(36.dp).clip(MaterialTheme.shapes.extraSmall),
67+ )
68+ } else {
69+ Icon(
70+ Icons.Filled.Description,
71+ contentDescription = null,
72+ modifier = Modifier.size(24.dp),
73+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
74+ )
75+ }
76+ Text(
77+ attachment.name,
78+ style = MaterialTheme.typography.labelMedium,
79+ maxLines = 1,
80+ overflow = TextOverflow.Ellipsis,
81+ modifier = Modifier.widthIn(max = 120.dp).padding(horizontal = 6.dp),
82+ )
83+ IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) {
84+ Icon(
85+ Icons.Filled.Close,
86+ contentDescription = stringResource(R.string.remove),
87+ modifier = Modifier.size(16.dp),
88+ )
89+ }
90+ }
91+ }
92+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+8−63
@@ -9,8 +9,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
9import androidx.activity.result.PickVisualMediaRequest
9import androidx.activity.result.PickVisualMediaRequest
10import androidx.activity.result.contract.ActivityResultContracts
10import androidx.activity.result.contract.ActivityResultContracts
11import androidx.compose.animation.AnimatedVisibility
11import androidx.compose.animation.AnimatedVisibility
12−import androidx.compose.foundation.horizontalScroll
13−import androidx.compose.foundation.rememberScrollState
14import androidx.compose.foundation.clickable
12import androidx.compose.foundation.clickable
15import androidx.compose.foundation.layout.Arrangement
13import androidx.compose.foundation.layout.Arrangement
16import androidx.compose.foundation.layout.Box
14import androidx.compose.foundation.layout.Box
@@ -26,9 +24,6 @@ import androidx.compose.foundation.layout.imePadding
26import androidx.compose.foundation.layout.navigationBars
24import androidx.compose.foundation.layout.navigationBars
27import androidx.compose.foundation.layout.padding
25import androidx.compose.foundation.layout.padding
28import androidx.compose.foundation.layout.size
26import androidx.compose.foundation.layout.size
29−import androidx.compose.foundation.layout.widthIn
30−import androidx.compose.ui.draw.clip
31−import androidx.compose.ui.layout.ContentScale
32import androidx.compose.foundation.layout.union
27import androidx.compose.foundation.layout.union
33import androidx.compose.foundation.layout.windowInsetsPadding
28import androidx.compose.foundation.layout.windowInsetsPadding
34import androidx.compose.foundation.lazy.LazyColumn
29import androidx.compose.foundation.lazy.LazyColumn
@@ -39,7 +34,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
39import androidx.compose.material.icons.automirrored.filled.Send
34import androidx.compose.material.icons.automirrored.filled.Send
40import androidx.compose.material.icons.filled.Add
35import androidx.compose.material.icons.filled.Add
41import androidx.compose.material.icons.filled.AutoAwesome
36import androidx.compose.material.icons.filled.AutoAwesome
42−import androidx.compose.material.icons.filled.Close
43import androidx.compose.material.icons.filled.Delete
37import androidx.compose.material.icons.filled.Delete
44import androidx.compose.material.icons.filled.Description
38import androidx.compose.material.icons.filled.Description
45import androidx.compose.material.icons.filled.Edit
39import androidx.compose.material.icons.filled.Edit
@@ -110,7 +104,6 @@ import androidx.compose.ui.text.style.TextOverflow
110import androidx.compose.ui.unit.dp
104import androidx.compose.ui.unit.dp
111import androidx.lifecycle.compose.collectAsStateWithLifecycle
105import androidx.lifecycle.compose.collectAsStateWithLifecycle
112import androidx.lifecycle.viewmodel.compose.viewModel
106import androidx.lifecycle.viewmodel.compose.viewModel
113−import coil.compose.AsyncImage
114import kotlinx.coroutines.Dispatchers
107import kotlinx.coroutines.Dispatchers
115import kotlinx.coroutines.launch
108import kotlinx.coroutines.launch
116import kotlinx.coroutines.withContext
109import kotlinx.coroutines.withContext
@@ -801,6 +794,10 @@ fun ChatScreen(
801 modifier = Modifier.fillMaxSize(),
794 modifier = Modifier.fillMaxSize(),
802 ) {
795 ) {
803 val lastMessageId = messages.lastOrNull()?.info?.id
796 val lastMessageId = messages.lastOrNull()?.info?.id
797+ // Text-to-speech for reading assistant replies aloud; shuts down when the
798+ // message list leaves composition.
799+ val tts = rememberTtsController()
800+ val speakingMessageId by tts.speakingId
804 LazyColumn(
801 LazyColumn(
805 state = listState,
802 state = listState,
806 modifier = Modifier.fillMaxSize(),
803 modifier = Modifier.fillMaxSize(),
@@ -830,6 +827,9 @@ fun ChatScreen(
830 modelLabel = modelLabel,
827 modelLabel = modelLabel,
831 onOpenFile = onOpenFile,
828 onOpenFile = onOpenFile,
832 onRevert = { vm.revertTo(message.info.id) },
829 onRevert = { vm.revertTo(message.info.id) },
830+ onEdit = { text -> vm.editMessage(message.info.id, text) },
831+ onSpeak = { text -> tts.toggle(message.info.id, text) },
832+ isSpeaking = message.info.id == speakingMessageId,
833 )
833 )
834 }
834 }
835 }
835 }
@@ -1048,19 +1048,7 @@ private fun ChatInputBar(
1048 }
1048 }
1049 }
1049 }
1050 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1050 // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1051− if (attachments.isNotEmpty()) {
1052− Row(
1053− modifier = Modifier
1054− .fillMaxWidth()
1055− .horizontalScroll(rememberScrollState())
1056− .padding(start = 8.dp, end = 8.dp, top = 8.dp),
1057− horizontalArrangement = Arrangement.spacedBy(8.dp),
1058− ) {
1059− attachments.forEach { att ->
1060− AttachmentChip(att, onRemove = { onRemoveAttachment(att.id) })
1051+ AttachmentStrip(attachments, onRemove = onRemoveAttachment)
1061− }
1062− }
1063− }
1064 Row(
1052 Row(
1065 modifier = Modifier.fillMaxWidth().padding(8.dp),
1053 modifier = Modifier.fillMaxWidth().padding(8.dp),
1066 verticalAlignment = Alignment.Bottom,
1054 verticalAlignment = Alignment.Bottom,
@@ -1275,49 +1263,6 @@ private fun EmptyConversation(
1275 }
1263 }
1276}
1264}
1277
1265
1278−/** A staged attachment: an image thumbnail (or a generic file icon) with its name and a
1279− * remove button, shown in the composer above the input field. */
1280−@Composable
1281−private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
1282− Surface(
1283− shape = MaterialTheme.shapes.small,
1284− color = MaterialTheme.colorScheme.surfaceVariant,
1285− tonalElevation = 1.dp,
1286− ) {
1287− Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 6.dp)) {
1288− if (attachment.previewModel != null) {
1289− AsyncImage(
1290− model = attachment.previewModel,
1291− contentDescription = attachment.name,
1292− contentScale = ContentScale.Crop,
1293− modifier = Modifier.size(36.dp).clip(MaterialTheme.shapes.extraSmall),
1294− )
1295− } else {
1296− Icon(
1297− Icons.Filled.Description,
1298− contentDescription = null,
1299− modifier = Modifier.size(24.dp),
1300− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1301− )
1302− }
1303− Text(
1304− attachment.name,
1305− style = MaterialTheme.typography.labelMedium,
1306− maxLines = 1,
1307− overflow = TextOverflow.Ellipsis,
1308− modifier = Modifier.widthIn(max = 120.dp).padding(horizontal = 6.dp),
1309− )
1310− IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) {
1311− Icon(
1312− Icons.Filled.Close,
1313− contentDescription = stringResource(R.string.remove),
1314− modifier = Modifier.size(16.dp),
1315− )
1316− }
1317− }
1318− }
1319−}
1320−
1321/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
1266/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
1322@Composable
1267@Composable
1323private fun RevertBanner(onUndo: () -> Unit) {
1268private fun RevertBanner(onUndo: () -> Unit) {
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+66−3
@@ -75,6 +75,25 @@ data class PendingAttachment(
75 val isImage: Boolean get() = mime.startsWith("image/")
75 val isImage: Boolean get() = mime.startsWith("image/")
76}
76}
77
77
78+/** Reduce a staged attachment to its persistable form (the self-contained data URL). */
79+private fun PendingAttachment.toPersisted() =
80+ soy.iko.opencode.data.repo.PersistedAttachment(id, name, mime, part.url, part.filename)
81+
82+/** Rebuild a staged attachment from persistence. The base64 data URL doubles as the Coil
83+ * preview model for images (the original content Uri didn't survive the process restart). */
84+private fun soy.iko.opencode.data.repo.PersistedAttachment.toPending() = PendingAttachment(
85+ id = id,
86+ name = name,
87+ mime = mime,
88+ previewModel = if (mime.startsWith("image/")) url else null,
89+ part = FilePromptPart(mime = mime, url = url, filename = filename),
90+)
91+
92+// Large by design: this VM owns the whole chat surface (streaming, drafts, attachments,
93+// catalogs, permissions, revert/edit, sharing, summarize/init/shell, TTS wiring). It's
94+// already grandfathered for TooManyFunctions in the detekt baseline; the class-size rule is
95+// suppressed for the same reason. A future split into sub-controllers is tracked separately.
96+@Suppress("LargeClass")
78@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
97@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
79class ChatViewModel(
98class ChatViewModel(
80 private val container: AppContainer,
99 private val container: AppContainer,
@@ -264,9 +283,10 @@ class ChatViewModel(
264 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
283 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
265 val draft: StateFlow<String> = _draft.asStateFlow()
284 val draft: StateFlow<String> = _draft.asStateFlow()
266
285
267− /** Attachments staged for the next prompt (images/files). In-memory only — a huge base64
286+ /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
287+ * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
268− * payload isn't worth persisting across process death, and the source Uris wouldn't
288+ * survives process death — the source content Uris wouldn't survive, but the data URLs
269− * survive anyway. Cleared on a successful send. */
289+ * do. Cleared on a successful send. */
270 private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
290 private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
271 val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
291 val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
272
292
@@ -341,6 +361,19 @@ class ChatViewModel(
341 }
361 }
342 }
362 }
343
363
364+ init {
365+ // Restore attachments staged before process death. They were persisted as
366+ // self-contained data URLs, so they re-send and (for images) re-preview intact.
367+ // Guarded on empty so it never clobbers attachments the user staged after opening.
368+ viewModelScope.launch {
369+ val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
370+ .getOrDefault(emptyList())
371+ if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
372+ _attachments.value = restored.map { it.toPending() }
373+ }
374+ }
375+ }
376+
344 fun selectModel(option: ModelOption) { _selectedModel.value = option }
377 fun selectModel(option: ModelOption) { _selectedModel.value = option }
345
378
346 fun selectAgent(name: String?) { _selectedAgent.value = name }
379 fun selectAgent(name: String?) { _selectedAgent.value = name }
@@ -626,11 +659,16 @@ class ChatViewModel(
626 // Restore the staged attachments too, so a failed send doesn't lose them —
659 // Restore the staged attachments too, so a failed send doesn't lose them —
627 // but only if the user hasn't staged new ones in the meantime.
660 // but only if the user hasn't staged new ones in the meantime.
628 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
661 if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
662+ // Re-persist so the restored attachments survive process death after a failure.
663+ persistAttachments()
629 // Retryable: this is the failed send whose prompt Retry re-submits.
664 // Retryable: this is the failed send whose prompt Retry re-submits.
630 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
665 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
631 _running.value = false
666 _running.value = false
632 }.onSuccess {
667 }.onSuccess {
633 suppressDraftPersist.set(false)
668 suppressDraftPersist.set(false)
669+ // Send succeeded — the attachments rode along, so clear their persisted copy
670+ // (or keep any the user staged after the optimistic clear).
671+ persistAttachments()
634 // Send succeeded — persist the cleared draft, but only if the user hasn't
672 // Send succeeded — persist the cleared draft, but only if the user hasn't
635 // typed a new one while the send was in flight. Otherwise clearing the
673 // typed a new one while the send was in flight. Otherwise clearing the
636 // store echoes back through the draft observer and wipes the in-progress
674 // store echoes back through the draft observer and wipes the in-progress
@@ -746,14 +784,27 @@ class ChatViewModel(
746
784
747 fun addAttachment(attachment: PendingAttachment) {
785 fun addAttachment(attachment: PendingAttachment) {
748 _attachments.update { it + attachment }
786 _attachments.update { it + attachment }
787+ persistAttachments()
749 }
788 }
750
789
751 fun removeAttachment(id: String) {
790 fun removeAttachment(id: String) {
752 _attachments.update { list -> list.filterNot { it.id == id } }
791 _attachments.update { list -> list.filterNot { it.id == id } }
792+ persistAttachments()
753 }
793 }
754
794
755 fun clearAttachments() {
795 fun clearAttachments() {
756 _attachments.value = emptyList()
796 _attachments.value = emptyList()
797+ persistAttachments()
798+ }
799+
800+ /** Persist the current staged attachments so an interrupted compose survives process
801+ * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
802+ private fun persistAttachments() {
803+ val snapshot = _attachments.value
804+ viewModelScope.launch {
805+ runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
806+ .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
807+ }
757 }
808 }
758
809
759 /** Revert the conversation to just before [messageId], hiding everything after it. The
810 /** Revert the conversation to just before [messageId], hiding everything after it. The
@@ -768,6 +819,16 @@ class ChatViewModel(
768 }
819 }
769 }
820 }
770
821
822+ /** Load a previously-sent user prompt back into the composer to edit and re-send.
823+ * Reverts the conversation to just before [messageId] (hiding it and everything after)
824+ * and prefills the draft with [text]. The next Send continues from that checkpoint,
825+ * effectively replacing the edited message; the reverted banner's Undo restores it.
826+ * Overwrites the current draft — this is an explicit edit action on that message. */
827+ fun editMessage(messageId: String, text: String) {
828+ revertTo(messageId)
829+ _draft.value = text
830+ }
831+
771 /** Undo the active revert checkpoint, restoring the hidden messages. */
832 /** Undo the active revert checkpoint, restoring the hidden messages. */
772 fun unrevert() {
833 fun unrevert() {
773 val conn = connection ?: return
834 val conn = connection ?: return
@@ -887,6 +948,8 @@ class ChatViewModel(
887 runCatchingCancellable { conn.repository.deleteSession(sessionId) }
948 runCatchingCancellable { conn.repository.deleteSession(sessionId) }
888 .onSuccess {
949 .onSuccess {
889 container.draftStore.remove(sessionId)
950 container.draftStore.remove(sessionId)
951+ container.attachmentDraftStore.remove(sessionId)
952+ container.messageCacheStore.remove(sessionId)
890 _sessionDeleted.value = true
953 _sessionDeleted.value = true
891 }
954 }
892 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
955 .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+72−20
@@ -9,8 +9,11 @@ import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
9import androidx.compose.foundation.layout.size
10import androidx.compose.material.icons.Icons
10import androidx.compose.material.icons.Icons
11import androidx.compose.material.icons.automirrored.filled.HelpOutline
11import androidx.compose.material.icons.automirrored.filled.HelpOutline
12+import androidx.compose.material.icons.automirrored.filled.VolumeUp
12import androidx.compose.material.icons.filled.ContentCopy
13import androidx.compose.material.icons.filled.ContentCopy
14+import androidx.compose.material.icons.filled.Edit
13import androidx.compose.material.icons.filled.Restore
15import androidx.compose.material.icons.filled.Restore
16+import androidx.compose.material.icons.filled.StopCircle
14import androidx.compose.material3.Icon
17import androidx.compose.material3.Icon
15import androidx.compose.material3.IconButton
18import androidx.compose.material3.IconButton
16import androidx.compose.material3.MaterialTheme
19import androidx.compose.material3.MaterialTheme
@@ -60,11 +63,14 @@ fun MessageBubble(
60 modelLabel: String? = null,
63 modelLabel: String? = null,
61 onOpenFile: ((String) -> Unit)? = null,
64 onOpenFile: ((String) -> Unit)? = null,
62 onRevert: (() -> Unit)? = null,
65 onRevert: (() -> Unit)? = null,
66+ onEdit: ((String) -> Unit)? = null,
67+ onSpeak: ((String) -> Unit)? = null,
68+ isSpeaking: Boolean = false,
63) {
69) {
64 when (message.info) {
70 when (message.info) {
65− is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert)
71+ is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit)
66 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
72 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
67− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert)
73+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking)
68 }
74 }
69}
75}
70
76
@@ -126,9 +132,11 @@ private fun UserBubble(
126 modifier: Modifier,
132 modifier: Modifier,
127 onOpenFile: ((String) -> Unit)? = null,
133 onOpenFile: ((String) -> Unit)? = null,
128 onRevert: (() -> Unit)? = null,
134 onRevert: (() -> Unit)? = null,
135+ onEdit: ((String) -> Unit)? = null,
129) {
136) {
130 val context = LocalContext.current
137 val context = LocalContext.current
131 val copyLabel = stringResource(R.string.copy)
138 val copyLabel = stringResource(R.string.copy)
139+ val editLabel = stringResource(R.string.edit_message)
132 // Collect text from all TextParts for copying, so a user can reuse/repost their
140 // Collect text from all TextParts for copying, so a user can reuse/repost their
133 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
141 // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
134 val textToCopy = remember(message.parts) {
142 val textToCopy = remember(message.parts) {
@@ -154,6 +162,19 @@ private fun UserBubble(
154 ) {
162 ) {
155 MessageTimestampText(message.info)
163 MessageTimestampText(message.info)
156 Row(verticalAlignment = Alignment.CenterVertically) {
164 Row(verticalAlignment = Alignment.CenterVertically) {
165+ // Edit affordance: reload this prompt into the composer, reverting the
166+ // conversation to before it so a Send replaces it. Only when there's text
167+ // to edit (an image-only prompt has nothing to reload).
168+ if (onEdit != null && textToCopy != null) {
169+ IconButton(onClick = { onEdit(textToCopy) }) {
170+ Icon(
171+ Icons.Filled.Edit,
172+ contentDescription = editLabel,
173+ modifier = Modifier.size(18.dp),
174+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
175+ )
176+ }
177+ }
157 onRevert?.let { RevertButton(it) }
178 onRevert?.let { RevertButton(it) }
158 // Copy affordance for user prompts, mirroring the assistant block.
179 // Copy affordance for user prompts, mirroring the assistant block.
159 // Without it, reusing a prior prompt requires discovering the
180 // Without it, reusing a prior prompt requires discovering the
@@ -185,6 +206,8 @@ private fun AssistantBlock(
185 modelLabel: String? = null,
206 modelLabel: String? = null,
186 onOpenFile: ((String) -> Unit)? = null,
207 onOpenFile: ((String) -> Unit)? = null,
187 onRevert: (() -> Unit)? = null,
208 onRevert: (() -> Unit)? = null,
209+ onSpeak: ((String) -> Unit)? = null,
210+ isSpeaking: Boolean = false,
188) {
211) {
189 Column(
212 Column(
190 modifier = modifier.fillMaxWidth(),
213 modifier = modifier.fillMaxWidth(),
@@ -242,9 +265,7 @@ private fun AssistantBlock(
242 }
265 }
243 MessageTimestampText(message.info)
266 MessageTimestampText(message.info)
244 }
267 }
245− val context = LocalContext.current
246− val copyLabel = stringResource(R.string.copy)
247− // Collect text from all TextParts for copying. Memoized so a
268+ // Collect text from all TextParts for copy/read-aloud. Memoized so a
248 // scroll-induced recomposition doesn't re-scan the parts list.
269 // scroll-induced recomposition doesn't re-scan the parts list.
249 val textToCopy = remember(message.parts) {
270 val textToCopy = remember(message.parts) {
250 message.parts
271 message.parts
@@ -252,21 +273,12 @@ private fun AssistantBlock(
252 .joinToString("\n\n") { it.text }
273 .joinToString("\n\n") { it.text }
253 .takeIf { it.isNotBlank() }
274 .takeIf { it.isNotBlank() }
254 }
275 }
255− Row(verticalAlignment = Alignment.CenterVertically) {
276+ AssistantActions(
256− onRevert?.let { RevertButton(it) }
257− if (textToCopy != null) {
277+ textToCopy = textToCopy,
258− IconButton(
278+ isSpeaking = isSpeaking,
259− onClick = { copyToClipboard(context, "message", textToCopy) },
279+ onSpeak = onSpeak,
260− ) {
280+ onRevert = onRevert,
261− Icon(
262− Icons.Filled.ContentCopy,
263− contentDescription = copyLabel,
264− modifier = Modifier.size(18.dp),
265− tint = MaterialTheme.colorScheme.onSurfaceVariant,
266− )
281+ )
267− }
268− }
269− }
270 }
282 }
271 } else {
283 } else {
272 MessageTimestampText(message.info)
284 MessageTimestampText(message.info)
@@ -274,6 +286,46 @@ private fun AssistantBlock(
274 }
286 }
275}
287}
276
288
289+/** Trailing action buttons for an assistant message: read-aloud (TTS), revert, copy.
290+ * Extracted from [AssistantBlock] to keep that function under the complexity threshold. */
291+@Composable
292+private fun AssistantActions(
293+ textToCopy: String?,
294+ isSpeaking: Boolean,
295+ onSpeak: ((String) -> Unit)?,
296+ onRevert: (() -> Unit)?,
297+) {
298+ val context = LocalContext.current
299+ val copyLabel = stringResource(R.string.copy)
300+ Row(verticalAlignment = Alignment.CenterVertically) {
301+ // Read-aloud toggle: speaks the assistant text via TextToSpeech, showing a Stop
302+ // icon while this message is the one being spoken.
303+ if (onSpeak != null && textToCopy != null) {
304+ val speakLabel = stringResource(if (isSpeaking) R.string.stop_reading else R.string.read_aloud)
305+ IconButton(onClick = { onSpeak(textToCopy) }) {
306+ Icon(
307+ if (isSpeaking) Icons.Filled.StopCircle else Icons.AutoMirrored.Filled.VolumeUp,
308+ contentDescription = speakLabel,
309+ modifier = Modifier.size(18.dp),
310+ tint = if (isSpeaking) MaterialTheme.colorScheme.primary
311+ else MaterialTheme.colorScheme.onSurfaceVariant,
312+ )
313+ }
314+ }
315+ onRevert?.let { RevertButton(it) }
316+ if (textToCopy != null) {
317+ IconButton(onClick = { copyToClipboard(context, "message", textToCopy) }) {
318+ Icon(
319+ Icons.Filled.ContentCopy,
320+ contentDescription = copyLabel,
321+ modifier = Modifier.size(18.dp),
322+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
323+ )
324+ }
325+ }
326+ }
327+}
328+
277@Composable
329@Composable
278private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
330private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
279 // Compact relative label by default; long-press reveals the full absolute timestamp
331 // Compact relative label by default; long-press reveals the full absolute timestamp
app/src/main/java/soy/iko/opencode/ui/chat/TtsController.ktnew file+79
@@ -0,0 +1,79 @@
1+package soy.iko.opencode.ui.chat
2+
3+import android.content.Context
4+import android.os.Handler
5+import android.os.Looper
6+import android.speech.tts.TextToSpeech
7+import android.speech.tts.UtteranceProgressListener
8+import androidx.compose.runtime.Composable
9+import androidx.compose.runtime.DisposableEffect
10+import androidx.compose.runtime.State
11+import androidx.compose.runtime.mutableStateOf
12+import androidx.compose.runtime.remember
13+import androidx.compose.ui.platform.LocalContext
14+
15+/**
16+ * Remember a [TtsController] scoped to the current composition; it shuts the engine down
17+ * when the composition leaves (so the process isn't left holding a TextToSpeech instance).
18+ */
19+@Composable
20+fun rememberTtsController(): TtsController {
21+ val context = LocalContext.current
22+ val controller = remember { TtsController(context.applicationContext) }
23+ DisposableEffect(Unit) { onDispose { controller.shutdown() } }
24+ return controller
25+}
26+
27+/**
28+ * Thin wrapper over Android [TextToSpeech] for reading an assistant message aloud. Exposes
29+ * [speakingId] — the id of the message currently being spoken, or null — so the UI can show
30+ * a play/stop toggle. [toggle] plays a message or stops it if it's already the one speaking.
31+ *
32+ * The engine initializes asynchronously; calls before it's ready are dropped (best-effort —
33+ * TTS is an optional convenience). Utterance-progress callbacks arrive on a binder thread,
34+ * so state is cleared on the main thread via [mainHandler].
35+ */
36+class TtsController(context: Context) {
37+
38+ private val mainHandler = Handler(Looper.getMainLooper())
39+ private var ready = false
40+
41+ private val _speakingId = mutableStateOf<String?>(null)
42+ val speakingId: State<String?> = _speakingId
43+
44+ private val tts: TextToSpeech = TextToSpeech(context) { status ->
45+ ready = status == TextToSpeech.SUCCESS
46+ }.apply {
47+ setOnUtteranceProgressListener(object : UtteranceProgressListener() {
48+ override fun onStart(utteranceId: String?) { /* speakingId already set by toggle() */ }
49+ override fun onDone(utteranceId: String?) = clearIfCurrent(utteranceId)
50+ @Deprecated("Deprecated in Java", ReplaceWith(""))
51+ override fun onError(utteranceId: String?) = clearIfCurrent(utteranceId)
52+ override fun onError(utteranceId: String?, errorCode: Int) = clearIfCurrent(utteranceId)
53+ })
54+ }
55+
56+ /** Clear the speaking state when the utterance that finished is the one we're tracking. */
57+ private fun clearIfCurrent(utteranceId: String?) {
58+ mainHandler.post { if (_speakingId.value == utteranceId) _speakingId.value = null }
59+ }
60+
61+ /** Speak [text] for message [id], or stop if [id] is already the one being spoken. */
62+ fun toggle(id: String, text: String) {
63+ if (_speakingId.value == id) { stop(); return }
64+ if (!ready || text.isBlank()) return
65+ tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, id)
66+ _speakingId.value = id
67+ }
68+
69+ fun stop() {
70+ tts.stop()
71+ _speakingId.value = null
72+ }
73+
74+ fun shutdown() {
75+ tts.stop()
76+ tts.shutdown()
77+ _speakingId.value = null
78+ }
79+}
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.ktnew file+161
@@ -0,0 +1,161 @@
1+package soy.iko.opencode.ui.search
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Box
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.PaddingValues
8+import androidx.compose.foundation.layout.fillMaxSize
9+import androidx.compose.foundation.layout.fillMaxWidth
10+import androidx.compose.foundation.layout.padding
11+import androidx.compose.foundation.layout.size
12+import androidx.compose.foundation.lazy.LazyColumn
13+import androidx.compose.foundation.lazy.items
14+import androidx.compose.foundation.text.KeyboardActions
15+import androidx.compose.foundation.text.KeyboardOptions
16+import androidx.compose.material.icons.Icons
17+import androidx.compose.material.icons.automirrored.filled.ArrowBack
18+import androidx.compose.material.icons.filled.Close
19+import androidx.compose.material.icons.filled.Search
20+import androidx.compose.material3.Card
21+import androidx.compose.material3.CircularProgressIndicator
22+import androidx.compose.material3.ExperimentalMaterial3Api
23+import androidx.compose.material3.Icon
24+import androidx.compose.material3.IconButton
25+import androidx.compose.material3.MaterialTheme
26+import androidx.compose.material3.OutlinedTextField
27+import androidx.compose.material3.Scaffold
28+import androidx.compose.material3.Text
29+import androidx.compose.material3.TopAppBar
30+import androidx.compose.runtime.Composable
31+import androidx.compose.runtime.LaunchedEffect
32+import androidx.compose.runtime.getValue
33+import androidx.compose.runtime.remember
34+import androidx.compose.ui.Alignment
35+import androidx.compose.ui.Modifier
36+import androidx.compose.ui.focus.focusRequester
37+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
38+import androidx.compose.ui.res.stringResource
39+import androidx.compose.ui.text.input.ImeAction
40+import androidx.compose.ui.text.style.TextOverflow
41+import androidx.compose.ui.unit.dp
42+import androidx.lifecycle.compose.collectAsStateWithLifecycle
43+import androidx.lifecycle.viewmodel.compose.viewModel
44+import soy.iko.opencode.R
45+import soy.iko.opencode.di.AppContainer
46+import soy.iko.opencode.ui.components.RelativeTimeText
47+import soy.iko.opencode.ui.vmFactory
48+
49+/** Cross-session message search: type a query, tap a result to open that conversation. */
50+@OptIn(ExperimentalMaterial3Api::class)
51+@Composable
52+fun GlobalSearchScreen(
53+ container: AppContainer,
54+ onOpenSession: (String) -> Unit,
55+ onBack: () -> Unit,
56+) {
57+ val vm: GlobalSearchViewModel = viewModel(factory = vmFactory { GlobalSearchViewModel(container) })
58+ val state by vm.state.collectAsStateWithLifecycle()
59+ val focusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
60+ val keyboard = LocalSoftwareKeyboardController.current
61+
62+ LaunchedEffect(Unit) {
63+ runCatching { focusRequester.requestFocus() }
64+ }
65+
66+ Scaffold(
67+ topBar = {
68+ TopAppBar(
69+ title = { Text(stringResource(R.string.search_all_title)) },
70+ navigationIcon = {
71+ IconButton(onClick = onBack) {
72+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
73+ }
74+ },
75+ )
76+ },
77+ ) { padding ->
78+ Column(modifier = Modifier.fillMaxSize().padding(padding)) {
79+ OutlinedTextField(
80+ value = state.query,
81+ onValueChange = vm::setQuery,
82+ modifier = Modifier
83+ .fillMaxWidth()
84+ .padding(horizontal = 16.dp, vertical = 8.dp)
85+ .focusRequester(focusRequester),
86+ placeholder = { Text(stringResource(R.string.search_all_hint)) },
87+ singleLine = true,
88+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
89+ trailingIcon = {
90+ if (state.query.isNotEmpty()) {
91+ IconButton(onClick = { vm.setQuery("") }) {
92+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
93+ }
94+ }
95+ },
96+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
97+ keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
98+ )
99+ Box(modifier = Modifier.fillMaxSize()) {
100+ when {
101+ state.searching -> CircularProgressIndicator(Modifier.align(Alignment.Center))
102+ state.error != null -> Text(
103+ state.error ?: "",
104+ color = MaterialTheme.colorScheme.error,
105+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
106+ )
107+ state.hasSearched && state.results.isEmpty() -> Text(
108+ stringResource(R.string.search_no_matches),
109+ color = MaterialTheme.colorScheme.onSurfaceVariant,
110+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
111+ )
112+ else -> LazyColumn(
113+ modifier = Modifier.fillMaxSize(),
114+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp),
115+ verticalArrangement = Arrangement.spacedBy(12.dp),
116+ ) {
117+ if (state.truncated) {
118+ item(key = "__truncated") {
119+ Text(
120+ stringResource(R.string.search_truncated),
121+ style = MaterialTheme.typography.labelSmall,
122+ color = MaterialTheme.colorScheme.onSurfaceVariant,
123+ modifier = Modifier.padding(vertical = 4.dp),
124+ )
125+ }
126+ }
127+ items(state.results, key = { it.session.id }) { hit ->
128+ SearchResultCard(hit = hit, onClick = { onOpenSession(hit.session.id) })
129+ }
130+ }
131+ }
132+ }
133+ }
134+ }
135+}
136+
137+@Composable
138+private fun SearchResultCard(hit: SearchHit, onClick: () -> Unit) {
139+ Card(modifier = Modifier.fillMaxWidth().clickable { onClick() }) {
140+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
141+ Text(
142+ hit.session.displayTitle,
143+ style = MaterialTheme.typography.titleMedium,
144+ maxLines = 1,
145+ overflow = TextOverflow.Ellipsis,
146+ )
147+ RelativeTimeText(
148+ hit.session.time?.updated ?: hit.session.time?.created,
149+ modifier = Modifier.padding(top = 2.dp),
150+ )
151+ Text(
152+ hit.snippet,
153+ style = MaterialTheme.typography.bodyMedium,
154+ color = MaterialTheme.colorScheme.onSurfaceVariant,
155+ maxLines = 3,
156+ overflow = TextOverflow.Ellipsis,
157+ modifier = Modifier.padding(top = 6.dp),
158+ )
159+ }
160+ }
161+}
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.ktnew file+127
@@ -0,0 +1,127 @@
1+package soy.iko.opencode.ui.search
2+
3+import androidx.compose.runtime.Immutable
4+import androidx.lifecycle.ViewModel
5+import androidx.lifecycle.viewModelScope
6+import soy.iko.opencode.R
7+import soy.iko.opencode.data.model.Session
8+import soy.iko.opencode.data.model.TextPart
9+import soy.iko.opencode.data.network.NetworkConfig
10+import soy.iko.opencode.di.AppContainer
11+import soy.iko.opencode.util.runCatchingCancellable
12+import kotlinx.coroutines.Job
13+import kotlinx.coroutines.coroutineScope
14+import kotlinx.coroutines.delay
15+import kotlinx.coroutines.flow.MutableStateFlow
16+import kotlinx.coroutines.flow.StateFlow
17+import kotlinx.coroutines.flow.asStateFlow
18+import kotlinx.coroutines.flow.update
19+import kotlinx.coroutines.launch
20+import kotlinx.coroutines.sync.Semaphore
21+import kotlinx.coroutines.sync.withPermit
22+
23+/** One search result: the [session] that matched and a [snippet] of the matching text. */
24+@Immutable
25+data class SearchHit(val session: Session, val snippet: String)
26+
27+@Immutable
28+data class GlobalSearchState(
29+ val query: String = "",
30+ val searching: Boolean = false,
31+ val results: List<SearchHit> = emptyList(),
32+ val error: String? = null,
33+ /** True once a search has run for the current query (distinguishes "no results" from
34+ * "nothing searched yet" so the UI shows the right empty state). */
35+ val hasSearched: Boolean = false,
36+ /** True when there were more sessions than [NetworkConfig.maxSearchSessions], so the
37+ * search only covered the most recent ones. */
38+ val truncated: Boolean = false,
39+)
40+
41+/**
42+ * Cross-session message search. opencode's `/find` searches project *files*, not chat
43+ * history, so message search is done client-side: fetch each session's messages (bounded and
44+ * concurrent) and match their text. Runs only on an explicit, debounced query since it
45+ * downloads history.
46+ */
47+class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
48+
49+ private val _state = MutableStateFlow(GlobalSearchState())
50+ val state: StateFlow<GlobalSearchState> = _state.asStateFlow()
51+
52+ private var searchJob: Job? = null
53+
54+ fun setQuery(query: String) {
55+ _state.update { it.copy(query = query) }
56+ searchJob?.cancel()
57+ val trimmed = query.trim()
58+ if (trimmed.length < NetworkConfig.minSearchQueryLength) {
59+ _state.update { it.copy(searching = false, results = emptyList(), hasSearched = false, error = null) }
60+ return
61+ }
62+ searchJob = viewModelScope.launch {
63+ delay(NetworkConfig.searchDebounceMs)
64+ runSearch(trimmed)
65+ }
66+ }
67+
68+ private suspend fun runSearch(query: String) {
69+ val conn = container.activeConnection.value
70+ if (conn == null) {
71+ _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
72+ return
73+ }
74+ _state.update { it.copy(searching = true, error = null) }
75+ val sessions = runCatchingCancellable { conn.repository.listSessions() }.getOrElse {
76+ _state.update { s -> s.copy(searching = false, error = container.friendlyError(it)) }
77+ return
78+ }
79+ val toSearch = sessions.take(NetworkConfig.maxSearchSessions)
80+ val truncated = sessions.size > toSearch.size
81+ val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
82+ val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
83+ coroutineScope {
84+ toSearch.forEach { session ->
85+ launch {
86+ semaphore.withPermit {
87+ val messages = runCatchingCancellable { conn.api.listMessages(session.id) }
88+ .getOrDefault(emptyList())
89+ val snippet = matchSnippet(messages, query)
90+ ?: session.displayTitle.takeIf { it.contains(query, ignoreCase = true) }
91+ if (snippet != null) hits[session.id] = SearchHit(session, snippet)
92+ }
93+ }
94+ }
95+ }
96+ // Preserve the session order (already recency-sorted by the server) in the results.
97+ val ordered = toSearch.mapNotNull { hits[it.id] }
98+ _state.update {
99+ it.copy(searching = false, results = ordered, hasSearched = true, truncated = truncated)
100+ }
101+ }
102+
103+ /** Find the first text part containing [query] across [messages] and return a short
104+ * snippet centered on the match, or null if nothing matches. */
105+ private fun matchSnippet(
106+ messages: List<soy.iko.opencode.data.model.MessageWithParts>,
107+ query: String,
108+ ): String? {
109+ for (message in messages) {
110+ for (part in message.parts) {
111+ if (part !is TextPart) continue
112+ val idx = part.text.indexOf(query, ignoreCase = true)
113+ if (idx >= 0) return buildSnippet(part.text, idx, query.length)
114+ }
115+ }
116+ return null
117+ }
118+
119+ private fun buildSnippet(text: String, matchStart: Int, matchLength: Int): String {
120+ val window = NetworkConfig.searchSnippetLength
121+ val start = (matchStart - window / 2).coerceAtLeast(0)
122+ val end = (matchStart + matchLength + window / 2).coerceAtMost(text.length)
123+ val prefix = if (start > 0) "…" else ""
124+ val suffix = if (end < text.length) "…" else ""
125+ return prefix + text.substring(start, end).trim() + suffix
126+ }
127+}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+54
@@ -28,6 +28,7 @@ import androidx.compose.material.icons.filled.Close
28import androidx.compose.material.icons.filled.Delete
28import androidx.compose.material.icons.filled.Delete
29import androidx.compose.material.icons.filled.Folder
29import androidx.compose.material.icons.filled.Folder
30import androidx.compose.material.icons.filled.MoreVert
30import androidx.compose.material.icons.filled.MoreVert
31+import androidx.compose.material.icons.filled.PushPin
31import androidx.compose.material.icons.filled.Refresh
32import androidx.compose.material.icons.filled.Refresh
32import androidx.compose.material.icons.filled.Search
33import androidx.compose.material.icons.filled.Search
33import androidx.compose.material.icons.filled.Settings
34import androidx.compose.material.icons.filled.Settings
@@ -100,6 +101,7 @@ fun SessionListScreen(
100 onOpenFiles: () -> Unit,
101 onOpenFiles: () -> Unit,
101 onOpenSettings: () -> Unit,
102 onOpenSettings: () -> Unit,
102 onAddServer: () -> Unit,
103 onAddServer: () -> Unit,
104+ onOpenSearch: () -> Unit = {},
103 selectedSessionId: String? = null,
105 selectedSessionId: String? = null,
104) {
106) {
105 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
107 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
@@ -201,6 +203,9 @@ fun SessionListScreen(
201 )
203 )
202 },
204 },
203 actions = {
205 actions = {
206+ IconButton(onClick = onOpenSearch) {
207+ Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search_all))
208+ }
204 Box {
209 Box {
205 IconButton(onClick = { showSortMenu = true }) {
210 IconButton(onClick = { showSortMenu = true }) {
206 Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = stringResource(R.string.sort))
211 Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = stringResource(R.string.sort))
@@ -214,6 +219,21 @@ fun SessionListScreen(
214 text = { Text(stringResource(R.string.sort_title)) },
219 text = { Text(stringResource(R.string.sort_title)) },
215 onClick = { vm.setSortMode(SessionSortMode.TITLE); showSortMenu = false },
220 onClick = { vm.setSortMode(SessionSortMode.TITLE); showSortMenu = false },
216 )
221 )
222+ // Toggle archived visibility. Only offered once at least one
223+ // session is archived, so the menu stays uncluttered otherwise.
224+ if (state.showArchived || state.hiddenArchivedCount > 0) {
225+ androidx.compose.material3.HorizontalDivider()
226+ DropdownMenuItem(
227+ text = {
228+ Text(
229+ stringResource(
230+ if (state.showArchived) R.string.hide_archived else R.string.show_archived,
231+ ),
232+ )
233+ },
234+ onClick = { vm.setShowArchived(!state.showArchived); showSortMenu = false },
235+ )
236+ }
217 }
237 }
218 }
238 }
219 IconButton(onClick = { vm.refresh() }) {
239 IconButton(onClick = { vm.refresh() }) {
@@ -278,6 +298,8 @@ fun SessionListScreen(
278 onCreateSession = openNewSession,
298 onCreateSession = openNewSession,
279 onRename = { pendingRenameId = it },
299 onRename = { pendingRenameId = it },
280 onDelete = { pendingDeleteId = it },
300 onDelete = { pendingDeleteId = it },
301+ onPin = { vm.togglePin(it) },
302+ onArchive = { vm.toggleArchive(it) },
281 )
303 )
282 }
304 }
283 }
305 }
@@ -369,6 +391,8 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
369 onCreateSession: () -> Unit,
391 onCreateSession: () -> Unit,
370 onRename: (String) -> Unit,
392 onRename: (String) -> Unit,
371 onDelete: (String) -> Unit,
393 onDelete: (String) -> Unit,
394+ onPin: (Session) -> Unit,
395+ onArchive: (Session) -> Unit,
372) {
396) {
373 when {
397 when {
374 state.loading -> {
398 state.loading -> {
@@ -483,6 +507,10 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
483 val onCardClick = remember(session.id) { { onOpenSession(session.id) } }
507 val onCardClick = remember(session.id) { { onOpenSession(session.id) } }
484 val onCardRename = remember(session.id) { { onRename(session.id) } }
508 val onCardRename = remember(session.id) { { onRename(session.id) } }
485 val onCardDelete = remember(session.id) { { onDelete(session.id) } }
509 val onCardDelete = remember(session.id) { { onDelete(session.id) } }
510+ val onCardPin = remember(session.id) { { onPin(session) } }
511+ val onCardArchive = remember(session.id) { { onArchive(session) } }
512+ val isPinned = session.id in state.pinnedIds
513+ val isArchived = session.id in state.archivedIds
486 SwipeToDismissBox(
514 SwipeToDismissBox(
487 state = swipeState,
515 state = swipeState,
488 enableDismissFromStartToEnd = false,
516 enableDismissFromStartToEnd = false,
@@ -513,9 +541,13 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
513 preview = state.previews[session.id],
541 preview = state.previews[session.id],
514 unreadCount = unread[session.id] ?: 0,
542 unreadCount = unread[session.id] ?: 0,
515 isSelected = session.id == selectedSessionId,
543 isSelected = session.id == selectedSessionId,
544+ isPinned = isPinned,
545+ isArchived = isArchived,
516 onClick = onCardClick,
546 onClick = onCardClick,
517 onRename = onCardRename,
547 onRename = onCardRename,
518 onDelete = onCardDelete,
548 onDelete = onCardDelete,
549+ onPin = onCardPin,
550+ onArchive = onCardArchive,
519 modifier = Modifier.testTag("session_card"),
551 modifier = Modifier.testTag("session_card"),
520 )
552 )
521 }
553 }
@@ -601,8 +633,12 @@ private fun SessionCard(
601 onClick: () -> Unit,
633 onClick: () -> Unit,
602 onRename: () -> Unit,
634 onRename: () -> Unit,
603 onDelete: () -> Unit,
635 onDelete: () -> Unit,
636+ onPin: () -> Unit,
637+ onArchive: () -> Unit,
604 modifier: Modifier = Modifier,
638 modifier: Modifier = Modifier,
605 isSelected: Boolean = false,
639 isSelected: Boolean = false,
640+ isPinned: Boolean = false,
641+ isArchived: Boolean = false,
606) {
642) {
607 // In two-pane mode the selected row is highlighted (border + container tint) so
643 // In two-pane mode the selected row is highlighted (border + container tint) so
608 // the user can tell which conversation is open in the detail pane at a glance —
644 // the user can tell which conversation is open in the detail pane at a glance —
@@ -657,6 +693,14 @@ private fun SessionCard(
657 }
693 }
658 }
694 }
659 }
695 }
696+ if (isPinned) {
697+ Icon(
698+ Icons.Filled.PushPin,
699+ contentDescription = stringResource(R.string.session_pinned),
700+ modifier = Modifier.padding(end = 4.dp).size(14.dp),
701+ tint = MaterialTheme.colorScheme.primary,
702+ )
703+ }
660 Text(
704 Text(
661 session.displayTitle,
705 session.displayTitle,
662 style = MaterialTheme.typography.titleMedium,
706 style = MaterialTheme.typography.titleMedium,
@@ -706,6 +750,8 @@ private fun SessionCard(
706 val renameLabel = stringResource(R.string.rename)
750 val renameLabel = stringResource(R.string.rename)
707 val deleteLabel = stringResource(R.string.delete)
751 val deleteLabel = stringResource(R.string.delete)
708 val moreLabel = stringResource(R.string.more)
752 val moreLabel = stringResource(R.string.more)
753+ val pinLabel = stringResource(if (isPinned) R.string.session_unpin else R.string.session_pin)
754+ val archiveLabel = stringResource(if (isArchived) R.string.session_unarchive else R.string.session_archive)
709 Box {
755 Box {
710 IconButton(onClick = { showRowMenu = true }) {
756 IconButton(onClick = { showRowMenu = true }) {
711 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
757 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
@@ -714,6 +760,14 @@ private fun SessionCard(
714 expanded = showRowMenu,
760 expanded = showRowMenu,
715 onDismissRequest = { showRowMenu = false },
761 onDismissRequest = { showRowMenu = false },
716 ) {
762 ) {
763+ DropdownMenuItem(
764+ text = { Text(pinLabel) },
765+ onClick = { showRowMenu = false; onPin() },
766+ )
767+ DropdownMenuItem(
768+ text = { Text(archiveLabel) },
769+ onClick = { showRowMenu = false; onArchive() },
770+ )
717 DropdownMenuItem(
771 DropdownMenuItem(
718 text = { Text(renameLabel) },
772 text = { Text(renameLabel) },
719 onClick = { showRowMenu = false; onRename() },
773 onClick = { showRowMenu = false; onRename() },
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+65−5
@@ -62,17 +62,34 @@ data class SessionListState(
62 val loading: Boolean = true,
62 val loading: Boolean = true,
63 val error: String? = null,
63 val error: String? = null,
64 val sortMode: SessionSortMode = SessionSortMode.RECENT,
64 val sortMode: SessionSortMode = SessionSortMode.RECENT,
65+ val pinnedIds: Set<String> = emptySet(),
66+ val archivedIds: Set<String> = emptySet(),
67+ val showArchived: Boolean = false,
65) {
68) {
69+ /** Sessions to display: archived ones hidden (unless [showArchived]), filtered by the
66− /** Sessions filtered by the current search query (title or preview text). */
70+ * search query (title or preview text), with pinned sessions floated to the top. */
67 val filtered: List<Session>
71 val filtered: List<Session>
68 get() {
72 get() {
69 val q = query.trim()
73 val q = query.trim()
70− if (q.isEmpty()) return sessions
74+ // Fast path: nothing to filter or reorder — return the same instance so
71− return sessions.filter { session ->
75+ // downstream can skip a recomposition.
76+ val noArchivedHidden = showArchived || archivedIds.isEmpty()
77+ if (q.isEmpty() && pinnedIds.isEmpty() && noArchivedHidden) return sessions
78+ fun visible(session: Session): Boolean {
79+ if (!showArchived && session.id in archivedIds) return false
80+ if (q.isEmpty()) return true
72− session.displayTitle.contains(q, ignoreCase = true) ||
81+ return session.displayTitle.contains(q, ignoreCase = true) ||
73 (previews[session.id]?.contains(q, ignoreCase = true) == true)
82 (previews[session.id]?.contains(q, ignoreCase = true) == true)
74 }
83 }
84+ val base = sessions.filter(::visible)
85+ // Float pinned to the top. sortedByDescending is stable, so the sort-mode order
86+ // already applied to `sessions` is preserved within the pinned/unpinned groups.
87+ return if (pinnedIds.isEmpty()) base else base.sortedByDescending { it.id in pinnedIds }
75 }
88 }
89+
90+ /** Archived sessions currently hidden — drives the "show archived" toggle's visibility. */
91+ val hiddenArchivedCount: Int
92+ get() = if (showArchived) 0 else sessions.count { it.id in archivedIds }
76}
93}
77
94
78/** Sort sessions by the given mode. RECENT sorts by last activity time desc; TITLE by
95/** Sort sessions by the given mode. RECENT sorts by last activity time desc; TITLE by
@@ -89,6 +106,11 @@ private fun Iterable<Session>.sortedByMode(mode: SessionSortMode): List<Session>
89 )
106 )
90}
107}
91
108
109+// Feature-rich session-list VM: many small, cohesive actions (create/delete/undo/rename/
110+// pin/archive/sort/search/switch-server). Splitting them apart would scatter tightly-related
111+// state, so the function-count rule is suppressed here (peers like ChatViewModel/AppContainer
112+// are grandfathered in the detekt baseline for the same reason).
113+@Suppress("TooManyFunctions")
92class SessionListViewModel(private val container: AppContainer) : ViewModel() {
114class SessionListViewModel(private val container: AppContainer) : ViewModel() {
93
115
94 /** Live SSE connection state, surfaced so the list can show a reconnect banner. */
116 /** Live SSE connection state, surfaced so the list can show a reconnect banner. */
@@ -179,7 +201,40 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
179 private val activeProfileId: String?
201 private val activeProfileId: String?
180 get() = container.activeConnection.value?.profile?.id
202 get() = container.activeConnection.value?.profile?.id
181
203
182− init { refresh(); observeSessionEvents() }
204+ init { refresh(); observeSessionEvents(); observeSessionPrefs() }
205+
206+ /** Keep the pinned/archived id sets in sync with their persisted store. */
207+ private fun observeSessionPrefs() {
208+ viewModelScope.launch {
209+ container.sessionPrefsStore.pinned.collect { ids -> _state.update { it.copy(pinnedIds = ids) } }
210+ }
211+ viewModelScope.launch {
212+ container.sessionPrefsStore.archived.collect { ids -> _state.update { it.copy(archivedIds = ids) } }
213+ }
214+ }
215+
216+ /** Pin or unpin [session] (pinned sessions sort to the top of the list). */
217+ fun togglePin(session: Session) {
218+ val pin = session.id !in _state.value.pinnedIds
219+ viewModelScope.launch {
220+ runCatchingCancellable { container.sessionPrefsStore.setPinned(session.id, pin) }
221+ .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
222+ }
223+ }
224+
225+ /** Archive or unarchive [session] (archived sessions are hidden unless "show archived"). */
226+ fun toggleArchive(session: Session) {
227+ val archive = session.id !in _state.value.archivedIds
228+ viewModelScope.launch {
229+ runCatchingCancellable { container.sessionPrefsStore.setArchived(session.id, archive) }
230+ .onFailure { _transientErrors.tryEmit(container.friendlyError(it)) }
231+ }
232+ }
233+
234+ /** Toggle whether archived sessions are shown in the list. */
235+ fun setShowArchived(show: Boolean) {
236+ _state.update { it.copy(showArchived = show) }
237+ }
183
238
184 /**
239 /**
185 * Keep the session list live by reacting to SSE session events. New/renamed
240 * Keep the session list live by reacting to SSE session events. New/renamed
@@ -244,6 +299,11 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
244 // trigger the failure-restore path that resurrects the row.
299 // trigger the failure-restore path that resurrects the row.
245 pendingDeletes.remove(id)
300 pendingDeletes.remove(id)
246 container.draftStore.remove(id)
301 container.draftStore.remove(id)
302+ // Also drop this session's other local artifacts so an
303+ // externally-deleted session leaves nothing orphaned.
304+ container.attachmentDraftStore.remove(id)
305+ container.messageCacheStore.remove(id)
306+ container.sessionPrefsStore.forget(id)
247 _state.update { s ->
307 _state.update { s ->
248 s.copy(
308 s.copy(
249 sessions = s.sessions.filterNot { it.id == id },
309 sessions = s.sessions.filterNot { it.id == id },
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+2
@@ -49,6 +49,7 @@ fun TwoPaneSessionChat(
49 onOpenSettings: () -> Unit,
49 onOpenSettings: () -> Unit,
50 onDisconnect: () -> Unit,
50 onDisconnect: () -> Unit,
51 onAddServer: () -> Unit,
51 onAddServer: () -> Unit,
52+ onOpenSearch: () -> Unit = {},
52 modifier: Modifier = Modifier,
53 modifier: Modifier = Modifier,
53) {
54) {
54 var selected by rememberSaveable { mutableStateOf<String?>(null) }
55 var selected by rememberSaveable { mutableStateOf<String?>(null) }
@@ -105,6 +106,7 @@ fun TwoPaneSessionChat(
105 onOpenFiles = onOpenFiles,
106 onOpenFiles = onOpenFiles,
106 onOpenSettings = onOpenSettings,
107 onOpenSettings = onOpenSettings,
107 onAddServer = onAddServer,
108 onAddServer = onAddServer,
109+ onOpenSearch = onOpenSearch,
108 selectedSessionId = selected,
110 selectedSessionId = selected,
109 )
111 )
110 }
112 }
app/src/main/res/values-es/strings.xmlnew file+362
@@ -0,0 +1,362 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <string name="app_name">opencode</string>
4+ <!-- Non-numeric fallback so a PackageManager failure can\'t show a stale version number. -->
5+ <string name="unknown_version">—</string>
6+
7+ <!-- Common -->
8+ <string name="back">Atrás</string>
9+ <string name="cancel">Cancelar</string>
10+ <string name="save">Guardar</string>
11+ <string name="delete">Eliminar</string>
12+ <string name="edit">Editar</string>
13+ <string name="remove">Quitar</string>
14+ <string name="copy">Copiar</string>
15+ <string name="copied">Copiado</string>
16+ <string name="no_share_app">No hay ninguna app disponible para compartir</string>
17+ <string name="retry">Reintentar</string>
18+ <string name="refresh">Actualizar</string>
19+ <string name="settings">Ajustes</string>
20+ <string name="files">Archivos</string>
21+ <string name="share">Compartir</string>
22+ <string name="share_subject">Sesión de opencode</string>
23+ <string name="send">Enviar</string>
24+ <string name="stop">Detener</string>
25+ <string name="rename">Renombrar</string>
26+ <string name="disconnect">Desconectar</string>
27+ <string name="loading">Cargando…</string>
28+
29+ <!-- Theme modes -->
30+ <string name="theme_system">Sistema</string>
31+ <string name="theme_light">Claro</string>
32+ <string name="theme_dark">Oscuro</string>
33+
34+ <!-- Connection banner -->
35+ <string name="connecting">Conectando…</string>
36+ <string name="reconnecting">Reconectando…</string>
37+ <string name="connection_failed">Error de conexión — comprueba las credenciales</string>
38+ <string name="reconnect">Reconectar</string>
39+ <string name="not_connected">Sin conexión.</string>
40+ <string name="no_servers_to_reconnect">No hay ningún servidor guardado al que reconectar.</string>
41+ <string name="retry_now">Reintentar ahora</string>
42+
43+ <!-- Server list -->
44+ <string name="servers_title">Servidores de opencode</string>
45+ <string name="add_server">Añadir servidor</string>
46+ <string name="edit_server">Editar servidor</string>
47+ <string name="connected">Conectado</string>
48+ <string name="no_servers_yet">Aún no hay servidores</string>
49+ <string name="no_servers_hint">Añade un endpoint de opencode serve para conectar.</string>
50+ <string name="remove_server_title">¿Quitar servidor?</string>
51+ <string name="remove_server_text">Se quitará «%1$s» de tus servidores guardados.</string>
52+ <string name="remove_server_active_text">«%1$s» es tu servidor actual. Al quitarlo también te desconectarás.</string>
53+ <string name="server_auth_short"> • autenticación</string>
54+ <string name="server_auth_basic"> • autenticación básica</string>
55+ <string name="server_removed">Servidor eliminado</string>
56+
57+ <!-- Server edit -->
58+ <string name="label_optional">Etiqueta (opcional)</string>
59+ <string name="base_url">URL base</string>
60+ <string name="base_url_hint">http://192.168.1.10:4096</string>
61+ <string name="invalid_url">Introduce una URL http:// o https:// válida.</string>
62+ <string name="check_connectivity">Comprobar conectividad</string>
63+ <string name="checking">Comprobando…</string>
64+ <string name="auth_required">Este servidor requiere autenticación.</string>
65+ <string name="auth_help">Toca "Comprobar conectividad" para probar el servidor. Los campos de autenticación aparecen automáticamente solo si el servidor los requiere.</string>
66+ <string name="username_optional">Usuario (opcional)</string>
67+ <string name="password_optional">Contraseña (opcional)</string>
68+ <string name="save_and_connect">Guardar y conectar</string>
69+ <string name="discard_title">¿Descartar cambios?</string>
70+ <string name="discard_text">Se perderán tus cambios.</string>
71+ <string name="discard">Descartar</string>
72+ <string name="keep_editing">Seguir editando</string>
73+ <string name="show_password">Mostrar contraseña</string>
74+ <string name="hide_password">Ocultar contraseña</string>
75+ <string name="test_credentials">Probar credenciales</string>
76+ <string name="discover_on_network">Descubrir en la red</string>
77+ <string name="searching_network">Buscando en la red local…</string>
78+ <string name="testing_credentials">Probando…</string>
79+ <string name="credentials_ok">Credenciales aceptadas.</string>
80+ <string name="credentials_rejected">Credenciales rechazadas.</string>
81+
82+ <!-- Session list -->
83+ <string name="new_session">Nueva sesión</string>
84+ <string name="create">Crear</string>
85+ <string name="directory_label">Directorio</string>
86+ <string name="directory_server_default">Predeterminado del servidor</string>
87+ <string name="directory_custom">Introduce una ruta…</string>
88+ <string name="directory_custom_hint">/ruta/al/proyecto</string>
89+ <string name="directory_loading">Cargando directorios…</string>
90+ <string name="session_directory_desc">Directorio: %1$s</string>
91+ <string name="unread">Sin leer</string>
92+ <string name="unread_count">%1$d sin leer</string>
93+ <string name="no_sessions_yet">Aún no hay sesiones</string>
94+ <string name="no_sessions_hint">Inicia una conversación con opencode.</string>
95+ <string name="search_sessions">Buscar sesiones…</string>
96+ <string name="no_sessions_match">Ninguna sesión coincide con «%1$s».</string>
97+ <string name="switch_server">Cambiar de servidor</string>
98+ <string name="delete_session_title">¿Eliminar sesión?</string>
99+ <string name="delete_session_text">«%1$s» se eliminará permanentemente. Esta acción no se puede deshacer.</string>
100+ <string name="rename_session">Renombrar sesión</string>
101+ <string name="session_title_hint">Título de la sesión</string>
102+ <string name="undo">Deshacer</string>
103+ <string name="session_deleted">Sesión eliminada</string>
104+ <string name="disconnect_active_title">¿Desconectar?</string>
105+ <string name="disconnect_active_text">Todavía hay una ejecución del agente activa. Al desconectar se detendrá.</string>
106+
107+ <!-- Chat -->
108+ <string name="session">Sesión</string>
109+ <string name="default_model">modelo predeterminado</string>
110+ <string name="message_placeholder">Escribe a opencode…</string>
111+ <string name="empty_chat_title">Inicia una conversación</string>
112+ <string name="empty_chat_hint">Escribe un mensaje abajo para empezar.</string>
113+ <string name="empty_chat_suggest_1">Explica qué hace este código</string>
114+ <string name="empty_chat_suggest_2">Encuentra y corrige un error</string>
115+ <string name="empty_chat_suggest_3">Añade una función</string>
116+ <string name="draft_chars_remaining">%1$d / %2$d</string>
117+ <string name="load_messages_failed">No se pudieron cargar los mensajes.</string>
118+ <string name="empty_detail_pane">Selecciona una sesión para ver la conversación.</string>
119+ <string name="working">trabajando…</string>
120+ <string name="latest">Más reciente</string>
121+ <string name="stop_and_exit_title">¿Detener y salir?</string>
122+ <string name="stop_and_exit_text">El agente sigue trabajando. ¿Salir de todos modos?</string>
123+ <string name="stop_and_exit">Detener y salir</string>
124+ <string name="stay">Quedarse</string>
125+ <string name="commands">Comandos</string>
126+ <string name="choose_agent">Elegir agente</string>
127+ <string name="choose_model">Elegir modelo</string>
128+ <string name="change_model_agent">Cambiar modelo y agente</string>
129+ <string name="share_conversation">Compartir conversación</string>
130+ <string name="stop_run_title">¿Detener la ejecución?</string>
131+ <string name="stop_run_text">El agente sigue trabajando. ¿Detenerlo ahora?</string>
132+ <string name="session_deleted_msg">Esta sesión se eliminó en otro dispositivo.</string>
133+ <string name="queued">En cola — se enviará cuando termine la ejecución</string>
134+
135+ <!-- Errors (view models) -->
136+ <string name="error_generic">Algo salió mal.</string>
137+ <string name="error_client_status">La solicitud falló (%1$d).</string>
138+ <string name="error_agent_reported">El agente informó de un error.</string>
139+ <string name="error_not_reachable">No se pudo contactar con %1$s</string>
140+ <string name="error_timeout">La solicitud agotó el tiempo de espera. Comprueba la conexión e inténtalo de nuevo.</string>
141+ <string name="error_server">El servidor informó de un error. Inténtalo más tarde.</string>
142+ <string name="error_load_timeout">La carga tardó demasiado. Inténtalo de nuevo.</string>
143+ <string name="error_switch_and_restore_failed">No se pudo conectar con el nuevo servidor (%1$s), y tampoco se pudo restaurar el servidor anterior (%2$s).</string>
144+
145+ <!-- Part renderer -->
146+ <string name="thinking">Pensando…</string>
147+ <string name="thoughts">Pensamientos</string>
148+ <string name="show_more">Mostrar más</string>
149+ <string name="show_less">Mostrar menos</string>
150+ <string name="show_more_lines">Mostrar %1$d líneas más</string>
151+ <string name="tool_completed">Completado</string>
152+ <string name="tool_error">Error</string>
153+ <string name="tool_running">En ejecución</string>
154+ <string name="tool_unknown">Estado de herramienta desconocido</string>
155+ <string name="state_expanded">Expandido</string>
156+ <string name="state_collapsed">Contraído</string>
157+
158+ <!-- Permission dialog -->
159+ <string name="permission_title">Permiso solicitado</string>
160+ <string name="permission_text">¿Permitir que esta herramienta se ejecute?</string>
161+ <string name="allow_once">Permitir una vez</string>
162+ <string name="always_allow">Permitir siempre</string>
163+ <string name="reject">Rechazar</string>
164+
165+ <!-- Pickers -->
166+ <string name="model">Modelo</string>
167+ <string name="no_models">El servidor no informó de ningún modelo.</string>
168+ <string name="agent">Agente</string>
169+ <string name="no_agents">El servidor no informó de ningún agente.</string>
170+ <string name="default_agent">Predeterminado</string>
171+ <string name="default_agent_desc">Agente predeterminado del servidor</string>
172+ <string name="no_commands">El servidor no informó de ningún comando.</string>
173+ <string name="no_commands_match">Ningún comando coincide con "%1$s".</string>
174+ <string name="load_failed">Error al cargar. Toca reintentar.</string>
175+ <string name="saving">Guardando…</string>
176+ <string name="image_failed">Error al cargar la imagen</string>
177+ <string name="search_models">Buscar modelos…</string>
178+ <string name="search_agents">Buscar agentes…</string>
179+ <string name="search_commands">Buscar comandos…</string>
180+ <string name="no_models_match">Ningún modelo coincide con "%1$s".</string>
181+ <string name="no_agents_match">Ningún agente coincide con "%1$s".</string>
182+
183+ <!-- File browser -->
184+ <string name="search_files">Buscar archivos…</string>
185+ <string name="root">Raíz</string>
186+ <string name="no_matches">Sin coincidencias.</string>
187+ <string name="git_added">Añadido</string>
188+ <string name="git_modified">Modificado</string>
189+ <string name="git_deleted">Eliminado</string>
190+ <string name="binary_file">Archivo binario — vista previa no compatible.</string>
191+ <string name="empty_folder">Esta carpeta está vacía</string>
192+ <string name="empty_file">Este archivo está vacío</string>
193+ <string name="parent_dir">Directorio superior</string>
194+ <string name="folder">Carpeta: %1$s</string>
195+ <string name="file_label">Archivo: %1$s</string>
196+ <string name="file">Archivo</string>
197+ <string name="show_diff">Mostrar diff</string>
198+ <string name="show_raw">Mostrar sin formato</string>
199+
200+ <!-- Message metadata -->
201+ <string name="image">imagen</string>
202+ <string name="tokens_in_out">%1$s entrada · %2$s salida</string>
203+ <string name="cost_format_short">$%1$.2f</string>
204+ <string name="cost_format_long">$%1$.4f</string>
205+
206+ <!-- Settings -->
207+ <string name="appearance">Apariencia</string>
208+ <string name="dynamic_color">Color dinámico</string>
209+ <string name="dynamic_color_desc">Usa los colores del fondo de pantalla de Material You (Android 12+)</string>
210+ <string name="connection">Conexión</string>
211+ <string name="manage_servers">Gestionar servidores</string>
212+ <string name="about">Acerca de</string>
213+ <string name="about_version">opencode-android %1$s</string>
214+ <string name="about_desc">Un cliente nativo de Android para la API HTTP de opencode serve.</string>
215+
216+ <!-- Diagnostics -->
217+ <string name="diagnostics">Diagnósticos</string>
218+ <string name="diagnostics_desc">Los informes de fallos se guardan localmente en este dispositivo.</string>
219+ <string name="crash_reports">Informes de fallos</string>
220+ <string name="no_crash_reports">No hay fallos registrados.</string>
221+ <string name="clear_all">Borrar todo</string>
222+ <string name="clear_all_confirm">¿Eliminar todos los informes de fallos guardados? Esta acción no se puede deshacer.</string>
223+ <string name="crash_report">Informe de fallos</string>
224+ <string name="close">Cerrar</string>
225+ <string name="delete_report_title">¿Eliminar informe de fallos?</string>
226+ <string name="delete_report_text">Este informe se eliminará permanentemente.</string>
227+ <string name="report_load_failed">Error al cargar el informe de fallos.</string>
228+ <string name="crash_count_format">%1$d</string>
229+ <string name="crash_count_overflow">99+</string>
230+ <string name="report_deleted">Informe de fallos eliminado</string>
231+ <string name="crash_report_share_subject">Informe de fallos de opencode</string>
232+
233+ <!-- Notifications -->
234+ <string name="notif_channel_status">Actividad del agente</string>
235+ <string name="notif_channel_status_desc">Se muestra mientras un agente de opencode está en ejecución.</string>
236+ <string name="notif_channel_completed">Sesión completada</string>
237+ <string name="notif_channel_completed_desc">Se muestra cuando una sesión en segundo plano termina una ejecución.</string>
238+ <string name="notif_running_title">opencode</string>
239+ <string name="notif_running_text">El agente está trabajando…</string>
240+ <string name="notif_completed_title">Sesión lista</string>
241+ <string name="notif_completed_text">opencode respondió en «%1$s».</string>
242+ <string name="notif_channel_permission">Solicitudes de permiso</string>
243+ <string name="notif_channel_permission_desc">Se muestra cuando un agente en ejecución necesita tu aprobación para continuar.</string>
244+ <string name="notif_channel_error">Errores de sesión</string>
245+ <string name="notif_channel_error_desc">Se muestra cuando falla la ejecución de una sesión en segundo plano.</string>
246+ <string name="notif_permission_title">Se necesita aprobación en «%1$s»</string>
247+ <string name="notif_permission_fallback">Una herramienta quiere ejecutarse.</string>
248+ <string name="notif_error_title">La ejecución falló</string>
249+ <string name="notif_error_text">opencode encontró un error en «%1$s».</string>
250+ <string name="notif_action_allow_once">Permitir una vez</string>
251+ <string name="notif_action_always">Siempre</string>
252+ <string name="notif_action_reject">Rechazar</string>
253+ <string name="notif_action_reply">Responder</string>
254+ <string name="notif_reply_hint">Envía un seguimiento…</string>
255+ <string name="notif_reply_sent">Enviado</string>
256+
257+ <!-- UX additions -->
258+ <string name="tool_input">Entrada</string>
259+ <string name="copy_path">Copiar ruta</string>
260+ <string name="path_copied">Ruta copiada</string>
261+ <string name="open_file">Abrir archivo</string>
262+ <string name="unknown_message">Tipo de mensaje desconocido</string>
263+ <string name="unknown_part">Contenido no compatible</string>
264+ <string name="tool_preview">🔧 %1$s</string>
265+ <string name="server_reachable">Servidor accesible ✓</string>
266+ <string name="suggest_scheme">Añadir prefijo http://</string>
267+ <string name="sort">Ordenar</string>
268+ <string name="sort_recent">Actividad reciente</string>
269+ <string name="sort_title">Título</string>
270+ <string name="last_used_never">Sin usar aún</string>
271+ <string name="last_used">Usado por última vez %1$s</string>
272+ <string name="duplicate_server">Duplicar</string>
273+ <string name="find_in_file">Buscar en el archivo</string>
274+ <string name="find_previous">Buscar anterior</string>
275+ <string name="find_next">Buscar siguiente</string>
276+ <string name="find_hint">Buscar…</string>
277+ <string name="no_matches_in_file">Sin coincidencias</string>
278+ <string name="match_count">%1$d / %2$d</string>
279+ <string name="wrap_lines">Ajustar líneas</string>
280+ <string name="send_on_enter">Enviar con Intro</string>
281+ <string name="send_on_enter_desc">Cuando está desactivado, Intro inserta un salto de línea; pulsa Ctrl+Intro para enviar.</string>
282+ <string name="offline">Estás sin conexión</string>
283+ <string name="run_command_title">¿Ejecutar /%1$s?</string>
284+ <string name="run_command_text">¿Ejecutar el comando «%1$s»?</string>
285+ <string name="run">Ejecutar</string>
286+ <string name="command_busy">Espera a que termine la respuesta actual antes de ejecutar un comando.</string>
287+
288+ <!-- UX additions: chat overflow, date separators, settings retry -->
289+ <string name="more">Más</string>
290+ <string name="delete_session_chat">Eliminar sesión</string>
291+ <string name="delete_session_chat_title">¿Eliminar esta sesión?</string>
292+ <string name="delete_session_chat_text">«%1$s» se eliminará permanentemente. Esta acción no se puede deshacer.</string>
293+ <string name="rename_session_chat">Renombrar sesión</string>
294+ <string name="session_deleted_chat">Sesión eliminada</string>
295+ <string name="today">Hoy</string>
296+ <string name="yesterday">Ayer</string>
297+
298+ <!-- Search -->
299+ <string name="clear_search">Borrar búsqueda</string>
300+ <string name="search_in_files">Buscar en el contenido de archivos…</string>
301+ <string name="search_symbols_label">Buscar símbolos…</string>
302+ <string name="search_mode_files">Archivos</string>
303+ <string name="search_mode_text">Contenido</string>
304+ <string name="search_mode_symbols">Símbolos</string>
305+
306+ <!-- Attachments -->
307+ <string name="add_attachment">Añadir adjunto</string>
308+ <string name="attach_photo">Foto</string>
309+ <string name="attach_camera">Cámara</string>
310+ <string name="attach_file">Archivo</string>
311+ <string name="attachment_too_large">El archivo es demasiado grande para adjuntarlo.</string>
312+ <string name="attachment_failed">No se pudo adjuntar ese archivo.</string>
313+ <string name="attachment_limit">Se alcanzó el límite de adjuntos.</string>
314+ <string name="no_camera_app">No hay cámara disponible.</string>
315+
316+ <!-- Voice input -->
317+ <string name="voice_input">Entrada de voz</string>
318+ <string name="voice_prompt">Di tu mensaje</string>
319+ <string name="no_voice_app">La entrada de voz no está disponible en este dispositivo.</string>
320+
321+ <!-- Revert -->
322+ <string name="revert_to_here">Revertir hasta aquí</string>
323+ <string name="reverted_banner">Revertido — los mensajes posteriores están ocultos.</string>
324+
325+ <!-- Sharing / summarize / init / shell -->
326+ <string name="create_share_link">Crear enlace para compartir</string>
327+ <string name="copy_share_link">Copiar enlace para compartir</string>
328+ <string name="stop_sharing">Dejar de compartir</string>
329+ <string name="link_copied">Enlace copiado</string>
330+ <string name="summarize_conversation">Resumir conversación</string>
331+ <string name="generate_agents_md">Generar AGENTS.md</string>
332+ <string name="run_shell_command">Ejecutar comando de shell</string>
333+ <string name="shell_command_label">Comando</string>
334+ <string name="shell_command_hint">ls -la</string>
335+ <string name="needs_model">Selecciona primero un modelo.</string>
336+
337+ <!-- App lock (biometric) -->
338+ <string name="app_lock">Bloqueo de la app</string>
339+ <string name="app_lock_desc">Requiere datos biométricos o credencial del dispositivo para abrir la app.</string>
340+ <string name="app_lock_unavailable">No hay datos biométricos ni credencial del dispositivo configurados.</string>
341+ <string name="app_lock_prompt_title">Desbloquear opencode</string>
342+ <string name="app_lock_prompt_subtitle">Autentícate para acceder a tus servidores.</string>
343+ <string name="app_locked">opencode está bloqueado</string>
344+ <string name="app_unlock">Desbloquear</string>
345+
346+ <!-- Nuevas funciones -->
347+ <string name="edit_message">Editar</string>
348+ <string name="read_aloud">Leer en voz alta</string>
349+ <string name="stop_reading">Dejar de leer</string>
350+ <string name="session_pin">Fijar</string>
351+ <string name="session_unpin">Desfijar</string>
352+ <string name="session_archive">Archivar</string>
353+ <string name="session_unarchive">Desarchivar</string>
354+ <string name="session_pinned">Fijada</string>
355+ <string name="show_archived">Mostrar archivadas</string>
356+ <string name="hide_archived">Ocultar archivadas</string>
357+ <string name="search_all">Buscar en todas las sesiones</string>
358+ <string name="search_all_title">Buscar sesiones</string>
359+ <string name="search_all_hint">Buscar en todas las conversaciones…</string>
360+ <string name="search_no_matches">No hay mensajes coincidentes</string>
361+ <string name="search_truncated">Mostrando resultados solo de las sesiones más recientes.</string>
362+</resources>
app/src/main/res/values/strings.xml+33
@@ -239,6 +239,20 @@
239 <string name="notif_running_text">Agent is working…</string>
239 <string name="notif_running_text">Agent is working…</string>
240 <string name="notif_completed_title">Session ready</string>
240 <string name="notif_completed_title">Session ready</string>
241 <string name="notif_completed_text">opencode replied in “%1$s”.</string>
241 <string name="notif_completed_text">opencode replied in “%1$s”.</string>
242+ <string name="notif_channel_permission">Permission requests</string>
243+ <string name="notif_channel_permission_desc">Shown when a running agent needs your approval to continue.</string>
244+ <string name="notif_channel_error">Session errors</string>
245+ <string name="notif_channel_error_desc">Shown when a background session run fails.</string>
246+ <string name="notif_permission_title">Approval needed in “%1$s”</string>
247+ <string name="notif_permission_fallback">A tool wants to run.</string>
248+ <string name="notif_error_title">Run failed</string>
249+ <string name="notif_error_text">opencode hit an error in “%1$s”.</string>
250+ <string name="notif_action_allow_once">Allow once</string>
251+ <string name="notif_action_always">Always</string>
252+ <string name="notif_action_reject">Reject</string>
253+ <string name="notif_action_reply">Reply</string>
254+ <string name="notif_reply_hint">Send a follow-up…</string>
255+ <string name="notif_reply_sent">Sent</string>
242
256
243 <!-- UX additions -->
257 <!-- UX additions -->
244 <string name="tool_input">Input</string>
258 <string name="tool_input">Input</string>
@@ -304,9 +318,28 @@
304 <string name="voice_prompt">Speak your message</string>
318 <string name="voice_prompt">Speak your message</string>
305 <string name="no_voice_app">Voice input isn\'t available on this device.</string>
319 <string name="no_voice_app">Voice input isn\'t available on this device.</string>
306
320
321+ <!-- Session organization -->
322+ <string name="session_pin">Pin</string>
323+ <string name="session_unpin">Unpin</string>
324+ <string name="session_archive">Archive</string>
325+ <string name="session_unarchive">Unarchive</string>
326+ <string name="session_pinned">Pinned</string>
327+ <string name="show_archived">Show archived</string>
328+ <string name="hide_archived">Hide archived</string>
329+
307 <!-- Revert -->
330 <!-- Revert -->
308 <string name="revert_to_here">Revert to here</string>
331 <string name="revert_to_here">Revert to here</string>
309 <string name="reverted_banner">Reverted — later messages are hidden.</string>
332 <string name="reverted_banner">Reverted — later messages are hidden.</string>
333+ <string name="edit_message">Edit</string>
334+ <string name="read_aloud">Read aloud</string>
335+ <string name="stop_reading">Stop reading</string>
336+
337+ <!-- Global search -->
338+ <string name="search_all">Search all sessions</string>
339+ <string name="search_all_title">Search sessions</string>
340+ <string name="search_all_hint">Search across all conversations…</string>
341+ <string name="search_no_matches">No matching messages</string>
342+ <string name="search_truncated">Showing results from the most recent sessions only.</string>
310
343
311 <!-- Sharing / summarize / init / shell -->
344 <!-- Sharing / summarize / init / shell -->
312 <string name="create_share_link">Create share link</string>
345 <string name="create_share_link">Create share link</string>
app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+49
@@ -546,4 +546,53 @@ class ChatViewModelTest {
546 testScope.testScheduler.advanceUntilIdle()
546 testScope.testScheduler.advanceUntilIdle()
547 assertNull(vm.sessionTitle.value)
547 assertNull(vm.sessionTitle.value)
548 }
548 }
549+
550+ // --- editMessage ---
551+
552+ @Test
553+ fun editMessage_prefillsDraftWithText() = testScope.runTest {
554+ val container = makeContainer()
555+ val vm = makeVm(container)
556+ vm.editMessage("m1", "the original prompt")
557+ testScheduler.advanceUntilIdle()
558+ assertEquals("the original prompt", vm.draft.value)
559+ }
560+
561+ // --- attachment persistence ---
562+
563+ private fun makeAttachment(id: String) = PendingAttachment(
564+ id = id,
565+ name = "$id.txt",
566+ mime = "text/plain",
567+ previewModel = null,
568+ part = soy.iko.opencode.data.model.FilePromptPart(
569+ mime = "text/plain",
570+ url = "data:text/plain;base64,QUJD",
571+ filename = "$id.txt",
572+ ),
573+ )
574+
575+ @Test
576+ fun addAttachment_persistsToStore() = testScope.runTest {
577+ val container = makeContainer()
578+ val vm = makeVm(container)
579+ vm.addAttachment(makeAttachment("a1"))
580+ testScheduler.advanceUntilIdle()
581+ assertEquals(1, container.fakeAttachmentDraftStore.load("session1").size)
582+ }
583+
584+ @Test
585+ fun attachments_restoredFromStoreOnInit() = testScope.runTest {
586+ val container = makeContainer()
587+ // Pre-populate the store as if a prior compose was interrupted by process death.
588+ val att = makeAttachment("a1")
589+ container.fakeAttachmentDraftStore.save(
590+ "session1",
591+ listOf(soy.iko.opencode.data.repo.PersistedAttachment(att.id, att.name, att.mime, att.part.url, att.part.filename)),
592+ )
593+ val vm = makeVm(container)
594+ testScheduler.advanceUntilIdle()
595+ assertEquals(1, vm.attachments.value.size)
596+ assertEquals("a1", vm.attachments.value[0].id)
597+ }
549}
598}
app/src/test/java/soy/iko/opencode/ui/file/FileViewModelTest.ktnew file+70
@@ -0,0 +1,70 @@
1+@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
2+
3+package soy.iko.opencode.ui.file
4+
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.ExperimentalCoroutinesApi
7+import kotlinx.coroutines.test.StandardTestDispatcher
8+import kotlinx.coroutines.test.TestScope
9+import kotlinx.coroutines.test.resetMain
10+import kotlinx.coroutines.test.runTest
11+import kotlinx.coroutines.test.setMain
12+import org.junit.After
13+import org.junit.Assert.assertEquals
14+import org.junit.Assert.assertFalse
15+import org.junit.Assert.assertNotNull
16+import org.junit.Assert.assertNull
17+import org.junit.Before
18+import org.junit.Test
19+import soy.iko.opencode.data.model.FileContent
20+import soy.iko.opencode.data.model.ServerProfile
21+import soy.iko.opencode.ui.testing.FakeAppContainer
22+import soy.iko.opencode.ui.testing.FakeEventStreamClient
23+import soy.iko.opencode.ui.testing.FakeOpencodeApiClient
24+import soy.iko.opencode.ui.testing.FakeOpencodeConnection
25+import soy.iko.opencode.ui.testing.FakeSessionRepository
26+
27+class FileViewModelTest {
28+ private val testDispatcher = StandardTestDispatcher()
29+ private val testScope = TestScope(testDispatcher)
30+
31+ @Before
32+ fun setUp() { Dispatchers.setMain(testDispatcher) }
33+
34+ @After
35+ fun tearDown() { Dispatchers.resetMain() }
36+
37+ private fun makeContainer(api: FakeOpencodeApiClient): FakeAppContainer {
38+ val events = FakeEventStreamClient()
39+ val repo = FakeSessionRepository(api, events)
40+ val container = FakeAppContainer()
41+ container.setActiveConnection(
42+ FakeOpencodeConnection(api, events, repo, ServerProfile(id = "s1", label = "T", baseUrl = "http://localhost")),
43+ )
44+ return container
45+ }
46+
47+ @Test
48+ fun load_success_populatesContent() = testScope.runTest {
49+ val api = FakeOpencodeApiClient().apply { fileContent = FileContent(content = "hello source") }
50+ val vm = FileViewModel(makeContainer(api), "src/Main.kt")
51+ testScheduler.advanceUntilIdle()
52+
53+ val state = vm.state.value
54+ assertFalse(state.loading)
55+ assertNotNull(state.content)
56+ assertEquals("hello source", state.content?.content)
57+ assertNull(state.error)
58+ }
59+
60+ @Test
61+ fun load_withoutConnection_showsNotConnected() = testScope.runTest {
62+ val vm = FileViewModel(FakeAppContainer(), "src/Main.kt")
63+ testScheduler.advanceUntilIdle()
64+
65+ val state = vm.state.value
66+ assertFalse(state.loading)
67+ assertNotNull(state.error)
68+ assertNull(state.content)
69+ }
70+}
app/src/test/java/soy/iko/opencode/ui/search/GlobalSearchViewModelTest.ktnew file+120
@@ -0,0 +1,120 @@
1+@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
2+
3+package soy.iko.opencode.ui.search
4+
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.ExperimentalCoroutinesApi
7+import kotlinx.coroutines.test.StandardTestDispatcher
8+import kotlinx.coroutines.test.TestScope
9+import kotlinx.coroutines.test.resetMain
10+import kotlinx.coroutines.test.runTest
11+import kotlinx.coroutines.test.setMain
12+import org.junit.After
13+import org.junit.Assert.assertEquals
14+import org.junit.Assert.assertFalse
15+import org.junit.Assert.assertTrue
16+import org.junit.Before
17+import org.junit.Test
18+import soy.iko.opencode.data.model.MessageWithParts
19+import soy.iko.opencode.data.model.ServerProfile
20+import soy.iko.opencode.data.model.Session
21+import soy.iko.opencode.data.model.TextPart
22+import soy.iko.opencode.data.model.UserMessage
23+import soy.iko.opencode.ui.testing.FakeAppContainer
24+import soy.iko.opencode.ui.testing.FakeEventStreamClient
25+import soy.iko.opencode.ui.testing.FakeOpencodeApiClient
26+import soy.iko.opencode.ui.testing.FakeOpencodeConnection
27+import soy.iko.opencode.ui.testing.FakeSessionRepository
28+
29+class GlobalSearchViewModelTest {
30+ private val testDispatcher = StandardTestDispatcher()
31+ private val testScope = TestScope(testDispatcher)
32+
33+ @Before
34+ fun setUp() { Dispatchers.setMain(testDispatcher) }
35+
36+ @After
37+ fun tearDown() { Dispatchers.resetMain() }
38+
39+ private fun makeContainer(
40+ sessions: List<Session>,
41+ messages: List<MessageWithParts>,
42+ ): FakeAppContainer {
43+ val api = FakeOpencodeApiClient().apply { this.messages = messages }
44+ val events = FakeEventStreamClient()
45+ val repo = FakeSessionRepository(api, events).apply { this.sessions = sessions }
46+ val container = FakeAppContainer()
47+ container.setActiveConnection(
48+ FakeOpencodeConnection(api, events, repo, ServerProfile(id = "s1", label = "T", baseUrl = "http://localhost")),
49+ )
50+ return container
51+ }
52+
53+ private fun textMessage(id: String, text: String) = MessageWithParts(
54+ info = UserMessage(id = id, sessionID = "s1"),
55+ parts = listOf(TextPart(id = "p-$id", text = text)),
56+ )
57+
58+ @Test
59+ fun query_matchesMessageText_andReturnsSnippet() = testScope.runTest {
60+ val container = makeContainer(
61+ sessions = listOf(Session(id = "s1", title = "Chat 1")),
62+ messages = listOf(textMessage("m1", "the quick brown fox jumps")),
63+ )
64+ val vm = GlobalSearchViewModel(container)
65+
66+ vm.setQuery("brown")
67+ testScheduler.advanceUntilIdle()
68+
69+ val state = vm.state.value
70+ assertTrue(state.hasSearched)
71+ assertEquals(1, state.results.size)
72+ assertEquals("s1", state.results[0].session.id)
73+ assertTrue("snippet should contain the match", state.results[0].snippet.contains("brown"))
74+ }
75+
76+ @Test
77+ fun query_belowMinLength_doesNotSearch() = testScope.runTest {
78+ val container = makeContainer(
79+ sessions = listOf(Session(id = "s1", title = "Chat 1")),
80+ messages = listOf(textMessage("m1", "hello world")),
81+ )
82+ val vm = GlobalSearchViewModel(container)
83+
84+ vm.setQuery("h")
85+ testScheduler.advanceUntilIdle()
86+
87+ assertFalse(vm.state.value.hasSearched)
88+ assertTrue(vm.state.value.results.isEmpty())
89+ }
90+
91+ @Test
92+ fun query_noMatch_setsHasSearchedWithEmptyResults() = testScope.runTest {
93+ val container = makeContainer(
94+ sessions = listOf(Session(id = "s1", title = "Chat 1")),
95+ messages = listOf(textMessage("m1", "hello world")),
96+ )
97+ val vm = GlobalSearchViewModel(container)
98+
99+ vm.setQuery("zzzznomatch")
100+ testScheduler.advanceUntilIdle()
101+
102+ assertTrue(vm.state.value.hasSearched)
103+ assertTrue(vm.state.value.results.isEmpty())
104+ }
105+
106+ @Test
107+ fun query_matchesSessionTitle_evenWithoutMessageMatch() = testScope.runTest {
108+ val container = makeContainer(
109+ sessions = listOf(Session(id = "s1", title = "Refactor auth")),
110+ messages = listOf(textMessage("m1", "unrelated content")),
111+ )
112+ val vm = GlobalSearchViewModel(container)
113+
114+ vm.setQuery("auth")
115+ testScheduler.advanceUntilIdle()
116+
117+ assertEquals(1, vm.state.value.results.size)
118+ assertEquals("s1", vm.state.value.results[0].session.id)
119+ }
120+}
app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+62
@@ -569,4 +569,66 @@ class SessionListViewModelTest {
569 // is non-empty (the tool fallback fired) rather than asserting the emoji/name.
569 // is non-empty (the tool fallback fired) rather than asserting the emoji/name.
570 assertTrue("preview should be non-empty: $preview", preview!!.isNotEmpty())
570 assertTrue("preview should be non-empty: $preview", preview!!.isNotEmpty())
571 }
571 }
572+
573+ // --- pin / archive / show-archived ---
574+
575+ @Test
576+ fun togglePin_floatsSessionToTopOfFiltered() = testScope.runTest {
577+ val repo = FakeSessionRepository(FakeOpencodeApiClient(), FakeEventStreamClient())
578+ repo.sessions = listOf(
579+ Session(id = "s1", title = "A", time = soy.iko.opencode.data.model.TimeInfo(created = 2L, updated = 2L)),
580+ Session(id = "s2", title = "B", time = soy.iko.opencode.data.model.TimeInfo(created = 1L, updated = 1L)),
581+ )
582+ val container = makeContainer(repo = repo)
583+ val vm = makeVm(container)
584+ // s1 is newest so it's first by default.
585+ assertEquals("s1", vm.state.value.filtered[0].id)
586+
587+ vm.togglePin(Session(id = "s2", title = "B"))
588+ testScheduler.advanceUntilIdle()
589+
590+ assertTrue("s2 should be pinned", "s2" in vm.state.value.pinnedIds)
591+ // Pinned s2 floats to the top even though it's older.
592+ assertEquals("s2", vm.state.value.filtered[0].id)
593+ }
594+
595+ @Test
596+ fun toggleArchive_hidesSessionUntilShowArchived() = testScope.runTest {
597+ val repo = FakeSessionRepository(FakeOpencodeApiClient(), FakeEventStreamClient())
598+ repo.sessions = listOf(
599+ Session(id = "s1", title = "A"),
600+ Session(id = "s2", title = "B"),
601+ )
602+ val container = makeContainer(repo = repo)
603+ val vm = makeVm(container)
604+
605+ vm.toggleArchive(Session(id = "s2", title = "B"))
606+ testScheduler.advanceUntilIdle()
607+
608+ assertTrue("s2 should be archived", "s2" in vm.state.value.archivedIds)
609+ // Archived session is hidden from the filtered list...
610+ assertFalse("archived s2 should be hidden", vm.state.value.filtered.any { it.id == "s2" })
611+ assertEquals(1, vm.state.value.hiddenArchivedCount)
612+
613+ // ...until the user opts to show archived.
614+ vm.setShowArchived(true)
615+ assertTrue("s2 should now be visible", vm.state.value.filtered.any { it.id == "s2" })
616+ assertEquals(0, vm.state.value.hiddenArchivedCount)
617+ }
618+
619+ @Test
620+ fun togglePin_twiceUnpins() = testScope.runTest {
621+ val repo = FakeSessionRepository(FakeOpencodeApiClient(), FakeEventStreamClient())
622+ repo.sessions = listOf(Session(id = "s1", title = "A"))
623+ val container = makeContainer(repo = repo)
624+ val vm = makeVm(container)
625+
626+ vm.togglePin(Session(id = "s1", title = "A"))
627+ testScheduler.advanceUntilIdle()
628+ assertTrue("s1" in vm.state.value.pinnedIds)
629+
630+ vm.togglePin(Session(id = "s1", title = "A"))
631+ testScheduler.advanceUntilIdle()
632+ assertFalse("s1" in vm.state.value.pinnedIds)
633+ }
572}
634}
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+63
@@ -26,8 +26,12 @@ import soy.iko.opencode.data.model.Session
26import soy.iko.opencode.data.network.EventStreamClient
26import soy.iko.opencode.data.network.EventStreamClient
27import soy.iko.opencode.data.network.NetworkConfig
27import soy.iko.opencode.data.network.NetworkConfig
28import soy.iko.opencode.data.network.OpencodeApiClient
28import soy.iko.opencode.data.network.OpencodeApiClient
29+import soy.iko.opencode.data.repo.AttachmentDraftStore
29import soy.iko.opencode.data.repo.DraftStore
30import soy.iko.opencode.data.repo.DraftStore
31+import soy.iko.opencode.data.repo.MessageCacheStore
32+import soy.iko.opencode.data.repo.PersistedAttachment
30import soy.iko.opencode.data.repo.ProfileStore
33import soy.iko.opencode.data.repo.ProfileStore
34+import soy.iko.opencode.data.repo.SessionPrefsStore
31import soy.iko.opencode.data.repo.SessionRepository
35import soy.iko.opencode.data.repo.SessionRepository
32import soy.iko.opencode.di.AppContainer
36import soy.iko.opencode.di.AppContainer
33import soy.iko.opencode.di.OpencodeConnection
37import soy.iko.opencode.di.OpencodeConnection
@@ -315,6 +319,59 @@ class FakeOpencodeConnection(
315 override suspend fun close() { /* no-op */ }
319 override suspend fun close() { /* no-op */ }
316}
320}
317
321
322+// ---------------------------------------------------------------------------
323+// FakeSessionPrefsStore
324+// ---------------------------------------------------------------------------
325+
326+class FakeSessionPrefsStore : SessionPrefsStore() {
327+ private val _pinned = MutableStateFlow<Set<String>>(emptySet())
328+ private val _archived = MutableStateFlow<Set<String>>(emptySet())
329+
330+ override val pinned: Flow<Set<String>> = _pinned.asStateFlow()
331+ override val archived: Flow<Set<String>> = _archived.asStateFlow()
332+
333+ override suspend fun setPinned(sessionId: String, pinned: Boolean) {
334+ _pinned.value = if (pinned) _pinned.value + sessionId else _pinned.value - sessionId
335+ }
336+
337+ override suspend fun setArchived(sessionId: String, archived: Boolean) {
338+ _archived.value = if (archived) _archived.value + sessionId else _archived.value - sessionId
339+ }
340+
341+ override suspend fun forget(sessionId: String) {
342+ _pinned.value = _pinned.value - sessionId
343+ _archived.value = _archived.value - sessionId
344+ }
345+}
346+
347+// ---------------------------------------------------------------------------
348+// FakeAttachmentDraftStore / FakeMessageCacheStore
349+// ---------------------------------------------------------------------------
350+
351+class FakeAttachmentDraftStore : AttachmentDraftStore() {
352+ private val store = mutableMapOf<String, List<PersistedAttachment>>()
353+
354+ override suspend fun load(sessionId: String): List<PersistedAttachment> = store[sessionId].orEmpty()
355+
356+ override suspend fun save(sessionId: String, attachments: List<PersistedAttachment>) {
357+ if (attachments.isEmpty()) store.remove(sessionId) else store[sessionId] = attachments
358+ }
359+
360+ override suspend fun remove(sessionId: String) { store.remove(sessionId) }
361+}
362+
363+class FakeMessageCacheStore : MessageCacheStore() {
364+ private val store = mutableMapOf<String, List<MessageWithParts>>()
365+
366+ override suspend fun load(sessionId: String): List<MessageWithParts> = store[sessionId].orEmpty()
367+
368+ override suspend fun save(sessionId: String, messages: List<MessageWithParts>) {
369+ if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages
370+ }
371+
372+ override suspend fun remove(sessionId: String) { store.remove(sessionId) }
373+}
374+
318// ---------------------------------------------------------------------------
375// ---------------------------------------------------------------------------
319// FakeAppContainer
376// FakeAppContainer
320// ---------------------------------------------------------------------------
377// ---------------------------------------------------------------------------
@@ -322,11 +379,17 @@ class FakeOpencodeConnection(
322class FakeAppContainer : AppContainer() {
379class FakeAppContainer : AppContainer() {
323 val fakeDraftStore = FakeDraftStore()
380 val fakeDraftStore = FakeDraftStore()
324 val fakeProfileStore = FakeProfileStore()
381 val fakeProfileStore = FakeProfileStore()
382+ val fakeSessionPrefsStore = FakeSessionPrefsStore()
383+ val fakeAttachmentDraftStore = FakeAttachmentDraftStore()
384+ val fakeMessageCacheStore = FakeMessageCacheStore()
325 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
385 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
326 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
386 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
327
387
328 override val draftStore: DraftStore = fakeDraftStore
388 override val draftStore: DraftStore = fakeDraftStore
329 override val profileStore: ProfileStore = fakeProfileStore
389 override val profileStore: ProfileStore = fakeProfileStore
390+ override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
391+ override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
392+ override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
330 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
393 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
331 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
394 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
332
395
gradle/libs.versions.toml+1−1
@@ -9,7 +9,7 @@ activityCompose = "1.9.3"
9lifecycle = "2.8.7"
9lifecycle = "2.8.7"
10navigationCompose = "2.8.5"
10navigationCompose = "2.8.5"
11datastore = "1.1.1"
11datastore = "1.1.1"
12−securityCrypto = "1.1.0-alpha06"
12+securityCrypto = "1.1.0"
13coil = "2.7.0"
13coil = "2.7.0"
14biometric = "1.1.0"
14biometric = "1.1.0"
15markdownRenderer = "0.27.0"
15markdownRenderer = "0.27.0"