diff --git a/README.md b/README.md
index 1415727..532bdb9 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,151 @@ release APK.
For a server on the same local network, a URL might look like
`http://192.168.1.10:3080`.
+## Conversation history loading
+
+The conversation screen initially requests the latest **five user rounds**. A
+round includes the prompt, thinking, tool calls/results and the assistant reply;
+an unfinished latest round counts too. Scrolling near the top loads five earlier
+rounds, with a retry button on failure. Replies exceeding 500 underlying records
+are split across pages so one long round cannot expand into an unbounded fetch.
+
+Deploy the accompanying Codeg server changes for `tailRounds` / `roundLimit`
+support. Existing server clients remain compatible. An older server may return
+the full transcript; Android accepts that response and disables history paging
+when window metadata is absent. Servers supporting only record paging use a
+30-record fallback for older pages.
+
+History pages are verified against the loaded prefix fingerprint. Completion
+and reconnect refreshes preserve the already-loaded window; incompatible
+history is reset to a fresh recent window. Session-wide usage statistics remain
+session-wide regardless of the loaded range.
+
+Debug builds emit `CodegTranscript` timing logs for detail/history downloads,
+decoded payload bytes (before HTTP compression), JSON decoding, and persisted
+timeline conversion. On a device, compare the same long session and network
+conditions before/after the change, including first entry, upward pagination,
+and a completed live reply. Also verify reading position, retry after a network
+failure, and a round spanning multiple history pages. Release builds omit these
+logs, and neither build logs transcript content through this instrumentation.
+
+## Local conversation cache
+
+Saved server profiles cache the conversation list (with folder/grouping context)
+and confirmed transcript pages in the app's private cache directory. On entry,
+cached data appears first. Idle snapshots younger than 60 seconds avoid another
+fetch; older snapshots refresh in the background. Live sessions always check the
+server and reattach. Network failures keep cached content visible with a retry.
+
+Use the list's refresh button/pull gesture or **Refresh** in a conversation's
+overflow menu to bypass freshness. Conversation refreshes retain the loaded
+history window and leave the composer and live stream intact. During a live
+reply, transcript replacement waits for completion so the response is not shown
+twice. Live/optimistic messages and interactive approval cards are not cached;
+the last confirmed snapshot stays readable until a completed response replaces it.
+
+Reopening a cached conversation decodes just its latest five-round/500-record
+page. Loaded older pages are available offline while retained in the same
+snapshot. An authoritative detail refresh replaces that snapshot: structural
+prefix hashes do not prove message text equality, so unrefreshed older cached
+pages are discarded. Old servers without window metadata retain their existing
+full-history fallback.
+
+Cache keys separate profiles, normalized server URLs and credentials; tokens
+and URLs are not written into cache filenames. Changing an endpoint/token or
+removing a profile clears its cache. Conversation mutations invalidate the
+affected detail and list; starting a reply marks the last confirmed detail stale.
+Clearing/deleting snapshots fences requests already in flight against writing
+them back.
+
+**Settings → Local cache** shows disk usage and clears cached data. The cache
+has a 100 MiB global LRU budget and a 10 MiB payload limit per conversation;
+oversized tails are not stored. It is disposable, excluded from app backups,
+and can also be removed by Android. Clearing it leaves currently displayed
+messages in memory. Unvisited or evicted history still requires the server.
+
+## Conversation file downloads
+
+Open **Files** in a conversation toolbar or **Settings** for the unified file manager.
+**Conversation files** lists files written, modified, or explicitly linked in the
+complete conversation, including history above the phone's loaded message window.
+Files appear before any download. Paths are deduplicated, with the latest operation,
+availability, size and source conversation. Search, switch conversations, or refresh
+manually. The compact index is cached per server and credentials; failed refreshes
+keep the previous list visible. Updated servers use `get_conversation_files`. If an
+older server returns `501 / not_implemented` or an unstructured 404 for that endpoint,
+the file manager reads complete history with the existing conversation API and builds
+the index on the phone. This fallback runs only in the file manager and does not replace
+the paged conversation cache. Large conversations take longer to index on older servers.
+Authentication, permission, and other server errors are reported without triggering fallback.
+Older servers cannot check current file availability in advance; those entries remain
+openable and say that availability is checked on opening. Historical deletion alone does
+not prove that a path is still missing today.
+
+The index recognizes structured write/edit/patch tools and local Markdown
+links, including files built by shell commands when the reply links them. It is not
+a filesystem change monitor: arbitrary shell side effects, agent tools with no
+recognizable paths, and images embedded without a local file URI cannot be indexed.
+It records file paths rather than historical versions; previews show current files.
+
+Tap **View** to preview a server file without saving it in Downloads. Tap **Save to
+phone** to start a persistent download. Non-APK Markdown file links also open a remote
+viewer directly; APK links retain the direct download/install flow. Absolute paths are server
+paths, including files outside registered projects such as the server's Downloads
+folder. The server validates access to the explicitly linked file through the
+authenticated ticket endpoint. Relative paths stay within the conversation folder.
+Paths containing spaces can use Markdown angle brackets,
+for example `[Download APK]()`.
+
+The app uses the server's authenticated `workspace_download_ticket` endpoint and
+hands the one-use URL to Android DownloadManager. The server token is never sent
+to the system downloader. No third-party hosting service or separate file server
+is needed. The connected Codeg server must support workspace download tickets.
+
+Select the **Download history** tab in the file manager for the independent
+download list. It shows records for the selected saved server and
+endpoint, with progress, refresh, retry, cancel, file viewing and a source
+conversation shortcut. Records survive app restarts. Removing a history entry
+does not delete the file. Files downloaded by older versions without persistent
+history remain in the phone's system Downloads folder.
+
+Android saves files under **Download/Codeg**, using unique names to preserve
+existing files. Multiple transfers can continue through the system downloader
+after leaving the conversation. Retry starts a new transfer with a fresh ticket;
+interrupted one-use transfers cannot reliably resume. The list opens saved copies;
+tapping **Save to phone**, an APK link, or the local viewer's **Download again** fetches the
+current server file, so a new APK at the same path does not reopen an older build.
+Earlier completed files remain intact. If a saved file is missing, retry downloads
+a new copy.
+
+Files open a separate viewer. Images support pinch zoom, pan and double tap
+zoom/reset; text and code support selection and copy. Viewing is offline after
+the download completes. Images are decoded off the main thread and limited to
+2048 pixels per side; remote image transfer is limited to 12 MiB, with download
+offered for larger images. Text preview is limited to 128 KiB or 2000 lines, with a
+visible truncation notice; copying copies that preview. The complete download
+remains unchanged. HTML is displayed as source; PDF, SVG, archives and other
+unsupported formats use external apps. Returning to the source conversation
+preserves its ViewModel, draft and scroll position.
+
+APK installation from the viewer opens Android's installer. If needed, allow installs from Codeg
+in system settings, then return and tap **Install** again. Ordinary web links open
+the browser; direct HTTP(S) APK links use the same download flow without sending
+Codeg credentials. Existing older Android builds need this version installed first
+before their conversation links can use this feature.
+
+## Hide folders on this device
+
+Use a folder row's overflow menu in **Folders**, or a project group's menu in
+**Chats**, to choose **Hide on this device**. The folder and its worktrees are
+removed from the project list, ordinary conversation groups and Recent. Pinned
+conversations, global search and pages already open remain accessible.
+
+Both lists offer **Show hidden folders** in their toolbar menus, with a Restore
+button for each hidden folder. Choices survive app restarts and are isolated by
+saved server profile and URL. They do not close the server workspace, change
+other clients, remove files or delete conversations. Clearing conversation cache
+does not reset these preferences.
+
## Security model
Server tokens are encrypted with AES-256-GCM using a key held by the Android
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 63d72ff..0c81a9f 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -64,6 +64,7 @@ android {
buildTypes {
debug {
applicationIdSuffix = ".debug"
+ versionNameSuffix = "-debug.${providers.gradleProperty("debugBuildTag").getOrElse("local")}"
isDebuggable = true
}
release {
diff --git a/app/src/debug/res/values-zh-rCN/strings.xml b/app/src/debug/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000..ecf0b0f
--- /dev/null
+++ b/app/src/debug/res/values-zh-rCN/strings.xml
@@ -0,0 +1,3 @@
+
+ Codeg Debug
+
diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml
new file mode 100644
index 0000000..ecf0b0f
--- /dev/null
+++ b/app/src/debug/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Codeg Debug
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 81fdddb..32a84d5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
+
Long = System::currentTimeMillis,
+) {
+ class Scope internal constructor(val profile: String, val endpoint: String, internal val epoch: Long)
+ class Ticket internal constructor(
+ val scope: Scope, val group: String,
+ internal val epoch: Long, internal val revision: Long,
+ )
+ data class Entry(val text: String, val savedAt: Long, val snapshot: String) {
+ fun isFresh(at: Long = System.currentTimeMillis()): Boolean = savedAt > 0 && at - savedAt in 0 until 60_000
+ }
+
+ @Serializable
+ private data class Manifest(
+ val version: Int = 1,
+ val snapshot: String,
+ val savedAt: Long,
+ val files: Map,
+ )
+
+ private val mutex = Mutex()
+ private val profileEpochs = mutableMapOf()
+ private val revisions = mutableMapOf()
+ private var epoch = 0L
+
+ /** Neither endpoint URLs nor credentials are used as directory/file names. */
+ @Synchronized
+ fun scope(profileId: String, baseUrl: String, token: String): Scope {
+ val profile = digest(profileId)
+ return Scope(profile, digest(baseUrl.trim().trimEnd('/') + "\u0000" + token), profileEpochs[profile] ?: 0L)
+ }
+
+ suspend fun ticket(scope: Scope, group: String): Ticket = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ require(group.matches(Regex("[a-z0-9-]+")))
+ Ticket(scope, group, epoch, revisions[key(scope, group)] ?: 0L)
+ }
+ }
+
+ suspend fun read(scope: Scope, group: String, page: String, snapshot: String? = null): Entry? = safe(null) {
+ withContext(Dispatchers.IO) {
+ mutex.withLock {
+ if (!validScope(scope)) return@withLock null
+ val directory = directory(scope, group)
+ val manifest = manifest(directory) ?: return@withLock null
+ if (snapshot != null && snapshot != manifest.snapshot) return@withLock null
+ val fileName = manifest.files[page] ?: return@withLock null
+ if (!fileName.matches(Regex("[a-f0-9-]+\\.json"))) return@withLock null
+ val file = File(directory, fileName)
+ if (!file.isFile || file.length() > maxConversationBytes) return@withLock null
+ val text = file.readText()
+ File(directory, "manifest.json").setLastModified(now())
+ Entry(text, manifest.savedAt, manifest.snapshot)
+ }
+ }
+ }
+
+ /** A new authoritative detail replaces all previous pages, even if structural hashes match. */
+ suspend fun replace(ticket: Ticket, pages: Map): String? = safe(null) {
+ withContext(Dispatchers.IO) {
+ mutex.withLock {
+ if (!valid(ticket)) return@withLock null
+ val selected = linkedMapOf()
+ var bytes = 0L
+ for ((name, text) in pages) {
+ val length = text.toByteArray(Charsets.UTF_8).size
+ if (bytes + length > maxConversationBytes) break
+ selected[name] = text
+ bytes += length
+ }
+ // An oversized new tail must not leave an old tail looking current.
+ val directory = directory(ticket.scope, ticket.group)
+ bump(ticket.scope, ticket.group)
+ if (selected.isEmpty()) {
+ directory.deleteRecursively()
+ return@withLock null
+ }
+ val snapshot = UUID.randomUUID().toString()
+ publish(directory, Manifest(snapshot = snapshot, savedAt = now(), files = emptyMap()), selected)
+ prune()
+ snapshot
+ }
+ }
+ }
+
+ /** History pages keep the tail's freshness time and snapshot identity. */
+ suspend fun append(ticket: Ticket, page: String, text: String): Boolean = safe(false) {
+ withContext(Dispatchers.IO) {
+ mutex.withLock {
+ if (!valid(ticket)) return@withLock false
+ val directory = directory(ticket.scope, ticket.group)
+ val current = manifest(directory) ?: return@withLock false
+ val retainedBytes = current.files.filterKeys { it != page }.values.sumOf { File(directory, it).length() }
+ if (retainedBytes + text.toByteArray(Charsets.UTF_8).size > maxConversationBytes) return@withLock false
+ publish(directory, current, mapOf(page to text))
+ prune()
+ true
+ }
+ }
+ }
+
+ suspend fun remove(scope: Scope, group: String, snapshot: String? = null): Unit = safe(Unit) {
+ withContext(Dispatchers.IO) {
+ mutex.withLock {
+ if (validScope(scope)) {
+ if (snapshot != null && manifest(directory(scope, group))?.snapshot != snapshot) return@withLock
+ bump(scope, group)
+ directory(scope, group).deleteRecursively()
+ }
+ }
+ }
+ }
+
+ /** Keep readable history while forcing a server check after a new prompt. */
+ suspend fun expire(scope: Scope, group: String): Unit = safe(Unit) {
+ withContext(Dispatchers.IO) {
+ mutex.withLock {
+ if (!validScope(scope)) return@withLock
+ expireSnapshot(scope, group)
+ }
+ }
+ }
+
+ suspend fun expire(ticket: Ticket): Unit = safe(Unit) {
+ withContext(Dispatchers.IO) {
+ mutex.withLock {
+ if (valid(ticket)) expireSnapshot(ticket.scope, ticket.group)
+ }
+ }
+ }
+
+ private fun expireSnapshot(scope: Scope, group: String) {
+ bump(scope, group)
+ val directory = directory(scope, group)
+ val current = manifest(directory) ?: return
+ publish(directory, current.copy(savedAt = 0), emptyMap())
+ }
+
+ suspend fun clearProfile(profileId: String) = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ val profile = digest(profileId)
+ synchronized(this@ConversationDiskCache) { profileEpochs[profile] = (profileEpochs[profile] ?: 0L) + 1L }
+ if (!File(root, profile).deleteRecursively()) throw IOException("Could not clear server cache")
+ }
+ }
+
+ suspend fun clearAll(): Unit = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ epoch++
+ revisions.clear()
+ if (!root.deleteRecursively()) throw IOException("Could not clear local cache")
+ }
+ }
+
+ suspend fun sizeBytes(): Long = withContext(Dispatchers.IO) { mutex.withLock { diskBytes(root) } }
+
+ private fun key(scope: Scope, group: String) = "${scope.profile}/${scope.endpoint}/$group"
+ private fun directory(scope: Scope, group: String) = File(root, key(scope, group))
+ @Synchronized
+ private fun validScope(scope: Scope) = (profileEpochs[scope.profile] ?: 0L) == scope.epoch
+ private fun valid(ticket: Ticket) = validScope(ticket.scope) && ticket.epoch == epoch &&
+ ticket.revision == (revisions[key(ticket.scope, ticket.group)] ?: 0L)
+ private fun bump(scope: Scope, group: String) {
+ val key = key(scope, group)
+ revisions[key] = (revisions[key] ?: 0L) + 1L
+ }
+
+ private fun manifest(directory: File): Manifest? = try {
+ val file = File(directory, "manifest.json")
+ if (!file.isFile || file.length() > 1024 * 1024) null
+ else Json.decodeFromString(file.readText()).takeIf { it.version == 1 }
+ } catch (_: Exception) { null }
+
+ private fun publish(directory: File, previous: Manifest, pages: Map) {
+ check(directory.mkdirs() || directory.isDirectory)
+ val files = previous.files.toMutableMap()
+ for ((name, text) in pages) {
+ val filename = "${UUID.randomUUID()}.json"
+ File(directory, filename).writeText(text)
+ files[name] = filename
+ }
+ val temp = File(directory, "manifest.tmp")
+ temp.writeText(Json.encodeToString(previous.copy(files = files)))
+ Files.move(temp.toPath(), File(directory, "manifest.json").toPath(),
+ StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
+ File(directory, "manifest.json").setLastModified(now())
+ val retained = files.values.toSet() + "manifest.json"
+ directory.listFiles()?.filter { it.name !in retained }?.forEach { it.delete() }
+ }
+
+ private fun prune() {
+ val groups = root.listFiles().orEmpty().flatMap { it.listFiles().orEmpty().toList() }
+ .flatMap { it.listFiles().orEmpty().toList() }
+ .sortedBy { File(it, "manifest.json").lastModified() }
+ var total = diskBytes(root)
+ for (group in groups) {
+ if (total <= maxBytes) break
+ val bytes = diskBytes(group)
+ // Eviction also fences a request that started before this snapshot disappeared.
+ val relative = group.relativeTo(root).invariantSeparatorsPath
+ revisions[relative] = (revisions[relative] ?: 0L) + 1L
+ if (group.deleteRecursively()) total -= bytes
+ }
+ }
+
+ private fun diskBytes(file: File): Long = if (file.exists()) file.walkTopDown().filter { it.isFile }.sumOf { it.length() } else 0
+
+ private suspend fun safe(fallback: T, block: suspend () -> T): T = try { block() }
+ catch (e: CancellationException) { throw e }
+ catch (_: Exception) { fallback }
+
+ private fun digest(value: String): String = MessageDigest.getInstance("SHA-256")
+ .digest(value.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
+}
diff --git a/app/src/main/java/app/codeg/android/core/cache/ConversationResponseCache.kt b/app/src/main/java/app/codeg/android/core/cache/ConversationResponseCache.kt
new file mode 100644
index 0000000..ac9714b
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/core/cache/ConversationResponseCache.kt
@@ -0,0 +1,64 @@
+package app.codeg.android.core.cache
+
+import app.codeg.android.core.model.ConversationDetail
+import app.codeg.android.core.model.MessageTurn
+import app.codeg.android.core.model.TurnRole
+import app.codeg.android.core.model.extendTranscriptHash
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.jsonObject
+
+/** Retain the original wire blocks: ContentBlock intentionally has no encoder. */
+internal fun transcriptCachePages(text: String, detail: ConversationDetail): Map {
+ val original = Json.parseToJsonElement(text).jsonObject
+ val turns = original["turns"] as? JsonArray ?: JsonArray(emptyList())
+ val offset = detail.turnsOffset
+ val total = detail.turnsTotal
+ val seed = detail.prefixHash
+ if (offset == null || total == null || seed == null) return mapOf("detail" to text)
+ require(offset >= 0 && total == offset + detail.turns.size && turns.size == detail.turns.size)
+ require(seed.matches(Regex("[0-9a-f]{16}")))
+
+ val ranges = mutableListOf()
+ var end = turns.size
+ while (end > 0) {
+ val start = roundStart(detail.turns, end)
+ ranges += start until end
+ end = start
+ }
+ if (ranges.isEmpty()) return mapOf("detail" to text)
+ val hashes = mutableMapOf(0 to seed)
+ var previous = 0
+ var hash: String = seed
+ for (range in ranges.asReversed()) {
+ val endIndex = range.last + 1
+ hash = requireNotNull(extendTranscriptHash(hash, detail.turns.subList(previous, endIndex)))
+ hashes[endIndex] = hash
+ previous = endIndex
+ }
+ val pages = linkedMapOf()
+ for ((index, range) in ranges.withIndex()) {
+ val start = range.first
+ val endIndex = range.last + 1
+ val body = if (index == 0) original.toMutableMap() else mutableMapOf()
+ body["turns"] = JsonArray(turns.subList(start, endIndex))
+ body["turns_offset"] = JsonPrimitive(offset + start)
+ body["turns_total"] = JsonPrimitive(total)
+ body["prefix_hash"] = JsonPrimitive(hashes.getValue(start))
+ if (index > 0) body["prefix_hash_before_index"] = JsonPrimitive(hashes.getValue(endIndex))
+ pages[if (index == 0) "detail" else "before-${offset + endIndex}"] = JsonObject(body).toString()
+ }
+ return pages
+}
+
+private fun roundStart(turns: List, end: Int): Int {
+ var rounds = 0
+ var start = end
+ while (start > 0 && end - start < 500) {
+ start--
+ if (turns[start].role == TurnRole.USER && ++rounds == 5) break
+ }
+ return start
+}
diff --git a/app/src/main/java/app/codeg/android/core/common/TranscriptPerformance.kt b/app/src/main/java/app/codeg/android/core/common/TranscriptPerformance.kt
new file mode 100644
index 0000000..8bbac32
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/core/common/TranscriptPerformance.kt
@@ -0,0 +1,12 @@
+package app.codeg.android.core.common
+
+import app.codeg.android.BuildConfig
+import java.util.logging.Logger
+
+/** Debug-only timings; never logs message text, server URLs or credentials. */
+internal fun logTranscriptTiming(stage: String, startedNanos: Long, records: Int? = null, payload: String? = null) {
+ if (!BuildConfig.DEBUG) return
+ val elapsedMs = (System.nanoTime() - startedNanos) / 1_000_000
+ val bytes = payload?.toByteArray(Charsets.UTF_8)?.size
+ Logger.getLogger("CodegTranscript").info("$stage elapsedMs=$elapsedMs records=$records payloadBytes=$bytes")
+}
diff --git a/app/src/main/java/app/codeg/android/core/data/ServerRepository.kt b/app/src/main/java/app/codeg/android/core/data/ServerRepository.kt
index d614066..0fc283b 100644
--- a/app/src/main/java/app/codeg/android/core/data/ServerRepository.kt
+++ b/app/src/main/java/app/codeg/android/core/data/ServerRepository.kt
@@ -1,6 +1,7 @@
package app.codeg.android.core.data
import app.codeg.android.core.common.DispatcherProvider
+import app.codeg.android.core.cache.ConversationDiskCache
import app.codeg.android.core.datastore.ServerProfile
import app.codeg.android.core.datastore.ServerStore
import app.codeg.android.core.network.CodegClient
@@ -9,9 +10,12 @@ import app.codeg.android.core.network.EventStream
import app.codeg.android.core.security.SecretStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@@ -28,6 +32,7 @@ class ServerRepository @Inject constructor(
private val secretStore: SecretStore,
private val factory: CodegClientFactory,
private val dispatchers: DispatcherProvider,
+ private val cache: ConversationDiskCache,
) {
val profiles: Flow> = serverStore.profiles
val selectedId: Flow = serverStore.selectedId
@@ -47,8 +52,9 @@ class ServerRepository @Inject constructor(
fun notifyFoldersChanged() { _foldersChanged.tryEmit(Unit) }
+ private val credentialRevision = MutableStateFlow(0L)
val selectedProfile: Flow =
- combine(profiles, selectedId) { list, id -> list.firstOrNull { it.id == id } }
+ combine(profiles, selectedId, credentialRevision) { list, id, _ -> list.firstOrNull { it.id == id } }
/** The token for a profile, read off the IO dispatcher (Keystore decrypt). */
suspend fun token(id: String): String? =
@@ -56,11 +62,16 @@ class ServerRepository @Inject constructor(
/** Save (insert/replace) a profile and its token. */
suspend fun save(profile: ServerProfile, token: String) {
+ val previous = profiles.first().firstOrNull { it.id == profile.id }
+ val changedIdentity = previous != null && (previous.baseUrl != profile.baseUrl || this.token(profile.id) != token)
+ if (changedIdentity) cache.clearProfile(profile.id)
withContext(dispatchers.io) { secretStore.put(profile.id, token) }
serverStore.upsert(profile)
+ if (changedIdentity) credentialRevision.update { it + 1 }
}
suspend fun delete(id: String) {
+ cache.clearProfile(id)
serverStore.delete(id)
withContext(dispatchers.io) { secretStore.remove(id) }
}
@@ -70,7 +81,7 @@ class ServerRepository @Inject constructor(
/** An API client for a saved profile, or null if its token is missing. */
suspend fun client(profile: ServerProfile): CodegClient? {
val token = token(profile.id) ?: return null
- return factory.client(profile.baseUrl, token)
+ return factory.savedClient(profile.id, profile.baseUrl, token)
}
/** An event stream for a saved profile, or null if its token is missing. */
diff --git a/app/src/main/java/app/codeg/android/core/datastore/FolderOrderStore.kt b/app/src/main/java/app/codeg/android/core/datastore/FolderOrderStore.kt
new file mode 100644
index 0000000..ee006fa
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/core/datastore/FolderOrderStore.kt
@@ -0,0 +1,41 @@
+package app.codeg.android.core.datastore
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import app.codeg.android.core.model.FolderDetail
+import app.codeg.android.core.model.FolderOrdering
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Uses the same profile-and-endpoint scope as local folder visibility. */
+@Singleton
+class FolderOrderStore @Inject constructor(private val dataStore: DataStore) {
+ fun order(scope: String) = dataStore.data.map { read(it, scope) }.distinctUntilChanged()
+
+ suspend fun move(scope: String, folders: List, hiddenIds: Set, folderId: Int, offset: Int) {
+ dataStore.edit { prefs ->
+ val next = FolderOrdering.move(folders, read(prefs, scope), hiddenIds, folderId, offset)
+ prefs[key(scope)] = next.joinToString(",")
+ }
+ }
+
+ suspend fun reset(scope: String) {
+ dataStore.edit { it.remove(key(scope)) }
+ }
+
+ suspend fun reorder(scope: String, folders: List, hiddenIds: Set, visibleOrder: List) {
+ dataStore.edit { prefs ->
+ val next = FolderOrdering.reorder(folders, read(prefs, scope), hiddenIds, visibleOrder)
+ prefs[key(scope)] = next.joinToString(",")
+ }
+ }
+
+ private fun read(prefs: Preferences, scope: String): List =
+ prefs[key(scope)].orEmpty().split(',').mapNotNull { it.toIntOrNull() }.distinct()
+
+ private fun key(scope: String) = stringPreferencesKey("folder_order_$scope")
+}
diff --git a/app/src/main/java/app/codeg/android/core/datastore/HiddenFoldersStore.kt b/app/src/main/java/app/codeg/android/core/datastore/HiddenFoldersStore.kt
new file mode 100644
index 0000000..ba7f234
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/core/datastore/HiddenFoldersStore.kt
@@ -0,0 +1,32 @@
+package app.codeg.android.core.datastore
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import java.security.MessageDigest
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+/** Device-local visibility, isolated by saved profile and endpoint. */
+@Singleton
+class HiddenFoldersStore @Inject constructor(private val dataStore: DataStore) {
+ fun scope(profile: ServerProfile): String = MessageDigest.getInstance("SHA-256")
+ .digest("${profile.id}\u0000${profile.baseUrl.trim().trimEnd('/')}".toByteArray(Charsets.UTF_8))
+ .joinToString("") { "%02x".format(it) }
+
+ fun hiddenIds(scope: String) = dataStore.data
+ .map { prefs -> prefs[key(scope)].orEmpty().mapNotNull { it.toIntOrNull() }.toSet() }
+ .distinctUntilChanged()
+
+ suspend fun setHidden(scope: String, folderId: Int, hidden: Boolean) {
+ dataStore.edit { prefs ->
+ val ids = prefs[key(scope)].orEmpty()
+ prefs[key(scope)] = if (hidden) ids + folderId.toString() else ids - folderId.toString()
+ }
+ }
+
+ private fun key(scope: String) = stringSetPreferencesKey("hidden_folders_$scope")
+}
diff --git a/app/src/main/java/app/codeg/android/core/datastore/SessionListPreferencesStore.kt b/app/src/main/java/app/codeg/android/core/datastore/SessionListPreferencesStore.kt
new file mode 100644
index 0000000..5ba8efc
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/core/datastore/SessionListPreferencesStore.kt
@@ -0,0 +1,71 @@
+package app.codeg.android.core.datastore
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+enum class SessionSortMode(val id: String) {
+ CREATED("created"), UPDATED("updated");
+
+ companion object {
+ fun from(id: String?) = entries.firstOrNull { it.id == id } ?: CREATED
+ }
+}
+
+enum class RecentSessionFilter(val id: String) {
+ ALL("all"), CHATS("chats"), FOLDERS("folders"), CLI("cli");
+
+ companion object {
+ fun from(id: String?) = entries.firstOrNull { it.id == id } ?: ALL
+ }
+}
+
+data class SessionListPreferences(
+ val sortMode: SessionSortMode = SessionSortMode.CREATED,
+ val showRecent: Boolean = true,
+ val recentFilter: RecentSessionFilter = RecentSessionFilter.ALL,
+ val recentShowCompleted: Boolean = false,
+)
+
+/** Device-local list preferences; never change the source data or global search. */
+@Singleton
+class SessionListPreferencesStore @Inject constructor(
+ private val dataStore: DataStore,
+) {
+ val preferences = dataStore.data.map { prefs ->
+ SessionListPreferences(
+ sortMode = SessionSortMode.from(prefs[SORT]),
+ showRecent = prefs[SHOW_RECENT] ?: true,
+ recentFilter = RecentSessionFilter.from(prefs[RECENT_FILTER]),
+ recentShowCompleted = prefs[RECENT_COMPLETED] ?: false,
+ )
+ }
+
+ suspend fun setSortMode(mode: SessionSortMode) {
+ dataStore.edit { it[SORT] = mode.id }
+ }
+
+ suspend fun setShowRecent(show: Boolean) {
+ dataStore.edit { it[SHOW_RECENT] = show }
+ }
+
+ suspend fun setRecentFilter(filter: RecentSessionFilter) {
+ dataStore.edit { it[RECENT_FILTER] = filter.id }
+ }
+
+ suspend fun setRecentShowCompleted(show: Boolean) {
+ dataStore.edit { it[RECENT_COMPLETED] = show }
+ }
+
+ private companion object {
+ val SORT = stringPreferencesKey("sessions_sort_mode")
+ val SHOW_RECENT = booleanPreferencesKey("sessions_show_recent")
+ val RECENT_FILTER = stringPreferencesKey("sessions_recent_filter")
+ val RECENT_COMPLETED = booleanPreferencesKey("sessions_recent_show_completed")
+ }
+}
diff --git a/app/src/main/java/app/codeg/android/core/designsystem/component/CacheStatus.kt b/app/src/main/java/app/codeg/android/core/designsystem/component/CacheStatus.kt
new file mode 100644
index 0000000..5324799
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/core/designsystem/component/CacheStatus.kt
@@ -0,0 +1,40 @@
+package app.codeg.android.core.designsystem.component
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import app.codeg.android.R
+import app.codeg.android.core.designsystem.theme.CodegTheme
+import java.text.DateFormat
+import java.util.Date
+
+/** Small, non-scrolling status shared by the conversation list and transcript. */
+@Composable
+fun CacheStatus(
+ refreshing: Boolean,
+ showingCached: Boolean,
+ lastUpdatedAt: Long?,
+ modifier: Modifier = Modifier,
+) {
+ if (!refreshing && !showingCached && lastUpdatedAt == null) return
+ val updated = lastUpdatedAt?.let {
+ val formatted = remember(it) {
+ DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(it))
+ }
+ stringResource(R.string.cache_updated_at, formatted)
+ }
+ val message = when {
+ refreshing -> stringResource(R.string.cache_refreshing)
+ showingCached -> listOfNotNull(stringResource(R.string.cache_local_copy), updated).joinToString(" · ")
+ else -> updated.orEmpty()
+ }
+ Text(
+ text = message,
+ style = MaterialTheme.typography.labelSmall,
+ color = CodegTheme.colors.textTertiary,
+ modifier = modifier,
+ )
+}
diff --git a/app/src/main/java/app/codeg/android/core/designsystem/markdown/InlineMarkdown.kt b/app/src/main/java/app/codeg/android/core/designsystem/markdown/InlineMarkdown.kt
index f2191e6..2c0abab 100644
--- a/app/src/main/java/app/codeg/android/core/designsystem/markdown/InlineMarkdown.kt
+++ b/app/src/main/java/app/codeg/android/core/designsystem/markdown/InlineMarkdown.kt
@@ -2,8 +2,13 @@ package app.codeg.android.core.designsystem.markdown
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
@@ -21,6 +26,7 @@ import app.codeg.android.core.designsystem.theme.CodegTheme
* literal. Link URLs are attached as a string annotation under tag `URL`.
*/
const val URL_TAG = "URL"
+val LocalMarkdownLinkHandler = staticCompositionLocalOf<((String) -> Unit)?> { null }
/** Cached inline-markdown for the current theme colors (recomputed if colors change).
* Slot-scoped `remember` only — deliberately NOT a shared cache, so the growing tail of
@@ -28,13 +34,27 @@ const val URL_TAG = "URL"
@Composable
fun rememberInlineMarkdown(raw: String): AnnotatedString {
val colors = CodegTheme.colors
+ val handler by rememberUpdatedState(LocalMarkdownLinkHandler.current)
+ val uriHandler by rememberUpdatedState(LocalUriHandler.current)
return remember(raw, colors.accent, colors.codeSurface, colors.textPrimary) {
- inlineMarkdown(
+ val parsed = inlineMarkdown(
raw = raw,
linkColor = colors.accent,
codeBg = colors.codeSurface,
codeColor = colors.textPrimary,
)
+ buildAnnotatedString {
+ append(parsed)
+ parsed.getStringAnnotations(URL_TAG, 0, parsed.length).forEach { annotation ->
+ addLink(LinkAnnotation.Clickable(annotation.item) {
+ val onLink = handler
+ if (onLink != null) onLink(annotation.item)
+ else if (annotation.item.startsWith("https://") || annotation.item.startsWith("http://")) {
+ runCatching { uriHandler.openUri(annotation.item) }
+ }
+ }, annotation.start, annotation.end)
+ }
+ }
}
}
@@ -129,10 +149,10 @@ private fun parseInline(
c == '[' -> {
val close = raw.indexOf(']', i + 1)
if (close > i && close + 1 < n && raw[close + 1] == '(') {
- val urlEnd = raw.indexOf(')', close + 2)
+ val urlEnd = linkDestinationEnd(raw, close + 2)
if (urlEnd > close) {
val text = raw.substring(i + 1, close)
- val url = raw.substring(close + 2, urlEnd)
+ val url = raw.substring(close + 2, urlEnd).trim().removeSurrounding("<", ">")
flushPlain()
builder.pushStringAnnotation(URL_TAG, url)
builder.withStyle(SpanStyle(color = linkColor)) {
@@ -155,3 +175,22 @@ private fun parseInline(
}
flushPlain()
}
+
+/** Parentheses in paths are common in build outputs; angle brackets allow spaces. */
+private fun linkDestinationEnd(raw: String, start: Int): Int {
+ if (raw.getOrNull(start) == '<') {
+ val end = raw.indexOf('>', start + 1)
+ return if (end >= 0 && raw.getOrNull(end + 1) == ')') end + 1 else -1
+ }
+ var depth = 1
+ var escaped = false
+ for (i in start until raw.length) {
+ if (escaped) { escaped = false; continue }
+ when (raw[i]) {
+ '\\' -> escaped = true
+ '(' -> depth++
+ ')' -> if (--depth == 0) return i
+ }
+ }
+ return -1
+}
diff --git a/app/src/main/java/app/codeg/android/core/designsystem/markdown/MarkdownCache.kt b/app/src/main/java/app/codeg/android/core/designsystem/markdown/MarkdownCache.kt
index fc08397..af7a744 100644
--- a/app/src/main/java/app/codeg/android/core/designsystem/markdown/MarkdownCache.kt
+++ b/app/src/main/java/app/codeg/android/core/designsystem/markdown/MarkdownCache.kt
@@ -5,13 +5,12 @@ package app.codeg.android.core.designsystem.markdown
* path. A long assistant message is split into per-block timeline nodes at build time,
* and the transcript re-builds whenever `ui.turns` changes (send / completion /
* reattach) — without a cache that means re-scanning every persisted message's markdown
- * on the main thread each time. Keyed by the message text (stable → String.hashCode is
+ * on each background timeline build. Keyed by the message text (stable → String.hashCode is
* JVM-cached after the first lookup), so unchanged messages are parsed once.
*
* The growing *live* text is deliberately NOT cached here (it flows through
* [LiveBlockParser] instead); a per-flush key would churn and evict useful persisted
- * entries. Access is single-threaded in practice (the main-thread node build), guarded
- * with `@Synchronized` as cheap insurance.
+ * entries. `@Synchronized` protects the cache across background timeline builds.
*/
object MarkdownCache {
private const val MAX_BLOCKS = 256
diff --git a/app/src/main/java/app/codeg/android/core/model/Conversation.kt b/app/src/main/java/app/codeg/android/core/model/Conversation.kt
index beb722c..8ebba0c 100644
--- a/app/src/main/java/app/codeg/android/core/model/Conversation.kt
+++ b/app/src/main/java/app/codeg/android/core/model/Conversation.kt
@@ -28,6 +28,9 @@ data class ConversationSummary(
* `updated_at` — the "Pinned" group sorts by this instead. */
@Serializable(with = InstantSerializer::class)
val pinnedAt: Instant? = null,
+ val kind: String? = null,
+ val source: String? = null,
+ val originCwd: String? = null,
) {
/** Non-empty, trimmed user title, or null for an unnamed session. Render
* verbatim (user data); fall back to a localized "Untitled session" only
@@ -60,6 +63,18 @@ data class ConversationDetail(
val turns: List = emptyList(),
val sessionStats: SessionStats? = null,
val inFlightUserTurnId: String? = null,
+ val turnsOffset: Int? = null,
+ val turnsTotal: Int? = null,
+ val prefixHash: String? = null,
+)
+
+@Serializable
+data class ConversationTurnsPage(
+ val turns: List,
+ val turnsOffset: Int,
+ val turnsTotal: Int,
+ val prefixHash: String,
+ val prefixHashBeforeIndex: String,
)
/** Returned by `acp_find_connection_for_conversation` when a live ACP
diff --git a/app/src/main/java/app/codeg/android/core/model/Folder.kt b/app/src/main/java/app/codeg/android/core/model/Folder.kt
index 18fa7de..f9a4b11 100644
--- a/app/src/main/java/app/codeg/android/core/model/Folder.kt
+++ b/app/src/main/java/app/codeg/android/core/model/Folder.kt
@@ -25,4 +25,8 @@ data class FolderDetail(
val color: String = "",
val parentId: Int? = null,
val isChat: Boolean = false,
-)
+ val kind: String? = null,
+) {
+ /** New servers send kind; older servers send is_chat. Never infer this from the name. */
+ val isChatFolder: Boolean get() = kind?.let { it == "chat" } ?: isChat
+}
diff --git a/app/src/main/java/app/codeg/android/core/model/FolderOrdering.kt b/app/src/main/java/app/codeg/android/core/model/FolderOrdering.kt
new file mode 100644
index 0000000..8e7cfb3
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/core/model/FolderOrdering.kt
@@ -0,0 +1,41 @@
+package app.codeg.android.core.model
+
+/** Local order takes priority; new folders retain the server's default order at the end. */
+object FolderOrdering {
+ fun sorted(folders: List, order: List = emptyList()): List {
+ val ranks = order.distinct().withIndex().associate { it.value to it.index }
+ return folders.sortedWith(compareBy { ranks[it.id] ?: Int.MAX_VALUE }
+ .thenBy { it.sortOrder }.thenBy { it.name.lowercase() }.thenBy { it.id })
+ }
+
+ /** Swap visible neighbors while preserving hidden folders' positions in the full order. */
+ fun move(
+ folders: List, order: List, hiddenIds: Set, folderId: Int, offset: Int,
+ ): List {
+ if (offset != -1 && offset != 1) return order
+ val all = sorted(folders.filterNot { it.isChatFolder }, order)
+ val hidden = FolderVisibility.hiddenIds(folders, hiddenIds)
+ val visible = all.filter { it.id !in hidden }
+ val index = visible.indexOfFirst { it.id == folderId }
+ if (index < 0) return order
+ val target = visible.getOrNull(index + offset) ?: return order
+ val ids = all.map { it.id }.toMutableList()
+ val from = ids.indexOf(folderId)
+ val to = ids.indexOf(target.id)
+ ids[from] = target.id
+ ids[to] = folderId
+ return ids
+ }
+
+ /** Replace only visible slots; hidden folders and newly arrived folders are retained. */
+ fun reorder(
+ folders: List, order: List, hiddenIds: Set, visibleOrder: List,
+ ): List {
+ val all = sorted(folders.filterNot { it.isChatFolder }, order)
+ val hidden = FolderVisibility.hiddenIds(folders, hiddenIds)
+ val visible = all.filter { it.id !in hidden }.map { it.id }
+ val requested = visibleOrder.distinct().filter { it in visible }
+ val replacement = (requested + visible.filterNot { it in requested }).iterator()
+ return all.map { if (it.id in hidden) it.id else replacement.next() }
+ }
+}
diff --git a/app/src/main/java/app/codeg/android/core/model/FolderVisibility.kt b/app/src/main/java/app/codeg/android/core/model/FolderVisibility.kt
index 42e09ed..1459555 100644
--- a/app/src/main/java/app/codeg/android/core/model/FolderVisibility.kt
+++ b/app/src/main/java/app/codeg/android/core/model/FolderVisibility.kt
@@ -11,6 +11,10 @@ package app.codeg.android.core.model
*/
object FolderVisibility {
+ /** Hiding a project also hides its worktrees; keep the full folder map for routing. */
+ fun hiddenIds(folders: List, hidden: Set): Set =
+ hidden + folders.filter { it.parentId in hidden }.map { it.id }
+
/** A folder is a worktree of a root repo when it has a parent. */
fun isWorktree(folder: FolderDetail): Boolean = folder.parentId != null
diff --git a/app/src/main/java/app/codeg/android/core/model/Message.kt b/app/src/main/java/app/codeg/android/core/model/Message.kt
index 0608c81..032b4bf 100644
--- a/app/src/main/java/app/codeg/android/core/model/Message.kt
+++ b/app/src/main/java/app/codeg/android/core/model/Message.kt
@@ -88,6 +88,7 @@ sealed interface ContentBlock {
val name: String,
val inputPreview: String?,
val meta: JsonObject? = null,
+ val status: String? = null,
) : ContentBlock
data class ToolResult(val id: String?, val outputPreview: String?, val isError: Boolean) : ContentBlock
data class Unknown(val type: String) : ContentBlock
@@ -119,6 +120,7 @@ sealed interface ContentBlock {
name = obj.nonEmptyString("tool_name") ?: "tool",
inputPreview = obj.stringOrNull("input_preview"),
meta = obj.objectOrNull("meta"),
+ status = obj.stringOrNull("status"),
)
"tool_result" -> ToolResult(
id = obj.stringOrNull("tool_use_id"),
diff --git a/app/src/main/java/app/codeg/android/core/model/SessionFile.kt b/app/src/main/java/app/codeg/android/core/model/SessionFile.kt
new file mode 100644
index 0000000..37758bf
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/core/model/SessionFile.kt
@@ -0,0 +1,16 @@
+package app.codeg.android.core.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SessionFile(
+ val path: String,
+ val operation: String,
+ val turnId: String,
+ val timestamp: String,
+ /** Null when an older server cannot check the path until it is opened. */
+ val available: Boolean? = null,
+ val size: Long? = null,
+) {
+ val filename: String get() = path.substringAfterLast('/').substringAfterLast('\\')
+}
diff --git a/app/src/main/java/app/codeg/android/core/model/SessionFileIndex.kt b/app/src/main/java/app/codeg/android/core/model/SessionFileIndex.kt
new file mode 100644
index 0000000..70b37e5
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/core/model/SessionFileIndex.kt
@@ -0,0 +1,115 @@
+package app.codeg.android.core.model
+
+import java.net.URI
+import java.net.URLDecoder
+import java.util.Locale
+import java.time.Instant
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
+
+/** Compatibility index for servers without get_conversation_files. Never scans the phone's filesystem. */
+internal fun collectSessionFiles(turns: List, root: String): List {
+ val failed = turns.flatMap { it.blocks }.filterIsInstance()
+ .filter { it.isError }.mapNotNull { it.id }.toSet()
+ val files = linkedMapOf()
+ for (turn in turns) {
+ val paths = mutableListOf>()
+ for (block in turn.blocks) when (block) {
+ is ContentBlock.ToolUse -> {
+ if (block.id in failed || block.status in setOf("failed", "pending", "in_progress")) continue
+ val operation = fileToolOperation(block.name) ?: continue
+ val input = block.inputPreview ?: continue
+ val parsed = runCatching { Json.parseToJsonElement(input) }.getOrNull()
+ if (parsed != null) collectInputPaths(parsed, operation, paths)
+ else if (operation == "apply_patch") collectPatchPaths(input, paths)
+ else PATH_FIELD.find(input)?.groupValues?.get(1)?.let { encoded ->
+ runCatching { Json.decodeFromString(encoded) }.getOrNull()?.let { paths += it to operation }
+ }
+ }
+ is ContentBlock.Text -> if (turn.role == TurnRole.ASSISTANT) {
+ for (match in LOCAL_LINK.findAll(block.text)) paths += (match.groupValues[1].ifEmpty { match.groupValues[2] }) to "referenced"
+ }
+ is ContentBlock.ImageGeneration -> block.image?.uri?.let { paths += it to "write" }
+ else -> Unit
+ }
+ for ((raw, operation) in paths) {
+ val path = sessionFilePath(raw, root, operation == "referenced" || raw.startsWith("file:")) ?: continue
+ val actualOperation = if (operation == "referenced") files[path]?.operation ?: operation else operation
+ files[path] = SessionFile(path, actualOperation, turn.id, turn.timestamp.toString())
+ }
+ }
+ return files.values.sortedWith(compareByDescending { Instant.parse(it.timestamp) }.thenBy { it.path })
+}
+
+private fun fileToolOperation(name: String): String? = when (
+ name.substringAfterLast('.').substringAfterLast(':').substringAfterLast("__").lowercase(Locale.ROOT)
+) {
+ "write", "write_file", "create_file", "save_file" -> "write"
+ "edit", "edit_file", "multiedit", "multi_edit", "replace", "replace_in_file", "notebookedit" -> "edit"
+ "apply_patch", "applypatch", "patch" -> "apply_patch"
+ else -> null
+}
+
+private val PATH_KEYS = listOf("file_path", "filePath", "path", "target_file", "targetFile", "filename", "notebook_path")
+private val PATH_FIELD = Regex(""""(?:file_path|filePath|path|target_file|targetFile|filename|notebook_path)"\s*:\s*("(?:[^"\\]|\\.)*")""")
+private val LOCAL_LINK = Regex("""\]\(\s*(?:<([^>]+)>|([^\s)]+))(?:\s+"[^"]*")?\s*\)""")
+private val LOCATION = Regex(":\\d+(?::\\d+)?$")
+
+private fun collectInputPaths(value: JsonElement, operation: String, paths: MutableList>, depth: Int = 0) {
+ if (depth > 6) return
+ if (value is JsonPrimitive && value.isString) {
+ val text = value.content
+ if (operation == "apply_patch") collectPatchPaths(text, paths)
+ runCatching { Json.parseToJsonElement(text) }.getOrNull()?.let { collectInputPaths(it, operation, paths, depth + 1) }
+ }
+ val obj = value as? JsonObject ?: return
+ for (key in PATH_KEYS) (obj[key] as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull?.let { paths += it to operation }
+ val changes = obj["changes"] as? JsonObject
+ if (changes != null) {
+ paths += changes.keys.map { it to "edit" }
+ return
+ }
+ for (key in listOf("input", "arguments", "params", "payload")) obj[key]?.let { collectInputPaths(it, operation, paths, depth + 1) }
+ // An edit's content can itself contain patch examples; inspect only real patch inputs.
+ if (operation == "apply_patch") for (key in listOf("patch", "content", "diff", "command", "cmd")) {
+ (obj[key] as? JsonPrimitive)?.contentOrNull?.let { collectPatchPaths(it, paths) }
+ }
+}
+
+private fun collectPatchPaths(text: String, paths: MutableList>) {
+ val prefixes = listOf("*** Add File: " to "write", "*** Update File: " to "edit", "*** Delete File: " to "delete", "*** Move to: " to "edit", "+++ b/" to "edit")
+ for (line in text.lineSequence()) for ((prefix, operation) in prefixes) {
+ if (line.startsWith(prefix)) paths += line.removePrefix(prefix) to operation
+ }
+}
+
+private fun sessionFilePath(input: String, root: String, link: Boolean): String? {
+ var raw = input.trim()
+ if (raw.isBlank() || raw.startsWith('#')) return null
+ if (raw.startsWith("file:", ignoreCase = true)) {
+ val uri = runCatching { URI(raw) }.getOrNull() ?: return null
+ if (!uri.authority.isNullOrEmpty() && uri.authority != "localhost") return null
+ raw = uri.rawPath ?: return null
+ } else if (Regex("^[a-zA-Z][a-zA-Z0-9+.-]*:").containsMatchIn(raw) && !Regex("^[a-zA-Z]:[/\\\\]").containsMatchIn(raw)) return null
+ if (link) {
+ raw = runCatching { URLDecoder.decode(raw.substringBefore('#').replace("+", "%2B"), "UTF-8") }.getOrNull() ?: return null
+ raw = raw.replace(LOCATION, "")
+ }
+ if (raw.isBlank() || raw.any { it.isISOControl() } || raw.endsWith('/') || raw.endsWith('\\')) return null
+ val windows = Regex("^/?[a-zA-Z]:[/\\\\]")
+ val absolute = raw.startsWith('/') || windows.containsMatchIn(raw)
+ if (!absolute && root.isBlank()) return null
+ val joined = if (absolute) raw else "${root.trimEnd('/', '\\')}/$raw"
+ val normalized = if (windows.containsMatchIn(joined)) joined.removePrefix("/").replace('\\', '/') else joined
+ if (!normalized.startsWith('/') && !windows.containsMatchIn(normalized)) return null
+ val parts = mutableListOf()
+ for (part in normalized.split('/')) when (part) {
+ "", "." -> Unit
+ ".." -> if (parts.isEmpty() || (windows.containsMatchIn(normalized) && parts.size == 1)) return null else parts.removeAt(parts.lastIndex)
+ else -> parts += part
+ }
+ return (if (normalized.startsWith('/')) "/" else "") + parts.joinToString("/")
+}
diff --git a/app/src/main/java/app/codeg/android/core/model/TranscriptHash.kt b/app/src/main/java/app/codeg/android/core/model/TranscriptHash.kt
new file mode 100644
index 0000000..c03a816
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/core/model/TranscriptHash.kt
@@ -0,0 +1,17 @@
+package app.codeg.android.core.model
+
+/** Matches Rust's id-free FNV-1a over role and little-endian timestamp millis. */
+internal fun extendTranscriptHash(seed: String, turns: List): String? {
+ var hash = seed.toULongOrNull(16) ?: return null
+ fun append(byte: ULong) { hash = (hash xor byte) * 0x100000001b3UL }
+ for (turn in turns) {
+ append(when (turn.role) {
+ TurnRole.USER -> 0UL
+ TurnRole.ASSISTANT -> 1UL
+ TurnRole.SYSTEM -> 2UL
+ })
+ val millis = turn.timestamp.toEpochMilli()
+ repeat(8) { shift -> append(((millis ushr (shift * 8)) and 255L).toULong()) }
+ }
+ return hash.toString(16).padStart(16, '0')
+}
diff --git a/app/src/main/java/app/codeg/android/core/model/WireRequests.kt b/app/src/main/java/app/codeg/android/core/model/WireRequests.kt
index 9092650..dbfabcc 100644
--- a/app/src/main/java/app/codeg/android/core/model/WireRequests.kt
+++ b/app/src/main/java/app/codeg/android/core/model/WireRequests.kt
@@ -43,6 +43,22 @@ data class ListConversationsBody(
@Serializable
data class ConversationIdBody(val conversationId: Int)
+@Serializable
+data class ConversationDetailBody(
+ val conversationId: Int,
+ val tailRounds: Int? = null,
+ val fromIndex: Int? = null,
+)
+
+@Serializable
+data class ConversationTurnsBody(
+ val conversationId: Int,
+ val beforeIndex: Int,
+ val roundLimit: Int = 5,
+ // Older servers require limit and ignore roundLimit.
+ val limit: Int = 30,
+)
+
@Serializable
data class CreateConversationBody(
val folderId: Int,
diff --git a/app/src/main/java/app/codeg/android/core/model/WorkspaceDownload.kt b/app/src/main/java/app/codeg/android/core/model/WorkspaceDownload.kt
new file mode 100644
index 0000000..edc33c3
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/core/model/WorkspaceDownload.kt
@@ -0,0 +1,9 @@
+package app.codeg.android.core.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class WorkspaceDownloadRequest(val rootPath: String, val path: String, val kind: String = "file")
+
+@Serializable
+data class WorkspaceDownloadTicket(val url: String, val filename: String)
diff --git a/app/src/main/java/app/codeg/android/core/network/CachedResponse.kt b/app/src/main/java/app/codeg/android/core/network/CachedResponse.kt
new file mode 100644
index 0000000..e4c4888
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/core/network/CachedResponse.kt
@@ -0,0 +1,16 @@
+package app.codeg.android.core.network
+
+import app.codeg.android.core.model.ConversationSummary
+import app.codeg.android.core.model.FolderDetail
+import kotlinx.serialization.Serializable
+
+data class CachedResponse(val value: T, val savedAt: Long, val snapshot: String) {
+ fun isFresh(now: Long = System.currentTimeMillis()): Boolean = savedAt > 0 && now - savedAt in 0 until 60_000
+}
+
+@Serializable
+data class SessionListSnapshot(
+ val folders: List,
+ val conversations: List,
+ val openFolderIds: Set,
+)
diff --git a/app/src/main/java/app/codeg/android/core/network/CodegClient.kt b/app/src/main/java/app/codeg/android/core/network/CodegClient.kt
index 6440884..f6c7028 100644
--- a/app/src/main/java/app/codeg/android/core/network/CodegClient.kt
+++ b/app/src/main/java/app/codeg/android/core/network/CodegClient.kt
@@ -1,8 +1,17 @@
package app.codeg.android.core.network
+import app.codeg.android.core.common.logTranscriptTiming
+import app.codeg.android.core.cache.ConversationDiskCache
+import app.codeg.android.core.cache.transcriptCachePages
+
import app.codeg.android.core.model.AgentType
+import app.codeg.android.core.model.WorkspaceDownloadRequest
+import app.codeg.android.core.model.WorkspaceDownloadTicket
import app.codeg.android.core.model.ConversationConnectionInfo
import app.codeg.android.core.model.ConversationDetail
+import app.codeg.android.core.model.ConversationDetailBody
+import app.codeg.android.core.model.ConversationTurnsBody
+import app.codeg.android.core.model.ConversationTurnsPage
import app.codeg.android.core.model.ConversationIdBody
import app.codeg.android.core.model.ConnectBody
import app.codeg.android.core.model.ConnectionIdBody
@@ -122,6 +131,10 @@ import app.codeg.android.core.model.SetModeBody
import app.codeg.android.core.model.SetConfigOptionBody
import io.ktor.client.HttpClient
import io.ktor.client.request.header
+import io.ktor.client.request.prepareGet
+import io.ktor.client.statement.bodyAsChannel
+import io.ktor.utils.io.readAvailable
+import java.io.ByteArrayOutputStream
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
@@ -129,6 +142,11 @@ import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import kotlinx.serialization.DeserializationStrategy
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.withContext
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.encodeToString
@@ -156,9 +174,66 @@ class CodegClient(
rawBaseUrl: String,
private val token: String,
private val http: HttpClient,
+ private val cache: ConversationDiskCache? = null,
+ private val cacheScope: ConversationDiskCache.Scope? = null,
) {
/** Base URL with any trailing slash trimmed; `/api/` is appended. */
val baseUrl: String = normalizeBaseUrl(rawBaseUrl)
+ val cacheIdentity: String? = cacheScope?.let { "${it.profile}/${it.endpoint}/${it.epoch}" }
+ private var transcriptIsLive = false
+
+ suspend fun cachedSessionList(): CachedResponse? =
+ cached("list", "list", SessionListSnapshot.serializer())
+
+ suspend fun cachedConversation(id: Int): CachedResponse? =
+ cached("session-$id", "detail", ConversationDetail.serializer())
+
+ suspend fun cachedConversationTurns(id: Int, beforeIndex: Int, snapshot: String): ConversationTurnsPage? =
+ cached("session-$id", "before-$beforeIndex", ConversationTurnsPage.serializer(), snapshot)?.value
+
+ private suspend fun cached(
+ group: String, page: String, serializer: DeserializationStrategy, snapshot: String? = null,
+ ): CachedResponse? {
+ val disk = cache ?: return null
+ val scope = cacheScope ?: return null
+ val entry = disk.read(scope, group, page, snapshot) ?: return null
+ return try {
+ withContext(Dispatchers.Default) {
+ CachedResponse(decode(entry.text, serializer), entry.savedAt, entry.snapshot)
+ }
+ } catch (e: CancellationException) { throw e }
+ catch (_: Exception) {
+ disk.remove(scope, group, snapshot = entry.snapshot)
+ null
+ }
+ }
+
+ /** Publish the list and its grouping context together, only after every request succeeds. */
+ suspend fun sessionList(): SessionListSnapshot {
+ val ticket = cacheScope?.let { cache?.ticket(it, "list") }
+ val responses = coroutineScope {
+ val folders = async { send("list_all_folder_details", encode(EmptyBody)) }
+ val conversations = async { send("list_all_conversations", encode(ListConversationsBody())) }
+ val openFolders = async { listOpenFolders() }
+ Triple(folders.await(), conversations.await(), openFolders.await().map { it.id }.toSet())
+ }
+ return withContext(Dispatchers.Default) {
+ val raw = buildJsonObject {
+ put("folders", CodegJson.response.parseToJsonElement(responses.first))
+ put("conversations", CodegJson.response.parseToJsonElement(responses.second))
+ put("open_folder_ids", JsonArray(responses.third.map { JsonPrimitive(it) }))
+ }.toString()
+ decode(raw, SessionListSnapshot.serializer()).also {
+ if (ticket != null) cache?.replace(ticket, mapOf("list" to raw))
+ }
+ }
+ }
+
+ private suspend fun invalidateConversationCache(id: Int) {
+ val scope = cacheScope ?: return
+ cache?.remove(scope, "session-$id")
+ cache?.remove(scope, "list")
+ }
// region Endpoints
@@ -196,19 +271,57 @@ class CodegClient(
ListSerializer(ConversationSummary.serializer()),
)
- /** Full session detail incl. message history. */
- suspend fun conversationDetail(id: Int): ConversationDetail =
- decode(
- send("get_folder_conversation", encode(ConversationIdBody(id))),
- ConversationDetail.serializer(),
- )
+ /** Recent rounds on entry, or refresh the exact already-loaded window. */
+ suspend fun conversationDetail(id: Int, fromIndex: Int? = null): ConversationDetail {
+ val ticket = cacheScope?.let { cache?.ticket(it, "session-$id") }
+ val started = System.nanoTime()
+ val text = send("get_folder_conversation", encode(ConversationDetailBody(
+ conversationId = id,
+ tailRounds = if (fromIndex == null) 5 else null,
+ fromIndex = fromIndex,
+ )))
+ return withContext(Dispatchers.Default) {
+ logTranscriptTiming("detail-download", started, payload = text)
+ val parsing = System.nanoTime()
+ decode(text, ConversationDetail.serializer()).also {
+ logTranscriptTiming("detail-decode", parsing, records = it.turns.size)
+ transcriptIsLive = it.inFlightUserTurnId != null || it.summary.status.isLive
+ if (ticket != null) {
+ // Disk/format failures must not turn a successful response into a load failure.
+ try {
+ if (transcriptIsLive) cache?.expire(ticket)
+ else cache?.replace(ticket, transcriptCachePages(text, it))
+ }
+ catch (e: CancellationException) { throw e }
+ catch (_: Exception) { /* A later fetch can rebuild this disposable cache. */ }
+ }
+ }
+ }
+ }
+
+ suspend fun conversationTurns(id: Int, beforeIndex: Int): ConversationTurnsPage {
+ val ticket = cacheScope?.let { cache?.ticket(it, "session-$id") }
+ val started = System.nanoTime()
+ val text = send("get_folder_conversation_turns", encode(ConversationTurnsBody(id, beforeIndex)))
+ return withContext(Dispatchers.Default) {
+ logTranscriptTiming("history-download", started, payload = text)
+ val parsing = System.nanoTime()
+ decode(text, ConversationTurnsPage.serializer()).also {
+ logTranscriptTiming("history-decode", parsing, records = it.turns.size)
+ if (ticket != null && !transcriptIsLive) cache?.append(ticket, "before-$beforeIndex", text)
+ }
+ }
+ }
/** Create a conversation row up front and return its id (a bare JSON int). */
- suspend fun createConversation(folderId: Int, agentType: AgentType, title: String?): Int =
- decode(
+ suspend fun createConversation(folderId: Int, agentType: AgentType, title: String?): Int {
+ val id = decode(
send("create_conversation", encode(CreateConversationBody(folderId, agentType, title))),
Int.serializer(),
)
+ cacheScope?.let { cache?.remove(it, "list") }
+ return id
+ }
/**
* Spawn (or resume, when [sessionId] is set) an agent process. Returns the
@@ -251,6 +364,11 @@ class CodegClient(
clientMessageId: String?,
) {
send("acp_prompt", encode(PromptBody(connectionId, blocks, folderId, conversationId, clientMessageId)))
+ transcriptIsLive = true
+ cacheScope?.let {
+ cache?.remove(it, "list")
+ if (conversationId != null) cache?.expire(it, "session-$conversationId")
+ }
}
/** Cancel the in-flight turn on a connection. */
@@ -288,21 +406,25 @@ class CodegClient(
/** Pin or unpin a conversation (server `update_conversation_pinned`). */
suspend fun setPinned(conversationId: Int, pinned: Boolean) {
send("update_conversation_pinned", encode(UpdateConversationPinnedBody(conversationId, pinned)))
+ invalidateConversationCache(conversationId)
}
/** Rename a conversation (server `update_conversation_title`). */
suspend fun renameConversation(conversationId: Int, title: String) {
send("update_conversation_title", encode(UpdateConversationTitleBody(conversationId, title)))
+ invalidateConversationCache(conversationId)
}
/** Set a conversation's lifecycle status (server `update_conversation_status`). */
suspend fun setStatus(conversationId: Int, status: String) {
send("update_conversation_status", encode(UpdateConversationStatusBody(conversationId, status)))
+ invalidateConversationCache(conversationId)
}
/** Permanently delete a conversation (server `delete_conversation`). */
suspend fun deleteConversation(conversationId: Int) {
send("delete_conversation", encode(ConversationIdBody(conversationId)))
+ invalidateConversationCache(conversationId)
}
// endregion
@@ -329,6 +451,69 @@ class CodegClient(
suspend fun readFilePreview(rootPath: String, path: String): FilePreviewContent =
decode(send("read_file_preview", encode(ReadFilePreviewBody(rootPath, path))), FilePreviewContent.serializer())
+ /** A short-lived, one-use URL: the system downloader never receives the server token. */
+ suspend fun cachedConversationFiles(id: Int): List? =
+ cached("files-$id", "files", ListSerializer(app.codeg.android.core.model.SessionFile.serializer()))?.value
+
+ suspend fun conversationFiles(id: Int): List {
+ val ticket = cacheScope?.let { cache?.ticket(it, "files-$id") }
+ val serializer = ListSerializer(app.codeg.android.core.model.SessionFile.serializer())
+ val files = try {
+ val raw = send("get_conversation_files", encode(ConversationIdBody(id)))
+ withContext(Dispatchers.Default) { decode(raw, serializer) }
+ } catch (e: ApiError.Server) {
+ if (!(e.status == 501 && e.code == "not_implemented") && !(e.status == 404 && e.code == null)) throw e
+ legacyConversationFiles(id)
+ }
+ if (ticket != null) withContext(Dispatchers.Default) {
+ cache?.replace(ticket, mapOf("files" to CodegJson.response.encodeToString(serializer, files)))
+ }
+ return files
+ }
+
+ private suspend fun legacyConversationFiles(id: Int): List {
+ // Only the file manager requests full history. Never replace the paged conversation cache.
+ val raw = send("get_folder_conversation", encode(ConversationDetailBody(id, fromIndex = 0)))
+ val detail = withContext(Dispatchers.Default) { decode(raw, ConversationDetail.serializer()) }
+ if ((detail.turnsOffset ?: 0) != 0 || (detail.turnsTotal != null && detail.turnsTotal != detail.turns.size)) {
+ throw ApiError.Decoding("Server did not return complete conversation history")
+ }
+ val root = detail.summary.originCwd?.takeIf { it.isNotBlank() }
+ ?: listFolders().firstOrNull { it.id == detail.summary.folderId }?.path
+ ?: throw ApiError.Decoding("Conversation folder not found")
+ return withContext(Dispatchers.Default) {
+ app.codeg.android.core.model.collectSessionFiles(detail.turns, root)
+ }
+ }
+
+ /** A bounded preview read; closing the response cancels the unused remainder. */
+ suspend fun workspacePreviewBytes(rootPath: String, path: String, limit: Int): ByteArray {
+ val ticket = workspaceDownloadTicket(rootPath, path)
+ return http.prepareGet(ticket.url).execute { response ->
+ check(response.status.value in 200..299) { "File unavailable (${response.status.value})" }
+ val channel = response.bodyAsChannel()
+ val output = ByteArrayOutputStream()
+ val buffer = ByteArray(8192)
+ while (output.size() < limit) {
+ val count = channel.readAvailable(buffer, 0, minOf(buffer.size, limit - output.size()))
+ if (count < 0) break
+ if (count > 0) output.write(buffer, 0, count)
+ }
+ output.toByteArray()
+ }
+ }
+
+ suspend fun workspaceDownloadTicket(rootPath: String, path: String): WorkspaceDownloadTicket {
+ val ticket = decodeCamel(
+ send("workspace_download_ticket", encode(WorkspaceDownloadRequest(rootPath, path))),
+ WorkspaceDownloadTicket.serializer(),
+ )
+ if (!Regex("^/api/workspace_download/[A-Za-z0-9_-]+$").matches(ticket.url)) {
+ throw ApiError.Decoding("Invalid workspace download URL")
+ }
+ return ticket.copy(url = baseUrl + ticket.url)
+ }
+
/** Working-tree changes. Throws [ApiError.Server] with code `not_a_git_repository` if not a repo. */
suspend fun gitStatus(path: String, showAllUntracked: Boolean = true): List =
decode(send("git_status", encode(GitStatusBody(path, showAllUntracked))), ListSerializer(GitStatusEntry.serializer()))
diff --git a/app/src/main/java/app/codeg/android/core/network/CodegClientFactory.kt b/app/src/main/java/app/codeg/android/core/network/CodegClientFactory.kt
index a978c89..c42eae1 100644
--- a/app/src/main/java/app/codeg/android/core/network/CodegClientFactory.kt
+++ b/app/src/main/java/app/codeg/android/core/network/CodegClientFactory.kt
@@ -1,6 +1,7 @@
package app.codeg.android.core.network
import dagger.Lazy
+import app.codeg.android.core.cache.ConversationDiskCache
import io.ktor.client.HttpClient
import javax.inject.Inject
import javax.inject.Singleton
@@ -22,10 +23,14 @@ import javax.inject.Singleton
@Singleton
class CodegClientFactory @Inject constructor(
private val http: Lazy,
+ private val cache: ConversationDiskCache,
) {
fun client(baseUrl: String, token: String): CodegClient =
CodegClient(baseUrl, token, http.get())
+ fun savedClient(profileId: String, baseUrl: String, token: String): CodegClient =
+ CodegClient(baseUrl, token, http.get(), cache, cache.scope(profileId, baseUrl, token))
+
fun eventStream(baseUrl: String, token: String): EventStream =
EventStream(baseUrl, token, http.get())
}
diff --git a/app/src/main/java/app/codeg/android/di/StorageModule.kt b/app/src/main/java/app/codeg/android/di/StorageModule.kt
index a59ca28..ede1680 100644
--- a/app/src/main/java/app/codeg/android/di/StorageModule.kt
+++ b/app/src/main/java/app/codeg/android/di/StorageModule.kt
@@ -7,6 +7,8 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import app.codeg.android.core.common.DefaultDispatcherProvider
import app.codeg.android.core.common.DispatcherProvider
+import app.codeg.android.core.cache.ConversationDiskCache
+import java.io.File
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -18,6 +20,11 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object StorageModule {
+ @Provides
+ @Singleton
+ fun provideConversationCache(@ApplicationContext context: Context): ConversationDiskCache =
+ ConversationDiskCache(File(context.cacheDir, "conversation-snapshots"))
+
/** Single Preferences DataStore backing both the server list and app settings. */
@Provides
@Singleton
diff --git a/app/src/main/java/app/codeg/android/feature/downloads/DownloadRecord.kt b/app/src/main/java/app/codeg/android/feature/downloads/DownloadRecord.kt
new file mode 100644
index 0000000..c2cff9b
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/downloads/DownloadRecord.kt
@@ -0,0 +1,37 @@
+package app.codeg.android.feature.downloads
+
+import app.codeg.android.core.datastore.ServerProfile
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal enum class FileDownloadStatus { PREPARING, QUEUED, DOWNLOADING, PAUSED, COMPLETE, FAILED, CANCELED }
+
+@Serializable
+internal data class DownloadRecord(
+ val key: String,
+ val link: String,
+ val filename: String,
+ val serverId: String,
+ val serverUrl: String,
+ val serverName: String,
+ val conversationId: Int? = null,
+ val conversationTitle: String = "",
+ val folderPath: String? = null,
+ val createdAt: Long = System.currentTimeMillis(),
+ val id: Long? = null,
+ val status: FileDownloadStatus = FileDownloadStatus.PREPARING,
+ val bytes: Long = 0,
+ val total: Long = -1,
+ val error: String? = null,
+) {
+ val busy: Boolean get() = status in setOf(FileDownloadStatus.PREPARING, FileDownloadStatus.QUEUED, FileDownloadStatus.DOWNLOADING, FileDownloadStatus.PAUSED)
+ val apk: Boolean get() = filename.endsWith(".apk", ignoreCase = true)
+ fun belongsTo(profile: ServerProfile): Boolean = serverId == profile.id &&
+ serverUrl.trim().trimEnd('/') == profile.baseUrl.trim().trimEnd('/')
+
+ fun restored(interruptedMessage: String): DownloadRecord =
+ if (busy && id == null) copy(status = FileDownloadStatus.FAILED, error = interruptedMessage) else this
+}
+
+internal fun List.activeDownload(profile: ServerProfile, conversationId: Int?, link: String): DownloadRecord? =
+ firstOrNull { it.belongsTo(profile) && it.conversationId == conversationId && it.link == link && it.busy }
diff --git a/app/src/main/java/app/codeg/android/feature/downloads/DownloadRepository.kt b/app/src/main/java/app/codeg/android/feature/downloads/DownloadRepository.kt
new file mode 100644
index 0000000..b45255d
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/downloads/DownloadRepository.kt
@@ -0,0 +1,280 @@
+package app.codeg.android.feature.downloads
+
+import android.app.DownloadManager
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Environment
+import android.provider.Settings
+import android.webkit.MimeTypeMap
+import app.codeg.android.R
+import app.codeg.android.core.data.ServerRepository
+import app.codeg.android.core.datastore.ServerProfile
+import app.codeg.android.core.network.displayMessage
+import app.codeg.android.feature.sessiondetail.PreparedDownload
+import app.codeg.android.feature.sessiondetail.isApkLink
+import app.codeg.android.feature.sessiondetail.messageWebUrl
+import app.codeg.android.feature.sessiondetail.safeDownloadFilename
+import app.codeg.android.feature.sessiondetail.workspaceFileLink
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.util.Locale
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+/** App-owned preparation; Android owns the transfer even while no screen is open. */
+@Singleton
+internal class DownloadRepository @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val servers: ServerRepository,
+ private val store: DownloadStore,
+) {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private val manager by lazy { requireNotNull(context.getSystemService(DownloadManager::class.java)) }
+ private val _records = MutableStateFlow>(emptyList())
+ val records = _records.asStateFlow()
+ private val _error = MutableStateFlow(null)
+ val error = _error.asStateFlow()
+ private val _refreshing = MutableStateFlow(false)
+ val refreshing = _refreshing.asStateFlow()
+ private val ready = CompletableDeferred()
+ private val mutex = Mutex()
+ private val jobs = mutableMapOf()
+
+ init {
+ scope.launch {
+ try {
+ val saved = store.read().map { it.restored(context.getString(R.string.download_interrupted)) }
+ _records.value = (_records.value + saved).distinctBy { it.key }.sortedByDescending { it.createdAt }
+ ready.complete(Unit)
+ while (true) {
+ _records.subscriptionCount.first { it > 0 }
+ refreshRecords(onlyActive = true)
+ delay(1000)
+ }
+ } catch (e: CancellationException) { throw e
+ } catch (e: Exception) {
+ _error.value = e.displayMessage()
+ ready.completeExceptionally(e)
+ }
+ }
+ }
+
+ fun start(profile: ServerProfile, conversationId: Int?, title: String, folderPath: String?, link: String): String {
+ _records.value.activeDownload(profile, conversationId, link)?.let { return it.key }
+ val record = DownloadRecord(
+ key = UUID.randomUUID().toString(), link = link,
+ filename = safeDownloadFilename(link.substringBefore('#').substringAfterLast('/')),
+ serverId = profile.id, serverUrl = profile.baseUrl, serverName = profile.name,
+ conversationId = conversationId, conversationTitle = title, folderPath = folderPath,
+ )
+ _records.value = listOf(record) + _records.value
+ jobs[record.key] = scope.launch {
+ try {
+ ready.await()
+ mutex.withLock {
+ store.put(record)
+ }
+ prepare(record.key)
+ } catch (e: CancellationException) { throw e
+ } catch (e: Exception) { fail(record.key, e)
+ } finally { jobs.remove(record.key) }
+ }
+ return record.key
+ }
+
+ fun retry(key: String) {
+ if (jobs[key]?.isActive == true) return
+ val record = _records.value.firstOrNull { it.key == key } ?: return
+ if (record.busy || record.status == FileDownloadStatus.COMPLETE) return
+ jobs[key] = scope.launch {
+ try {
+ record.id?.let { withContext(Dispatchers.IO) { manager.remove(it) } }
+ update(key) { it.copy(id = null, status = FileDownloadStatus.PREPARING, bytes = 0, total = -1, error = null) }
+ prepare(key)
+ } catch (e: CancellationException) { throw e
+ } catch (e: Exception) { fail(key, e)
+ } finally { jobs.remove(key) }
+ }
+ }
+
+ /** Fetch the current server version, preserving the earlier downloaded file and history. */
+ fun redownload(key: String): String? {
+ val record = _records.value.firstOrNull { it.key == key } ?: return null
+ return start(ServerProfile(record.serverId, record.serverName, record.serverUrl),
+ record.conversationId, record.conversationTitle, record.folderPath, record.link)
+ }
+
+ private suspend fun prepare(key: String) {
+ val record = requireNotNull(_records.value.firstOrNull { it.key == key })
+ val prepared = messageWebUrl(record.link)?.let { web ->
+ require(isApkLink(record.link)) { context.getString(R.string.download_invalid_link) }
+ PreparedDownload(web.toASCIIString(), web.path.substringAfterLast('/'))
+ } ?: run {
+ val profile = servers.profiles.first().firstOrNull { record.belongsTo(it) }
+ ?: error(context.getString(R.string.download_source_unavailable))
+ val client = servers.client(profile) ?: error(context.getString(R.string.download_server_unavailable))
+ val target = workspaceFileLink(record.link, listOfNotNull(record.folderPath), record.folderPath)
+ ?: error(context.getString(R.string.download_outside_workspace))
+ val ticket = client.workspaceDownloadTicket(target.rootPath, target.path)
+ PreparedDownload(ticket.url, ticket.filename)
+ }
+ require(messageWebUrl(prepared.url) != null) { context.getString(R.string.download_invalid_link) }
+ val filename = safeDownloadFilename(prepared.filename)
+ update(key) { it.copy(filename = filename) }
+ withContext(NonCancellable) {
+ val id = withContext(Dispatchers.IO) {
+ manager.enqueue(DownloadManager.Request(Uri.parse(prepared.url))
+ .setTitle(filename)
+ .setMimeType(downloadMimeType(filename))
+ .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+ .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "Codeg/${uniqueFilename(filename)}"))
+ }
+ // Persist the system ID before leaving this block so navigation cannot orphan the record.
+ update(key) { it.copy(id = id, status = FileDownloadStatus.QUEUED) }
+ }
+ }
+
+ fun refresh() {
+ if (_refreshing.value) return
+ _refreshing.value = true
+ scope.launch {
+ try { ready.await(); refreshRecords(onlyActive = false); _error.value = null
+ } catch (e: CancellationException) { throw e
+ } catch (e: Exception) { _error.value = e.displayMessage()
+ } finally { _refreshing.value = false }
+ }
+ }
+
+ private suspend fun refreshRecords(onlyActive: Boolean) {
+ for (record in _records.value) {
+ val id = record.id ?: continue
+ if (onlyActive && !record.busy) continue
+ try {
+ val next = withContext(Dispatchers.IO) {
+ manager.query(DownloadManager.Query().setFilterById(id)).use { cursor ->
+ check(cursor.moveToFirst()) { context.getString(R.string.download_missing) }
+ fun number(column: String) = cursor.getLong(cursor.getColumnIndexOrThrow(column))
+ val status = when (number(DownloadManager.COLUMN_STATUS).toInt()) {
+ DownloadManager.STATUS_SUCCESSFUL -> FileDownloadStatus.COMPLETE
+ DownloadManager.STATUS_FAILED -> FileDownloadStatus.FAILED
+ DownloadManager.STATUS_PAUSED -> FileDownloadStatus.PAUSED
+ DownloadManager.STATUS_PENDING -> FileDownloadStatus.QUEUED
+ else -> FileDownloadStatus.DOWNLOADING
+ }
+ val error = if (status == FileDownloadStatus.FAILED) context.getString(
+ R.string.download_failed_reason, number(DownloadManager.COLUMN_REASON).toInt(),
+ ) else null
+ record.copy(status = status, bytes = number(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR),
+ total = number(DownloadManager.COLUMN_TOTAL_SIZE_BYTES), error = error)
+ }
+ }
+ update(record.key) { current ->
+ if (current.id == id && current.status != FileDownloadStatus.CANCELED) next else current
+ }
+ } catch (e: CancellationException) { throw e
+ } catch (e: Exception) { update(record.key) { if (it.id == id) it.copy(status = FileDownloadStatus.FAILED, error = e.displayMessage()) else it } }
+ }
+ }
+
+ fun cancel(key: String) = scope.launch {
+ try {
+ jobs[key]?.cancelAndJoin()
+ val record = _records.value.firstOrNull { it.key == key } ?: return@launch
+ if (!record.busy) return@launch
+ record.id?.let { withContext(Dispatchers.IO) { manager.remove(it) } }
+ update(key) { it.copy(id = null, status = FileDownloadStatus.CANCELED, error = null) }
+ } catch (e: CancellationException) { throw e
+ } catch (e: Exception) { _error.value = e.displayMessage() }
+ }
+
+ /** Removing history never deletes an already downloaded file. */
+ fun remove(key: String) = scope.launch {
+ try {
+ mutex.withLock {
+ if (_records.value.firstOrNull { it.key == key }?.busy == true) return@withLock
+ store.remove(key)
+ _records.value = _records.value.filterNot { it.key == key }
+ }
+ } catch (e: CancellationException) { throw e
+ } catch (e: Exception) { _error.value = e.displayMessage() }
+ }
+
+ suspend fun contentUri(key: String): Uri {
+ val record = _records.value.firstOrNull { it.key == key && it.status == FileDownloadStatus.COMPLETE }
+ ?: error(context.getString(R.string.download_missing))
+ return try {
+ withContext(Dispatchers.IO) {
+ val uri = manager.getUriForDownloadedFile(requireNotNull(record.id)) ?: error(context.getString(R.string.download_missing))
+ context.contentResolver.openFileDescriptor(uri, "r")?.use { } ?: error(context.getString(R.string.download_missing))
+ uri
+ }
+ } catch (e: CancellationException) { throw e
+ } catch (e: Exception) {
+ update(key) { if (it.id == record.id) it.copy(status = FileDownloadStatus.FAILED, error = context.getString(R.string.download_missing)) else it }
+ throw e
+ }
+ }
+
+ fun openExternal(key: String) = scope.launch {
+ try {
+ val record = _records.value.firstOrNull { it.key == key } ?: return@launch
+ val uri = contentUri(key)
+ if (record.apk && !context.packageManager.canRequestPackageInstalls()) {
+ context.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ update(key) { it.copy(error = context.getString(R.string.download_allow_install)) }
+ } else {
+ context.startActivity(Intent(Intent.ACTION_VIEW).setDataAndType(uri, downloadMimeType(record.filename))
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION))
+ update(key) { it.copy(error = null) }
+ }
+ } catch (e: CancellationException) { throw e
+ } catch (e: Exception) {
+ try { update(key) { it.copy(error = context.getString(R.string.download_open_failed)) }
+ } catch (writeError: CancellationException) { throw writeError
+ } catch (writeError: Exception) { _error.value = writeError.displayMessage() }
+ }
+ }
+
+ private suspend fun fail(key: String, error: Exception) {
+ try { update(key) { it.copy(status = FileDownloadStatus.FAILED, error = error.displayMessage()) }
+ } catch (e: CancellationException) { throw e
+ } catch (e: Exception) { _error.value = e.displayMessage() }
+ }
+
+ private suspend fun update(key: String, transform: (DownloadRecord) -> DownloadRecord) = mutex.withLock {
+ val before = _records.value.firstOrNull { it.key == key } ?: return@withLock
+ val after = transform(before)
+ // Progress stays in memory; durable changes are written atomically by DataStore.
+ _records.value = _records.value.map { if (it.key == key) after else it }
+ if (before.copy(bytes = after.bytes, total = after.total) != after) store.put(after)
+ }
+
+ private fun uniqueFilename(filename: String): String {
+ val suffix = UUID.randomUUID().toString().take(8)
+ val dot = filename.lastIndexOf('.')
+ return if (dot > 0) filename.substring(0, dot) + "-$suffix" + filename.substring(dot) else "$filename-$suffix"
+ }
+}
+
+internal fun downloadMimeType(filename: String): String = if (filename.endsWith(".apk", ignoreCase = true)) {
+ "application/vnd.android.package-archive"
+} else MimeTypeMap.getSingleton().getMimeTypeFromExtension(filename.substringAfterLast('.', "").lowercase(Locale.ROOT))
+ ?: "application/octet-stream"
diff --git a/app/src/main/java/app/codeg/android/feature/downloads/DownloadStore.kt b/app/src/main/java/app/codeg/android/feature/downloads/DownloadStore.kt
new file mode 100644
index 0000000..b6a6b61
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/downloads/DownloadStore.kt
@@ -0,0 +1,32 @@
+package app.codeg.android.feature.downloads
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.flow.first
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+/** Durable metadata only; no server auth token, issued ticket URL or downloaded file contents. */
+@Singleton
+internal class DownloadStore @Inject constructor(private val dataStore: DataStore) {
+ private val key = stringPreferencesKey("file_download_history_v1")
+ private val json = Json { ignoreUnknownKeys = true }
+
+ suspend fun read(): List = decode(dataStore.data.first()[key])
+
+ suspend fun put(record: DownloadRecord) {
+ dataStore.edit { prefs ->
+ prefs[key] = json.encodeToString((listOf(record) + decode(prefs[key]).filterNot { it.key == record.key }).sortedByDescending { it.createdAt })
+ }
+ }
+
+ suspend fun remove(id: String) {
+ dataStore.edit { prefs -> prefs[key] = json.encodeToString(decode(prefs[key]).filterNot { it.key == id }) }
+ }
+
+ private fun decode(value: String?): List = value?.let { json.decodeFromString>(it) }.orEmpty()
+}
diff --git a/app/src/main/java/app/codeg/android/feature/downloads/DownloadsScreen.kt b/app/src/main/java/app/codeg/android/feature/downloads/DownloadsScreen.kt
new file mode 100644
index 0000000..b4f9ae8
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/downloads/DownloadsScreen.kt
@@ -0,0 +1,137 @@
+package app.codeg.android.feature.downloads
+
+import android.text.format.DateFormat
+import android.text.format.Formatter
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import app.codeg.android.R
+import app.codeg.android.core.designsystem.theme.CodegTheme
+import app.codeg.android.feature.sessiondetail.FileDownloadBanner
+import java.util.Date
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun DownloadsScreen(
+ onBack: () -> Unit,
+ onOpenFile: (String) -> Unit,
+ onOpenSource: (Int) -> Unit,
+ onViewRemote: (Int, String) -> Unit,
+ initialTab: Int = 0,
+ viewModel: DownloadsViewModel = hiltViewModel(),
+ filesViewModel: SessionFilesViewModel = hiltViewModel(),
+) {
+ val files by filesViewModel.state.collectAsStateWithLifecycle()
+ var tab by rememberSaveable { mutableIntStateOf(initialTab) }
+ val records by viewModel.records.collectAsStateWithLifecycle()
+ val downloadingRefresh by viewModel.refreshing.collectAsStateWithLifecycle()
+ val refreshing = if (tab == 0) files.loading else downloadingRefresh
+ val refresh: () -> Unit = { if (tab == 0) filesViewModel.refresh() else viewModel.refresh() }
+ val error by viewModel.error.collectAsStateWithLifecycle()
+ val sourceError by viewModel.sourceError.collectAsStateWithLifecycle()
+ val colors = CodegTheme.colors
+ val context = LocalContext.current
+ LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refresh() }
+ Scaffold(
+ containerColor = Color.Transparent,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.session_files_title)) },
+ navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.common_back)) } },
+ actions = { IconButton(onClick = refresh, enabled = !refreshing) { Icon(Icons.Rounded.Refresh, stringResource(R.string.downloads_refresh)) } },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent, titleContentColor = colors.textPrimary),
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize()) {
+ TabRow(selectedTabIndex = tab, containerColor = Color.Transparent, contentColor = colors.textPrimary) {
+ Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text(stringResource(R.string.session_files_tab)) })
+ Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(stringResource(R.string.downloads_title)) })
+ }
+ PullToRefreshBox(isRefreshing = refreshing, onRefresh = refresh, modifier = Modifier.weight(1f)) {
+ if (tab == 0) {
+ SessionFilesContent(files, records, filesViewModel::selectConversation, onViewRemote,
+ { path -> filesViewModel.download(path, onOpenFile) }, onOpenSource)
+ } else {
+ LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ item {
+ Text(stringResource(R.string.downloads_description), modifier = Modifier.padding(horizontal = 16.dp), color = colors.textSecondary,
+ style = MaterialTheme.typography.bodySmall)
+ }
+ (error ?: sourceError)?.let { message -> item { Text(message, color = colors.danger, modifier = Modifier.padding(horizontal = 16.dp)) } }
+ if (records.isEmpty() && !refreshing) item {
+ Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
+ Text(stringResource(R.string.downloads_empty), color = colors.textSecondary)
+ }
+ }
+ items(records, key = { it.key }) { record ->
+ Card(Modifier.padding(horizontal = 12.dp).fillMaxWidth().clickable { onOpenFile(record.key) },
+ colors = CardDefaults.cardColors(containerColor = colors.bgElevated.copy(alpha = 0.65f)),
+ border = BorderStroke(0.5.dp, colors.surfaceStroke),
+ ) {
+ Column(Modifier.padding(vertical = 8.dp)) {
+ FileDownloadBanner(record, { viewModel.retry(record.key) }, { onOpenFile(record.key) })
+ Text(
+ "${DateFormat.getDateFormat(context).format(Date(record.createdAt))} ${DateFormat.getTimeFormat(context).format(Date(record.createdAt))}" +
+ if (record.total >= 0) " · ${Formatter.formatFileSize(context, record.total)}" else "",
+ color = colors.textTertiary, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
+ if (record.conversationId != null) TextButton(
+ onClick = { viewModel.source(record.key, onOpenSource) }, modifier = Modifier.weight(1f),
+ ) {
+ Text(record.conversationTitle.ifBlank { stringResource(R.string.file_source_conversation) }, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ } else Box(Modifier.weight(1f))
+ if (record.busy) TextButton(onClick = { viewModel.cancel(record.key) }) { Text(stringResource(R.string.common_cancel)) }
+ else TextButton(onClick = { viewModel.remove(record.key) }) { Text(stringResource(R.string.downloads_remove_record)) }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/codeg/android/feature/downloads/DownloadsViewModel.kt b/app/src/main/java/app/codeg/android/feature/downloads/DownloadsViewModel.kt
new file mode 100644
index 0000000..d4d0e78
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/downloads/DownloadsViewModel.kt
@@ -0,0 +1,102 @@
+package app.codeg.android.feature.downloads
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.ImageDecoder
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.codeg.android.R
+import app.codeg.android.core.data.ServerRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+internal data class LocalPreviewState(
+ val loading: Boolean = false,
+ val image: Bitmap? = null,
+ val text: TextFilePreview? = null,
+ val error: String? = null,
+)
+
+@HiltViewModel
+internal class DownloadsViewModel @Inject constructor(
+ private val downloads: DownloadRepository,
+ private val servers: ServerRepository,
+ savedStateHandle: SavedStateHandle,
+ @ApplicationContext private val context: Context,
+) : ViewModel() {
+ private val key: String? = savedStateHandle["downloadKey"]
+ val records = combine(downloads.records, servers.selectedProfile) { list, profile ->
+ list.filter { profile != null && it.belongsTo(profile) }
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+ val record = downloads.records.map { list -> list.firstOrNull { it.key == key } }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
+ val error = downloads.error
+ val refreshing = downloads.refreshing
+ val preview = MutableStateFlow(LocalPreviewState())
+ val sourceError = MutableStateFlow(null)
+ private var previewJob: Job? = null
+
+ fun refresh() { downloads.refresh() }
+ fun retry(key: String) { downloads.retry(key) }
+ fun cancel(key: String) { downloads.cancel(key) }
+ fun remove(key: String) { downloads.remove(key) }
+ fun openExternal(key: String) { downloads.openExternal(key) }
+ fun redownload(key: String): String? = downloads.redownload(key)
+
+ fun source(key: String, onOpen: (Int) -> Unit) = viewModelScope.launch {
+ val record = downloads.records.value.firstOrNull { it.key == key } ?: return@launch
+ val profile = servers.selectedProfile.first()
+ if (profile == null || !record.belongsTo(profile) || record.conversationId == null) {
+ sourceError.value = context.getString(R.string.download_source_unavailable)
+ } else {
+ sourceError.value = null
+ onOpen(record.conversationId)
+ }
+ }
+
+ fun loadPreview() {
+ val current = record.value ?: return
+ if (current.status != FileDownloadStatus.COMPLETE) return
+ previewJob?.cancel()
+ previewJob = viewModelScope.launch {
+ preview.value = LocalPreviewState(loading = true)
+ try {
+ val uri = downloads.contentUri(current.key)
+ preview.value = withContext(Dispatchers.IO) {
+ when (filePreviewKind(current.filename)) {
+ FilePreviewKind.IMAGE -> {
+ val image = ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri)) { decoder, info, _ ->
+ val scale = minOf(1f, 2048f / info.size.width, 2048f / info.size.height)
+ decoder.setTargetSize(maxOf(1, (info.size.width * scale).toInt()), maxOf(1, (info.size.height * scale).toInt()))
+ decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
+ }
+ LocalPreviewState(image = image)
+ }
+ FilePreviewKind.TEXT -> {
+ val text = context.contentResolver.openInputStream(uri)?.use(::readTextPreview)
+ ?: error(context.getString(R.string.download_missing))
+ LocalPreviewState(text = text)
+ }
+ FilePreviewKind.EXTERNAL -> LocalPreviewState()
+ }
+ }
+ } catch (e: CancellationException) { throw e
+ } catch (e: Exception) {
+ preview.value = LocalPreviewState(error = context.getString(R.string.file_preview_failed))
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/codeg/android/feature/downloads/FileViewerScreen.kt b/app/src/main/java/app/codeg/android/feature/downloads/FileViewerScreen.kt
new file mode 100644
index 0000000..cbc523d
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/downloads/FileViewerScreen.kt
@@ -0,0 +1,172 @@
+package app.codeg.android.feature.downloads
+
+import android.graphics.Bitmap
+import android.text.format.Formatter
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.rememberTransformableState
+import androidx.compose.foundation.gestures.transformable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.automirrored.rounded.OpenInNew
+import androidx.compose.material.icons.rounded.Download
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import app.codeg.android.R
+import app.codeg.android.core.designsystem.component.CopyButton
+import app.codeg.android.core.designsystem.theme.CodegTheme
+import app.codeg.android.feature.sessiondetail.FileDownloadBanner
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun FileViewerScreen(
+ onBack: () -> Unit,
+ onDownloads: () -> Unit,
+ onOpenFile: (String) -> Unit,
+ onOpenSource: (Int) -> Unit,
+ viewModel: DownloadsViewModel = hiltViewModel(),
+) {
+ val record by viewModel.record.collectAsStateWithLifecycle()
+ val preview by viewModel.preview.collectAsStateWithLifecycle()
+ val sourceError by viewModel.sourceError.collectAsStateWithLifecycle()
+ val error by viewModel.error.collectAsStateWithLifecycle()
+ val colors = CodegTheme.colors
+ val context = LocalContext.current
+ LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refresh() }
+ LaunchedEffect(record?.id, record?.status) { viewModel.loadPreview() }
+ Scaffold(
+ containerColor = Color.Transparent,
+ topBar = {
+ TopAppBar(
+ title = { Text(record?.filename ?: stringResource(R.string.file_viewer_title), maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.common_back)) } },
+ actions = {
+ if (record?.status == FileDownloadStatus.COMPLETE) IconButton(onClick = { record?.let { viewModel.redownload(it.key)?.let(onOpenFile) } }) {
+ Icon(Icons.Rounded.Refresh, stringResource(R.string.file_redownload))
+ }
+ if (record?.status == FileDownloadStatus.COMPLETE) IconButton(onClick = { record?.let { viewModel.openExternal(it.key) } }) {
+ Icon(Icons.AutoMirrored.Rounded.OpenInNew, stringResource(R.string.file_open_external))
+ }
+ IconButton(onClick = onDownloads) { Icon(Icons.Rounded.Download, stringResource(R.string.downloads_title)) }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent, titleContentColor = colors.textPrimary),
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize()) {
+ val current = record
+ if (current == null) {
+ Text(error ?: stringResource(R.string.download_missing), color = colors.textSecondary, modifier = Modifier.padding(20.dp))
+ return@Column
+ }
+ if (current.status != FileDownloadStatus.COMPLETE) {
+ FileDownloadBanner(current, { viewModel.retry(current.key) }, {})
+ if (current.busy) TextButton(onClick = { viewModel.cancel(current.key) }, modifier = Modifier.align(Alignment.End)) { Text(stringResource(R.string.common_cancel)) }
+ }
+ Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) {
+ if (current.conversationId != null) TextButton(onClick = { viewModel.source(current.key, onOpenSource) }, modifier = Modifier.weight(1f)) {
+ Text(stringResource(R.string.file_source_conversation), maxLines = 1)
+ } else Box(Modifier.weight(1f))
+ if (current.total >= 0) Text(Formatter.formatFileSize(context, current.total), color = colors.textTertiary, style = MaterialTheme.typography.bodySmall)
+ preview.text?.let { CopyButton(it.text) }
+ }
+ (sourceError ?: error ?: current.error?.takeIf { current.status == FileDownloadStatus.COMPLETE })?.let {
+ Text(it, color = colors.danger, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
+ }
+ Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
+ when {
+ current.status != FileDownloadStatus.COMPLETE -> Text(stringResource(R.string.file_wait_for_download), color = colors.textSecondary, modifier = Modifier.padding(24.dp))
+ preview.loading -> CircularProgressIndicator()
+ preview.image != null -> ZoomableImage(requireNotNull(preview.image), current.filename)
+ preview.text != null -> TextPreviewContent(requireNotNull(preview.text))
+ else -> Column(Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(preview.error ?: stringResource(if (current.apk) R.string.file_apk_description else R.string.file_preview_external), color = colors.textSecondary)
+ Button(onClick = { viewModel.openExternal(current.key) }) {
+ Text(stringResource(if (current.apk) R.string.download_install else R.string.file_open_external))
+ }
+ if (preview.error != null) TextButton(onClick = viewModel::loadPreview) { Text(stringResource(R.string.common_retry)) }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+internal fun TextPreviewContent(preview: TextFilePreview) {
+ val colors = CodegTheme.colors
+ Column(Modifier.fillMaxSize()) {
+ if (preview.truncated) Text(stringResource(R.string.file_preview_truncated), color = colors.textTertiary,
+ style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
+ SelectionContainer(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).horizontalScroll(rememberScrollState())) {
+ Text(preview.text, fontFamily = FontFamily.Monospace, color = colors.textPrimary, style = MaterialTheme.typography.bodySmall,
+ softWrap = false, modifier = Modifier.padding(16.dp))
+ }
+ }
+}
+
+@Composable
+internal fun ZoomableImage(bitmap: Bitmap, description: String) {
+ var zoom by remember(bitmap) { mutableFloatStateOf(1f) }
+ var pan by remember(bitmap) { mutableStateOf(Offset.Zero) }
+ var size by remember { mutableStateOf(IntSize.Zero) }
+ val state = rememberTransformableState { change, delta, _ ->
+ zoom = (zoom * change).coerceIn(1f, 5f)
+ val x = size.width * (zoom - 1) / 2
+ val y = size.height * (zoom - 1) / 2
+ pan = Offset((pan.x + delta.x).coerceIn(-x, x), (pan.y + delta.y).coerceIn(-y, y))
+ }
+ Box(Modifier.fillMaxSize().clipToBounds().onSizeChanged { size = it }.transformable(state)
+ .pointerInput(bitmap) { detectTapGestures(onDoubleTap = { zoom = if (zoom > 1f) 1f else 2f; pan = Offset.Zero }) }) {
+ Image(remember(bitmap) { bitmap.asImageBitmap() }, contentDescription = description, contentScale = ContentScale.Fit,
+ modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = zoom; scaleY = zoom; translationX = pan.x; translationY = pan.y })
+ }
+}
diff --git a/app/src/main/java/app/codeg/android/feature/downloads/LocalFilePreview.kt b/app/src/main/java/app/codeg/android/feature/downloads/LocalFilePreview.kt
new file mode 100644
index 0000000..f4c2b71
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/downloads/LocalFilePreview.kt
@@ -0,0 +1,58 @@
+package app.codeg.android.feature.downloads
+
+import java.io.InputStream
+import java.nio.ByteBuffer
+import java.nio.CharBuffer
+import java.nio.charset.CodingErrorAction
+import java.util.Locale
+
+internal enum class FilePreviewKind { IMAGE, TEXT, EXTERNAL }
+
+internal fun filePreviewKind(filename: String): FilePreviewKind {
+ val extension = filename.substringAfterLast('.', "").lowercase(Locale.ROOT)
+ return when {
+ extension in setOf("png", "jpg", "jpeg", "webp", "gif", "bmp", "heic", "heif", "avif") -> FilePreviewKind.IMAGE
+ extension in setOf("txt", "md", "markdown", "kt", "kts", "java", "py", "js", "jsx", "ts", "tsx", "json", "jsonl",
+ "xml", "html", "htm", "css", "scss", "less", "csv", "tsv", "yaml", "yml", "toml", "ini", "conf", "config",
+ "properties", "log", "sh", "bash", "zsh", "bat", "ps1", "php", "rb", "rs", "go", "c", "h", "cpp", "hpp",
+ "swift", "m", "mm", "dart", "sql", "vue", "svelte", "gradle", "gitignore", "env", "rst", "tex") ||
+ filename.lowercase(Locale.ROOT) in setOf("dockerfile", "makefile", "license", "readme") -> FilePreviewKind.TEXT
+ else -> FilePreviewKind.EXTERNAL
+ }
+}
+
+internal data class TextFilePreview(val text: String, val truncated: Boolean)
+internal const val MAX_PREVIEW_BYTES = 128 * 1024
+internal const val MAX_PREVIEW_LINES = 2000
+
+/** A bounded read, including UTF-8/UTF-16 BOMs and a cutoff inside a multibyte character. */
+internal fun readTextPreview(input: InputStream): TextFilePreview {
+ val buffer = ByteArray(MAX_PREVIEW_BYTES + 1)
+ var used = 0
+ while (used < buffer.size) {
+ val count = input.read(buffer, used, buffer.size - used)
+ if (count < 0) break
+ if (count == 0) {
+ val byte = input.read()
+ if (byte < 0) break
+ buffer[used++] = byte.toByte()
+ } else used += count
+ }
+ val truncated = used > MAX_PREVIEW_BYTES
+ val size = minOf(used, MAX_PREVIEW_BYTES)
+ val (charset, bom) = when {
+ size >= 3 && buffer[0] == 0xEF.toByte() && buffer[1] == 0xBB.toByte() && buffer[2] == 0xBF.toByte() -> Charsets.UTF_8 to 3
+ size >= 2 && buffer[0] == 0xFF.toByte() && buffer[1] == 0xFE.toByte() -> Charsets.UTF_16LE to 2
+ size >= 2 && buffer[0] == 0xFE.toByte() && buffer[1] == 0xFF.toByte() -> Charsets.UTF_16BE to 2
+ else -> Charsets.UTF_8 to 0
+ }
+ val output = CharBuffer.allocate(size + 1)
+ val decoder = charset.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT)
+ val result = decoder.decode(ByteBuffer.wrap(buffer, bom, size - bom), output, !truncated)
+ if (result.isError) result.throwException()
+ output.flip()
+ val text = output.toString()
+ require(text.none { it == '\u0000' }) { "Binary file" }
+ val lines = text.lineSequence().take(MAX_PREVIEW_LINES + 1).toList()
+ return TextFilePreview(lines.take(MAX_PREVIEW_LINES).joinToString("\n"), truncated || lines.size > MAX_PREVIEW_LINES)
+}
diff --git a/app/src/main/java/app/codeg/android/feature/downloads/SessionFileViewerScreen.kt b/app/src/main/java/app/codeg/android/feature/downloads/SessionFileViewerScreen.kt
new file mode 100644
index 0000000..4c2e860
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/downloads/SessionFileViewerScreen.kt
@@ -0,0 +1,65 @@
+package app.codeg.android.feature.downloads
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.Download
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import app.codeg.android.R
+import app.codeg.android.core.designsystem.component.CopyButton
+import app.codeg.android.core.designsystem.theme.CodegTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun SessionFileViewerScreen(
+ onBack: () -> Unit,
+ onOpenDownload: (String) -> Unit,
+ onOpenSource: (Int) -> Unit,
+ viewModel: SessionFilesViewModel = hiltViewModel(),
+) {
+ val preview by viewModel.preview.collectAsStateWithLifecycle()
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val colors = CodegTheme.colors
+ val path = viewModel.path.orEmpty()
+ val filename = path.substringAfterLast('/').substringAfterLast('\\')
+ Scaffold(containerColor = Color.Transparent, topBar = {
+ TopAppBar(title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.common_back)) } },
+ actions = {
+ IconButton(onClick = viewModel::loadPreview, enabled = !preview.loading) { Icon(Icons.Rounded.Refresh, stringResource(R.string.downloads_refresh)) }
+ IconButton(onClick = { viewModel.download(path, onOpenDownload) }, enabled = state.error == null) { Icon(Icons.Rounded.Download, stringResource(R.string.session_file_download)) }
+ }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent, titleContentColor = colors.textPrimary))
+ }) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize()) {
+ Text(path, color = colors.textTertiary, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp), maxLines = 3)
+ Text(stringResource(R.string.session_file_current), color = colors.textSecondary, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ TextButton(onClick = { state.conversationId?.let(onOpenSource) }, enabled = state.error == null, modifier = Modifier.weight(1f)) { Text(stringResource(R.string.file_source_conversation)) }
+ preview.text?.let { CopyButton(it.text) }
+ }
+ Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {
+ when {
+ state.error != null -> Text(state.error.orEmpty(), color = colors.danger, modifier = Modifier.padding(24.dp))
+ preview.loading -> CircularProgressIndicator()
+ preview.image != null -> ZoomableImage(requireNotNull(preview.image), filename)
+ preview.text != null -> TextPreviewContent(requireNotNull(preview.text))
+ else -> Column(Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(preview.error ?: stringResource(R.string.file_preview_external), color = colors.textSecondary)
+ Button(onClick = { viewModel.download(path, onOpenDownload) }) { Text(stringResource(R.string.session_file_download)) }
+ if (preview.error != null) TextButton(onClick = viewModel::loadPreview) { Text(stringResource(R.string.common_retry)) }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/codeg/android/feature/downloads/SessionFilesContent.kt b/app/src/main/java/app/codeg/android/feature/downloads/SessionFilesContent.kt
new file mode 100644
index 0000000..40a6e26
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/downloads/SessionFilesContent.kt
@@ -0,0 +1,88 @@
+package app.codeg.android.feature.downloads
+
+import android.text.format.DateFormat
+import android.text.format.Formatter
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import app.codeg.android.R
+import app.codeg.android.core.designsystem.theme.CodegTheme
+import java.time.Instant
+import java.util.Date
+
+@Composable
+internal fun SessionFilesContent(
+ state: SessionFilesState,
+ records: List,
+ onSelect: (Int?) -> Unit,
+ onView: (Int, String) -> Unit,
+ onDownload: (String) -> Unit,
+ onSource: (Int) -> Unit,
+) {
+ val colors = CodegTheme.colors
+ val context = LocalContext.current
+ var query by rememberSaveable(state.conversationId) { mutableStateOf("") }
+ LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(12.dp)) {
+ item {
+ Text(stringResource(R.string.session_files_description), color = colors.textSecondary, style = MaterialTheme.typography.bodySmall)
+ OutlinedTextField(query, { query = it }, modifier = Modifier.fillMaxWidth(), singleLine = true,
+ placeholder = { Text(stringResource(R.string.session_files_search)) })
+ if (state.conversationId != null) Row {
+ TextButton(onClick = { onSelect(null) }) { Text(stringResource(R.string.session_files_choose)) }
+ TextButton(onClick = { onSource(state.conversationId) }) { Text(stringResource(R.string.file_source_conversation)) }
+ } else Text(stringResource(R.string.session_files_choose), color = colors.textSecondary, modifier = Modifier.padding(top = 8.dp))
+ }
+ state.error?.let { message -> item { Text(message, color = colors.danger) } }
+ if (state.conversationId == null) {
+ items(state.conversations.filter { it.title.orEmpty().contains(query, ignoreCase = true) }, key = { it.id }) { session ->
+ Card(Modifier.fillMaxWidth().clickable { onSelect(session.id) }, colors = CardDefaults.cardColors(containerColor = colors.bgElevated)) {
+ Text(session.title ?: "#${session.id}", modifier = Modifier.padding(16.dp), color = colors.textPrimary, maxLines = 2)
+ }
+ }
+ } else {
+ val visible = state.files.filter { it.path.contains(query, ignoreCase = true) }
+ if (visible.isEmpty() && !state.loading && state.error == null) item {
+ Text(stringResource(R.string.session_files_empty), color = colors.textSecondary, modifier = Modifier.padding(20.dp))
+ }
+ items(visible, key = { it.path }) { file ->
+ Card(Modifier.fillMaxWidth().clickable(enabled = file.available != false) { onView(state.conversationId, file.path) },
+ colors = CardDefaults.cardColors(containerColor = colors.bgElevated), border = BorderStroke(0.5.dp, colors.surfaceStroke)) {
+ Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(file.filename, color = colors.textPrimary, style = MaterialTheme.typography.titleSmall)
+ Text(file.path, color = colors.textTertiary, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ Text(stringResource(when (file.operation) {
+ "write" -> R.string.session_file_written
+ "edit", "apply_patch" -> R.string.session_file_modified
+ "delete" -> R.string.session_file_deleted
+ else -> R.string.session_file_referenced
+ }) + (file.size?.let { " · ${Formatter.formatFileSize(context, it)}" } ?: ""), color = colors.textSecondary, style = MaterialTheme.typography.bodySmall)
+ val timestamp = runCatching {
+ val date = Date.from(Instant.parse(file.timestamp))
+ "${DateFormat.getDateFormat(context).format(date)} ${DateFormat.getTimeFormat(context).format(date)}"
+ }.getOrDefault(file.timestamp)
+ Text(timestamp, color = colors.textTertiary, style = MaterialTheme.typography.bodySmall)
+ if (file.available == false) Text(stringResource(R.string.session_file_unavailable), color = colors.danger, style = MaterialTheme.typography.bodySmall)
+ if (file.available == null) Text(stringResource(R.string.session_file_check_on_open), color = colors.textTertiary, style = MaterialTheme.typography.bodySmall)
+ Row {
+ TextButton(onClick = { onView(state.conversationId, file.path) }, enabled = file.available != false) { Text(stringResource(R.string.session_file_view)) }
+ TextButton(onClick = { onDownload(file.path) }, enabled = file.available != false) { Text(stringResource(R.string.session_file_download)) }
+ if (records.any { it.conversationId == state.conversationId && android.net.Uri.decode(it.link) == file.path && it.status == FileDownloadStatus.COMPLETE }) {
+ Text(stringResource(R.string.session_file_saved), color = colors.textTertiary, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/codeg/android/feature/downloads/SessionFilesViewModel.kt b/app/src/main/java/app/codeg/android/feature/downloads/SessionFilesViewModel.kt
new file mode 100644
index 0000000..2dc429e
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/downloads/SessionFilesViewModel.kt
@@ -0,0 +1,145 @@
+package app.codeg.android.feature.downloads
+
+import android.content.Context
+import android.graphics.ImageDecoder
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.codeg.android.R
+import app.codeg.android.core.data.ServerRepository
+import app.codeg.android.core.model.ConversationSummary
+import app.codeg.android.core.model.SessionFile
+import app.codeg.android.core.network.ApiError
+import app.codeg.android.core.network.displayMessage
+import app.codeg.android.feature.sessiondetail.workspaceFileLink
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.nio.ByteBuffer
+import javax.inject.Inject
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+internal data class SessionFilesState(
+ val conversationId: Int? = null,
+ val conversations: List = emptyList(),
+ val files: List = emptyList(),
+ val loading: Boolean = false,
+ val error: String? = null,
+)
+
+@HiltViewModel
+internal class SessionFilesViewModel @Inject constructor(
+ private val servers: ServerRepository,
+ private val downloads: DownloadRepository,
+ private val savedState: SavedStateHandle,
+ @ApplicationContext private val context: Context,
+) : ViewModel() {
+ private val initialId: Int? = savedState.get("conversationId")?.takeIf { it > 0 }
+ val path: String? = savedState["path"]
+ val state = MutableStateFlow(SessionFilesState(conversationId = initialId))
+ val preview = MutableStateFlow(LocalPreviewState())
+ private var refreshJob: Job? = null
+ private var previewJob: Job? = null
+ private var profile: app.codeg.android.core.datastore.ServerProfile? = null
+
+ init {
+ viewModelScope.launch {
+ servers.selectedProfile.collectLatest { selected ->
+ refreshJob?.cancel()
+ previewJob?.cancel()
+ profile = selected
+ val identity = selected?.let { "${it.id}\u0000${it.baseUrl.trimEnd('/')}" }
+ val origin: String? = savedState["fileOrigin"]
+ if (origin != null && origin != identity) {
+ state.value = SessionFilesState(error = context.getString(R.string.download_source_unavailable))
+ preview.value = LocalPreviewState()
+ profile = null
+ return@collectLatest
+ }
+ if (identity != null) savedState["fileOrigin"] = identity
+ state.value = SessionFilesState(conversationId = initialId)
+ preview.value = LocalPreviewState()
+ if (path != null) loadPreview() else refresh()
+ }
+ }
+ }
+
+ fun selectConversation(id: Int?) {
+ state.value = state.value.copy(conversationId = id, files = emptyList(), error = null)
+ refresh()
+ }
+
+ fun refresh() {
+ refreshJob?.cancel()
+ val selected = profile ?: return
+ refreshJob = viewModelScope.launch {
+ state.value = state.value.copy(loading = true, error = null)
+ try {
+ val client = servers.client(selected) ?: error(context.getString(R.string.download_source_unavailable))
+ val id = state.value.conversationId
+ if (id == null) {
+ val sessions = client.listConversations(sortBy = "updated_at")
+ state.value = state.value.copy(conversations = sessions.sortedByDescending { it.updatedAt }, loading = false)
+ } else {
+ if (state.value.files.isEmpty()) client.cachedConversationFiles(id)?.let {
+ state.value = state.value.copy(files = it)
+ }
+ val files = client.conversationFiles(id)
+ state.value = state.value.copy(files = files, loading = false)
+ }
+ } catch (e: CancellationException) { throw e
+ } catch (e: Exception) {
+ val message = if (e is ApiError.Server && e.status == 404 && e.code == null) context.getString(R.string.session_files_server_update) else e.displayMessage()
+ state.value = state.value.copy(loading = false, error = message)
+ }
+ }
+ }
+
+ fun download(filePath: String, onStarted: (String) -> Unit) = viewModelScope.launch {
+ val selected = profile ?: return@launch
+ if (servers.selectedProfile.first() != selected) return@launch
+ val id = state.value.conversationId
+ val title = state.value.conversations.firstOrNull { it.id == id }?.title.orEmpty()
+ onStarted(downloads.start(selected, id, title, null, android.net.Uri.encode(filePath, "/\\:")))
+ }
+
+ fun loadPreview() {
+ previewJob?.cancel()
+ val selected = profile ?: return
+ val target = path?.let { workspaceFileLink(android.net.Uri.encode(it, "/\\:"), emptyList()) } ?: return
+ val filename = path.substringAfterLast('/')
+ if (filePreviewKind(filename) == FilePreviewKind.EXTERNAL) return
+ previewJob = viewModelScope.launch {
+ preview.value = LocalPreviewState(loading = true)
+ try {
+ val client = servers.client(selected) ?: error("Server unavailable")
+ val image = filePreviewKind(filename) == FilePreviewKind.IMAGE
+ val limit = if (image) 12 * 1024 * 1024 + 1 else MAX_PREVIEW_BYTES + 1
+ val bytes = client.workspacePreviewBytes(target.rootPath, target.path, limit)
+ if (image && bytes.size >= limit) {
+ preview.value = LocalPreviewState(error = context.getString(R.string.session_file_image_large))
+ return@launch
+ }
+ preview.value = withContext(Dispatchers.IO) {
+ if (image) {
+ val bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(ByteBuffer.wrap(bytes))) { decoder, info, _ ->
+ val scale = minOf(1f, 2048f / info.size.width, 2048f / info.size.height)
+ decoder.setTargetSize(maxOf(1, (info.size.width * scale).toInt()), maxOf(1, (info.size.height * scale).toInt()))
+ decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
+ }
+ LocalPreviewState(image = bitmap)
+ } else LocalPreviewState(text = bytes.inputStream().use(::readTextPreview))
+ }
+ } catch (e: CancellationException) { throw e
+ } catch (e: Exception) {
+ preview.value = LocalPreviewState(error = context.getString(R.string.session_file_preview_failed))
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/codeg/android/feature/main/MainShell.kt b/app/src/main/java/app/codeg/android/feature/main/MainShell.kt
index 8374e74..e3c6e84 100644
--- a/app/src/main/java/app/codeg/android/feature/main/MainShell.kt
+++ b/app/src/main/java/app/codeg/android/feature/main/MainShell.kt
@@ -63,6 +63,8 @@ import app.codeg.android.app.AppViewModel
import app.codeg.android.core.datastore.ServerProfile
import app.codeg.android.core.designsystem.theme.CodegTheme
import app.codeg.android.feature.activity.ActivityScreen
+import app.codeg.android.feature.downloads.DownloadsScreen
+import app.codeg.android.feature.downloads.FileViewerScreen
import app.codeg.android.feature.projects.ProjectDetailScreen
import app.codeg.android.feature.search.SearchScreen
import app.codeg.android.feature.projects.ProjectListScreen
@@ -70,6 +72,7 @@ import app.codeg.android.feature.server.ServerEditorScreen
import app.codeg.android.feature.settings.SettingsScreen
import app.codeg.android.feature.server.ServerListScreen
import app.codeg.android.feature.sessiondetail.SessionDetailScreen
+import app.codeg.android.feature.sessiondetail.SessionDetailViewModel
import app.codeg.android.feature.sessions.SessionListScreen
/**
@@ -171,6 +174,25 @@ fun MainShell(appViewModel: AppViewModel, servers: List) {
restoreState = true
}
}
+ val sourceEntryIds = remember { mutableMapOf() }
+ val sourceKey: (Int) -> String = { id -> "${selectedProfile?.id}\u0000${selectedProfile?.baseUrl}\u0000$id" }
+ val rememberSource: (Int?, String) -> Unit = { id, entryId -> if (id != null) sourceEntryIds[sourceKey(id)] = entryId }
+ val openDownloads: () -> Unit = {
+ if (nav.currentDestination?.route == "download/{downloadKey}" && nav.previousBackStackEntry?.destination?.route == "downloads") {
+ nav.popBackStack()
+ } else nav.navigate("downloads") { launchSingleTop = true }
+ }
+ val openFile: (String) -> Unit = { key ->
+ if (nav.currentDestination?.route == "download/{downloadKey}") nav.popBackStack()
+ nav.navigate("download/$key")
+ }
+ val openSource: (Int) -> Unit = { id ->
+ // A newly created conversation still occupies its original draft destination.
+ val draft = runCatching { nav.getBackStackEntry("$ROUTE_NEW_TASK?folderId={folderId}") }.getOrNull()
+ if (draft != null && draft.id == sourceEntryIds[sourceKey(id)]) {
+ nav.popBackStack(draft.destination.id, false)
+ } else if (!nav.popBackStack("conversation/$id", false)) nav.navigate("conversation/$id") { launchSingleTop = true }
+ }
// Handle incoming codeg:// deep links.
val pendingLink by app.codeg.android.core.common.DeepLinkBus.pending.collectAsStateWithLifecycle()
@@ -267,6 +289,7 @@ fun MainShell(appViewModel: AppViewModel, servers: List) {
onManageServers = { nav.navigate(ROUTE_SERVERS) },
onOpenConversation = { id -> nav.navigate("conversation/$id") },
onNewTask = { nav.navigate(ROUTE_NEW_TASK) },
+ onSearch = { nav.navigate(HomeTab.SEARCH.route) { launchSingleTop = true } },
)
}
}
@@ -280,16 +303,39 @@ fun MainShell(appViewModel: AppViewModel, servers: List) {
SearchScreen(onOpenConversation = { id -> nav.navigate("conversation/$id") })
}
composable(HomeTab.SETTINGS.route) {
- SettingsScreen()
+ SettingsScreen(onOpenDownloads = openDownloads)
+ }
+
+ composable("downloads") {
+ DownloadsScreen(onBack = { nav.popBackStack() }, onOpenFile = openFile, onOpenSource = openSource,
+ initialTab = if (nav.previousBackStackEntry?.destination?.route == "download/{downloadKey}") 1 else 0,
+ onViewRemote = { id, path -> nav.navigate("session-file/$id?path=${android.net.Uri.encode(path)}") })
+ }
+ composable("session-files/{conversationId}", arguments = listOf(navArgument("conversationId") { type = NavType.IntType })) {
+ DownloadsScreen(onBack = { nav.popBackStack() }, onOpenFile = openFile, onOpenSource = openSource,
+ onViewRemote = { id, path -> nav.navigate("session-file/$id?path=${android.net.Uri.encode(path)}") })
+ }
+ composable("session-file/{conversationId}?path={path}", arguments = listOf(
+ navArgument("conversationId") { type = NavType.IntType }, navArgument("path") { type = NavType.StringType },
+ )) {
+ app.codeg.android.feature.downloads.SessionFileViewerScreen(onBack = { nav.popBackStack() }, onOpenDownload = openFile, onOpenSource = openSource)
+ }
+ composable("download/{downloadKey}", arguments = listOf(navArgument("downloadKey") { type = NavType.StringType })) {
+ FileViewerScreen(onBack = { nav.popBackStack() }, onDownloads = openDownloads, onOpenFile = openFile, onOpenSource = openSource)
}
composable(
route = "conversation/{id}",
arguments = listOf(navArgument("id") { type = NavType.IntType }),
- ) {
+ ) { entry ->
+ val model: SessionDetailViewModel = hiltViewModel()
SessionDetailScreen(
+ viewModel = model,
onBack = { nav.popBackStack() },
onOpenSession = { folderId -> nav.navigate("$ROUTE_NEW_TASK?folderId=$folderId") },
+ onOpenDownloads = { val id = model.ui.value.summary?.id; rememberSource(id, entry.id); if (id != null) nav.navigate("session-files/$id") else openDownloads() },
+ onOpenFile = { key -> rememberSource(model.ui.value.summary?.id, entry.id); openFile(key) },
+ onViewRemote = { id, path -> rememberSource(id, entry.id); nav.navigate("session-file/$id?path=${android.net.Uri.encode(path)}") },
)
}
composable(
@@ -300,10 +346,15 @@ fun MainShell(appViewModel: AppViewModel, servers: List) {
defaultValue = -1
},
),
- ) {
+ ) { entry ->
+ val model: SessionDetailViewModel = hiltViewModel()
SessionDetailScreen(
+ viewModel = model,
onBack = { nav.popBackStack() },
onOpenSession = { folderId -> nav.navigate("$ROUTE_NEW_TASK?folderId=$folderId") },
+ onOpenDownloads = { val id = model.ui.value.summary?.id; rememberSource(id, entry.id); if (id != null) nav.navigate("session-files/$id") else openDownloads() },
+ onOpenFile = { key -> rememberSource(model.ui.value.summary?.id, entry.id); openFile(key) },
+ onViewRemote = { id, path -> rememberSource(id, entry.id); nav.navigate("session-file/$id?path=${android.net.Uri.encode(path)}") },
)
}
diff --git a/app/src/main/java/app/codeg/android/feature/projects/FolderManagementSheet.kt b/app/src/main/java/app/codeg/android/feature/projects/FolderManagementSheet.kt
new file mode 100644
index 0000000..72bca5c
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/projects/FolderManagementSheet.kt
@@ -0,0 +1,188 @@
+package app.codeg.android.feature.projects
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
+import androidx.compose.foundation.gestures.scrollBy
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.DragHandle
+import androidx.compose.material.icons.rounded.Visibility
+import androidx.compose.material.icons.rounded.VisibilityOff
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.CustomAccessibilityAction
+import androidx.compose.ui.semantics.customActions
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import kotlinx.coroutines.isActive
+import app.codeg.android.R
+import app.codeg.android.core.designsystem.theme.CodegTheme
+import app.codeg.android.core.model.FolderDetail
+import app.codeg.android.core.model.FolderVisibility
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun FolderManagementSheet(
+ folders: List,
+ hiddenIds: Set,
+ onSetHidden: (Int, Boolean) -> Unit,
+ onMove: (Int, Int) -> Unit,
+ onReorder: (List) -> Unit,
+ onReset: () -> Unit,
+ onDismiss: () -> Unit,
+ error: String?,
+) {
+ val colors = CodegTheme.colors
+ val hidden = remember(folders, hiddenIds) { FolderVisibility.hiddenIds(folders, hiddenIds) }
+ val listState = rememberLazyListState()
+ var displayed by remember(folders) { mutableStateOf(folders) }
+ var draggedId by remember(folders) { mutableStateOf(null) }
+ var dragTop by remember { mutableStateOf(0f) }
+ var fingerY by remember { mutableStateOf(0f) }
+ var dragHeight by remember { mutableStateOf(0) }
+ val saveOrder by rememberUpdatedState(onReorder)
+ val haptic = LocalHapticFeedback.current
+ val edge = with(LocalDensity.current) { 48.dp.toPx() }
+ LaunchedEffect(draggedId) {
+ if (draggedId == null) return@LaunchedEffect
+ while (isActive) {
+ withFrameNanos { }
+ val info = listState.layoutInfo
+ val from = displayed.indexOfFirst { it.id == draggedId }
+ val current = info.visibleItemsInfo.firstOrNull { it.key == draggedId }
+ val center = (dragTop + dragHeight / 2f)
+ .coerceIn(info.viewportStartOffset.toFloat(), (info.viewportEndOffset - 1).toFloat())
+ val target = info.visibleItemsInfo.firstOrNull {
+ it.key != draggedId && center >= it.offset && center < it.offset + it.size
+ }
+ if (from >= 0 && current?.index == from && target != null && displayed.getOrNull(target.index)?.id == target.key) {
+ // Keep the viewport fixed while the first visible item changes places.
+ listState.requestScrollToItem(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset)
+ displayed = displayed.toMutableList().apply { add(target.index, removeAt(from)) }
+ }
+ val scroll = when {
+ listState.canScrollBackward && fingerY < info.viewportStartOffset + edge -> ((fingerY - info.viewportStartOffset - edge) / 5f).coerceAtLeast(-edge / 3f)
+ listState.canScrollForward && fingerY > info.viewportEndOffset - edge -> ((fingerY - info.viewportEndOffset + edge) / 5f).coerceAtMost(edge / 3f)
+ else -> 0f
+ }
+ if (scroll != 0f) listState.scrollBy(scroll)
+ }
+ }
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ containerColor = colors.bgElevated,
+ ) {
+ Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 28.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(stringResource(R.string.folders_manage), style = MaterialTheme.typography.titleLarge, color = colors.textPrimary)
+ Text(stringResource(R.string.folders_management_description), style = MaterialTheme.typography.bodySmall, color = colors.textSecondary)
+ error?.let { Text(it, color = colors.danger, style = MaterialTheme.typography.bodySmall) }
+ LazyColumn(
+ state = listState,
+ // Programmatic edge scrolling must not let the list steal the held pointer.
+ userScrollEnabled = draggedId == null,
+ modifier = Modifier.weight(1f, fill = false).fillMaxWidth().heightIn(max = 440.dp).pointerInput(folders) {
+ detectDragGesturesAfterLongPress(
+ onDragStart = { position ->
+ listState.layoutInfo.visibleItemsInfo.firstOrNull {
+ position.y >= it.offset && position.y < it.offset + it.size
+ }?.let {
+ draggedId = it.key as Int
+ dragTop = it.offset.toFloat()
+ fingerY = position.y
+ dragHeight = it.size
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
+ },
+ onDrag = { change, amount ->
+ if (draggedId != null) {
+ change.consume()
+ dragTop += amount.y
+ fingerY += amount.y
+ }
+ },
+ onDragEnd = {
+ if (draggedId != null && displayed != folders) saveOrder(displayed.map { it.id })
+ draggedId = null
+ },
+ onDragCancel = { displayed = folders; draggedId = null },
+ )
+ },
+ ) {
+ itemsIndexed(displayed, key = { _, folder -> folder.id }) { index, folder ->
+ val dragging = draggedId == folder.id
+ val isHidden = folder.id in hidden
+ val inheritedHidden = folder.parentId in hidden
+ val upLabel = stringResource(R.string.folders_move_up, folder.name)
+ val downLabel = stringResource(R.string.folders_move_down, folder.name)
+ val placement = if (dragging) Modifier.zIndex(1f).graphicsLayer {
+ translationY = dragTop - (listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == folder.id }?.offset ?: dragTop.toInt())
+ shadowElevation = 6.dp.toPx()
+ } else Modifier.animateItem()
+ Row(placement.fillMaxWidth().height(64.dp)
+ .background(if (dragging) colors.accentDim else colors.bgElevated, RoundedCornerShape(8.dp))
+ .semantics(mergeDescendants = true) {
+ customActions = buildList {
+ if (index > 0) add(CustomAccessibilityAction(upLabel) { onMove(folder.id, -1); true })
+ if (index < displayed.lastIndex) add(CustomAccessibilityAction(downLabel) { onMove(folder.id, 1); true })
+ }
+ }.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(folder.name, color = if (isHidden) colors.textTertiary else colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(if (inheritedHidden) stringResource(R.string.folders_hidden_with_parent) else folder.path, color = colors.textTertiary, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ IconButton(
+ enabled = !inheritedHidden && draggedId == null,
+ onClick = { onSetHidden(folder.id, !isHidden) },
+ ) {
+ Icon(
+ if (isHidden) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility,
+ stringResource(if (isHidden) R.string.folders_show_named else R.string.folders_hide_named, folder.name),
+ tint = if (isHidden) colors.textTertiary else colors.accent,
+ )
+ }
+ Icon(Icons.Rounded.DragHandle, stringResource(R.string.folders_drag, folder.name), tint = colors.textTertiary)
+ }
+ }
+ }
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ TextButton(onClick = onReset) { Text(stringResource(R.string.folders_reset_order)) }
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_done)) }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/codeg/android/feature/projects/ProjectListScreen.kt b/app/src/main/java/app/codeg/android/feature/projects/ProjectListScreen.kt
index 757f61f..3422d52 100644
--- a/app/src/main/java/app/codeg/android/feature/projects/ProjectListScreen.kt
+++ b/app/src/main/java/app/codeg/android/feature/projects/ProjectListScreen.kt
@@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.AltRoute
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
+import androidx.compose.material.icons.automirrored.rounded.Sort
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.material.icons.rounded.CreateNewFolder
@@ -26,9 +27,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@@ -60,6 +63,7 @@ import app.codeg.android.core.designsystem.component.LoadingView
import app.codeg.android.core.designsystem.theme.CodegTheme
import app.codeg.android.core.designsystem.theme.colorFromHex
import app.codeg.android.core.model.FolderDetail
+import app.codeg.android.core.model.FolderOrdering
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -72,6 +76,10 @@ fun ProjectListScreen(
var menuOpen by remember { mutableStateOf(false) }
var showBrowser by remember { mutableStateOf(false) }
var showClone by remember { mutableStateOf(false) }
+ var showOrder by remember(ui.hiddenScope) { mutableStateOf(false) }
+ val visibleFolders = remember(ui.folders, ui.hiddenFolderIds, ui.folderOrder) {
+ FolderOrdering.sorted(ui.visibleFolders, ui.folderOrder)
+ }
Scaffold(
containerColor = Color.Transparent,
@@ -79,6 +87,14 @@ fun ProjectListScreen(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.tab_folders)) },
+ actions = {
+ IconButton(
+ onClick = { showOrder = true },
+ enabled = ui.hiddenScope != null && ui.folders.any { !it.isChatFolder },
+ ) {
+ Icon(Icons.AutoMirrored.Rounded.Sort, stringResource(R.string.folders_manage))
+ }
+ },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = colors.textPrimary,
@@ -117,27 +133,40 @@ fun ProjectListScreen(
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
InlineError(Icons.Rounded.FolderOpen, stringResource(R.string.projects_load_failed), ui.error!!, viewModel::refresh)
}
- ui.hasLoaded && ui.folders.isEmpty() ->
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- EmptyState(Icons.Rounded.FolderOpen, stringResource(R.string.projects_empty_title), stringResource(R.string.projects_empty_message))
+ ui.hasLoaded && visibleFolders.isEmpty() ->
+ Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
+ EmptyState(
+ Icons.Rounded.FolderOpen,
+ stringResource(if (ui.folders.isNotEmpty()) R.string.folders_all_hidden_title else R.string.projects_empty_title),
+ stringResource(if (ui.folders.isNotEmpty()) R.string.folders_hidden_hint else R.string.projects_empty_message),
+ )
+ if (ui.hiddenFolderIds.isNotEmpty()) {
+ TextButton(onClick = { showOrder = true }) { Text(stringResource(R.string.folders_manage)) }
+ }
+ ui.error?.let { Text(it, color = colors.danger, modifier = Modifier.padding(16.dp)) }
}
else -> PullToRefreshBox(isRefreshing = ui.refreshing, onRefresh = viewModel::refresh, modifier = Modifier.fillMaxSize()) {
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 88.dp),
) {
+ ui.error?.let { error ->
+ item { Text(error, color = colors.danger, modifier = Modifier.padding(12.dp)) }
+ }
// One continuous grouped surface holding every folder as a
// borderless row split by inset hairlines — the iOS grouped-list
// look, matching the Settings tab. Folder counts are small, so a
// single card inside the lazy list is fine.
item {
GlassCard(padding = 0.dp) {
- val list = sortedFolders(ui.folders)
+ val list = visibleFolders
list.forEachIndexed { index, folder ->
if (index > 0) {
HorizontalDivider(color = colors.hairline, modifier = Modifier.padding(start = 66.dp))
}
- FolderRow(folder, ui.runningCount(folder.id)) { onOpenFolder(folder.id) }
+ androidx.compose.runtime.key(ui.hiddenScope, folder.id) {
+ FolderRow(folder, ui.runningCount(folder.id)) { onOpenFolder(folder.id) }
+ }
}
}
}
@@ -147,6 +176,19 @@ fun ProjectListScreen(
}
}
+ if (showOrder) {
+ FolderManagementSheet(
+ folders = FolderOrdering.sorted(ui.folders.filterNot { it.isChatFolder }, ui.folderOrder),
+ hiddenIds = ui.hiddenFolderIds,
+ onSetHidden = viewModel::setFolderHidden,
+ onMove = viewModel::moveFolder,
+ onReorder = viewModel::reorderFolders,
+ onReset = viewModel::resetFolderOrder,
+ onDismiss = { showOrder = false },
+ error = ui.error,
+ )
+ }
+
if (showBrowser) {
DirectoryBrowserSheet(
title = stringResource(R.string.projects_open_folder),
@@ -233,6 +275,3 @@ fun BranchPill(branch: String) {
)
}
}
-
-private fun sortedFolders(folders: List): List =
- folders.sortedWith(compareBy({ it.sortOrder }, { it.name.lowercase() }))
diff --git a/app/src/main/java/app/codeg/android/feature/projects/ProjectsViewModel.kt b/app/src/main/java/app/codeg/android/feature/projects/ProjectsViewModel.kt
index 9264206..74a2128 100644
--- a/app/src/main/java/app/codeg/android/feature/projects/ProjectsViewModel.kt
+++ b/app/src/main/java/app/codeg/android/feature/projects/ProjectsViewModel.kt
@@ -1,14 +1,20 @@
package app.codeg.android.feature.projects
+import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.codeg.android.core.data.ServerRepository
+import app.codeg.android.R
import app.codeg.android.core.datastore.ServerProfile
+import app.codeg.android.core.datastore.HiddenFoldersStore
+import app.codeg.android.core.datastore.FolderOrderStore
import app.codeg.android.core.model.ConversationSummary
import app.codeg.android.core.model.FolderDetail
+import app.codeg.android.core.model.FolderVisibility
import app.codeg.android.core.network.CodegClient
import app.codeg.android.core.network.displayMessage
import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
@@ -16,6 +22,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -24,6 +31,9 @@ import javax.inject.Inject
@HiltViewModel
class ProjectsViewModel @Inject constructor(
private val repository: ServerRepository,
+ private val hiddenFoldersStore: HiddenFoldersStore,
+ private val folderOrderStore: FolderOrderStore,
+ @ApplicationContext private val appContext: Context,
) : ViewModel() {
private val _ui = MutableStateFlow(ProjectsUiState())
@@ -31,6 +41,7 @@ class ProjectsViewModel @Inject constructor(
private var client: CodegClient? = null
private var loadedEndpoint: String? = null
+ private var fetchGeneration = 0
init {
viewModelScope.launch {
@@ -44,6 +55,7 @@ class ProjectsViewModel @Inject constructor(
}
private suspend fun onProfile(profile: ServerProfile?) {
+ fetchGeneration++
if (profile == null) {
client = null; loadedEndpoint = null
_ui.value = ProjectsUiState()
@@ -53,8 +65,28 @@ class ProjectsViewModel @Inject constructor(
val changed = endpoint != loadedEndpoint
loadedEndpoint = endpoint
client = repository.client(profile)
- if (changed) _ui.update { ProjectsUiState() }
- fetch(initial = true)
+ if (changed) _ui.value = ProjectsUiState(loading = true)
+ if (client == null) {
+ _ui.value = ProjectsUiState(error = appContext.getString(R.string.sessions_missing_token))
+ return
+ }
+ val scope = hiddenFoldersStore.scope(profile)
+ val hiddenIds = hiddenFoldersStore.hiddenIds(scope).first()
+ val folderOrder = folderOrderStore.order(scope).first()
+ _ui.update { it.copy(hiddenScope = scope, hiddenFolderIds = hiddenIds, folderOrder = folderOrder) }
+ coroutineScope {
+ launch {
+ folderOrderStore.order(scope).collect { order ->
+ _ui.update { it.copy(folderOrder = order) }
+ }
+ }
+ launch {
+ hiddenFoldersStore.hiddenIds(scope).collect { ids ->
+ _ui.update { it.copy(hiddenFolderIds = ids) }
+ }
+ }
+ fetch(initial = true)
+ }
}
fun refresh() {
@@ -64,6 +96,7 @@ class ProjectsViewModel @Inject constructor(
private suspend fun fetch(initial: Boolean) {
val c = client ?: return
+ val generation = ++fetchGeneration
_ui.update { if (initial) it.copy(loading = true, error = null) else it.copy(refreshing = true) }
try {
val result = coroutineScope {
@@ -71,16 +104,67 @@ class ProjectsViewModel @Inject constructor(
val conv = async { runCatching { c.listConversations() }.getOrDefault(emptyList()) }
f.await() to conv.await()
}
+ if (generation != fetchGeneration || client !== c) return
_ui.update {
it.copy(folders = result.first, conversations = result.second, hasLoaded = true, loading = false, refreshing = false)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
+ if (generation != fetchGeneration || client !== c) return
_ui.update { it.copy(loading = false, refreshing = false, error = e.displayMessage()) }
}
}
+ fun setFolderHidden(folderId: Int, hidden: Boolean) {
+ val scope = _ui.value.hiddenScope ?: return
+ viewModelScope.launch {
+ try {
+ hiddenFoldersStore.setHidden(scope, folderId, hidden)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ if (_ui.value.hiddenScope == scope) _ui.update { it.copy(error = e.displayMessage()) }
+ }
+ }
+ }
+
+ fun moveFolder(folderId: Int, offset: Int) {
+ val state = _ui.value
+ val scope = state.hiddenScope ?: return
+ saveFolderOrder(scope) {
+ folderOrderStore.move(scope, state.folders, emptySet(), folderId, offset)
+ }
+ }
+
+ fun resetFolderOrder() {
+ val scope = _ui.value.hiddenScope ?: return
+ saveFolderOrder(scope) {
+ folderOrderStore.reset(scope)
+ if (_ui.value.hiddenScope == scope) repository.notifyFoldersChanged()
+ }
+ }
+
+ fun reorderFolders(visibleOrder: List) {
+ val state = _ui.value
+ val scope = state.hiddenScope ?: return
+ saveFolderOrder(scope) {
+ folderOrderStore.reorder(scope, state.folders, emptySet(), visibleOrder)
+ }
+ }
+
+ private fun saveFolderOrder(scope: String, save: suspend () -> Unit) {
+ viewModelScope.launch {
+ try {
+ save()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ if (_ui.value.hiddenScope == scope) _ui.update { it.copy(error = e.displayMessage()) }
+ }
+ }
+ }
+
fun openFolder(path: String, onResult: (String?) -> Unit) {
val c = client ?: return onResult("No server selected")
viewModelScope.launch {
@@ -119,12 +203,19 @@ class ProjectsViewModel @Inject constructor(
data class ProjectsUiState(
val folders: List = emptyList(),
val conversations: List = emptyList(),
+ val hiddenScope: String? = null,
+ val hiddenFolderIds: Set = emptySet(),
+ val folderOrder: List = emptyList(),
val loading: Boolean = false,
val refreshing: Boolean = false,
val hasLoaded: Boolean = false,
val error: String? = null,
) {
val isBusy: Boolean get() = loading || refreshing
+ val visibleFolders: List get() {
+ val hidden = FolderVisibility.hiddenIds(folders, hiddenFolderIds)
+ return folders.filter { it.id !in hidden }
+ }
fun runningCount(folderId: Int): Int =
conversations.count { it.folderId == folderId && it.status.isLive }
}
diff --git a/app/src/main/java/app/codeg/android/feature/search/SearchViewModel.kt b/app/src/main/java/app/codeg/android/feature/search/SearchViewModel.kt
index 527da1d..9eeb150 100644
--- a/app/src/main/java/app/codeg/android/feature/search/SearchViewModel.kt
+++ b/app/src/main/java/app/codeg/android/feature/search/SearchViewModel.kt
@@ -7,6 +7,7 @@ import app.codeg.android.core.datastore.RecentSearchesStore
import app.codeg.android.core.model.ConversationSummary
import app.codeg.android.core.network.CodegClient
import app.codeg.android.core.network.displayMessage
+import app.codeg.android.feature.sessions.SessionGrouping
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.FlowPreview
@@ -15,7 +16,9 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -38,51 +41,74 @@ class SearchViewModel @Inject constructor(
val recent: StateFlow> =
recentStore.searches.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
- private var client: CodegClient? = null
- private var folderNames: Map = emptyMap()
+ private val client = MutableStateFlow(null)
+ private val retries = MutableStateFlow(0)
init {
viewModelScope.launch {
- repository.selectedProfile.collectLatest { profile ->
- client = profile?.let { repository.client(it) }
- folderNames = client?.let { c -> runCatching { c.listFolders() }.getOrNull()?.associate { it.id to it.name } } ?: emptyMap()
- _ui.update { it.copy(folderNames = folderNames) }
+ repository.selectedProfile.distinctUntilChanged().collectLatest { profile ->
+ client.value = null
+ _ui.value = SearchUiState(query = _query.value.trim())
+ val active = profile?.let { repository.client(it) }
+ client.value = active
+ if (active != null) {
+ val folders = try {
+ active.listFolders()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ emptyList()
+ }
+ if (client.value === active) {
+ _ui.update { it.copy(folderNames = SessionGrouping.folderNames(folders)) }
+ }
+ }
}
}
viewModelScope.launch {
- _query.debounce(300).collectLatest { q -> runSearch(q) }
+ combine(_query.debounce(300), client, retries) { q, active, _ -> q to active }
+ .collectLatest { (q, active) -> runSearch(q, active) }
}
}
- fun onQueryChange(value: String) { _query.value = value }
+ fun onQueryChange(value: String) {
+ if (_query.value == value) return
+ _query.value = value
+ _ui.update {
+ it.copy(query = value.trim(), results = emptyList(), searching = value.isNotBlank() && client.value != null, error = null)
+ }
+ }
fun submit() {
val q = _query.value.trim()
if (q.isNotEmpty()) viewModelScope.launch { recentStore.add(q) }
}
- fun useRecent(term: String) { _query.value = term }
+ fun useRecent(term: String) = onQueryChange(term)
fun clearRecent() { viewModelScope.launch { recentStore.clear() } }
/** Re-run the current query after a failure (drives the inline-error Retry button). */
- fun retry() { viewModelScope.launch { runSearch(_query.value) } }
+ fun retry() { retries.value += 1 }
- private suspend fun runSearch(raw: String) {
+ private suspend fun runSearch(raw: String, active: CodegClient?) {
val q = raw.trim()
- if (q.isEmpty()) {
- _ui.update { it.copy(results = emptyList(), searching = false, query = "", error = null) }
+ if (q != _query.value.trim()) return
+ if (q.isEmpty() || active == null) {
+ _ui.update { it.copy(results = emptyList(), searching = false, query = q, error = null) }
return
}
- val c = client ?: return
_ui.update { it.copy(searching = true, query = q, error = null) }
try {
- val results = c.listConversations(search = q, sortBy = "updated")
+ // Global server search is independent of the home list's folders, folds and row limits.
+ val results = active.listConversations(search = q, sortBy = "updated")
+ if (client.value !== active || _query.value.trim() != q) return
_ui.update { it.copy(results = results, searching = false) }
recentStore.add(q)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
+ if (client.value !== active || _query.value.trim() != q) return
_ui.update { it.copy(searching = false, error = e.displayMessage()) }
}
}
diff --git a/app/src/main/java/app/codeg/android/feature/sessiondetail/FileDownloadBanner.kt b/app/src/main/java/app/codeg/android/feature/sessiondetail/FileDownloadBanner.kt
new file mode 100644
index 0000000..47d5cba
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/sessiondetail/FileDownloadBanner.kt
@@ -0,0 +1,60 @@
+package app.codeg.android.feature.sessiondetail
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import app.codeg.android.R
+import app.codeg.android.feature.downloads.DownloadRecord
+import app.codeg.android.feature.downloads.FileDownloadStatus
+import app.codeg.android.core.designsystem.theme.CodegTheme
+
+@Composable
+internal fun FileDownloadBanner(state: DownloadRecord, onRetry: () -> Unit, onOpen: () -> Unit, onDismiss: (() -> Unit)? = null) {
+ val colors = CodegTheme.colors
+ Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(state.filename, color = colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
+ val label = when (state.status) {
+ FileDownloadStatus.PREPARING -> stringResource(R.string.download_preparing)
+ FileDownloadStatus.QUEUED -> stringResource(R.string.download_queued)
+ FileDownloadStatus.PAUSED -> stringResource(R.string.download_paused)
+ FileDownloadStatus.COMPLETE -> stringResource(R.string.download_complete)
+ FileDownloadStatus.FAILED -> state.error ?: stringResource(R.string.download_interrupted)
+ FileDownloadStatus.CANCELED -> stringResource(R.string.download_canceled)
+ FileDownloadStatus.DOWNLOADING -> if (state.total > 0) {
+ stringResource(R.string.download_progress, (state.bytes * 100 / state.total).coerceIn(0, 100).toInt())
+ } else stringResource(R.string.download_running)
+ }
+ Text(label, color = if (state.status == FileDownloadStatus.FAILED) colors.danger else colors.textSecondary, style = MaterialTheme.typography.bodySmall)
+ if (state.status != FileDownloadStatus.FAILED) state.error?.let { Text(it, color = colors.danger, style = MaterialTheme.typography.bodySmall) }
+ }
+ when (state.status) {
+ FileDownloadStatus.FAILED, FileDownloadStatus.CANCELED -> TextButton(onClick = onRetry) { Text(stringResource(R.string.common_retry)) }
+ FileDownloadStatus.COMPLETE -> TextButton(onClick = onOpen) { Text(stringResource(if (state.apk) R.string.download_install else R.string.download_open)) }
+ else -> Unit
+ }
+ if (!state.busy && onDismiss != null) IconButton(onClick = onDismiss) { Icon(Icons.Rounded.Close, stringResource(R.string.common_dismiss), tint = colors.textSecondary) }
+ }
+ if (state.busy) {
+ if (state.total > 0 && state.status != FileDownloadStatus.PREPARING) {
+ LinearProgressIndicator(progress = { (state.bytes.toFloat() / state.total).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxWidth())
+ } else LinearProgressIndicator(Modifier.fillMaxWidth())
+ }
+ }
+}
diff --git a/app/src/main/java/app/codeg/android/feature/sessiondetail/MessageFileLink.kt b/app/src/main/java/app/codeg/android/feature/sessiondetail/MessageFileLink.kt
new file mode 100644
index 0000000..fc807bb
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/sessiondetail/MessageFileLink.kt
@@ -0,0 +1,80 @@
+package app.codeg.android.feature.sessiondetail
+
+import java.net.URI
+import java.net.URLDecoder
+import java.util.Locale
+
+internal data class WorkspaceFileLink(val rootPath: String, val path: String)
+internal data class PreparedDownload(val url: String, val filename: String)
+
+internal fun messageWebUrl(link: String): URI? = runCatching {
+ URI(link.trim().removeSurrounding("<", ">")).takeIf {
+ it.scheme?.lowercase(Locale.ROOT) in setOf("http", "https") && !it.host.isNullOrBlank() && it.userInfo == null
+ }
+}.getOrNull()
+
+internal fun isApkLink(link: String): Boolean = messageWebUrl(link)?.path?.endsWith(".apk", ignoreCase = true) == true
+
+/** Absolute paths are server paths, never paths on the phone. */
+internal fun workspaceFileLink(link: String, roots: List, currentRoot: String? = roots.firstOrNull()): WorkspaceFileLink? {
+ var raw = link.trim().removeSurrounding("<", ">").substringBefore('#')
+ if (raw.startsWith("file:", ignoreCase = true)) {
+ val uri = runCatching { URI(raw) }.getOrNull() ?: return null
+ if (!uri.authority.isNullOrEmpty() && uri.authority != "localhost") return null
+ raw = uri.rawPath ?: return null
+ } else if (Regex("^[a-zA-Z][a-zA-Z0-9+.-]*:").containsMatchIn(raw) && !Regex("^[a-zA-Z]:[/\\\\]").containsMatchIn(raw)) {
+ return null
+ }
+ raw = runCatching { URLDecoder.decode(raw.replace("+", "%2B"), "UTF-8") }.getOrNull() ?: return null
+ raw = raw.replace(Regex(":\\d+(?::\\d+)?$"), "")
+ if (raw.isBlank() || raw.any { it.isISOControl() }) return null
+ val normalizedRoots = roots.mapNotNull { root -> normalizeServerPath(root)?.let { root to it } }
+ val absolute = raw.startsWith('/') || Regex("^[a-zA-Z]:[/\\\\]").containsMatchIn(raw)
+ val relativeRoot = currentRoot?.let(::normalizeServerPath)
+ val target = normalizeServerPath(if (absolute) raw else "${relativeRoot ?: return null}/$raw") ?: return null
+ val candidates = if (absolute) normalizedRoots else normalizedRoots.filter { it.second == relativeRoot }
+ val match = candidates.sortedByDescending { it.second.length }.firstOrNull { (_, root) ->
+ target.startsWith("${root.trimEnd('/')}/")
+ }
+ if (match != null) {
+ val (original, root) = match
+ return WorkspaceFileLink(original, target.removePrefix(root.trimEnd('/') + "/"))
+ }
+ // Generated artifacts can live outside projects (for example, the server's Downloads folder).
+ // Use only the explicitly linked file's parent; the authenticated server validates the file.
+ if (!absolute || raw.endsWith('/') || raw.endsWith('\\') || raw.replace('\\', '/').split('/').any { it == ".." }) return null
+ val filename = target.substringAfterLast('/')
+ if (filename.isBlank() || Regex("^[a-zA-Z]:$").matches(filename)) return null
+ val parent = target.substringBeforeLast('/', "").let {
+ when {
+ it.isEmpty() && target.startsWith('/') -> "/"
+ Regex("^[a-zA-Z]:$").matches(it) -> "$it/"
+ else -> it
+ }
+ }
+ return parent.takeIf { it.isNotEmpty() }?.let { WorkspaceFileLink(it, filename) }
+}
+
+private fun normalizeServerPath(path: String): String? {
+ val windows = Regex("^/?[a-zA-Z]:[/\\\\]").containsMatchIn(path)
+ val raw = if (windows) path.removePrefix("/").replace('\\', '/') else path
+ if (!raw.startsWith('/') && !windows) return null
+ val parts = mutableListOf()
+ for (part in raw.split('/')) when (part) {
+ "", "." -> Unit
+ ".." -> if (parts.isEmpty() || (windows && parts.size == 1)) return null else parts.removeAt(parts.lastIndex)
+ else -> parts += part
+ }
+ return (if (windows) "" else "/") + parts.joinToString("/")
+}
+
+internal fun safeDownloadFilename(name: String): String {
+ val clean = name.substringAfterLast('/').substringAfterLast('\\')
+ .filterNot { it.isISOControl() || it in "<>:\"|?*" }.trim().trimStart('.')
+ val extension = clean.substringAfterLast('.', "").takeIf { it.length in 1..12 }?.let { ".$it" }.orEmpty()
+ var stem = clean.removeSuffix(extension)
+ while ((stem + extension).toByteArray(Charsets.UTF_8).size > 180 && stem.isNotEmpty()) {
+ stem = stem.substring(0, stem.offsetByCodePoints(stem.length, -1))
+ }
+ return (stem + extension).ifBlank { "download" }
+}
diff --git a/app/src/main/java/app/codeg/android/feature/sessiondetail/SessionActions.kt b/app/src/main/java/app/codeg/android/feature/sessiondetail/SessionActions.kt
index 2e82be7..127c903 100644
--- a/app/src/main/java/app/codeg/android/feature/sessiondetail/SessionActions.kt
+++ b/app/src/main/java/app/codeg/android/feature/sessiondetail/SessionActions.kt
@@ -71,6 +71,11 @@ fun SessionActionsMenu(
Icon(Icons.Rounded.MoreVert, contentDescription = stringResource(R.string.sessionactions_session_actions), tint = colors.textPrimary)
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.common_refresh)) },
+ enabled = !ui.refreshing,
+ onClick = { menu = false; viewModel.refresh() },
+ )
DropdownMenuItem(text = { Text(stringResource(R.string.sessionactions_rename)) }, onClick = { menu = false; showRename = true })
DropdownMenuItem(text = { Text(if (summary.isPinned) stringResource(R.string.sessionactions_unpin) else stringResource(R.string.sessionactions_pin)) }, onClick = { menu = false; viewModel.togglePin() })
DropdownMenuItem(text = { Text(stringResource(R.string.sessionactions_session_details)) }, onClick = { menu = false; showDetails = true })
diff --git a/app/src/main/java/app/codeg/android/feature/sessiondetail/SessionDetailScreen.kt b/app/src/main/java/app/codeg/android/feature/sessiondetail/SessionDetailScreen.kt
index d3df5dd..a6ecfb8 100644
--- a/app/src/main/java/app/codeg/android/feature/sessiondetail/SessionDetailScreen.kt
+++ b/app/src/main/java/app/codeg/android/feature/sessiondetail/SessionDetailScreen.kt
@@ -1,5 +1,8 @@
package app.codeg.android.feature.sessiondetail
+import android.content.Intent
+import android.net.Uri
+import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
@@ -13,6 +16,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -24,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -36,12 +41,15 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material.icons.rounded.FolderOpen
import androidx.compose.material.icons.rounded.Forum
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
@@ -51,9 +59,11 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -69,11 +79,14 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.codeg.android.R
+import app.codeg.android.core.common.logTranscriptTiming
import app.codeg.android.core.designsystem.component.AgentAvatar
+import app.codeg.android.core.designsystem.component.CacheStatus
import app.codeg.android.core.designsystem.component.EmptyState
import app.codeg.android.core.designsystem.component.InlineError
import app.codeg.android.core.designsystem.component.LoadingView
import app.codeg.android.core.designsystem.theme.CodegTheme
+import app.codeg.android.core.designsystem.markdown.LocalMarkdownLinkHandler
import app.codeg.android.feature.sessiondetail.interactive.AskQuestionCard
import app.codeg.android.feature.sessiondetail.interactive.PermissionRequestCard
import app.codeg.android.feature.sessiondetail.interactive.PlanApprovalCard
@@ -82,7 +95,14 @@ import app.codeg.android.feature.sessiondetail.timeline.NodeBody
import app.codeg.android.feature.sessiondetail.timeline.NodeContent
import app.codeg.android.feature.sessiondetail.timeline.TimelineRow
import app.codeg.android.feature.sessiondetail.timeline.TranscriptTimeline
+import app.codeg.android.feature.sessiondetail.timeline.TimelineNode
+import app.codeg.android.core.model.TurnRole
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.drop
+
+private data class PersistedTimeline(val source: SessionDetailUiState, val nodes: List)
/**
* The session detail screen: the transcript rendered as a vertical **timeline**
@@ -95,16 +115,35 @@ import kotlinx.coroutines.launch
fun SessionDetailScreen(
onBack: () -> Unit,
onOpenSession: (Int) -> Unit = {},
+ onOpenDownloads: () -> Unit = {},
+ onViewRemote: (Int, String) -> Unit = { _, _ -> },
+ onOpenFile: (String) -> Unit = {},
viewModel: SessionDetailViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
+ val fileDownload by viewModel.fileDownload.collectAsStateWithLifecycle()
val colors = CodegTheme.colors
- var draft by remember { mutableStateOf("") }
+ var draft by rememberSaveable { mutableStateOf("") }
var showInsert by remember { mutableStateOf(false) }
var showOptions by remember { mutableStateOf(false) }
+ var scrollTarget by rememberSaveable { mutableStateOf(null) }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val context = LocalContext.current
+ val onMarkdownLink: (String) -> Unit = remember(context, viewModel, onOpenFile, onViewRemote) {
+ { link ->
+ val web = messageWebUrl(link)
+ if (web != null && !isApkLink(link)) {
+ runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(web.toASCIIString()))) }
+ .onFailure { Toast.makeText(context, R.string.download_open_failed, Toast.LENGTH_LONG).show() }
+ } else {
+ val id = viewModel.ui.value.summary?.id
+ val path = if (web == null) viewModel.previewFilePath(link) else null
+ if (id != null && path != null) onViewRemote(id, path)
+ else viewModel.downloadFile(link)?.let(onOpenFile)
+ }
+ }
+ }
// A failed send returns the user's typed text to the composer so it isn't lost — but only
// when the composer is empty, so it never clobbers a fresh draft typed during the send.
@@ -137,9 +176,23 @@ fun SessionDetailScreen(
// On reattach the live turn (rebuilt from the snapshot) owns the in-flight reply, so
// hide the partial copy the server persists into `turns` to avoid a doubled reply.
val hideReattachDup = ui.liveFromReattach && ui.live != null
- val persistedNodes = remember(ui.turns, ui.pendingUserTurns, ui.agent, hideReattachDup) {
- TranscriptTimeline.buildPersisted(ui.turns, ui.pendingUserTurns, ui.agent, hideReattachDup)
+ val timelineState = remember(ui.turns, ui.pendingUserTurns, ui.agent, hideReattachDup, ui.historyChunkStarts) { ui }
+ val persistedTimeline by produceState(
+ initialValue = null,
+ key1 = timelineState,
+ ) {
+ value = withContext(Dispatchers.Default) {
+ val started = System.nanoTime()
+ val adapted = TranscriptTimeline.buildPersisted(
+ timelineState.turns, timelineState.pendingUserTurns, timelineState.agent,
+ hideReattachDup, timelineState.historyChunkStarts,
+ )
+ logTranscriptTiming("timeline-build", started, records = timelineState.turns.size)
+ PersistedTimeline(timelineState, adapted)
+ }
}
+ val persistedNodes = persistedTimeline?.nodes.orEmpty()
+ val historyRendered = persistedTimeline?.source === timelineState
val nodes = remember(persistedNodes, ui.live, ui.agent) {
TranscriptTimeline.withLive(persistedNodes, ui.live, ui.agent)
}
@@ -153,31 +206,65 @@ fun SessionDetailScreen(
last.index >= info.totalItemsCount - 1
}
}
- var stick by remember { mutableStateOf(true) }
- // Re-evaluate "follow" only when a *user* scroll settles — programmatic scrolls
- // and streamed growth must not flip it (that would race the auto-follow off).
+ var stick by rememberSaveable { mutableStateOf(true) }
+ var initiallyPositioned by rememberSaveable { mutableStateOf(false) }
+ // Unpin immediately on a drag, including when a short window fits on screen.
+ // Streamed growth alone must not turn auto-follow off.
LaunchedEffect(listState) {
- snapshotFlow { listState.isScrollInProgress }.collect { inProgress ->
- if (!inProgress) stick = isAtBottom
+ listState.interactionSource.interactions.collect { interaction ->
+ if (interaction is DragInteraction.Start) stick = false
+ }
+ }
+ LaunchedEffect(listState, initiallyPositioned) {
+ snapshotFlow { listState.isScrollInProgress }.drop(1).collect { inProgress ->
+ if (initiallyPositioned && !inProgress) stick = isAtBottom
+ }
+ }
+ LaunchedEffect(nodes.isNotEmpty()) {
+ if (!initiallyPositioned && nodes.isNotEmpty()) {
+ listState.scrollToItem(nodes.size + 1)
+ initiallyPositioned = true
+ }
+ }
+ LaunchedEffect(listState, ui.historyWindow?.offset, ui.loadingOlder, ui.olderError, initiallyPositioned, historyRendered, nodes.size) {
+ snapshotFlow {
+ Triple(
+ listState.isScrollInProgress,
+ listState.firstVisibleItemIndex,
+ !stick && historyRendered && listState.layoutInfo.totalItemsCount == nodes.size + 2,
+ )
+ }.collect { (scrolling, index, ready) ->
+ if (initiallyPositioned && scrolling && ready && index <= 4 &&
+ (ui.historyWindow?.offset ?: 0) > 0 && !ui.loadingOlder && ui.olderError == null
+ ) viewModel.loadOlderTurns()
}
}
// A freshly sent message always re-pins to the bottom.
+ var followedPendingId by rememberSaveable { mutableStateOf(null) }
LaunchedEffect(ui.pendingUserTurns.lastOrNull()?.id) {
- if (ui.pendingUserTurns.isNotEmpty()) stick = true
+ val pendingId = ui.pendingUserTurns.lastOrNull()?.id
+ if (pendingId != null && pendingId != followedPendingId) stick = true
+ followedPendingId = pendingId
}
// Follow new / streamed content while pinned.
LaunchedEffect(nodes.size, ui.live, stick) {
- if (stick && nodes.isNotEmpty()) listState.scrollToItem(nodes.size)
+ if (stick && nodes.isNotEmpty()) listState.scrollToItem(nodes.size + 1)
}
- val scrollToId: (String) -> Unit = remember(nodes) {
- { id ->
- val idx = nodes.indexOfFirst { it.id == id }
- if (idx >= 0) {
- stick = false // unpin so the stream doesn't immediately yank us back down
- scope.launch { listState.animateScrollToItem(idx) }
- }
+ LaunchedEffect(scrollTarget) {
+ scrollTarget?.let { id ->
+ if (!viewModel.loadThroughTurn(id) && scrollTarget == id) scrollTarget = null
+ }
+ }
+ LaunchedEffect(persistedNodes, scrollTarget) {
+ val target = scrollTarget ?: return@LaunchedEffect
+ val index = persistedNodes.indexOfFirst { it.id == target }
+ if (index >= 0) {
+ stick = false
+ listState.animateScrollToItem(index + 1)
+ scrollTarget = null
}
}
+ val scrollToId: (String) -> Unit = { id -> stick = false; scrollTarget = id }
val title = when {
ui.isNew -> stringResource(R.string.session_new_task)
@@ -188,6 +275,7 @@ fun SessionDetailScreen(
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
+ Column {
TopAppBar(
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = {
@@ -200,6 +288,9 @@ fun SessionDetailScreen(
}
},
actions = {
+ IconButton(onClick = onOpenDownloads) {
+ Icon(Icons.Rounded.FolderOpen, stringResource(R.string.session_files_title), tint = colors.textSecondary)
+ }
// The agent avatar is the options button (mode / config / folder / branch).
Box(
Modifier
@@ -220,9 +311,27 @@ fun SessionDetailScreen(
titleContentColor = colors.textPrimary,
),
)
+ if (!ui.isNew) CacheStatus(
+ refreshing = ui.refreshing,
+ showingCached = ui.showingCached,
+ lastUpdatedAt = ui.lastUpdatedAt,
+ modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 4.dp),
+ )
+ fileDownload?.let { download ->
+ FileDownloadBanner(download, viewModel::retryFileDownload, { onOpenFile(download.key) }, viewModel::dismissFileDownload)
+ }
+ if (hasContent) ui.error?.let { error ->
+ Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(error, color = colors.danger, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis)
+ TextButton(onClick = viewModel::refresh, enabled = !ui.refreshing) {
+ Text(stringResource(R.string.common_retry))
+ }
+ }
+ }
+ }
},
bottomBar = {
- Column(Modifier.imePadding()) {
+ Column(Modifier.imePadding().navigationBarsPadding()) {
ui.pendingPermission?.let { p ->
PermissionRequestCard(
parsed = p.parsed,
@@ -282,7 +391,7 @@ fun SessionDetailScreen(
) { padding ->
Box(Modifier.padding(padding).fillMaxSize()) {
when {
- ui.loading && !hasContent ->
+ (ui.loading || (persistedTimeline == null && ui.turns.isNotEmpty())) && !hasContent ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
LoadingView(message = stringResource(R.string.common_loading))
}
@@ -293,7 +402,7 @@ fun SessionDetailScreen(
icon = Icons.Rounded.Forum,
title = stringResource(R.string.session_load_failed),
message = ui.error!!,
- onRetry = viewModel::load,
+ onRetry = viewModel::refresh,
retryLabel = stringResource(R.string.common_retry),
)
}
@@ -311,12 +420,29 @@ fun SessionDetailScreen(
)
}
- else -> CompositionLocalProvider(LocalTimelineScroll provides scrollToId) {
+ else -> CompositionLocalProvider(LocalTimelineScroll provides scrollToId, LocalMarkdownLinkHandler provides onMarkdownLink) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
) {
+ item(key = "__timeline_history__") {
+ if ((ui.historyWindow?.offset ?: 0) > 0) {
+ Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
+ if (ui.turns.firstOrNull()?.role == TurnRole.ASSISTANT) {
+ Text(stringResource(R.string.session_round_continues), color = colors.textSecondary)
+ }
+ if (ui.loadingOlder) {
+ CircularProgressIndicator(Modifier.padding(12.dp).size(20.dp), strokeWidth = 2.dp)
+ } else {
+ ui.olderError?.let { Text(it, color = colors.textSecondary) }
+ TextButton(onClick = { stick = false; viewModel.loadOlderTurns() }) {
+ Text(stringResource(if (ui.olderError != null) R.string.common_retry else R.string.session_load_older))
+ }
+ }
+ }
+ }
+ }
items(nodes, key = { it.id }, contentType = { it.contentTypeKey }) { node ->
TimelineRow(
marker = node.marker,
@@ -347,7 +473,7 @@ fun SessionDetailScreen(
) {
JumpToLatestButton {
stick = true
- scope.launch { listState.animateScrollToItem(nodes.size) }
+ scope.launch { listState.animateScrollToItem(nodes.size + 1) }
}
}
}
diff --git a/app/src/main/java/app/codeg/android/feature/sessiondetail/SessionDetailViewModel.kt b/app/src/main/java/app/codeg/android/feature/sessiondetail/SessionDetailViewModel.kt
index 7385019..82d1254 100644
--- a/app/src/main/java/app/codeg/android/feature/sessiondetail/SessionDetailViewModel.kt
+++ b/app/src/main/java/app/codeg/android/feature/sessiondetail/SessionDetailViewModel.kt
@@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.codeg.android.R
import app.codeg.android.core.data.ServerRepository
+import app.codeg.android.feature.downloads.DownloadRepository
import app.codeg.android.core.datastore.SelectorPrefs
import app.codeg.android.core.datastore.SelectorPrefsStore
import app.codeg.android.core.datastore.ServerProfile
@@ -23,6 +24,7 @@ import android.net.Uri
import app.codeg.android.core.model.ConversationConnectionInfo
import app.codeg.android.core.model.ConversationStatus
import app.codeg.android.core.model.ConversationSummary
+import app.codeg.android.core.model.ConversationDetail
import app.codeg.android.core.model.FolderDetail
import app.codeg.android.core.model.FolderVisibility
import app.codeg.android.core.model.GitBranchList
@@ -54,6 +56,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
@@ -89,11 +94,12 @@ private const val STATUS_THINKING = "Thinking…"
* snapshot live-message reconstruction are simplified; see notes).
*/
@HiltViewModel
-class SessionDetailViewModel @Inject constructor(
+class SessionDetailViewModel @Inject internal constructor(
private val repository: ServerRepository,
private val selectorPrefs: SelectorPrefsStore,
- savedStateHandle: SavedStateHandle,
+ private val savedStateHandle: SavedStateHandle,
@ApplicationContext private val appContext: Context,
+ private val downloads: DownloadRepository,
) : ViewModel() {
private val conversationIdArg: Int? = savedStateHandle.get("id")?.takeIf { it >= 0 }
@@ -141,51 +147,107 @@ class SessionDetailViewModel @Inject constructor(
private var liveBuilder: LiveTurnBuilder? = null
private var liveFlushScheduled = false
private var turnBaseline = 0
+ private var loadJob: Job? = null
+ private var refreshJob: Job? = null
+ private var historyCacheSnapshot: String? = null
+ private var olderJob: Job? = null
+ private var historyGeneration = 0
private val _ui = MutableStateFlow(SessionDetailUiState(isNew = conversationIdArg == null))
val ui: StateFlow = _ui.asStateFlow()
+ private val activeDownloadKey = MutableStateFlow(savedStateHandle["active_download_key"])
+ internal val fileDownload = combine(downloads.records, activeDownloadKey) { records, key -> records.firstOrNull { it.key == key } }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
+
+ internal fun downloadFile(link: String): String? {
+ val selected = profile ?: return null
+ val activeFolderId = _ui.value.summary?.folderId ?: _ui.value.selectedFolderId
+ val currentPath = _ui.value.folderPath ?: allFolders.firstOrNull { it.id == activeFolderId }?.path
+ val key = downloads.start(selected, conversationId, _ui.value.summary?.trimmedTitle.orEmpty(), currentPath, link)
+ activeDownloadKey.value = key
+ savedStateHandle["active_download_key"] = key
+ return key
+ }
+
+ internal fun previewFilePath(link: String): String? {
+ val current = _ui.value.folderPath
+ val target = workspaceFileLink(link, allFolders.map { it.path }, current) ?: return null
+ if (target.path.endsWith(".apk", ignoreCase = true)) return null
+ return target.rootPath.trimEnd('/', '\\') + "/" + target.path
+ }
+
+ internal fun retryFileDownload() { activeDownloadKey.value?.let(downloads::retry) }
+ internal fun dismissFileDownload() {
+ activeDownloadKey.value = null
+ savedStateHandle.remove("active_download_key")
+ }
+
init {
load()
}
fun load() {
- _ui.update { it.copy(loading = true, error = null) }
- viewModelScope.launch {
+ loadJob?.cancel()
+ refreshJob?.cancel()
+ reconcileJob?.cancel()
+ invalidateOlderRequest()
+ _ui.update { it.copy(loading = it.summary == null, refreshing = false, error = null) }
+ loadJob = viewModelScope.launch {
try {
val p = repository.selectedProfile.first() ?: throw IllegalStateException("No server selected")
profile = p
val c = repository.client(p) ?: throw IllegalStateException("Missing token for this server")
client = c
- allFolders = runCatching { c.listFolders() }.getOrDefault(emptyList())
+ val folders = async {
+ val known = c.cachedSessionList()?.value?.folders ?: runCatching { c.listFolders() }.getOrDefault(emptyList())
+ if (conversationId != null) {
+ allFolders = known
+ _ui.value.summary?.let { summary ->
+ folder = known.firstOrNull { it.id == summary.folderId }
+ currentBranch = summary.gitBranch ?: folder?.gitBranch
+ _ui.update {
+ it.copy(
+ folderName = folder?.let { f -> FolderVisibility.displayName(f, allFolders) },
+ folderPath = folder?.path, selectedFolderId = folder?.id, currentBranch = currentBranch,
+ )
+ }
+ }
+ }
+ known
+ }
val id = conversationId
if (id != null) {
- val detail = c.conversationDetail(id)
- agent = detail.summary.agentType
- externalId = detail.summary.externalId
+ val cached = c.cachedConversation(id)
+ if (cached != null) {
+ historyCacheSnapshot = cached.snapshot
+ showLoadedDetail(cached.value, cached.savedAt)
+ }
+ val needsNetwork = cached == null || !cached.isFresh() ||
+ cached.value.summary.status.isLive || cached.value.inFlightUserTurnId != null
+ if (needsNetwork && cached != null) _ui.update { it.copy(refreshing = true) }
+ val detail = if (needsNetwork) c.conversationDetail(id).also {
+ historyCacheSnapshot = null
+ showLoadedDetail(it)
+ } else cached!!.value
+ // Reattach when the server reports the turn live — via an explicit
+ // in-flight user turn id or the conversation status (iOS parity).
+ if (needsNetwork && (detail.inFlightUserTurnId != null || detail.summary.status.isLive)) reattachIfLive()
+ allFolders = folders.await()
folder = allFolders.firstOrNull { it.id == detail.summary.folderId }
currentBranch = detail.summary.gitBranch ?: folder?.gitBranch
_ui.update {
it.copy(
- loading = false,
- summary = detail.summary,
- turns = detail.turns,
- sessionStats = detail.sessionStats,
- agent = detail.summary.agentType,
folderName = folder?.let { f -> FolderVisibility.displayName(f, allFolders) },
folderPath = folder?.path,
selectedFolderId = folder?.id,
currentBranch = currentBranch,
- isDraftEditable = false,
- error = null,
)
}
- // Reattach when the server reports the turn live — via an explicit
- // in-flight user turn id or the conversation status (iOS parity).
- if (detail.inFlightUserTurnId != null || detail.summary.status.isLive) reattachIfLive()
} else {
+ allFolders = folders.await()
// New task: load the draft pickers' sources (top-level open folders +
// installed/enabled agents) and pick sensible defaults — honoring a
// preselected folder (e.g. a worktree a branch switch opened).
@@ -221,11 +283,135 @@ class SessionDetailViewModel @Inject constructor(
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
- _ui.update { it.copy(loading = false, error = e.displayMessage()) }
+ _ui.update { it.copy(loading = false, refreshing = false, error = e.displayMessage()) }
+ }
+ }
+ }
+
+ private fun showLoadedDetail(detail: ConversationDetail, cachedAt: Long? = null) {
+ agent = detail.summary.agentType
+ externalId = detail.summary.externalId
+ currentBranch = detail.summary.gitBranch
+ folder = allFolders.firstOrNull { it.id == detail.summary.folderId }
+ _ui.update {
+ it.copy(
+ loading = false, refreshing = false, summary = detail.summary, turns = detail.turns,
+ historyWindow = TranscriptWindow.from(detail), historyChunkStarts = emptySet(), olderError = null,
+ sessionStats = detail.sessionStats, agent = agent,
+ folderName = folder?.let { f -> FolderVisibility.displayName(f, allFolders) },
+ folderPath = folder?.path, selectedFolderId = folder?.id, currentBranch = currentBranch,
+ isDraftEditable = false, error = null,
+ lastUpdatedAt = cachedAt ?: System.currentTimeMillis(), showingCached = cachedAt != null,
+ )
+ }
+ }
+
+ /** Explicit refresh always reaches the server, preserving the live builder and composer. */
+ fun refresh() {
+ if (_ui.value.refreshing || _ui.value.loading) return
+ if (conversationId == null || client == null) { load(); return }
+ _ui.update { it.copy(refreshing = true, error = null) }
+ refreshJob = viewModelScope.launch {
+ try {
+ loadJob?.join()
+ reconcileJob?.join()
+ val c = client ?: return@launch
+ val id = conversationId ?: return@launch
+ invalidateOlderRequest()
+ val builder = liveBuilder
+ val generation = historyGeneration
+ val detail = fetchDetailWindowed(c, id)
+ if (builder !== liveBuilder || generation != historyGeneration) return@launch
+ historyCacheSnapshot = null
+ _ui.update {
+ // The live builder owns unconfirmed user/assistant records until reconcile.
+ val replaceTranscript = it.live == null && it.pendingUserTurns.isEmpty()
+ it.copy(
+ turns = if (replaceTranscript) detail.turns else it.turns,
+ historyWindow = if (replaceTranscript) TranscriptWindow.from(detail) else it.historyWindow,
+ historyChunkStarts = if (!replaceTranscript || it.historyWindow?.acceptsRefresh(TranscriptWindow.from(detail)) == true) it.historyChunkStarts else emptySet(),
+ summary = detail.summary, sessionStats = detail.sessionStats ?: it.sessionStats,
+ olderError = null, lastUpdatedAt = System.currentTimeMillis(), showingCached = false,
+ )
+ }
+ if (_ui.value.live == null && (detail.inFlightUserTurnId != null || detail.summary.status.isLive)) reattachIfLive()
+ } catch (e: CancellationException) { throw e }
+ catch (e: Exception) { _ui.update { it.copy(error = e.displayMessage()) } }
+ finally { _ui.update { it.copy(refreshing = false) } }
+ }
+ }
+
+ private fun invalidateOlderRequest() {
+ historyGeneration++
+ olderJob?.cancel()
+ olderJob = null
+ _ui.update { it.copy(loadingOlder = false) }
+ }
+
+ fun loadOlderTurns() {
+ val c = client ?: return
+ val id = conversationId ?: return
+ val state = _ui.value
+ val window = state.historyWindow ?: return
+ if (window.offset == 0 || state.loading || state.refreshing || state.loadingOlder || reconcileJob?.isActive == true) return
+ val generation = historyGeneration
+ _ui.update { it.copy(loadingOlder = true, olderError = null) }
+ olderJob = viewModelScope.launch {
+ try {
+ val cachedPage = historyCacheSnapshot?.let { c.cachedConversationTurns(id, window.offset, it) }
+ val page = if (cachedPage != null && window.prepend(cachedPage, state.turns) != null) cachedPage
+ else c.conversationTurns(id, window.offset)
+ if (generation != historyGeneration || conversationId != id) return@launch
+ val current = _ui.value
+ val next = window.prepend(page, current.turns)
+ if (current.historyWindow != window || next == null) {
+ _ui.update { it.copy(loadingOlder = false) }
+ // History was rewritten; the old index seam is no longer usable.
+ reconcileAfterMissedLive(resetWindow = true)
+ return@launch
+ }
+ _ui.update {
+ it.copy(
+ turns = page.turns + it.turns,
+ historyWindow = next,
+ loadingOlder = false,
+ // Keep partial replies as separate chunks so prepending cannot re-key
+ // the Markdown/tool rows the user is currently reading.
+ historyChunkStarts = it.historyChunkStarts +
+ listOfNotNull(it.turns.firstOrNull()?.takeIf { t -> t.role == TurnRole.ASSISTANT }?.id),
+ )
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ if (generation == historyGeneration) {
+ _ui.update { it.copy(loadingOlder = false, olderError = e.displayMessage()) }
+ }
}
}
}
+ private suspend fun fetchDetailWindowed(c: CodegClient, id: Int): ConversationDetail {
+ val window = _ui.value.historyWindow ?: return c.conversationDetail(id)
+ val detail = c.conversationDetail(id, fromIndex = window.offset)
+ // Legacy servers return the full history without window metadata.
+ if (detail.turnsOffset == null || window.acceptsRefresh(TranscriptWindow.from(detail))) return detail
+ return c.conversationDetail(id)
+ }
+
+ /** Used by an in-thread jump whose target is above the loaded window. */
+ suspend fun loadThroughTurn(turnId: String): Boolean {
+ while (_ui.value.turns.none { it.id == turnId }) {
+ val offset = _ui.value.historyWindow?.offset ?: return false
+ if (offset == 0 || _ui.value.loading || reconcileJob?.isActive == true) return false
+ if (!_ui.value.loadingOlder) loadOlderTurns()
+ olderJob?.join()
+ val after = _ui.value.historyWindow?.offset ?: return false
+ if (_ui.value.olderError != null || after >= offset) return false
+ }
+ return true
+ }
+
// region Send / cancel
fun send(text: String) = send(text, withAttachments = true)
@@ -267,7 +453,7 @@ class SessionDetailViewModel @Inject constructor(
)
val builder = LiveTurnBuilder()
liveBuilder = builder
- turnBaseline = _ui.value.turns.size
+ turnBaseline = _ui.value.historyWindow?.countIncludingLocal(_ui.value.turns.size) ?: _ui.value.turns.size
_ui.update {
it.copy(
pendingUserTurns = it.pendingUserTurns + userTurn,
@@ -297,6 +483,7 @@ class SessionDetailViewModel @Inject constructor(
// that row and not whatever conversationId happens to be by the time the failure lands.
var createdId: Int? = null
try {
+ loadJob?.join()
ensureConversationCreated(text.ifEmpty { "Image" })
if (createdConversation) createdId = conversationId
try {
@@ -524,7 +711,7 @@ class SessionDetailViewModel @Inject constructor(
// streaming; the snapshot's live message + active tools are seeded on arrival.
val builder = LiveTurnBuilder()
liveBuilder = builder
- turnBaseline = _ui.value.turns.size
+ turnBaseline = _ui.value.historyWindow?.countIncludingLocal(_ui.value.turns.size) ?: _ui.value.turns.size
_ui.update { it.copy(isInFlight = true, sendStatus = "Reattaching…", live = builder.snapshot()) }
try {
openStream(found.connectionId, seedOnAttach = true)
@@ -757,13 +944,35 @@ class SessionDetailViewModel @Inject constructor(
/** Server reported a live turn at load but no in-flight state surfaced — refetch so
* the transcript shows the final reply rather than the beat-stale load. */
- private fun reconcileAfterMissedLive() {
+ private fun reconcileAfterMissedLive(resetWindow: Boolean = false) {
val c = client ?: return
val id = conversationId ?: return
- viewModelScope.launch {
- runCatching { c.conversationDetail(id) }.getOrNull()?.let { d ->
+ val builder = liveBuilder
+ invalidateOlderRequest()
+ reconcileJob?.cancel()
+ reconcileJob = viewModelScope.launch {
+ try {
+ val d = if (resetWindow) c.conversationDetail(id) else fetchDetailWindowed(c, id)
+ if (liveBuilder !== builder || conversationId != id) return@launch
+ historyCacheSnapshot = null
_ui.update {
- it.copy(turns = d.turns, sessionStats = d.sessionStats ?: it.sessionStats, summary = d.summary)
+ it.copy(
+ turns = d.turns,
+ historyWindow = TranscriptWindow.from(d),
+ historyChunkStarts = if (it.historyWindow?.acceptsRefresh(TranscriptWindow.from(d)) == true) {
+ it.historyChunkStarts
+ } else emptySet(),
+ olderError = null,
+ sessionStats = d.sessionStats ?: it.sessionStats,
+ summary = d.summary,
+ lastUpdatedAt = System.currentTimeMillis(), showingCached = false,
+ )
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ if (resetWindow && liveBuilder === builder) {
+ _ui.update { it.copy(olderError = e.displayMessage()) }
}
}
}
@@ -883,17 +1092,20 @@ class SessionDetailViewModel @Inject constructor(
// live builder while we poll, bail rather than wiping the newer turn's state (its
// pending user turn, live reply, in-flight flag). Mirrors iOS's `liveTurn === live`.
val builder = liveBuilder
+ invalidateOlderRequest()
reconcileJob?.cancel()
reconcileJob = viewModelScope.launch {
repeat(5) { attempt ->
delay(if (attempt == 0) 300 else 600)
if (liveBuilder !== builder) return@launch
- val detail = runCatching { c.conversationDetail(id) }.getOrNull() ?: return@repeat
+ val detail = runCatching { fetchDetailWindowed(c, id) }.getOrNull() ?: return@repeat
+ if (liveBuilder !== builder) return@launch
val last = detail.turns.lastOrNull()
- val advanced = detail.turns.size > turnBaseline &&
+ val advanced = (detail.turnsTotal ?: detail.turns.size) > turnBaseline &&
last?.role == TurnRole.ASSISTANT && last.blocks.isNotEmpty()
if (advanced) {
if (liveBuilder !== builder) return@launch
+ historyCacheSnapshot = null
// This turn ended without a `turn_complete` of its own (a dead
// connection, an exhausted reconnect), so `finalizeTurn` never ran:
// resolve its parked plan-revision notes here instead of leaving them
@@ -903,8 +1115,14 @@ class SessionDetailViewModel @Inject constructor(
_ui.update {
it.copy(
turns = detail.turns,
+ historyWindow = TranscriptWindow.from(detail),
+ historyChunkStarts = if (it.historyWindow?.acceptsRefresh(TranscriptWindow.from(detail)) == true) {
+ it.historyChunkStarts
+ } else emptySet(),
+ olderError = null,
sessionStats = detail.sessionStats ?: it.sessionStats,
summary = detail.summary,
+ lastUpdatedAt = System.currentTimeMillis(), showingCached = false,
pendingUserTurns = emptyList(),
live = null,
liveFromReattach = false,
@@ -1200,6 +1418,7 @@ class SessionDetailViewModel @Inject constructor(
/** List the folder's branches for the picker; refreshes the current-branch
* label opportunistically. Null when there's no git context / on failure. */
suspend fun loadBranches(): GitBranchList? {
+ loadJob?.join()
val c = client ?: return null
val path = folder?.path ?: return null
return try {
@@ -1326,6 +1545,7 @@ class SessionDetailViewModel @Inject constructor(
* catalog (slower) and pre-selects the user's remembered pick (validated against the
* catalog), falling back to the catalog's fresh-session defaults. iOS parity. */
suspend fun loadAgentOptions(): AgentOptionsData {
+ loadJob?.join()
val c = client ?: return AgentOptionsData()
val convId = conversationId
val snapshot = if (convId != null) runCatching { c.sessionSnapshotByConversation(convId) }.getOrNull() else null
@@ -1530,6 +1750,9 @@ sealed interface BranchSwitchOutcome {
data class SessionDetailUiState(
val isNew: Boolean,
val loading: Boolean = false,
+ val refreshing: Boolean = false,
+ val lastUpdatedAt: Long? = null,
+ val showingCached: Boolean = false,
val summary: ConversationSummary? = null,
val folderName: String? = null,
val folderPath: String? = null,
@@ -1539,6 +1762,10 @@ data class SessionDetailUiState(
val availableFolders: List = emptyList(),
val availableAgents: List = emptyList(),
val turns: List = emptyList(),
+ val historyWindow: TranscriptWindow? = null,
+ val historyChunkStarts: Set = emptySet(),
+ val loadingOlder: Boolean = false,
+ val olderError: String? = null,
val pendingUserTurns: List = emptyList(),
val live: LiveTurnState? = null,
/** The live turn was rebuilt from a reattach snapshot (owns the in-flight reply). */
diff --git a/app/src/main/java/app/codeg/android/feature/sessiondetail/TranscriptWindow.kt b/app/src/main/java/app/codeg/android/feature/sessiondetail/TranscriptWindow.kt
new file mode 100644
index 0000000..d7a4b37
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/sessiondetail/TranscriptWindow.kt
@@ -0,0 +1,41 @@
+package app.codeg.android.feature.sessiondetail
+
+import app.codeg.android.core.model.ConversationDetail
+import app.codeg.android.core.model.ConversationTurnsPage
+import app.codeg.android.core.model.MessageTurn
+import app.codeg.android.core.model.TurnRole
+
+/** Coordinates describe persisted records only; optimistic/live turns never change them. */
+data class TranscriptWindow(val offset: Int, val total: Int, val prefixHash: String) {
+ fun countIncludingLocal(loadedCount: Int): Int = maxOf(total, offset + loadedCount)
+
+ fun acceptsRefresh(next: TranscriptWindow?): Boolean =
+ next != null && next.offset == offset && next.prefixHash == prefixHash
+
+ fun prepend(page: ConversationTurnsPage, current: List): TranscriptWindow? {
+ if (page.turnsOffset !in 0 until offset || page.turnsTotal < total ||
+ page.turns.size != offset - page.turnsOffset ||
+ page.prefixHashBeforeIndex != prefixHash ||
+ extendTranscriptHash(page.prefixHash, page.turns) != prefixHash
+ ) return null
+ val ids = current.mapTo(HashSet()) { it.id }
+ if (page.turns.any { !ids.add(it.id) }) return null
+ return copy(offset = page.turnsOffset, prefixHash = page.prefixHash)
+ }
+
+ companion object {
+ fun from(detail: ConversationDetail): TranscriptWindow? {
+ val offset = detail.turnsOffset ?: return null
+ val total = detail.turnsTotal ?: return null
+ val hash = detail.prefixHash ?: return null
+ if (offset < 0 || total < offset || total - offset != detail.turns.size ||
+ !hash.matches(Regex("[0-9a-f]{16}"))
+ ) return null
+ return TranscriptWindow(offset, total, hash)
+ }
+ }
+}
+
+/** Matches Rust's id-free FNV-1a over role and little-endian timestamp millis. */
+internal fun extendTranscriptHash(seed: String, turns: List): String? =
+ app.codeg.android.core.model.extendTranscriptHash(seed, turns)
diff --git a/app/src/main/java/app/codeg/android/feature/sessiondetail/timeline/TimelineNode.kt b/app/src/main/java/app/codeg/android/feature/sessiondetail/timeline/TimelineNode.kt
index 9e89fb7..a147172 100644
--- a/app/src/main/java/app/codeg/android/feature/sessiondetail/timeline/TimelineNode.kt
+++ b/app/src/main/java/app/codeg/android/feature/sessiondetail/timeline/TimelineNode.kt
@@ -153,6 +153,7 @@ object TranscriptTimeline {
pending: List,
agent: AgentType,
hideTrailingAssistantReply: Boolean = false,
+ chunkStarts: Set = emptySet(),
): List {
val source = if (hideTrailingAssistantReply) turns.dropTrailingAssistantRun() else turns
val nodes = mutableListOf()
@@ -174,7 +175,7 @@ object TranscriptTimeline {
}
TurnRole.ASSISTANT -> {
var j = i + 1
- while (j < source.size && source[j].role == TurnRole.ASSISTANT) j++
+ while (j < source.size && source[j].role == TurnRole.ASSISTANT && source[j].id !in chunkStarts) j++
val merged = merge(source.subList(i, j))
nodes.addAll(assistantNodes(merged, lastUserId, agent))
i = j
diff --git a/app/src/main/java/app/codeg/android/feature/sessions/SessionListCollapseState.kt b/app/src/main/java/app/codeg/android/feature/sessions/SessionListCollapseState.kt
new file mode 100644
index 0000000..df2bcf1
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/sessions/SessionListCollapseState.kt
@@ -0,0 +1,21 @@
+package app.codeg.android.feature.sessions
+
+/** Compact mode temporarily hides rows without overwriting the user's normal layout. */
+internal data class SessionListCollapseState(
+ val collapsed: Set = emptySet(),
+ val compact: Boolean = false,
+ val expandedInCompact: Set = emptySet(),
+) {
+ fun isCollapsed(id: String): Boolean =
+ if (compact) id !in expandedInCompact else id in collapsed
+
+ fun toggleCompact(): SessionListCollapseState =
+ copy(compact = !compact, expandedInCompact = emptySet())
+
+ fun toggleSection(id: String): SessionListCollapseState =
+ if (compact) {
+ copy(expandedInCompact = if (id in expandedInCompact) expandedInCompact - id else expandedInCompact + id)
+ } else {
+ copy(collapsed = if (id in collapsed) collapsed - id else collapsed + id)
+ }
+}
diff --git a/app/src/main/java/app/codeg/android/feature/sessions/SessionListCollapseStore.kt b/app/src/main/java/app/codeg/android/feature/sessions/SessionListCollapseStore.kt
new file mode 100644
index 0000000..0738868
--- /dev/null
+++ b/app/src/main/java/app/codeg/android/feature/sessions/SessionListCollapseStore.kt
@@ -0,0 +1,42 @@
+package app.codeg.android.feature.sessions
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Per-server layout survives a fresh app launch, including the layout to restore. */
+@Singleton
+class SessionListCollapseStore @Inject constructor(
+ private val dataStore: DataStore,
+) {
+ internal fun state(serverId: String) = dataStore.data
+ .map { it.layout(serverId) }
+ .distinctUntilChanged()
+
+ internal suspend fun update(serverId: String, transform: (SessionListCollapseState) -> SessionListCollapseState) {
+ dataStore.edit { prefs ->
+ val next = transform(prefs.layout(serverId))
+ prefs[compactKey(serverId)] = next.compact
+ prefs[collapsedKey(serverId)] = next.collapsed
+ prefs[expandedKey(serverId)] = next.expandedInCompact
+ }
+ }
+
+ private fun Preferences.layout(serverId: String) = SessionListCollapseState(
+ compact = this[compactKey(serverId)] ?: false,
+ collapsed = this[collapsedKey(serverId)] ?: emptySet(),
+ expandedInCompact = this[expandedKey(serverId)] ?: emptySet(),
+ )
+
+ private fun compactKey(id: String) = booleanPreferencesKey("sessions_layout_${id}_compact")
+ private fun collapsedKey(id: String) = stringSetPreferencesKey("sessions_layout_${id}_collapsed")
+ private fun expandedKey(id: String) = stringSetPreferencesKey("sessions_layout_${id}_expanded_compact")
+}
+
+internal data class ServerSessionListLayout(val serverId: String, val state: SessionListCollapseState)
diff --git a/app/src/main/java/app/codeg/android/feature/sessions/SessionListScreen.kt b/app/src/main/java/app/codeg/android/feature/sessions/SessionListScreen.kt
index b699776..40117ae 100644
--- a/app/src/main/java/app/codeg/android/feature/sessions/SessionListScreen.kt
+++ b/app/src/main/java/app/codeg/android/feature/sessions/SessionListScreen.kt
@@ -5,6 +5,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
@@ -26,6 +27,12 @@ import androidx.compose.material.icons.rounded.Folder
import androidx.compose.material.icons.rounded.Forum
import androidx.compose.material.icons.rounded.Inbox
import androidx.compose.material.icons.rounded.PushPin
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material.icons.rounded.Search
+import androidx.compose.material.icons.rounded.UnfoldLess
+import androidx.compose.material.icons.rounded.UnfoldMore
+import androidx.compose.material.icons.automirrored.rounded.Sort
+import androidx.compose.material.icons.rounded.History
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -60,17 +67,23 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.codeg.android.R
import app.codeg.android.core.datastore.ServerProfile
+import app.codeg.android.core.datastore.RecentSessionFilter
+import app.codeg.android.core.datastore.SessionListPreferences
+import app.codeg.android.core.datastore.SessionSortMode
import app.codeg.android.core.designsystem.component.EmptyState
+import app.codeg.android.core.designsystem.component.CacheStatus
import app.codeg.android.core.designsystem.component.InlineError
import app.codeg.android.core.designsystem.component.LoadingView
import app.codeg.android.core.designsystem.theme.CodegTheme
import app.codeg.android.core.designsystem.theme.colorFromHex
import app.codeg.android.core.model.ConversationSummary
+import app.codeg.android.core.model.FolderOrdering
import app.codeg.android.feature.main.LocalBarsVisible
+import app.codeg.android.feature.projects.FolderManagementSheet
/**
- * The Chats tab: the grouped session list for the selected server (Pinned /
- * per-folder / Other), a server switcher in the title, and a "New Task" action.
+ * The Chats tab: Pinned / Chats / projects / Other / Recent, with per-section previews
+ * and access to global search. Source data is never truncated by the preview.
*
* The iOS card-zoom interaction is adapted to native **collapsible sections**: tap a
* section header to fold/unfold its rows. The grouping itself is computed off the main
@@ -86,22 +99,28 @@ fun SessionListScreen(
onManageServers: () -> Unit,
onOpenConversation: (Int) -> Unit,
onNewTask: () -> Unit,
+ onSearch: () -> Unit,
viewModel: SessionListViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
val sections by viewModel.sections.collectAsStateWithLifecycle()
- // Collapsed section ids. In the composable (not the VM) so collapsing never
- // re-runs the grouping flow; rememberSaveable keeps the fold state across
- // rotation / process death.
- var collapsed by rememberSaveable { mutableStateOf(emptySet()) }
+ val storedLayout by viewModel.collapseLayout.collectAsStateWithLifecycle()
+ // Wait for this server's saved layout instead of briefly expanding every group.
+ val layout = storedLayout?.takeIf { it.serverId == selectedId }
+ val collapseState = layout?.state ?: SessionListCollapseState()
val colors = CodegTheme.colors
val selectedName = servers.firstOrNull { it.id == selectedId }?.name
?: stringResource(R.string.app_name)
+ var showOrder by remember(selectedId, ui.hiddenScope) { mutableStateOf(false) }
+ val orderedFolders = remember(ui.folders, ui.folderOrder) {
+ FolderOrdering.sorted(ui.folders.filterNot { it.isChatFolder }, ui.folderOrder)
+ }
Scaffold(
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
+ Column {
TopAppBar(
title = {
ServerSwitcher(
@@ -113,6 +132,21 @@ fun SessionListScreen(
)
},
actions = {
+ IconButton(onClick = viewModel::refresh, enabled = selectedId != null && !ui.isRefreshing) {
+ Icon(Icons.Rounded.Refresh, contentDescription = stringResource(R.string.common_refresh), tint = colors.textSecondary)
+ }
+ IconButton(onClick = onSearch) {
+ Icon(Icons.Rounded.Search, contentDescription = stringResource(R.string.tab_search), tint = colors.textPrimary)
+ }
+ SessionListOptions(
+ preferences = ui.preferences,
+ onSort = viewModel::setSortMode,
+ onShowRecent = viewModel::setShowRecent,
+ onRecentFilter = viewModel::setRecentFilter,
+ onRecentShowCompleted = viewModel::setRecentShowCompleted,
+ canOrderFolders = ui.hiddenScope != null && orderedFolders.isNotEmpty(),
+ onOrderFolders = { showOrder = true },
+ )
IconButton(onClick = onManageServers) {
Icon(
Icons.Rounded.Dns,
@@ -126,6 +160,33 @@ fun SessionListScreen(
titleContentColor = colors.textPrimary,
),
)
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(Modifier.weight(1f)) {
+ CacheStatus(
+ refreshing = ui.isRefreshing,
+ showingCached = ui.showingCached,
+ lastUpdatedAt = ui.lastUpdatedAt,
+ )
+ }
+ TextButton(
+ onClick = { layout?.let { viewModel.toggleCompact(it.serverId) } },
+ enabled = layout != null && sections.isNotEmpty() && !ui.isEmpty,
+ ) {
+ Icon(
+ if (collapseState.compact) Icons.Rounded.UnfoldMore else Icons.Rounded.UnfoldLess,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Text(
+ stringResource(if (collapseState.compact) R.string.sessions_restore_layout else R.string.sessions_collapse_groups),
+ modifier = Modifier.padding(start = 4.dp),
+ )
+ }
+ }
+ }
},
floatingActionButton = {
ExtendedFloatingActionButton(
@@ -161,7 +222,20 @@ fun SessionListScreen(
}
ui.hasLoaded && ui.isEmpty -> {
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(Modifier.fillMaxSize()) {
+ ui.error?.let { error ->
+ RefreshErrorBanner(
+ message = error,
+ onRetry = viewModel::refresh,
+ onDismiss = viewModel::dismissError,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
+ )
+ }
+ Column(
+ Modifier.weight(1f).fillMaxWidth(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
EmptyState(
icon = Icons.Rounded.Forum,
title = stringResource(R.string.sessions_empty_title),
@@ -169,6 +243,16 @@ fun SessionListScreen(
actionLabel = stringResource(R.string.session_new_task),
onAction = onNewTask,
)
+ TextButton(onClick = viewModel::refresh, enabled = !ui.isRefreshing) {
+ Text(stringResource(R.string.common_refresh))
+ }
+ }
+ }
+ }
+
+ layout == null && selectedId != null -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ LoadingView(message = stringResource(R.string.common_loading))
}
}
@@ -179,13 +263,15 @@ fun SessionListScreen(
modifier = Modifier.fillMaxSize(),
) {
SessionList(
+ serverId = selectedId,
sections = sections,
- collapsed = collapsed,
- onToggleSection = { id ->
- collapsed = if (id in collapsed) collapsed - id else collapsed + id
- },
+ preferences = ui.preferences,
+ collapseState = collapseState,
+ onToggleSection = { id -> layout?.let { viewModel.toggleSection(it.serverId, id) } },
onOpenConversation = onOpenConversation,
onTogglePin = { conv -> viewModel.setPinned(conv, !conv.isPinned) },
+ hasHiddenFolders = ui.hiddenFolderIds.isNotEmpty(),
+ onManageFolders = { showOrder = true },
// A refresh that failed over a still-populated list: surface
// it inline above the rows rather than swallowing it.
refreshError = ui.error,
@@ -197,19 +283,36 @@ fun SessionListScreen(
}
}
}
+ if (showOrder) {
+ FolderManagementSheet(
+ folders = orderedFolders,
+ hiddenIds = ui.hiddenFolderIds,
+ onSetHidden = viewModel::setFolderHidden,
+ onMove = viewModel::moveFolder,
+ onReorder = viewModel::reorderFolders,
+ onReset = viewModel::resetFolderOrder,
+ onDismiss = { showOrder = false },
+ error = ui.error,
+ )
+ }
}
@Composable
private fun SessionList(
+ serverId: String?,
sections: List,
- collapsed: Set,
+ preferences: SessionListPreferences,
+ collapseState: SessionListCollapseState,
onToggleSection: (String) -> Unit,
onOpenConversation: (Int) -> Unit,
onTogglePin: (ConversationSummary) -> Unit,
+ hasHiddenFolders: Boolean,
+ onManageFolders: () -> Unit,
refreshError: String?,
onRetry: () -> Unit,
onDismissError: () -> Unit,
) {
+ var rowLimits by rememberSaveable(serverId, preferences) { mutableStateOf(mapOf()) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
@@ -230,14 +333,24 @@ private fun SessionList(
}
}
+ if (hasHiddenFolders && sections.none { it.count > 0 }) {
+ item(key = "hidden-folders-empty", contentType = "empty") {
+ Column(Modifier.fillMaxWidth().padding(16.dp)) {
+ Text(stringResource(R.string.folders_hidden_hint), color = CodegTheme.colors.textSecondary)
+ TextButton(onClick = onManageFolders) { Text(stringResource(R.string.folders_manage)) }
+ }
+ }
+ }
sections.forEach { section ->
- val isCollapsed = section.id in collapsed
+ val isCollapsed = collapseState.isCollapsed(section.id)
item(key = "h-${section.id}", contentType = "header") {
val style = sectionStyle(section.kind)
CollapsibleSectionHeader(
icon = style.icon,
tint = style.tint,
- label = style.label,
+ label = if (section.kind == SectionKind.Recent && preferences.recentFilter != RecentSessionFilter.ALL) {
+ "${style.label} · ${stringResource(recentFilterLabel(preferences.recentFilter))}"
+ } else style.label,
count = section.count,
collapsed = isCollapsed,
onToggle = { onToggleSection(section.id) },
@@ -246,9 +359,11 @@ private fun SessionList(
)
}
if (!isCollapsed) {
+ val limit = rowLimits[section.id] ?: section.pageSize
+ val visibleRows = section.rows.take(limit)
items(
- items = section.rows,
- key = { "row-${it.conversation.id}" },
+ items = visibleRows,
+ key = { "row-${section.id}-${it.conversation.id}" },
contentType = { "row" },
) { row ->
SessionRow(
@@ -257,8 +372,39 @@ private fun SessionList(
modifier = Modifier.animateItem(),
folderName = row.folderName,
onTogglePin = { onTogglePin(row.conversation) },
+ timestamp = row.timestamp,
)
}
+ if (section.kind == SectionKind.Recent && section.count == 0) {
+ item(key = "recent-empty", contentType = "empty") {
+ Text(
+ stringResource(R.string.sessions_recent_empty),
+ color = CodegTheme.colors.textSecondary,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ }
+ }
+ if (visibleRows.size < section.count) {
+ item(key = "more-${section.id}", contentType = "more") {
+ TextButton(
+ onClick = { rowLimits = rowLimits + (section.id to (limit + section.pageSize)) },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
+ ) {
+ Text(stringResource(R.string.sessions_load_more, section.count - visibleRows.size))
+ }
+ }
+ }
+ if (limit > section.pageSize) {
+ item(key = "reset-${section.id}", contentType = "more") {
+ TextButton(
+ onClick = { rowLimits = rowLimits - section.id },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
+ ) {
+ Text(stringResource(R.string.sessions_reset_preview, section.pageSize))
+ }
+ }
+ }
}
}
}
@@ -276,6 +422,16 @@ private fun sectionStyle(kind: SectionKind): SectionStyle {
tint = colors.accent,
label = stringResource(R.string.sessions_pinned),
)
+ SectionKind.Chats -> SectionStyle(
+ icon = Icons.Rounded.Forum,
+ tint = colors.accent,
+ label = stringResource(R.string.sessions_chats),
+ )
+ SectionKind.Recent -> SectionStyle(
+ icon = Icons.Rounded.History,
+ tint = colors.accent,
+ label = stringResource(R.string.sessions_recent),
+ )
is SectionKind.Folder -> SectionStyle(
icon = Icons.Rounded.Folder,
tint = colorFromHex(kind.colorHex) ?: colors.accent,
@@ -289,6 +445,75 @@ private fun sectionStyle(kind: SectionKind): SectionStyle {
}
}
+private fun recentFilterLabel(filter: RecentSessionFilter): Int = when (filter) {
+ RecentSessionFilter.ALL -> R.string.sessions_recent_all
+ RecentSessionFilter.CHATS -> R.string.sessions_chats
+ RecentSessionFilter.FOLDERS -> R.string.sessions_recent_folders
+ RecentSessionFilter.CLI -> R.string.sessions_recent_cli
+}
+
+@Composable
+private fun SessionListOptions(
+ preferences: SessionListPreferences,
+ onSort: (SessionSortMode) -> Unit,
+ onShowRecent: (Boolean) -> Unit,
+ onRecentFilter: (RecentSessionFilter) -> Unit,
+ onRecentShowCompleted: (Boolean) -> Unit,
+ canOrderFolders: Boolean,
+ onOrderFolders: () -> Unit,
+) {
+ var open by remember { mutableStateOf(false) }
+ Box {
+ IconButton(onClick = { open = true }) {
+ Icon(
+ Icons.AutoMirrored.Rounded.Sort,
+ contentDescription = stringResource(R.string.sessions_list_options),
+ tint = CodegTheme.colors.textPrimary,
+ )
+ }
+ DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.folders_manage)) },
+ enabled = canOrderFolders,
+ onClick = { open = false; onOrderFolders() },
+ )
+ HorizontalDivider()
+ SessionSortMode.entries.forEach { mode ->
+ DropdownMenuItem(
+ text = { Text(stringResource(if (mode == SessionSortMode.CREATED) R.string.sessions_sort_created else R.string.sessions_sort_updated)) },
+ trailingIcon = { if (mode == preferences.sortMode) Icon(Icons.Rounded.Check, contentDescription = null) },
+ onClick = { onSort(mode); open = false },
+ )
+ }
+ HorizontalDivider()
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.sessions_show_recent)) },
+ trailingIcon = { if (preferences.showRecent) Icon(Icons.Rounded.Check, contentDescription = null) },
+ onClick = { onShowRecent(!preferences.showRecent); open = false },
+ )
+ if (preferences.showRecent) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.sessions_recent_filter)) },
+ enabled = false,
+ onClick = {},
+ )
+ RecentSessionFilter.entries.forEach { filter ->
+ DropdownMenuItem(
+ text = { Text(stringResource(recentFilterLabel(filter))) },
+ trailingIcon = { if (filter == preferences.recentFilter) Icon(Icons.Rounded.Check, contentDescription = null) },
+ onClick = { onRecentFilter(filter); open = false },
+ )
+ }
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.sessions_recent_completed)) },
+ trailingIcon = { if (preferences.recentShowCompleted) Icon(Icons.Rounded.Check, contentDescription = null) },
+ onClick = { onRecentShowCompleted(!preferences.recentShowCompleted); open = false },
+ )
+ }
+ }
+ }
+}
+
/**
* A dismissible banner for a refresh that failed while rows are still on screen —
* the stale rows stay visible and the error is surfaced inline with Retry / dismiss
diff --git a/app/src/main/java/app/codeg/android/feature/sessions/SessionListViewModel.kt b/app/src/main/java/app/codeg/android/feature/sessions/SessionListViewModel.kt
index 62dd482..14cc329 100644
--- a/app/src/main/java/app/codeg/android/feature/sessions/SessionListViewModel.kt
+++ b/app/src/main/java/app/codeg/android/feature/sessions/SessionListViewModel.kt
@@ -6,27 +6,39 @@ import androidx.lifecycle.viewModelScope
import app.codeg.android.R
import app.codeg.android.core.data.ServerRepository
import app.codeg.android.core.datastore.ServerProfile
+import app.codeg.android.core.datastore.HiddenFoldersStore
+import app.codeg.android.core.datastore.FolderOrderStore
+import app.codeg.android.core.datastore.RecentSessionFilter
+import app.codeg.android.core.datastore.SessionListPreferences
+import app.codeg.android.core.datastore.SessionListPreferencesStore
+import app.codeg.android.core.datastore.SessionSortMode
import app.codeg.android.core.model.ConversationSummary
+import app.codeg.android.core.model.ConversationStatus
import app.codeg.android.core.model.FolderDetail
+import app.codeg.android.core.model.FolderOrdering
import app.codeg.android.core.network.CodegClient
import app.codeg.android.core.network.displayMessage
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import java.time.Instant
import javax.inject.Inject
@@ -34,7 +46,7 @@ import javax.inject.Inject
* Drives the Chats (session list) screen: loads folders + conversations for the
* selected server and keeps the full list in memory so grouping / pin toggles
* stay instant. Faithful port of the iOS `SessionListViewModel` — the grouped
- * display (Pinned / per-folder / Other) is derived by [SessionGrouping].
+ * display (Pinned / Chats / per-project / Other / Recent) is derived by [SessionGrouping].
*
* Rebinds automatically when the selected server changes (or is edited in place
* to a new endpoint): a monotonic [fetchGeneration] guards against a slow fetch
@@ -44,14 +56,34 @@ import javax.inject.Inject
class SessionListViewModel @Inject constructor(
private val repository: ServerRepository,
@ApplicationContext private val appContext: Context,
+ private val preferencesStore: SessionListPreferencesStore,
+ private val collapseStore: SessionListCollapseStore,
+ private val hiddenFoldersStore: HiddenFoldersStore,
+ private val folderOrderStore: FolderOrderStore,
) : ViewModel() {
private val _ui = MutableStateFlow(SessionListUiState())
val ui: StateFlow = _ui.asStateFlow()
+ @OptIn(ExperimentalCoroutinesApi::class)
+ internal val collapseLayout: StateFlow = repository.selectedProfile
+ .map { it?.id }
+ .distinctUntilChanged()
+ .flatMapLatest { serverId ->
+ if (serverId == null) flowOf(null)
+ else collapseStore.state(serverId)
+ .catch { e ->
+ if (e is CancellationException) throw e
+ _ui.update { it.copy(error = e.displayMessage()) }
+ emit(SessionListCollapseState())
+ }
+ .map { ServerSessionListLayout(serverId, it) }
+ }
+ .stateIn(viewModelScope, SharingStarted.Eagerly, null)
+
/**
- * The grouped, fully-sorted sections the Chats list renders (Pinned / per-folder
- * / Other). Derived from [_ui] but recomputed ONLY when the folders/conversations
+ * The grouped, fully-sorted sections the Chats list renders (Pinned / Chats / per-project
+ * / Other / Recent). Derived from [_ui] but recomputed ONLY when the source data or preferences
* actually change — a refresh-spinner or error toggle keeps the same list
* references, so [distinctUntilChanged] short-circuits and nothing re-sorts. The
* O(n·log n) grouping runs on [Dispatchers.Default], never on the main thread or
@@ -59,9 +91,9 @@ class SessionListViewModel @Inject constructor(
*/
val sections: StateFlow> =
_ui
- .map { GroupingInput(it.folders, it.conversations) }
+ .map { GroupingInput(it.folders, it.conversations, it.openFolderIds, it.preferences, it.hiddenFolderIds, it.folderOrder) }
.distinctUntilChanged()
- .map { buildSessionSections(it.folders, it.conversations) }
+ .map { buildSessionSections(it.folders, it.conversations, it.preferences, it.openFolderIds, it.hiddenFolderIds, it.folderOrder) }
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
@@ -69,6 +101,10 @@ class SessionListViewModel @Inject constructor(
private data class GroupingInput(
val folders: List,
val conversations: List,
+ val openFolderIds: Set,
+ val preferences: SessionListPreferences,
+ val hiddenFolderIds: Set,
+ val folderOrder: List,
)
private var client: CodegClient? = null
@@ -76,6 +112,11 @@ class SessionListViewModel @Inject constructor(
private var fetchGeneration = 0
init {
+ viewModelScope.launch {
+ preferencesStore.preferences.collect { preferences ->
+ _ui.update { it.copy(preferences = preferences) }
+ }
+ }
viewModelScope.launch {
repository.selectedProfile.collectLatest { profile -> onProfile(profile) }
}
@@ -83,25 +124,46 @@ class SessionListViewModel @Inject constructor(
viewModelScope.launch {
repository.conversationsChanged.collect { if (client != null && !_ui.value.isBusy) fetch(isInitial = false) }
}
+ viewModelScope.launch {
+ repository.foldersChanged.collect { if (client != null && !_ui.value.isBusy) fetch(isInitial = false) }
+ }
}
private suspend fun onProfile(profile: ServerProfile?) {
+ fetchGeneration++
if (profile == null) {
client = null
loadedEndpoint = null
- _ui.value = SessionListUiState()
+ _ui.update { SessionListUiState(preferences = it.preferences) }
return
}
- val endpoint = "${profile.id}|${profile.baseUrl}"
+ val resolved = repository.client(profile)
+ val endpoint = resolved?.cacheIdentity ?: "${profile.id}|${profile.baseUrl}"
val changed = endpoint != loadedEndpoint
loadedEndpoint = endpoint
- val resolved = repository.client(profile)
client = resolved
+ if (changed) _ui.update { SessionListUiState(preferences = it.preferences, isLoading = true) }
if (resolved == null) {
- _ui.update { it.copy(isLoading = false, error = appContext.getString(R.string.sessions_missing_token)) }
+ _ui.update { SessionListUiState(preferences = it.preferences, error = appContext.getString(R.string.sessions_missing_token)) }
return
}
- fetch(isInitial = true, force = changed)
+ val scope = hiddenFoldersStore.scope(profile)
+ val hiddenIds = hiddenFoldersStore.hiddenIds(scope).first()
+ val folderOrder = folderOrderStore.order(scope).first()
+ _ui.update { it.copy(hiddenScope = scope, hiddenFolderIds = hiddenIds, folderOrder = folderOrder) }
+ coroutineScope {
+ launch {
+ folderOrderStore.order(scope).collect { order ->
+ _ui.update { it.copy(folderOrder = order) }
+ }
+ }
+ launch {
+ hiddenFoldersStore.hiddenIds(scope).collect { ids ->
+ _ui.update { it.copy(hiddenFolderIds = ids) }
+ }
+ }
+ fetch(isInitial = true, force = changed)
+ }
}
/** Pull-to-refresh / toolbar refresh — keeps the current list on screen. */
@@ -115,26 +177,42 @@ class SessionListViewModel @Inject constructor(
fetchGeneration += 1
val token = fetchGeneration
if (force) {
- _ui.update { it.copy(folders = emptyList(), conversations = emptyList(), hasLoaded = false) }
+ _ui.update { it.copy(folders = emptyList(), conversations = emptyList(), openFolderIds = emptySet(), hasLoaded = false, lastUpdatedAt = null, showingCached = false) }
}
_ui.update {
- if (isInitial) it.copy(isLoading = true, error = null)
+ if (isInitial) it.copy(isLoading = true, isRefreshing = false, error = null)
else it.copy(isRefreshing = true, error = null)
}
try {
- val result = coroutineScope {
- val folders = async { active.listFolders() }
- val conversations = async { active.listConversations() }
- folders.await() to conversations.await()
+ if (isInitial) {
+ val cached = active.cachedSessionList()
+ if (token != fetchGeneration) return
+ if (cached != null) {
+ _ui.update {
+ it.copy(
+ folders = cached.value.folders,
+ conversations = cached.value.conversations,
+ openFolderIds = cached.value.openFolderIds,
+ hasLoaded = true, isLoading = false,
+ lastUpdatedAt = cached.savedAt, showingCached = true,
+ )
+ }
+ if (cached.isFresh() && cached.value.conversations.none { it.status.isLive }) return
+ _ui.update { it.copy(isRefreshing = true) }
+ }
}
+ val result = active.sessionList()
if (token != fetchGeneration) return
_ui.update {
it.copy(
- folders = result.first,
- conversations = result.second,
+ folders = result.folders,
+ conversations = result.conversations,
+ openFolderIds = result.openFolderIds,
hasLoaded = true,
isLoading = false,
isRefreshing = false,
+ lastUpdatedAt = System.currentTimeMillis(),
+ showingCached = false,
)
}
} catch (e: CancellationException) {
@@ -152,6 +230,7 @@ class SessionListViewModel @Inject constructor(
*/
fun setPinned(conversation: ConversationSummary, pinned: Boolean) {
val active = client ?: return
+ val generation = fetchGeneration
val previous = _ui.value.conversations
_ui.update { state ->
state.copy(
@@ -163,33 +242,104 @@ class SessionListViewModel @Inject constructor(
viewModelScope.launch {
try {
active.setPinned(conversation.id, pinned)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
- _ui.update { it.copy(conversations = previous, error = e.displayMessage()) }
+ if (generation == fetchGeneration && client === active) {
+ _ui.update { it.copy(conversations = previous, error = e.displayMessage()) }
+ }
}
}
}
fun dismissError() = _ui.update { it.copy(error = null) }
+
+ fun setFolderHidden(folderId: Int, hidden: Boolean) {
+ val scope = _ui.value.hiddenScope ?: return
+ savePreference { hiddenFoldersStore.setHidden(scope, folderId, hidden) }
+ }
+
+ fun moveFolder(folderId: Int, offset: Int) {
+ val state = _ui.value
+ val scope = state.hiddenScope ?: return
+ saveFolderOrder(scope) {
+ folderOrderStore.move(scope, state.folders, emptySet(), folderId, offset)
+ }
+ }
+
+ fun resetFolderOrder() {
+ val scope = _ui.value.hiddenScope ?: return
+ saveFolderOrder(scope) {
+ folderOrderStore.reset(scope)
+ if (_ui.value.hiddenScope == scope) repository.notifyFoldersChanged()
+ }
+ }
+
+ fun reorderFolders(visibleOrder: List) {
+ val state = _ui.value
+ val scope = state.hiddenScope ?: return
+ saveFolderOrder(scope) {
+ folderOrderStore.reorder(scope, state.folders, emptySet(), visibleOrder)
+ }
+ }
+
+ private fun saveFolderOrder(scope: String, save: suspend () -> Unit) {
+ viewModelScope.launch {
+ try {
+ save()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ if (_ui.value.hiddenScope == scope) _ui.update { it.copy(error = e.displayMessage()) }
+ }
+ }
+ }
+
+ internal fun toggleCompact(serverId: String) = savePreference {
+ collapseStore.update(serverId) { it.toggleCompact() }
+ }
+
+ internal fun toggleSection(serverId: String, sectionId: String) = savePreference {
+ collapseStore.update(serverId) { it.toggleSection(sectionId) }
+ }
+
+ fun setSortMode(mode: SessionSortMode) = savePreference { preferencesStore.setSortMode(mode) }
+ fun setShowRecent(show: Boolean) = savePreference { preferencesStore.setShowRecent(show) }
+ fun setRecentFilter(filter: RecentSessionFilter) = savePreference { preferencesStore.setRecentFilter(filter) }
+ fun setRecentShowCompleted(show: Boolean) = savePreference { preferencesStore.setRecentShowCompleted(show) }
+
+ private fun savePreference(save: suspend () -> Unit) {
+ viewModelScope.launch {
+ try {
+ save()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ _ui.update { it.copy(error = e.displayMessage()) }
+ }
+ }
+ }
}
/** Raw list state; the grouped display is derived by [SessionGrouping]. */
data class SessionListUiState(
val folders: List = emptyList(),
val conversations: List = emptyList(),
+ val openFolderIds: Set = emptySet(),
+ val preferences: SessionListPreferences = SessionListPreferences(),
+ val hiddenScope: String? = null,
+ val hiddenFolderIds: Set = emptySet(),
+ val folderOrder: List = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val hasLoaded: Boolean = false,
+ val lastUpdatedAt: Long? = null,
+ val showingCached: Boolean = false,
val error: String? = null,
) {
val isBusy: Boolean get() = isLoading || isRefreshing
- /**
- * Empty ⟺ no conversations: every conversation lands in some section (Pinned, a
- * folder group, or Other), so folders-but-no-sessions correctly shows the empty
- * state instead of a wall of empty folder headers (matches iOS). Reads raw [_ui]
- * fields, so the decision stays atomic with the data and never flickers against
- * the separately-derived [SessionListViewModel.sections] flow.
- */
+ /** Source-empty state; a list filtered empty by hidden folders offers a restore action. */
val isEmpty: Boolean get() = conversations.isEmpty()
}
@@ -200,46 +350,67 @@ data class FolderGroup(
)
/**
- * Pure grouping/filtering of a session list, ported 1:1 from the iOS
- * `SessionListViewModel` derived-data accessors. Pinned conversations appear
- * only in the Pinned group; each folder group lists its non-pinned
- * conversations newest-first.
+ * Web-style grouping: pinned once, folderless chats in one flat section, and
+ * worktree sessions under their root project. Display pagination never trims
+ * these source lists, so every conversation remains reachable.
*/
object SessionGrouping {
- fun sortedFolders(folders: List): List =
- folders.sortedWith(
- compareBy({ it.sortOrder }, { it.name.lowercase() }),
- )
+ private val updatedFirst = compareByDescending { it.updatedAt }
+ .thenByDescending { it.createdAt }.thenByDescending { it.id }
+ private val createdFirst = compareByDescending { it.createdAt }
+ .thenByDescending { it.updatedAt }.thenByDescending { it.id }
+
+ private fun comparator(mode: SessionSortMode) =
+ if (mode == SessionSortMode.CREATED) createdFirst else updatedFirst
+
+ fun sortedFolders(folders: List, folderOrder: List = emptyList()): List =
+ FolderOrdering.sorted(folders, folderOrder)
/** `folderId -> name` for the dim folder tag shown on cross-folder rows. */
fun folderNames(folders: List): Map {
+ val byId = folders.associateBy { it.id }
val out = LinkedHashMap()
- for (folder in folders) out.putIfAbsent(folder.id, folder.name)
+ for (folder in folders) {
+ if (folder.isChatFolder) continue
+ val root = byId[folder.parentId]?.takeUnless { it.isChatFolder } ?: folder
+ out.putIfAbsent(folder.id, root.name)
+ }
return out
}
/** Pinned conversations across all folders, most-recently-pinned first. */
fun pinned(conversations: List, search: String = ""): List =
matching(conversations.filter { it.isPinned }, search)
- .sortedByDescending { it.pinnedAt ?: Instant.MIN }
+ .sortedWith(compareByDescending { it.pinnedAt ?: Instant.MIN }.thenByDescending { it.id })
- /**
- * One group per folder (in [sortedFolders] order) holding that folder's
- * non-pinned conversations, newest-first. With no search every folder is
- * included; when searching, folders with no match are dropped.
- */
+ /** Chat kind survives a missing folder; the folder flag supports older servers. */
+ private fun isChat(conversation: ConversationSummary, folders: Map): Boolean =
+ conversation.kind == "chat" || folders[conversation.folderId]?.isChatFolder == true
+
+ fun chats(folders: List, conversations: List): List {
+ val byId = folders.associateBy { it.id }
+ return conversations.filter { !it.isPinned && isChat(it, byId) }
+ .sortedWith(updatedFirst)
+ }
+
+ /** Non-empty project groups; a worktree with a missing parent keeps its own group. */
fun folderGroups(
folders: List,
conversations: List,
search: String = "",
+ sortMode: SessionSortMode = SessionSortMode.CREATED,
+ folderOrder: List = emptyList(),
): List {
- val searching = search.trim().isNotEmpty()
- val unpinnedByFolder = conversations.filter { !it.isPinned }.groupBy { it.folderId }
- return sortedFolders(folders).mapNotNull { folder ->
- val convs = matching(unpinnedByFolder[folder.id] ?: emptyList(), search)
- .sortedByDescending { it.updatedAt }
- if (searching && convs.isEmpty()) null else FolderGroup(folder, convs)
+ val byId = folders.associateBy { it.id }
+ val byProject = conversations.filter { !it.isPinned && !isChat(it, byId) }.groupBy { conversation ->
+ val folder = byId[conversation.folderId]
+ byId[folder?.parentId]?.takeUnless { it.isChatFolder } ?: folder
+ }
+ return sortedFolders(byProject.keys.filterNotNull(), folderOrder).mapNotNull { folder ->
+ val convs = matching(byProject.getValue(folder), search)
+ .sortedWith(comparator(sortMode))
+ if (convs.isEmpty()) null else FolderGroup(folder, convs)
}
}
@@ -248,12 +419,35 @@ object SessionGrouping {
folders: List,
conversations: List,
search: String = "",
+ sortMode: SessionSortMode = SessionSortMode.CREATED,
): List {
- val known = folders.map { it.id }.toSet()
+ val known = folders.associateBy { it.id }
return matching(
- conversations.filter { !it.isPinned && it.folderId !in known },
+ conversations.filter { !it.isPinned && !isChat(it, known) && it.folderId !in known },
search,
- ).sortedByDescending { it.updatedAt }
+ ).sortedWith(comparator(sortMode))
+ }
+
+ /** Recent is an additional entry point, with no age cutoff and no source-list limit. */
+ fun recent(
+ folders: List,
+ conversations: List,
+ openFolderIds: Set,
+ preferences: SessionListPreferences,
+ ): List {
+ val byId = folders.associateBy { it.id }
+ return conversations.filter { conversation ->
+ val chat = isChat(conversation, byId)
+ !conversation.isPinned &&
+ (preferences.recentShowCompleted || conversation.status != ConversationStatus.COMPLETED) &&
+ (chat || conversation.folderId in openFolderIds) &&
+ when (preferences.recentFilter) {
+ RecentSessionFilter.ALL -> true
+ RecentSessionFilter.CHATS -> chat
+ RecentSessionFilter.FOLDERS -> !chat
+ RecentSessionFilter.CLI -> conversation.source == "cli"
+ }
+ }.sortedWith(updatedFirst)
}
private fun matching(convs: List, search: String): List {
diff --git a/app/src/main/java/app/codeg/android/feature/sessions/SessionRow.kt b/app/src/main/java/app/codeg/android/feature/sessions/SessionRow.kt
index 0026d6e..f2bb083 100644
--- a/app/src/main/java/app/codeg/android/feature/sessions/SessionRow.kt
+++ b/app/src/main/java/app/codeg/android/feature/sessions/SessionRow.kt
@@ -40,6 +40,7 @@ import app.codeg.android.core.designsystem.component.statusColor
import app.codeg.android.core.designsystem.theme.CodegTheme
import app.codeg.android.core.model.ConversationSummary
import androidx.compose.ui.res.stringResource
+import java.time.Instant
/**
* One conversation row in the Chats / Activity / Search lists: a single
@@ -59,6 +60,7 @@ fun SessionRow(
folderName: String? = null,
selected: Boolean = false,
onTogglePin: (() -> Unit)? = null,
+ timestamp: Instant = conversation.updatedAt,
) {
val colors = CodegTheme.colors
var menuOpen by remember { mutableStateOf(false) }
@@ -131,7 +133,7 @@ fun SessionRow(
LivePulse()
} else {
Text(
- text = RelativeTime.compact(conversation.updatedAt),
+ text = RelativeTime.compact(timestamp),
style = MaterialTheme.typography.labelSmall,
color = colors.textTertiary,
)
diff --git a/app/src/main/java/app/codeg/android/feature/sessions/SessionSections.kt b/app/src/main/java/app/codeg/android/feature/sessions/SessionSections.kt
index f716a6b..0cd19f6 100644
--- a/app/src/main/java/app/codeg/android/feature/sessions/SessionSections.kt
+++ b/app/src/main/java/app/codeg/android/feature/sessions/SessionSections.kt
@@ -2,6 +2,13 @@ package app.codeg.android.feature.sessions
import app.codeg.android.core.model.ConversationSummary
import app.codeg.android.core.model.FolderDetail
+import app.codeg.android.core.model.FolderVisibility
+import app.codeg.android.core.datastore.SessionListPreferences
+import app.codeg.android.core.datastore.SessionSortMode
+import java.time.Instant
+
+internal const val SESSION_PAGE_SIZE = 20
+internal const val RECENT_PAGE_SIZE = 10
/**
* One rendered group in the Chats list, fully sorted and filtered.
@@ -9,9 +16,8 @@ import app.codeg.android.core.model.FolderDetail
* Built off the main thread by [buildSessionSections] (exposed as
* [SessionListViewModel.sections]) so the O(n·log n) grouping/sorting never runs
* during composition — the `LazyColumn` just iterates these pre-sorted rows, which
- * is what keeps a very large session list scrolling smoothly. Mirrors the iOS
- * `SessionListView` sectioning (Pinned / per-folder / Other), realized on Android
- * as native collapsible sections rather than zoom-drill cards.
+ * is what keeps a very large session list scrolling smoothly. Uses the web
+ * client's Pinned / Chats / project split with native collapsible sections.
*/
data class SessionSection(
/** Stable id used for the collapse set and the header's LazyColumn key. */
@@ -20,6 +26,7 @@ data class SessionSection(
val rows: List,
) {
val count: Int get() = rows.size
+ val pageSize: Int get() = if (kind == SectionKind.Recent) RECENT_PAGE_SIZE else SESSION_PAGE_SIZE
}
/** What a [SessionSection] represents — drives its header icon, label, and tint. */
@@ -27,8 +34,13 @@ sealed interface SectionKind {
/** Pinned conversations across all folders. */
data object Pinned : SectionKind
+ /** All folderless chats, without exposing their per-conversation scratch folders. */
+ data object Chats : SectionKind
+
+ data object Recent : SectionKind
+
/** One folder's non-pinned conversations. [colorHex] is the folder's server color. */
- data class Folder(val name: String, val colorHex: String) : SectionKind
+ data class Folder(val name: String, val colorHex: String, val folderId: Int? = null) : SectionKind
/** Non-pinned conversations whose folder isn't in the folder list (orphans). */
data object Other : SectionKind
@@ -37,16 +49,16 @@ sealed interface SectionKind {
/** One conversation row, with its cross-folder tag pre-resolved. */
data class SessionRowItem(
val conversation: ConversationSummary,
- /** Dim folder tag shown on cross-folder rows (Pinned/Other); `null` inside a
+ /** Dim folder tag shown on cross-folder rows (Pinned/Other/Recent); `null` inside a
* folder group, where the folder is already the header. */
val folderName: String?,
+ val timestamp: Instant = conversation.updatedAt,
)
/**
* Compose [SessionGrouping]'s pure accessors into the ordered section list shown by
- * the Chats screen: Pinned (if any) → one section per folder in
- * [SessionGrouping.sortedFolders] order (empty folders kept, matching iOS so their
- * header still shows) → Other (if any).
+ * the Chats screen: Pinned → Chats → non-empty projects → Other → Recent.
+ * Device-hidden folders are omitted outside Pinned; row limits apply only when rendering.
*
* `folderName` is resolved here, once, for the cross-folder sections only, so each
* row needs no map lookup at compose time. Pure and allocation-light; the view model
@@ -55,33 +67,63 @@ data class SessionRowItem(
fun buildSessionSections(
folders: List,
conversations: List,
+ preferences: SessionListPreferences = SessionListPreferences(),
+ openFolderIds: Set = folders.map { it.id }.toSet(),
+ hiddenFolderIds: Set = emptySet(),
+ folderOrder: List = emptyList(),
): List {
val folderNames = SessionGrouping.folderNames(folders)
+ val hidden = FolderVisibility.hiddenIds(folders, hiddenFolderIds)
+ val visibleConversations = if (hidden.isEmpty()) conversations else conversations.filter { it.folderId !in hidden }
val out = ArrayList()
+ fun row(conversation: ConversationSummary, crossFolder: Boolean = false, updated: Boolean = false) =
+ SessionRowItem(
+ conversation,
+ if (crossFolder) folderNames[conversation.folderId] else null,
+ if (updated || preferences.sortMode == SessionSortMode.UPDATED) conversation.updatedAt else conversation.createdAt,
+ )
val pinned = SessionGrouping.pinned(conversations)
if (pinned.isNotEmpty()) {
out += SessionSection(
id = "pinned",
kind = SectionKind.Pinned,
- rows = pinned.map { SessionRowItem(it, folderNames[it.folderId]) },
+ rows = pinned.map { row(it, crossFolder = true) },
+ )
+ }
+
+ val chats = SessionGrouping.chats(folders, visibleConversations)
+ if (chats.isNotEmpty()) {
+ out += SessionSection(
+ id = "chats",
+ kind = SectionKind.Chats,
+ rows = chats.map { row(it, updated = true) },
)
}
- for (group in SessionGrouping.folderGroups(folders, conversations)) {
+ for (group in SessionGrouping.folderGroups(folders, visibleConversations, sortMode = preferences.sortMode, folderOrder = folderOrder)) {
out += SessionSection(
id = "folder-${group.folder.id}",
- kind = SectionKind.Folder(group.folder.name, group.folder.color),
- rows = group.conversations.map { SessionRowItem(it, folderName = null) },
+ kind = SectionKind.Folder(group.folder.name, group.folder.color, group.folder.id),
+ rows = group.conversations.map { row(it) },
)
}
- val other = SessionGrouping.ungrouped(folders, conversations)
+ val other = SessionGrouping.ungrouped(folders, visibleConversations, sortMode = preferences.sortMode)
if (other.isNotEmpty()) {
out += SessionSection(
id = "other",
kind = SectionKind.Other,
- rows = other.map { SessionRowItem(it, folderNames[it.folderId]) },
+ rows = other.map { row(it, crossFolder = true) },
+ )
+ }
+
+ if (preferences.showRecent) {
+ out += SessionSection(
+ id = "recent",
+ kind = SectionKind.Recent,
+ rows = SessionGrouping.recent(folders, visibleConversations, openFolderIds, preferences)
+ .map { row(it, crossFolder = true, updated = true) },
)
}
diff --git a/app/src/main/java/app/codeg/android/feature/settings/SettingsScreen.kt b/app/src/main/java/app/codeg/android/feature/settings/SettingsScreen.kt
index 64eda45..e84794c 100644
--- a/app/src/main/java/app/codeg/android/feature/settings/SettingsScreen.kt
+++ b/app/src/main/java/app/codeg/android/feature/settings/SettingsScreen.kt
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Brush
import androidx.compose.material.icons.rounded.Difference
+import androidx.compose.material.icons.rounded.FolderOpen
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Language
@@ -35,6 +36,8 @@ import androidx.compose.material.icons.rounded.School
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -46,6 +49,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -69,6 +73,7 @@ import app.codeg.android.core.designsystem.theme.AccentPalette
import app.codeg.android.core.designsystem.theme.CodegTheme
import app.codeg.android.core.datastore.ThemeMode
import app.codeg.android.core.model.AgentType
+import android.text.format.Formatter
private enum class SettingsLeaf(@StringRes val titleRes: Int, val icon: ImageVector, @StringRes val groupRes: Int, val implemented: Boolean) {
APPEARANCE(R.string.settings_leaf_appearance, Icons.Rounded.Brush, R.string.settings_group_personalization, true),
@@ -83,13 +88,15 @@ private enum class SettingsLeaf(@StringRes val titleRes: Int, val icon: ImageVec
VERSION_CONTROL(R.string.settings_leaf_version_control, Icons.Rounded.Difference, R.string.settings_group_integrations, true),
CHAT_CHANNELS(R.string.settings_leaf_chat_channels, Icons.AutoMirrored.Rounded.Chat, R.string.settings_group_integrations, true),
SYSTEM(R.string.settings_leaf_system, Icons.Rounded.Settings, R.string.settings_group_system, true),
+ LOCAL_CACHE(R.string.settings_local_cache, Icons.Rounded.Memory, R.string.settings_group_system, true),
+ DOWNLOADS(R.string.session_files_title, Icons.Rounded.FolderOpen, R.string.settings_group_system, true),
OPEN_SOURCE_LICENSES(R.string.settings_leaf_open_source_licenses, Icons.Rounded.Policy, R.string.settings_group_system, true),
ABOUT(R.string.settings_leaf_about, Icons.Rounded.Info, R.string.settings_group_system, true),
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
+fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), onOpenDownloads: () -> Unit = {}) {
val colors = CodegTheme.colors
var leaf by remember { mutableStateOf(null) }
// A second level beneath the Agents leaf: the per-agent config editor.
@@ -121,7 +128,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
) { padding ->
Box(Modifier.padding(padding).fillMaxSize()) {
when (leaf) {
- null -> SettingsRoot(onSelect = { leaf = it; agentDetail = null })
+ null -> SettingsRoot(onSelect = { if (it == SettingsLeaf.DOWNLOADS) onOpenDownloads() else { leaf = it; agentDetail = null } })
SettingsLeaf.APPEARANCE -> AppearanceSettings(viewModel)
SettingsLeaf.LANGUAGE -> LanguageContent()
SettingsLeaf.ABOUT -> AboutScreen(viewModel)
@@ -129,6 +136,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
SettingsLeaf.GENERAL -> GeneralContent()
SettingsLeaf.MODEL_PROVIDERS -> ModelProvidersContent()
SettingsLeaf.SYSTEM -> SystemContent()
+ SettingsLeaf.LOCAL_CACHE -> LocalCacheSettings(viewModel)
SettingsLeaf.OPEN_SOURCE_LICENSES -> OpenSourceLicensesScreen()
SettingsLeaf.MCP -> McpContent()
SettingsLeaf.AGENTS ->
@@ -181,6 +189,37 @@ private fun SettingsRoot(onSelect: (SettingsLeaf) -> Unit) {
}
}
+@Composable
+private fun LocalCacheSettings(viewModel: SettingsViewModel) {
+ val colors = CodegTheme.colors
+ val cache by viewModel.cache.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ LaunchedEffect(viewModel) { viewModel.refreshCacheSize() }
+ Column(
+ Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(stringResource(R.string.settings_cache_description), color = colors.textSecondary)
+ Text(
+ stringResource(R.string.settings_cache_size, cache.sizeBytes?.let { Formatter.formatFileSize(context, it) } ?: "—"),
+ color = colors.textPrimary,
+ )
+ Button(onClick = viewModel::clearCache, enabled = !cache.clearing) {
+ if (cache.clearing) {
+ CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
+ }
+ Text(
+ stringResource(if (cache.clearing) R.string.settings_cache_clearing else R.string.settings_cache_clear),
+ modifier = Modifier.padding(start = if (cache.clearing) 8.dp else 0.dp),
+ )
+ }
+ if (cache.cleared) Text(stringResource(R.string.settings_cache_cleared), color = colors.textSecondary)
+ cache.error?.let {
+ Text(stringResource(R.string.settings_cache_failed, it), color = colors.danger)
+ }
+ }
+}
+
@Composable
private fun AppearanceSettings(viewModel: SettingsViewModel) {
val colors = CodegTheme.colors
diff --git a/app/src/main/java/app/codeg/android/feature/settings/SettingsViewModel.kt b/app/src/main/java/app/codeg/android/feature/settings/SettingsViewModel.kt
index e3ecf5a..11d0944 100644
--- a/app/src/main/java/app/codeg/android/feature/settings/SettingsViewModel.kt
+++ b/app/src/main/java/app/codeg/android/feature/settings/SettingsViewModel.kt
@@ -2,6 +2,7 @@ package app.codeg.android.feature.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import app.codeg.android.core.cache.ConversationDiskCache
import app.codeg.android.core.data.ServerRepository
import app.codeg.android.core.datastore.AppSettings
import app.codeg.android.core.datastore.AppSettingsStore
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.CancellationException
import javax.inject.Inject
/** Backs the Settings tab: live appearance prefs + the About server/version info. */
@@ -22,6 +24,7 @@ import javax.inject.Inject
class SettingsViewModel @Inject constructor(
private val appSettingsStore: AppSettingsStore,
private val repository: ServerRepository,
+ private val diskCache: ConversationDiskCache,
) : ViewModel() {
val settings: StateFlow =
@@ -30,6 +33,37 @@ class SettingsViewModel @Inject constructor(
private val _about = MutableStateFlow(AboutInfo())
val about: StateFlow = _about.asStateFlow()
+ private val _cache = MutableStateFlow(LocalCacheInfo())
+ val cache: StateFlow = _cache.asStateFlow()
+
+ fun refreshCacheSize() = viewModelScope.launch {
+ try {
+ val bytes = diskCache.sizeBytes()
+ _cache.update { it.copy(sizeBytes = bytes) }
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (error: Exception) {
+ _cache.update { it.copy(error = error.localizedMessage ?: error.toString()) }
+ }
+ }
+
+ fun clearCache() {
+ if (_cache.value.clearing) return
+ _cache.update { it.copy(clearing = true, cleared = false, error = null) }
+ viewModelScope.launch {
+ try {
+ diskCache.clearAll()
+ val bytes = diskCache.sizeBytes()
+ _cache.update { it.copy(clearing = false, cleared = true, sizeBytes = bytes) }
+ } catch (cancelled: CancellationException) {
+ _cache.update { it.copy(clearing = false) }
+ throw cancelled
+ } catch (error: Exception) {
+ _cache.update { it.copy(clearing = false, error = error.localizedMessage ?: error.toString()) }
+ }
+ }
+ }
+
init {
viewModelScope.launch {
val profile = repository.selectedProfile.first()
@@ -50,3 +84,10 @@ data class AboutInfo(
val serverName: String? = null,
val serverVersion: String? = null,
)
+
+data class LocalCacheInfo(
+ val sizeBytes: Long? = null,
+ val clearing: Boolean = false,
+ val cleared: Boolean = false,
+ val error: String? = null,
+)
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 941e222..42c7b42 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -54,6 +54,20 @@
置顶
取消置顶
已置顶
+ 聊天
+ 加载更多(剩余 %1$d 条)
+ 恢复前 %1$d 条
+ 排序与最近会话
+ 按创建时间排序
+ 按更新时间排序
+ 最近
+ 显示最近
+ 最近会话筛选
+ 全部会话
+ 项目会话
+ CLI 会话
+ 最近中包含已完成会话
+ 没有符合最近筛选条件的会话,可通过搜索查找历史或已完成会话。
其他
无法加载会话
还没有会话
@@ -62,12 +76,78 @@
还没有消息
该会话的消息会显示在这里。
回到最新
+ 加载更早消息
+ 本轮还有更早内容,可继续向上加载
智能体选项
收起
展开
+ 收起分组
+ 恢复列表
添加
+ 文件夹管理
+ 长按拖动排序,点击眼睛显示或隐藏。修改自动保存在本机。
+ 隐藏 %1$s
+ 显示 %1$s
+ 随上级文件夹隐藏,显示上级后可恢复
+ 长按拖动 %1$s
+ 上移 %1$s
+ 下移 %1$s
+ 恢复服务器顺序
+ 正在准备下载…
+ 正在下载…
+ 正在等待网络,恢复后自动重试…
+ 正在下载 %1$d%%
+ 已保存到手机 Download/Codeg 目录
+ 安装
+ 打开
+ 下载已中断,请点击重试重新下载。
+ 下载记录或文件已不存在。
+ 下载失败(错误码 %1$d),请点击重试重新下载。
+ 下载链接无效或暂不支持。
+ 服务器连接尚未就绪,请在会话加载后重试。
+ 无法解析此文件链接。请使用服务器上的绝对文件路径,或当前会话目录内的相对文件路径。
+ 请允许 Codeg 安装应用,返回此页面后再次点击“安装”。
+ 无法打开此文件或链接,请确认已安装可处理它的应用。
+ 下载记录
+ 文件管理
+ 会话文件
+ 汇总完整会话中写入、修改和明确链接的文件,包含较早的消息。刷新可获取最近变更。
+ 搜索文件或会话
+ 选择会话
+ 暂无识别到的文件操作或有效文件链接。
+ 请更新 Codeg 服务端后查看会话文件。
+ 写入
+ 修改
+ 会话中已删除
+ 会话引用
+ 文件已删除、移动或服务端无法访问。
+ 打开文件时检查是否仍可访问。
+ 查看
+ 保存到手机
+ 已下载
+ 显示服务端当前文件,下载后可保留本地副本。
+ 暂时无法预览,文件可能已移动、删除或超出预览限制。可以重试或下载查看。
+ 图片超过 12 MB 预览限制,请下载查看。
+ 当前服务器的下载记录。已下载文件保存在手机 Download/Codeg,离线也能查看。移除记录不会删除文件。
+ 还没有下载记录。在会话文件中选择文件,点击“保存到手机”。
+ 刷新下载列表
+ 移除记录
+ 已取消下载
+ 已加入下载队列,等待系统开始下载…
+ 来源服务器已变更或会话不可用,无法打开来源会话。
+ 查看文件
+ 重新下载服务器上的当前文件
+ 返回来源会话
+ 使用其他应用打开
+ 无法预览此文件。文件可能已被移走,或内容格式不支持。
+ 此格式请使用手机上的其他应用打开。
+ APK 已保存到手机,点击安装后由系统安装器继续处理。
+ 下载完成后可在这里查看文件。
+ 文件较大,仅预览前 128 KiB 或 2000 行;复制仅包含当前预览,完整文件已保存在手机。
+ 文件夹已隐藏
+ 文件夹已在本机隐藏,可在“文件夹管理”中点击眼睛恢复显示。
打开文件夹
打开此文件夹
克隆仓库
@@ -86,9 +166,9 @@
更新于 %1$s
- 搜索会话
+ 搜索全部会话
搜索
- 按标题或模型搜索你的会话。
+ 按标题或模型搜索所有聊天和项目会话,包括历史会话。
最近搜索
清除
没有结果
@@ -836,4 +916,15 @@
测试消息已发送。
令牌已移除。
规格不是有效的 JSON
+ 刷新
+ 本地缓存
+ 更新于 %1$s
+ 更新中…
+ 本地缓存
+ 已打开的会话会保存在手机上,便于下次快速查看。清除缓存后,当前页面和服务器上的会话仍会保留。
+ 已用空间:%1$s
+ 清除本地缓存
+ 清除中…
+ 本地缓存已清除
+ 无法访问本地缓存:%1$s
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7bccd99..b186d1f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -54,6 +54,20 @@
Pin
Unpin
Pinned
+ Chats
+ Load more (%1$d remaining)
+ Show first %1$d
+ Sorting and recent sessions
+ Sort by creation time
+ Sort by update time
+ Recent
+ Show Recent
+ Recent filter
+ All sessions
+ Project sessions
+ CLI sessions
+ Include completed in Recent
+ No sessions match the Recent filter. Use search to find older or completed sessions.
Other
Couldn\'t load sessions
No sessions yet
@@ -62,12 +76,78 @@
No messages yet
Messages in this session will appear here.
Jump to latest
+ Load earlier messages
+ Earlier content in this reply is available above
Agent options
Collapse
Expand
+ Collapse groups
+ Restore list
Add
+ Manage folders
+ Hold to reorder. Tap the eye to show or hide. Changes save on this device.
+ Hide %1$s
+ Show %1$s
+ Hidden with its parent. Show the parent to restore.
+ Hold to drag %1$s
+ Move %1$s up
+ Move %1$s down
+ Restore server order
+ Preparing download…
+ Downloading…
+ Waiting for network; retries automatically…
+ Downloading %1$d%%
+ Saved to Downloads/Codeg
+ Install
+ Open
+ Download interrupted. Tap Retry to start again.
+ Download record or file is no longer available.
+ Download failed (code %1$d). Tap Retry to start again.
+ This download link is invalid or unsupported.
+ The server connection is not ready. Try again after the conversation loads.
+ Unable to resolve this file link. Use an absolute server file path or a relative file path within the current conversation folder.
+ Allow installs from Codeg, return here, then tap Install again.
+ Unable to open this file or link. Check that a compatible app is installed.
+ Download history
+ Files
+ Conversation files
+ Files written, modified, or explicitly linked in this conversation, including earlier messages. Refresh to find recent changes.
+ Search files or conversations
+ Choose conversation
+ No file operations or available file links found.
+ Update the Codeg server to load conversation files.
+ Written
+ Modified
+ Deleted in conversation
+ Linked in conversation
+ File is deleted, moved, or unavailable on the server.
+ Availability will be checked when you open the file.
+ View
+ Save to phone
+ Downloaded
+ Preview shows the current server file. Download to keep a local copy.
+ Cannot preview this file. It may have moved, been deleted, or exceeded the preview limit. You can retry or download it.
+ Image exceeds the 12 MB preview limit. Download it to view.
+ Downloads for the current server. Saved files are available offline in Download/Codeg. Removing history does not delete files.
+ No downloads yet. Choose a conversation file and tap Save to phone.
+ Refresh downloads
+ Remove record
+ Download canceled
+ Queued; waiting for the system to start the download…
+ The source server has changed or the conversation is unavailable.
+ View file
+ Download the current server file again
+ Back to source conversation
+ Open with another app
+ Unable to preview this file. It may have been moved, or its content format is unsupported.
+ Open this format using another app on your phone.
+ The APK is saved on your phone. Tap Install to continue with the system installer.
+ The file will be available here after the download completes.
+ Preview limited to 128 KiB or 2000 lines. Copy includes this preview only; the complete file is saved on your phone.
+ Folders are hidden
+ Folders are hidden on this device. Open Manage folders and tap the eye to show them.
Open Folder
Open This Folder
Clone Repository
@@ -86,9 +166,9 @@
Updated %1$s
- Search sessions
+ Search all sessions
Search
- Search your sessions by title or model.
+ Search all chats and project sessions by title or model, including history.
Recent
Clear
No results
@@ -849,4 +929,15 @@
Test message sent.
Token removed.
Spec is not valid JSON
+ Refresh
+ Local copy
+ Updated %1$s
+ Updating…
+ Local cache
+ Previously opened conversations are saved on this device for faster access. Clearing the cache keeps the current screen and server conversations unchanged.
+ Storage used: %1$s
+ Clear local cache
+ Clearing…
+ Local cache cleared
+ Could not access local cache: %1$s
diff --git a/app/src/test/java/app/codeg/android/core/cache/ConversationDiskCacheTest.kt b/app/src/test/java/app/codeg/android/core/cache/ConversationDiskCacheTest.kt
new file mode 100644
index 0000000..9f417a5
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/core/cache/ConversationDiskCacheTest.kt
@@ -0,0 +1,209 @@
+package app.codeg.android.core.cache
+
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.*
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+class ConversationDiskCacheTest {
+ @get:Rule val temporaryFolder = TemporaryFolder()
+
+ @Test fun `persisted snapshots can be read by a new cache instance`() = runBlocking {
+ val root = temporaryFolder.newFolder()
+ val cache = ConversationDiskCache(root, now = { 12_000L })
+ val scope = cache.scope("server", "https://example.test", "token")
+ val snapshot = cache.replace(cache.ticket(scope, "session-7"), mapOf("tail" to "{\"turns\":[]}"))
+ assertNotNull(snapshot)
+
+ val reopened = ConversationDiskCache(root)
+ val restoredScope = reopened.scope("server", "https://example.test/", "token")
+ val entry = reopened.read(restoredScope, "session-7", "tail")!!
+ assertEquals("{\"turns\":[]}", entry.text)
+ assertEquals(12_000L, entry.savedAt)
+ assertEquals(snapshot, entry.snapshot)
+ assertTrue(entry.isFresh(71_999L))
+ assertFalse(entry.isFresh(72_000L))
+ }
+
+ @Test fun `profile endpoint and credentials isolate cached data`() = runBlocking {
+ val cache = ConversationDiskCache(temporaryFolder.newFolder())
+ val original = cache.scope("server-a", "https://first.test", "token-a")
+ assertNotNull(cache.replace(cache.ticket(original, "session-7"), mapOf("tail" to "original")))
+ val isolated = listOf(
+ cache.scope("server-b", "https://first.test", "token-a"),
+ cache.scope("server-a", "https://second.test", "token-a"),
+ cache.scope("server-a", "https://first.test", "token-b"),
+ )
+ for ((index, scope) in isolated.withIndex()) {
+ assertNull(cache.read(scope, "session-7", "tail"))
+ assertNotNull(cache.replace(cache.ticket(scope, "session-7"), mapOf("tail" to "isolated-$index")))
+ }
+ assertEquals("original", cache.read(original, "session-7", "tail")?.text)
+ isolated.forEachIndexed { index, scope ->
+ assertEquals("isolated-$index", cache.read(scope, "session-7", "tail")?.text)
+ }
+ }
+
+ @Test fun `clear all rejects pending writes while permitting a new request`() = runBlocking {
+ val cache = ConversationDiskCache(temporaryFolder.newFolder())
+ val scope = cache.scope("server", "https://example.test", "token")
+ assertNotNull(cache.replace(cache.ticket(scope, "session-7"), mapOf("tail" to "old")))
+ val pending = cache.ticket(scope, "session-7")
+ cache.clearAll()
+
+ assertEquals(0L, cache.sizeBytes())
+ assertNull(cache.read(scope, "session-7", "tail"))
+ assertNull(cache.replace(pending, mapOf("tail" to "stale")))
+ assertFalse(cache.append(pending, "older-5", "stale history"))
+ assertNotNull(cache.replace(cache.ticket(scope, "session-7"), mapOf("tail" to "new")))
+ assertEquals("new", cache.read(scope, "session-7", "tail")?.text)
+ }
+
+ @Test fun `clearing a profile fences its old scopes and preserves other servers`() = runBlocking {
+ val cache = ConversationDiskCache(temporaryFolder.newFolder())
+ val removed = cache.scope("server-a", "https://example.test", "token")
+ val retained = cache.scope("server-b", "https://example.test", "token")
+ assertNotNull(cache.replace(cache.ticket(removed, "session-7"), mapOf("tail" to "removed")))
+ assertNotNull(cache.replace(cache.ticket(retained, "session-7"), mapOf("tail" to "retained")))
+ val pending = cache.ticket(removed, "session-7")
+
+ cache.clearProfile("server-a")
+ assertNull(cache.replace(pending, mapOf("tail" to "stale")))
+ assertNull(cache.replace(cache.ticket(removed, "session-7"), mapOf("tail" to "still stale")))
+ assertEquals("retained", cache.read(retained, "session-7", "tail")?.text)
+
+ val renewed = cache.scope("server-a", "https://example.test", "token")
+ assertNotNull(cache.replace(cache.ticket(renewed, "session-7"), mapOf("tail" to "renewed")))
+ assertNull(cache.read(removed, "session-7", "tail"))
+ assertEquals("renewed", cache.read(renewed, "session-7", "tail")?.text)
+ }
+
+ @Test fun `authoritative replacement invalidates pending writes and previous history`() = runBlocking {
+ var clock = 10_000L
+ val cache = ConversationDiskCache(temporaryFolder.newFolder(), now = { clock })
+ val scope = cache.scope("server", "https://example.test", "token")
+ val oldSnapshot = cache.replace(cache.ticket(scope, "session-7"), mapOf("tail" to "old tail"))!!
+ assertTrue(cache.append(cache.ticket(scope, "session-7"), "older-5", "old history"))
+ val pendingHistory = cache.ticket(scope, "session-7")
+ clock = 20_000L
+ val newSnapshot = cache.replace(cache.ticket(scope, "session-7"), mapOf("tail" to "new tail"))!!
+
+ assertNotEquals(oldSnapshot, newSnapshot)
+ assertFalse(cache.append(pendingHistory, "older-0", "stale history"))
+ assertNull(cache.replace(pendingHistory, mapOf("tail" to "stale tail")))
+ assertNull(cache.read(scope, "session-7", "older-5"))
+ assertNull(cache.read(scope, "session-7", "tail", oldSnapshot))
+ assertEquals("new tail", cache.read(scope, "session-7", "tail", newSnapshot)?.text)
+ cache.remove(scope, "session-7", snapshot = oldSnapshot)
+ assertEquals("new tail", cache.read(scope, "session-7", "tail", newSnapshot)?.text)
+ assertEquals(20_000L, cache.read(scope, "session-7", "tail")?.savedAt)
+ }
+
+ @Test fun `history append retains tail freshness and snapshot identity`() = runBlocking {
+ var clock = 10_000L
+ val cache = ConversationDiskCache(temporaryFolder.newFolder(), now = { clock })
+ val scope = cache.scope("server", "https://example.test", "token")
+ val snapshot = cache.replace(cache.ticket(scope, "session-7"), mapOf("tail" to "tail"))!!
+ clock = 50_000L
+ assertTrue(cache.append(cache.ticket(scope, "session-7"), "older-5", "history"))
+
+ for (page in listOf("tail", "older-5")) {
+ val entry = cache.read(scope, "session-7", page, snapshot)!!
+ assertEquals(10_000L, entry.savedAt)
+ assertEquals(snapshot, entry.snapshot)
+ }
+ assertEquals("tail", cache.read(scope, "session-7", "tail")?.text)
+ assertEquals("history", cache.read(scope, "session-7", "older-5")?.text)
+ }
+
+ @Test fun `corrupt manifests and missing page files are cache misses`() = runBlocking {
+ val root = temporaryFolder.newFolder()
+ val cache = ConversationDiskCache(root)
+ val scope = cache.scope("server", "https://example.test", "token")
+ assertNotNull(cache.replace(cache.ticket(scope, "session-7"), mapOf("tail" to "payload")))
+ root.walkTopDown().single { it.name == "manifest.json" }.writeText("{broken")
+ assertNull(cache.read(scope, "session-7", "tail"))
+
+ assertNotNull(cache.replace(cache.ticket(scope, "session-7"), mapOf("tail" to "recovered")))
+ root.walkTopDown().single { it.isFile && it.name != "manifest.json" }.delete()
+ assertNull(cache.read(scope, "session-7", "tail"))
+ }
+
+ @Test fun `conversation limit preserves newest page and rejects oversized new tail`() = runBlocking {
+ val cache = ConversationDiskCache(temporaryFolder.newFolder(), maxConversationBytes = 10L)
+ val scope = cache.scope("server", "https://example.test", "token")
+ assertNotNull(cache.replace(cache.ticket(scope, "session-7"), linkedMapOf(
+ "tail" to "你好", // Six UTF-8 bytes.
+ "older-5" to "你好",
+ )))
+ assertEquals("你好", cache.read(scope, "session-7", "tail")?.text)
+ assertNull(cache.read(scope, "session-7", "older-5"))
+ assertFalse(cache.append(cache.ticket(scope, "session-7"), "older-5", "你好"))
+ assertTrue(cache.append(cache.ticket(scope, "session-7"), "older-5", "1234"))
+ assertEquals("你好", cache.read(scope, "session-7", "tail")?.text)
+
+ assertNull(cache.replace(cache.ticket(scope, "session-7"), mapOf("tail" to "界界界界")))
+ assertNull(cache.read(scope, "session-7", "tail"))
+ assertNull(cache.read(scope, "session-7", "older-5"))
+ assertEquals(0L, cache.sizeBytes())
+ }
+
+ @Test fun `response expiration fences stale tickets and invalidates pending snapshot writes`() = runBlocking {
+ var clock = 100_000L
+ val cache = ConversationDiskCache(temporaryFolder.newFolder(), now = { clock })
+ val scope = cache.scope("server", "https://example.test", "token")
+ assertNotNull(cache.replace(cache.ticket(scope, "session-7"), mapOf("tail" to "old")))
+ val beforeClear = cache.ticket(scope, "session-7")
+ cache.clearAll()
+ clock = 200_000L
+ val snapshot = cache.replace(cache.ticket(scope, "session-7"), mapOf("tail" to "new"))!!
+ cache.expire(beforeClear)
+ assertEquals(200_000L, cache.read(scope, "session-7", "tail", snapshot)?.savedAt)
+
+ val beforeReplace = cache.ticket(scope, "session-7")
+ clock = 300_000L
+ val newestSnapshot = cache.replace(cache.ticket(scope, "session-7"), mapOf("tail" to "newest"))!!
+ cache.expire(beforeReplace)
+ assertEquals(300_000L, cache.read(scope, "session-7", "tail", newestSnapshot)?.savedAt)
+
+ val valid = cache.ticket(scope, "session-7")
+ val pendingHistory = cache.ticket(scope, "session-7")
+ cache.expire(valid)
+ val expired = cache.read(scope, "session-7", "tail", newestSnapshot)!!
+ assertEquals("newest", expired.text)
+ assertEquals(0L, expired.savedAt)
+ assertFalse(expired.isFresh(clock))
+ assertFalse(cache.append(pendingHistory, "older-5", "stale history"))
+ assertNull(cache.replace(pendingHistory, mapOf("tail" to "stale tail")))
+ }
+
+ @Test fun `global budget evicts least recently accessed conversation including manifest overhead`() = runBlocking {
+ var clock = 10_000L
+ val payload = "x".repeat(1_000)
+ // Measure one complete group so the budget includes manifests and immutable filenames.
+ val probe = ConversationDiskCache(temporaryFolder.newFolder(), now = { clock })
+ val probeScope = probe.scope("server", "https://example.test", "token")
+ assertNotNull(probe.replace(probe.ticket(probeScope, "session-a"), mapOf("tail" to payload)))
+ val oneGroupBytes = probe.sizeBytes()
+ assertTrue(oneGroupBytes > payload.length)
+
+ val budget = oneGroupBytes * 2 + 16
+ val cache = ConversationDiskCache(temporaryFolder.newFolder(), maxBytes = budget, now = { clock })
+ val scope = cache.scope("server", "https://example.test", "token")
+ assertNotNull(cache.replace(cache.ticket(scope, "session-a"), mapOf("tail" to payload)))
+ clock = 20_000L
+ assertNotNull(cache.replace(cache.ticket(scope, "session-b"), mapOf("tail" to payload)))
+ val pendingB = cache.ticket(scope, "session-b")
+ clock = 30_000L
+ assertNotNull(cache.read(scope, "session-a", "tail"))
+ clock = 40_000L
+ assertNotNull(cache.replace(cache.ticket(scope, "session-c"), mapOf("tail" to payload)))
+
+ assertNull(cache.read(scope, "session-b", "tail"))
+ assertNotNull(cache.read(scope, "session-a", "tail"))
+ assertNotNull(cache.read(scope, "session-c", "tail"))
+ assertTrue(cache.sizeBytes() <= budget)
+ assertNull(cache.replace(pendingB, mapOf("tail" to "stale response")))
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/core/datastore/FolderOrderStoreTest.kt b/app/src/test/java/app/codeg/android/core/datastore/FolderOrderStoreTest.kt
new file mode 100644
index 0000000..fc7ea23
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/core/datastore/FolderOrderStoreTest.kt
@@ -0,0 +1,79 @@
+package app.codeg.android.core.datastore
+
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import app.codeg.android.core.model.FolderDetail
+import java.io.File
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+class FolderOrderStoreTest {
+ @get:Rule val temporaryFolder = TemporaryFolder()
+ private val folders = (1..3).map { FolderDetail(it, "f$it", "/f$it", sortOrder = it) }
+
+ private suspend fun withStore(file: File, block: suspend (FolderOrderStore) -> Unit) {
+ val job = SupervisorJob()
+ val dataStore = PreferenceDataStoreFactory.create(scope = CoroutineScope(job + Dispatchers.IO)) { file }
+ try {
+ block(FolderOrderStore(dataStore))
+ } finally {
+ job.cancelAndJoin()
+ }
+ }
+
+ @Test
+ fun `manual order survives closing and reopening the datastore`() = runBlocking {
+ val file = File(temporaryFolder.root, "order.preferences_pb")
+ withStore(file) { store ->
+ store.move("server-a", folders, emptySet(), 3, -1)
+ store.move("server-a", folders, emptySet(), 3, -1)
+ }
+ withStore(file) { store ->
+ assertEquals(listOf(3, 1, 2), store.order("server-a").first())
+ }
+ }
+
+ @Test
+ fun `dropped order survives reopening and reset restores server order`() = runBlocking {
+ val file = File(temporaryFolder.root, "drag.preferences_pb")
+ withStore(file) { store ->
+ store.reorder("server-a", folders, emptySet(), listOf(3, 1, 2))
+ }
+ withStore(file) { store ->
+ assertEquals(listOf(3, 1, 2), store.order("server-a").first())
+ store.reset("server-a")
+ assertEquals(listOf(1, 2, 3), app.codeg.android.core.model.FolderOrdering.sorted(folders, store.order("server-a").first()).map { it.id })
+ }
+ }
+
+ @Test
+ fun `independent scopes can reset their order without changing the other server`() = runBlocking {
+ withStore(File(temporaryFolder.root, "order.preferences_pb")) { store ->
+ assertEquals(emptyList(), store.order("server-a").first())
+ store.move("server-a", folders, emptySet(), 3, -1)
+ store.move("server-b", folders, emptySet(), 1, 1)
+ store.reset("server-a")
+ assertEquals(emptyList(), store.order("server-a").first())
+ assertEquals(listOf(2, 1, 3), store.order("server-b").first())
+ }
+ }
+
+ @Test
+ fun `rapid moves use the latest saved order rather than an old screen snapshot`() = runBlocking {
+ withStore(File(temporaryFolder.root, "order.preferences_pb")) { store ->
+ coroutineScope {
+ repeat(2) { launch { store.move("server-a", folders, emptySet(), 3, -1) } }
+ }
+ assertEquals(listOf(3, 1, 2), store.order("server-a").first())
+ }
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/core/datastore/HiddenFoldersStoreTest.kt b/app/src/test/java/app/codeg/android/core/datastore/HiddenFoldersStoreTest.kt
new file mode 100644
index 0000000..cbd21f1
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/core/datastore/HiddenFoldersStoreTest.kt
@@ -0,0 +1,87 @@
+package app.codeg.android.core.datastore
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.emptyPreferences
+import app.codeg.android.core.model.FolderDetail
+import app.codeg.android.core.model.FolderOrdering
+import app.codeg.android.core.model.FolderVisibility
+import java.io.File
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+class HiddenFoldersStoreTest {
+ @get:Rule val temporaryFolder = TemporaryFolder()
+
+ @Test
+ fun `choices are isolated by profile and endpoint and can be restored`() = runTest {
+ val store = HiddenFoldersStore(MemoryPreferences())
+ val profile = ServerProfile("one", "Server", "http://localhost:3080/")
+ val scope = store.scope(profile)
+ assertEquals(scope, store.scope(profile.copy(name = "Renamed", baseUrl = "http://localhost:3080")))
+ val otherProfile = store.scope(profile.copy(id = "two"))
+ val otherEndpoint = store.scope(profile.copy(baseUrl = "http://localhost:4000"))
+ assertNotEquals(scope, otherProfile)
+ assertNotEquals(scope, otherEndpoint)
+ store.setHidden(scope, 7, true)
+ store.setHidden(scope, 9, true)
+ store.setHidden(scope, 7, true)
+ assertEquals(setOf(7, 9), store.hiddenIds(scope).first())
+ assertEquals(emptySet(), store.hiddenIds(otherProfile).first())
+ assertEquals(emptySet(), store.hiddenIds(otherEndpoint).first())
+ store.setHidden(scope, 7, false)
+ assertEquals(setOf(9), store.hiddenIds(scope).first())
+ }
+
+ @Test
+ fun `hidden folders survive closing and reopening the datastore`() = runTest {
+ val file = File(temporaryFolder.root, "hidden.preferences_pb")
+ val profile = ServerProfile("one", "Server", "http://localhost:3080")
+ val firstJob = SupervisorJob()
+ val first = HiddenFoldersStore(PreferenceDataStoreFactory.create(scope = CoroutineScope(firstJob)) { file })
+ first.setHidden(first.scope(profile), 12, true)
+ firstJob.cancelAndJoin()
+ val secondJob = SupervisorJob()
+ try {
+ val second = HiddenFoldersStore(PreferenceDataStoreFactory.create(scope = CoroutineScope(secondJob)) { file })
+ assertEquals(setOf(12), second.hiddenIds(second.scope(profile)).first())
+ } finally {
+ secondJob.cancelAndJoin()
+ }
+ }
+
+ @Test
+ fun `management can reorder hidden folders and restore them without losing order`() = runTest {
+ val data = MemoryPreferences()
+ val hidden = HiddenFoldersStore(data)
+ val order = FolderOrderStore(data)
+ val folders = (1..3).map { FolderDetail(it, "f$it", "/f$it", sortOrder = it) }
+ hidden.setHidden("server-a", 2, true)
+ order.reorder("server-a", folders, emptySet(), listOf(2, 3, 1))
+ assertEquals(setOf(2), hidden.hiddenIds("server-a").first())
+ assertEquals(listOf(2, 3, 1), order.order("server-a").first())
+ val visible = folders.filter { it.id !in FolderVisibility.hiddenIds(folders, hidden.hiddenIds("server-a").first()) }
+ assertEquals(listOf(3, 1), FolderOrdering.sorted(visible, order.order("server-a").first()).map { it.id })
+ hidden.setHidden("server-a", 2, false)
+ assertEquals(emptySet(), hidden.hiddenIds("server-a").first())
+ assertEquals(listOf(2, 3, 1), FolderOrdering.sorted(folders, order.order("server-a").first()).map { it.id })
+ order.reset("server-a")
+ assertEquals(listOf(1, 2, 3), FolderOrdering.sorted(folders, order.order("server-a").first()).map { it.id })
+ }
+
+ private class MemoryPreferences : DataStore {
+ override val data = MutableStateFlow(emptyPreferences())
+ override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences =
+ transform(data.value).also { data.value = it }
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/core/datastore/SessionListPreferencesStoreTest.kt b/app/src/test/java/app/codeg/android/core/datastore/SessionListPreferencesStoreTest.kt
new file mode 100644
index 0000000..ea42201
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/core/datastore/SessionListPreferencesStoreTest.kt
@@ -0,0 +1,48 @@
+package app.codeg.android.core.datastore
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.datastore.preferences.core.preferencesOf
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class SessionListPreferencesStoreTest {
+ @Test
+ fun `default and unknown stored values match web defaults`() = runTest {
+ val dataStore = MemoryPreferences()
+ assertEquals(SessionListPreferences(), SessionListPreferencesStore(dataStore).preferences.first())
+ dataStore.data.value = preferencesOf(
+ stringPreferencesKey("sessions_sort_mode") to "unknown",
+ stringPreferencesKey("sessions_recent_filter") to "unknown",
+ )
+ assertEquals(SessionListPreferences(), SessionListPreferencesStore(dataStore).preferences.first())
+ }
+
+ @Test
+ fun `choices survive store recreation and preserve appearance settings`() = runTest {
+ val dataStore = MemoryPreferences()
+ val appearance = AppSettingsStore(dataStore)
+ appearance.setThemeMode(ThemeMode.DARK)
+ val store = SessionListPreferencesStore(dataStore)
+ store.setSortMode(SessionSortMode.UPDATED)
+ store.setShowRecent(false)
+ store.setRecentFilter(RecentSessionFilter.CLI)
+ store.setRecentShowCompleted(true)
+ assertEquals(
+ SessionListPreferences(SessionSortMode.UPDATED, false, RecentSessionFilter.CLI, true),
+ SessionListPreferencesStore(dataStore).preferences.first(),
+ )
+ assertEquals(ThemeMode.DARK, appearance.settings.first().themeMode)
+ }
+
+ private class MemoryPreferences : DataStore {
+ override val data = MutableStateFlow(emptyPreferences())
+ override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences =
+ transform(data.value).also { data.value = it }
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/core/designsystem/markdown/InlineMarkdownLinkTest.kt b/app/src/test/java/app/codeg/android/core/designsystem/markdown/InlineMarkdownLinkTest.kt
new file mode 100644
index 0000000..ef46d74
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/core/designsystem/markdown/InlineMarkdownLinkTest.kt
@@ -0,0 +1,22 @@
+package app.codeg.android.core.designsystem.markdown
+
+import androidx.compose.ui.graphics.Color
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class InlineMarkdownLinkTest {
+ @Test fun `APK links with spaces and balanced parentheses retain their full target`() {
+ listOf(
+ "[下载]() done" to "/work/Android Project/app-debug.apk",
+ "[下载](/work/build(test)/app.apk) done" to "/work/build(test)/app.apk",
+ "[下载](https://example.test/app.apk?v=2) done" to "https://example.test/app.apk?v=2",
+ ).forEach { (raw, url) ->
+ val parsed = inlineMarkdown(raw, Color.Blue, Color.Gray, Color.Black)
+ assertEquals("下载 done", parsed.text)
+ val link = parsed.getStringAnnotations(URL_TAG, 0, parsed.length).single()
+ assertEquals(url, link.item)
+ assertEquals(0, link.start)
+ assertEquals(2, link.end)
+ }
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/core/model/FolderOrderingTest.kt b/app/src/test/java/app/codeg/android/core/model/FolderOrderingTest.kt
new file mode 100644
index 0000000..17547f3
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/core/model/FolderOrderingTest.kt
@@ -0,0 +1,55 @@
+package app.codeg.android.core.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class FolderOrderingTest {
+ private fun folder(id: Int, sortOrder: Int = id, parentId: Int? = null) =
+ FolderDetail(id, "f$id", "/f$id", sortOrder = sortOrder, parentId = parentId)
+
+ @Test
+ fun `no custom order uses the server order and resolves ties deterministically`() {
+ val folders = listOf(folder(3, 2), folder(2, 1), folder(1, 1))
+ assertEquals(listOf(1, 2, 3), FolderOrdering.sorted(folders).map { it.id })
+ }
+
+ @Test
+ fun `custom order ignores duplicates and removed ids and appends new folders without losing source data`() {
+ val folders = listOf(folder(1), folder(2), folder(3), folder(4))
+ val ordered = FolderOrdering.sorted(folders, listOf(3, 99, 3, 1))
+ assertEquals(listOf(3, 1, 2, 4), ordered.map { it.id })
+ assertEquals(folders.toSet(), ordered.toSet())
+ assertEquals(listOf(1, 2, 3, 4), folders.map { it.id })
+ }
+
+ @Test
+ fun `moving visible neighbors preserves hidden projects and their worktree positions`() {
+ val folders = (1..5).map { folder(it, parentId = if (it == 3) 2 else null) } +
+ folder(6).copy(kind = "chat")
+ val moved = FolderOrdering.move(folders, emptyList(), setOf(2), 4, -1)
+ assertEquals(listOf(4, 2, 3, 1, 5), moved)
+ val restored = FolderOrdering.sorted(folders.filterNot { it.isChatFolder }, moved)
+ assertEquals(listOf(4, 2, 3, 1, 5), restored.map { it.id })
+ }
+
+ @Test
+ fun `boundary invalid hidden and chat moves are noops`() {
+ val folders = listOf(folder(1), folder(2), folder(3).copy(kind = "chat"))
+ val order = listOf(1, 2)
+ assertEquals(order, FolderOrdering.move(folders, order, emptySet(), 1, -1))
+ assertEquals(order, FolderOrdering.move(folders, order, emptySet(), 2, 1))
+ assertEquals(order, FolderOrdering.move(folders, order, emptySet(), 1, 2))
+ assertEquals(order, FolderOrdering.move(folders, order, setOf(1), 1, 1))
+ assertEquals(order, FolderOrdering.move(folders, order, emptySet(), 3, -1))
+ assertEquals(order, FolderOrdering.move(folders, order, emptySet(), 99, -1))
+ }
+
+ @Test
+ fun `dragging across multiple rows preserves hidden slots and includes new folders`() {
+ val folders = (1..6).map { folder(it, parentId = if (it == 3) 2 else null) } +
+ folder(7).copy(kind = "chat")
+ assertEquals(listOf(5, 2, 3, 1, 4, 6),
+ FolderOrdering.reorder(folders, emptyList(), setOf(2), listOf(5, 99, 5, 2, 7, 1, 4)))
+ assertEquals(listOf(1, 2, 3, 4, 5, 6), folders.filterNot { it.isChatFolder }.map { it.id })
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/core/model/SessionFileIndexTest.kt b/app/src/test/java/app/codeg/android/core/model/SessionFileIndexTest.kt
new file mode 100644
index 0000000..6052d69
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/core/model/SessionFileIndexTest.kt
@@ -0,0 +1,50 @@
+package app.codeg.android.core.model
+
+import java.time.Instant
+import kotlinx.serialization.json.Json
+import org.junit.Assert.*
+import org.junit.Test
+
+class SessionFileIndexTest {
+ private fun turn(id: String, vararg blocks: ContentBlock) = MessageTurn(id, TurnRole.ASSISTANT, blocks.toList(), Instant.parse("2026-10-24T00:00:00Z"))
+ private fun call(id: String, path: String, status: String? = null) = ContentBlock.ToolUse(id, "functions.mcp__fastctx__replace", """{"path":"$path"}""", status = status)
+
+ @Test fun `complete history deduplicates paths and ignores failed and pending calls`() {
+ val files = collectSessionFiles(listOf(
+ turn("early", call("a", "src/../a.rs"), call("old", "old.rs")),
+ turn("later", call("b", "/repo/a.rs"), call("c", "bad.rs"), call("d", "pending.rs", "pending")),
+ turn("result", ContentBlock.ToolResult("c", "failed", true)),
+ ), "/repo")
+ assertEquals(listOf("/repo/a.rs", "/repo/old.rs"), files.map { it.path })
+ assertEquals("later", files.first().turnId)
+ assertEquals("edit", files.first().operation)
+ assertTrue(files.all { it.available == null })
+ }
+
+ @Test fun `wrapped edits and patches never index patch examples inside file content`() {
+ val files = collectSessionFiles(listOf(turn("t",
+ ContentBlock.ToolUse("a", "Write", """{"arguments":{"filePath":"a.rs","content":"*** Add File: fake.rs"}}"""),
+ ContentBlock.ToolUse("b", "apply_patch", """{"changes":{"b.rs":{"diff":"+++ b/fake.rs"}}}"""),
+ ContentBlock.ToolUse("c", "functions.apply_patch", "*** Add File: c.rs\n+x\n*** Delete File: d.rs"),
+ ContentBlock.ToolUse("d", "write_file", """{"file_path":"truncated.rs","content":"a long unfinished payload"""),
+ )), "/repo")
+ assertEquals(listOf("a.rs", "b.rs", "c.rs", "d.rs", "truncated.rs"), files.map { it.filename })
+ assertEquals("delete", files.first { it.filename == "d.rs" }.operation)
+ assertNull(files.first { it.filename == "d.rs" }.available)
+ }
+
+ @Test fun `local links preserve spaces percent signs and plus signs and exclude web links`() {
+ val files = collectSessionFiles(listOf(turn("t",
+ call("a", "literal%23name.rs"),
+ ContentBlock.Text("[artifact]() [web](https://example.test/a) [anchor](#top) [remote](file://remote/a)"),
+ )), "/repo")
+ assertEquals(listOf("/Downloads/hello world#v2+.py", "/repo/literal%23name.rs"), files.map { it.path })
+ }
+
+ @Test fun `windows roots are resolved as server paths and tool status survives wire decoding`() {
+ val files = collectSessionFiles(listOf(turn("t", ContentBlock.ToolUse("a", "Write", """{"path":"src/a.kt"}"""))), "C:\\repo")
+ assertEquals("C:/repo/src/a.kt", files.single().path)
+ val block = ContentBlock.fromWire(Json.parseToJsonElement("""{"type":"tool_use","tool_name":"Write","status":"in_progress"}""") as kotlinx.serialization.json.JsonObject) as ContentBlock.ToolUse
+ assertEquals("in_progress", block.status)
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/core/network/CodegJsonTest.kt b/app/src/test/java/app/codeg/android/core/network/CodegJsonTest.kt
index 185bad3..2a83b8d 100644
--- a/app/src/test/java/app/codeg/android/core/network/CodegJsonTest.kt
+++ b/app/src/test/java/app/codeg/android/core/network/CodegJsonTest.kt
@@ -46,6 +46,30 @@ class CodegJsonTest {
assertEquals(AgentType.CODEX, folder.defaultAgentType)
assertEquals(3, folder.sortOrder)
assertTrue(folder.isChat)
+ assertTrue(folder.isChatFolder)
+ }
+
+ @Test
+ fun `current chat kind is decoded for folders and conversations`() {
+ val folder = CodegJson.response.decodeFromString(
+ """{"id":1,"name":"Chat","path":"/data/chat-sessions/one","kind":"chat"}""",
+ )
+ val conversation = CodegJson.response.decodeFromString(
+ """{"id":7,"folder_id":1,"kind":"chat","source":"cli","agent_type":"codex","status":"completed",
+ "created_at":"2024-01-02T03:04:05Z","updated_at":"2024-01-02T03:04:06Z"}""",
+ )
+ assertTrue(folder.isChatFolder)
+ assertEquals("chat", conversation.kind)
+ assertEquals("cli", conversation.source)
+ }
+
+ @Test
+ fun `explicit folder kind takes precedence over legacy flag and name`() {
+ val folder = CodegJson.response.decodeFromString(
+ """{"id":1,"name":"Chat","path":"/projects/Chat","kind":"regular","is_chat":true}""",
+ )
+ assertFalse(folder.isChatFolder)
+ assertFalse(folder.copy(kind = "future_kind").isChatFolder)
}
@Test
@@ -60,6 +84,7 @@ class CodegJsonTest {
assertEquals(ConversationStatus.IN_PROGRESS, c.status)
assertEquals(5, c.messageCount)
assertEquals("Fix auth", c.trimmedTitle)
+ assertEquals(null, c.source) // Older servers need not send a source.
}
@Test
diff --git a/app/src/test/java/app/codeg/android/core/network/ConversationCacheTest.kt b/app/src/test/java/app/codeg/android/core/network/ConversationCacheTest.kt
new file mode 100644
index 0000000..f760635
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/core/network/ConversationCacheTest.kt
@@ -0,0 +1,212 @@
+package app.codeg.android.core.network
+
+import app.codeg.android.core.cache.ConversationDiskCache
+import app.codeg.android.core.model.ContentBlock
+import app.codeg.android.feature.sessiondetail.TranscriptWindow
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.http.HttpHeaders
+import io.ktor.http.headersOf
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import org.junit.Assert.*
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+class ConversationCacheTest {
+ @get:Rule val temp = TemporaryFolder()
+ private val seed = "cbf29ce484222325"
+ private val summary = """{"id":7,"folder_id":1,"agent_type":"claude_code","status":"completed","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}"""
+
+ private fun detail(rounds: Int, answer: String = "answer"): String {
+ val records = (0 until rounds).flatMap { i -> listOf(
+ """{"id":"u$i","role":"user","timestamp":"2026-01-01T00:00:00Z","blocks":[{"type":"text","text":"prompt $i"}]}""",
+ """{"id":"a$i","role":"assistant","timestamp":"2026-01-01T00:00:01Z","blocks":[{"type":"text","text":"$answer $i"},{"type":"future_block","opaque":{"value":42}},{"type":"tool_use","tool_name":"shell","input_preview":"pwd","meta":{"custom":true}}]}""",
+ ) }
+ return """{"summary":$summary,"turns":[${records.joinToString(",")}],"turns_offset":0,"turns_total":${records.size},"prefix_hash":"$seed"}"""
+ }
+
+ private fun http(body: suspend (String) -> String) = HttpClient(MockEngine { request ->
+ respond(body(request.url.encodedPath), headers = headersOf(HttpHeaders.ContentType, "application/json"))
+ })
+
+ @Test fun `restart reads five round tail and older pages offline without losing unknown blocks`() = runBlocking {
+ val root = temp.newFolder()
+ val cache = ConversationDiskCache(root)
+ val scope = cache.scope("profile", "https://test", "token")
+ val http = http { detail(12) }
+ try {
+ val client = CodegClient("https://test", "token", http, cache, scope)
+ client.conversationDetail(7, fromIndex = 0)
+ val restarted = ConversationDiskCache(root)
+ val offlineHttp = http { throw java.io.IOException("offline") }
+ try {
+ val offline = CodegClient("https://test", "token", offlineHttp, restarted, restarted.scope("profile", "https://test", "token"))
+ val cached = requireNotNull(offline.cachedConversation(7))
+ assertEquals(14, cached.value.turnsOffset)
+ assertEquals(10, cached.value.turns.size)
+ assertEquals("u7", cached.value.turns.first().id)
+ assertTrue(cached.value.turns.last().blocks.any { it is ContentBlock.ToolUse })
+ val raw = requireNotNull(restarted.read(restarted.scope("profile", "https://test", "token"), "session-7", "detail"))
+ val blocks = Json.parseToJsonElement(raw.text).jsonObject["turns"]!!.jsonArray.last().jsonObject["blocks"]!!.jsonArray
+ assertEquals("{\"value\":42}", blocks[1].jsonObject["opaque"].toString())
+ var window = requireNotNull(TranscriptWindow.from(cached.value))
+ var turns = cached.value.turns
+ while (window.offset > 0) {
+ val page = requireNotNull(offline.cachedConversationTurns(7, window.offset, cached.snapshot))
+ window = requireNotNull(window.prepend(page, turns))
+ turns = page.turns + turns
+ }
+ assertEquals((0 until 12).flatMap { listOf("u$it", "a$it") }, turns.map { it.id })
+ } finally { offlineHttp.close() }
+ } finally { http.close() }
+ }
+
+ @Test fun `explicit detail fetch bypasses fresh cache and discards old history with same structure`() = runBlocking {
+ val cache = ConversationDiskCache(temp.newFolder())
+ val scope = cache.scope("profile", "https://test", "token")
+ var requests = 0
+ val http = http { requests++; if (requests == 1) detail(12) else detail(12, "changed") }
+ try {
+ val client = CodegClient("https://test", "token", http, cache, scope)
+ client.conversationDetail(7)
+ val previous = requireNotNull(client.cachedConversation(7))
+ assertTrue(previous.isFresh())
+ assertNotNull(client.cachedConversationTurns(7, 14, previous.snapshot))
+ assertEquals(1, requests)
+ client.conversationDetail(7)
+ assertEquals(2, requests)
+ val current = requireNotNull(client.cachedConversation(7))
+ assertNotEquals(previous.snapshot, current.snapshot)
+ assertTrue((current.value.turns.last().blocks.first() as ContentBlock.Text).text.startsWith("changed"))
+ assertNull(client.cachedConversationTurns(7, 14, previous.snapshot))
+ val rewritten = requireNotNull(client.cachedConversationTurns(7, 14, current.snapshot))
+ assertTrue((rewritten.turns.last().blocks.first() as ContentBlock.Text).text.startsWith("changed"))
+ } finally { http.close() }
+ }
+
+ @Test fun `clearing while network request runs does not repopulate cache`() = runBlocking {
+ val cache = ConversationDiskCache(temp.newFolder())
+ val scope = cache.scope("profile", "https://test", "token")
+ val started = CompletableDeferred()
+ val response = CompletableDeferred()
+ val http = http { started.complete(Unit); response.await() }
+ try {
+ val client = CodegClient("https://test", "token", http, cache, scope)
+ val pending = async { client.conversationDetail(7) }
+ started.await()
+ cache.clearAll()
+ response.complete(detail(1))
+ assertEquals(2, pending.await().turns.size)
+ assertNull(client.cachedConversation(7))
+ } finally { http.close() }
+ }
+
+ @Test fun `network or disk failure preserves readable prior content and successful fetch`() = runBlocking {
+ val cache = ConversationDiskCache(temp.newFolder())
+ val scope = cache.scope("profile", "https://test", "token")
+ var offline = false
+ val http = http { if (offline) throw java.io.IOException("offline") else detail(1) }
+ try {
+ val client = CodegClient("https://test", "token", http, cache, scope)
+ client.conversationDetail(7)
+ val old = requireNotNull(client.cachedConversation(7))
+ offline = true
+ try { client.conversationDetail(7); fail("expected transport failure") } catch (_: ApiError.Transport) { }
+ assertEquals(old, client.cachedConversation(7))
+ offline = false
+ val broken = ConversationDiskCache(temp.newFile())
+ val noDisk = CodegClient("https://test", "token", http, broken, broken.scope("profile", "https://test", "token"))
+ assertEquals(2, noDisk.conversationDetail(7).turns.size)
+ assertNull(noDisk.cachedConversation(7))
+ } finally { http.close() }
+ }
+
+ @Test fun `delete invalidates both detail and session list`() = runBlocking {
+ val cache = ConversationDiskCache(temp.newFolder())
+ val scope = cache.scope("profile", "https://test", "token")
+ val http = http { path -> when {
+ path.endsWith("get_folder_conversation") -> detail(1)
+ path.endsWith("list_all_conversations") -> "[$summary]"
+ path.endsWith("delete_conversation") -> "null"
+ else -> "[]"
+ } }
+ try {
+ val client = CodegClient("https://test", "token", http, cache, scope)
+ client.sessionList()
+ client.conversationDetail(7)
+ assertNotNull(client.cachedSessionList())
+ assertNotNull(client.cachedConversation(7))
+ client.deleteConversation(7)
+ assertNull(client.cachedSessionList())
+ assertNull(client.cachedConversation(7))
+ } finally { http.close() }
+ }
+
+ @Test fun `prompt expires authoritative history without saving optimistic or live content`() = runBlocking {
+ val cache = ConversationDiskCache(temp.newFolder())
+ val scope = cache.scope("profile", "https://test", "token")
+ val http = http { path -> if (path.endsWith("get_folder_conversation")) detail(1) else "null" }
+ try {
+ val client = CodegClient("https://test", "token", http, cache, scope)
+ client.conversationDetail(7)
+ val before = requireNotNull(client.cachedConversation(7))
+ client.prompt("connection", emptyList(), 1, 7, "pending-user")
+ val after = requireNotNull(client.cachedConversation(7))
+ assertEquals(before.value, after.value)
+ assertFalse(after.isFresh())
+ } finally { http.close() }
+ }
+
+ @Test fun `active reply never replaces confirmed disk history`() = runBlocking {
+ val cache = ConversationDiskCache(temp.newFolder())
+ val scope = cache.scope("profile", "https://test", "token")
+ var live = false
+ val http = http {
+ if (live) detail(2, "partial").replace("\"completed\"", "\"in_progress\"")
+ else detail(1)
+ }
+ try {
+ val client = CodegClient("https://test", "token", http, cache, scope)
+ client.conversationDetail(7)
+ val confirmed = requireNotNull(client.cachedConversation(7))
+ live = true
+ assertEquals(4, client.conversationDetail(7).turns.size)
+ val cached = requireNotNull(client.cachedConversation(7))
+ assertEquals(confirmed.value, cached.value)
+ assertFalse(cached.isFresh())
+ } finally { http.close() }
+ }
+
+ @Test fun `huge single round is cached in bounded pages with continuous seams`() = runBlocking {
+ val cache = ConversationDiskCache(temp.newFolder())
+ val scope = cache.scope("profile", "https://test", "token")
+ val turns = (0..1100).joinToString(",") { i ->
+ """{"id":"$i","role":"${if (i == 0) "user" else "assistant"}","timestamp":"2026-01-01T00:00:00Z","blocks":[]}"""
+ }
+ val raw = """{"summary":$summary,"turns":[$turns],"turns_offset":0,"turns_total":1101,"prefix_hash":"$seed"}"""
+ val http = http { raw }
+ try {
+ val client = CodegClient("https://test", "token", http, cache, scope)
+ client.conversationDetail(7, 0)
+ val cached = requireNotNull(client.cachedConversation(7))
+ assertEquals(500, cached.value.turns.size)
+ assertEquals(601, cached.value.turnsOffset)
+ var window = requireNotNull(TranscriptWindow.from(cached.value))
+ var loaded = cached.value.turns
+ while (window.offset > 0) {
+ val page = requireNotNull(client.cachedConversationTurns(7, window.offset, cached.snapshot))
+ assertTrue(page.turns.size <= 500)
+ window = requireNotNull(window.prepend(page, loaded))
+ loaded = page.turns + loaded
+ }
+ assertEquals((0..1100).map(Int::toString), loaded.map { it.id })
+ } finally { http.close() }
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/core/network/ConversationPaginationTest.kt b/app/src/test/java/app/codeg/android/core/network/ConversationPaginationTest.kt
new file mode 100644
index 0000000..cf86a59
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/core/network/ConversationPaginationTest.kt
@@ -0,0 +1,49 @@
+package app.codeg.android.core.network
+
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.http.HttpHeaders
+import io.ktor.http.content.TextContent
+import io.ktor.http.headersOf
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import org.junit.Assert.*
+import org.junit.Test
+
+class ConversationPaginationTest {
+ @Test fun `entry refresh and history use bounded request selectors`() = runTest {
+ val requests = mutableListOf>()
+ val http = HttpClient(MockEngine { request ->
+ requests += request.url.encodedPath to (request.body as TextContent).text
+ val body = if (request.url.encodedPath.endsWith("get_folder_conversation_turns")) {
+ """{"turns":[],"turns_offset":0,"turns_total":0,"prefix_hash":"cbf29ce484222325","prefix_hash_before_index":"cbf29ce484222325"}"""
+ } else {
+ """{"summary":{"id":7,"folder_id":1,"agent_type":"claude_code","status":"idle","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"},"turns":[],"turns_offset":0,"turns_total":0,"prefix_hash":"cbf29ce484222325"}"""
+ }
+ respond(body, headers = headersOf(HttpHeaders.ContentType, "application/json"))
+ })
+ try {
+ val client = CodegClient("https://example.test", "test-token", http)
+ val detail = client.conversationDetail(7)
+ assertEquals(0, detail.turnsOffset)
+ assertEquals("cbf29ce484222325", detail.prefixHash)
+ client.conversationDetail(7, fromIndex = 12)
+ client.conversationTurns(7, beforeIndex = 12)
+ val entry = Json.parseToJsonElement(requests[0].second).jsonObject
+ assertEquals("5", entry["tailRounds"].toString())
+ assertFalse(entry.containsKey("fromIndex"))
+ val refresh = Json.parseToJsonElement(requests[1].second).jsonObject
+ assertEquals("12", refresh["fromIndex"].toString())
+ assertFalse(refresh.containsKey("tailRounds"))
+ val older = Json.parseToJsonElement(requests[2].second).jsonObject
+ assertTrue(requests[2].first.endsWith("get_folder_conversation_turns"))
+ assertEquals("12", older["beforeIndex"].toString())
+ assertEquals("5", older["roundLimit"].toString())
+ assertEquals("30", older["limit"].toString())
+ } finally {
+ http.close()
+ }
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/core/network/SessionFilesTest.kt b/app/src/test/java/app/codeg/android/core/network/SessionFilesTest.kt
new file mode 100644
index 0000000..5517255
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/core/network/SessionFilesTest.kt
@@ -0,0 +1,142 @@
+package app.codeg.android.core.network
+
+import app.codeg.android.core.cache.ConversationDiskCache
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.content.TextContent
+import io.ktor.http.headersOf
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.Rule
+import org.junit.rules.TemporaryFolder
+
+class SessionFilesTest {
+ @get:Rule val temp = TemporaryFolder()
+ private val summary = """{"id":42,"folder_id":3,"agent_type":"codex","status":"completed","created_at":"2026-10-24T00:00:00Z","updated_at":"2026-10-24T00:00:00Z"}"""
+
+ @Test fun `file index requests complete session without history pagination`() = runTest {
+ val http = HttpClient(MockEngine { request ->
+ assertEquals("/api/get_conversation_files", request.url.encodedPath)
+ assertEquals("Bearer test-token", request.headers[HttpHeaders.Authorization])
+ assertEquals("""{"conversationId":42}""", (request.body as TextContent).text)
+ respond("""[{"path":"/repo/early.kt","operation":"edit","turn_id":"turn-1","timestamp":"2026-10-24T00:00:00Z","available":true,"size":42}]""", headers = headersOf(HttpHeaders.ContentType, "application/json"))
+ })
+ try {
+ val file = CodegClient("https://example.test", "test-token", http).conversationFiles(42).single()
+ assertEquals("turn-1", file.turnId)
+ assertEquals("early.kt", file.filename)
+ assertEquals(true, file.available)
+ } finally { http.close() }
+ }
+
+ @Test fun `preview uses a ticket without token and bounds the returned bytes`() = runTest {
+ var count = 0
+ val http = HttpClient(MockEngine { request ->
+ count++
+ if (request.url.encodedPath.endsWith("workspace_download_ticket")) {
+ assertEquals("Bearer test-token", request.headers[HttpHeaders.Authorization])
+ respond("""{"url":"/api/workspace_download/preview-1","filename":"large.txt"}""")
+ } else {
+ assertEquals("/api/workspace_download/preview-1", request.url.encodedPath)
+ assertNull(request.headers[HttpHeaders.Authorization])
+ respond("abcdefghijk")
+ }
+ })
+ try {
+ val bytes = CodegClient("https://example.test", "test-token", http).workspacePreviewBytes("/repo", "large.txt", 4)
+ assertEquals("abcd", bytes.toString(Charsets.UTF_8))
+ assertEquals(2, count)
+ } finally { http.close() }
+ }
+
+ @Test fun `501 from old server falls back to complete history and caches files without changing transcript cache`() = runTest {
+ val requests = mutableListOf()
+ val http = HttpClient(MockEngine { request ->
+ requests += request.url.encodedPath
+ assertEquals("Bearer test-token", request.headers[HttpHeaders.Authorization])
+ when (request.url.encodedPath) {
+ "/api/get_conversation_files" -> respond("""{"code":"not_implemented","message":"API endpoint 'get_conversation_files' is not available in web mode"}""", HttpStatusCode.NotImplemented)
+ "/api/get_folder_conversation" -> {
+ val body = Json.parseToJsonElement((request.body as TextContent).text).jsonObject
+ assertEquals("0", body["fromIndex"]?.jsonPrimitive?.content)
+ assertNull(body["tailRounds"])
+ respond("""{"summary":$summary,"turns_offset":0,"turns":[
+ {"id":"early","role":"assistant","timestamp":"2026-10-24T00:00:00Z","blocks":[{"type":"tool_use","tool_name":"Write","input_preview":"{\"file_path\":\"old.kt\",\"content\":\"x\"}"}]},
+ {"id":"latest","role":"assistant","timestamp":"2026-10-24T00:00:01Z","blocks":[{"type":"text","text":"[Image](/Downloads/new.png)"}]}
+ ]}""")
+ }
+ "/api/list_all_folder_details" -> respond("""[{"id":3,"name":"Repo","path":"/repo"}]""")
+ else -> error("Unexpected request: ${request.url}")
+ }
+ })
+ val cache = ConversationDiskCache(temp.newFolder())
+ val scope = cache.scope("profile", "https://example.test", "test-token")
+ try {
+ val client = CodegClient("https://example.test", "test-token", http, cache, scope)
+ val files = client.conversationFiles(42)
+ assertEquals(listOf("/Downloads/new.png", "/repo/old.kt"), files.map { it.path })
+ assertTrue(files.all { it.available == null })
+ assertEquals(files, client.cachedConversationFiles(42))
+ assertNull(client.cachedConversation(42))
+ assertEquals(3, requests.size)
+ } finally { http.close() }
+ }
+
+ @Test fun `plain 404 also falls back and original working directory wins over current folder`() = runTest {
+ val requests = mutableListOf()
+ val http = HttpClient(MockEngine { request ->
+ requests += request.url.encodedPath
+ if (request.url.encodedPath == "/api/get_conversation_files") respond("", HttpStatusCode.NotFound)
+ else {
+ assertEquals("/api/get_folder_conversation", request.url.encodedPath)
+ respond("""{"summary":${summary.dropLast(1)},"origin_cwd":"/original"},"turns":[{"id":"old","role":"assistant","timestamp":"2026-10-24T00:00:00Z","blocks":[{"type":"tool_use","tool_name":"Write","input_preview":"{\"path\":\"a.kt\"}"}]}]}""")
+ }
+ })
+ try {
+ assertEquals("/original/a.kt", CodegClient("https://example.test", "test-token", http).conversationFiles(42).single().path)
+ assertEquals(2, requests.size)
+ } finally { http.close() }
+ }
+
+ @Test fun `permission and server failures never trigger compatibility history reads`() = runTest {
+ for (status in listOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden, HttpStatusCode.InternalServerError)) {
+ var requests = 0
+ val http = HttpClient(MockEngine {
+ requests++
+ respond("""{"code":"failure","message":"failed"}""", status)
+ })
+ try {
+ try {
+ CodegClient("https://example.test", "test-token", http).conversationFiles(42)
+ fail("Expected $status error")
+ } catch (_: ApiError) { }
+ assertEquals(1, requests)
+ } finally { http.close() }
+ }
+ }
+
+ @Test fun `legacy fallback rejects incomplete history instead of hiding older files`() = runTest {
+ for (metadata in listOf("\"turns_offset\":10", "\"turns_offset\":0,\"turns_total\":10")) {
+ val http = HttpClient(MockEngine { request ->
+ if (request.url.encodedPath == "/api/get_conversation_files") respond("""{"code":"not_implemented"}""", HttpStatusCode.NotImplemented)
+ else {
+ assertEquals("/api/get_folder_conversation", request.url.encodedPath)
+ respond("""{"summary":$summary,"turns":[],$metadata}""")
+ }
+ })
+ try {
+ try {
+ CodegClient("https://example.test", "test-token", http).conversationFiles(42)
+ fail("Incomplete history was accepted")
+ } catch (_: ApiError.Decoding) { }
+ } finally { http.close() }
+ }
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/core/network/WorkspaceDownloadTest.kt b/app/src/test/java/app/codeg/android/core/network/WorkspaceDownloadTest.kt
new file mode 100644
index 0000000..7bf09ce
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/core/network/WorkspaceDownloadTest.kt
@@ -0,0 +1,63 @@
+package app.codeg.android.core.network
+
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.content.TextContent
+import io.ktor.http.headersOf
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import org.junit.Assert.*
+import org.junit.Test
+
+class WorkspaceDownloadTest {
+ @Test fun `ticket request is authenticated and yields a download URL without credentials`() = runTest {
+ var requests = 0
+ val http = HttpClient(MockEngine { request ->
+ requests++
+ assertEquals("/codeg/api/workspace_download_ticket", request.url.encodedPath)
+ assertEquals("Bearer test-token", request.headers[HttpHeaders.Authorization])
+ val body = Json.parseToJsonElement((request.body as TextContent).text).jsonObject
+ assertEquals("/work", body["rootPath"]?.jsonPrimitive?.content)
+ assertEquals("app/build/app.apk", body["path"]?.jsonPrimitive?.content)
+ assertEquals("file", body["kind"]?.jsonPrimitive?.content)
+ respond("""{"ticket":"abc-123","url":"/api/workspace_download/abc-123","filename":"app.apk","expiresAt":123}""",
+ headers = headersOf(HttpHeaders.ContentType, "application/json"))
+ })
+ try {
+ val client = CodegClient("https://example.test/codeg/", "test-token", http)
+ val first = client.workspaceDownloadTicket("/work", "app/build/app.apk")
+ assertEquals("https://example.test/codeg/api/workspace_download/abc-123", first.url)
+ assertEquals("app.apk", first.filename)
+ assertFalse(first.url.contains("test-token"))
+ client.workspaceDownloadTicket("/work", "app/build/app.apk")
+ assertEquals(2, requests) // Retry always asks for a new one-use ticket.
+ } finally { http.close() }
+ }
+
+ @Test fun `ticket URLs cannot redirect the download to an unrelated host or API`() = runTest {
+ listOf("https://other.test/app.apk", "//other.test/app.apk", "/api/secret", "/api/workspace_download/../secret").forEach { url ->
+ val http = HttpClient(MockEngine { respond("""{"url":"$url","filename":"app.apk"}""") })
+ try {
+ try {
+ CodegClient("https://example.test", "test-token", http).workspaceDownloadTicket("/work", "app.apk")
+ fail("Accepted $url")
+ } catch (_: ApiError.Decoding) { }
+ } finally { http.close() }
+ }
+ }
+
+ @Test fun `missing files surface failures before enqueue`() = runTest {
+ val http = HttpClient(MockEngine { respond("""{"message":"Missing file"}""", HttpStatusCode.NotFound) })
+ try {
+ try {
+ CodegClient("https://example.test", "test-token", http).workspaceDownloadTicket("/work", "gone.apk")
+ fail("Missing file was accepted")
+ } catch (e: ApiError.Server) { assertEquals(404, e.status) }
+ } finally { http.close() }
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/feature/downloads/DownloadStoreTest.kt b/app/src/test/java/app/codeg/android/feature/downloads/DownloadStoreTest.kt
new file mode 100644
index 0000000..49449b9
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/feature/downloads/DownloadStoreTest.kt
@@ -0,0 +1,60 @@
+package app.codeg.android.feature.downloads
+
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import app.codeg.android.core.datastore.ServerProfile
+import java.io.File
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.*
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+class DownloadStoreTest {
+ @get:Rule val temporaryFolder = TemporaryFolder()
+ private val profile = ServerProfile("server1", "Local", "http://localhost:3080")
+ private fun record(key: String) = DownloadRecord(key, "/Downloads/app.apk", "app.apk", profile.id, profile.baseUrl, profile.name,
+ conversationId = 12, createdAt = 1, id = 42, status = FileDownloadStatus.COMPLETE)
+
+ @Test fun `metadata survives process restart updates atomically and removal retains other records`() = runTest {
+ val file = File(temporaryFolder.root, "downloads.preferences_pb")
+ val firstJob = SupervisorJob()
+ val first = DownloadStore(PreferenceDataStoreFactory.create(scope = CoroutineScope(firstJob)) { file })
+ first.put(record("one"))
+ first.put(record("two").copy(createdAt = 2))
+ first.put(record("one").copy(filename = "updated.apk"))
+ firstJob.cancelAndJoin()
+ val secondJob = SupervisorJob()
+ try {
+ val second = DownloadStore(PreferenceDataStoreFactory.create(scope = CoroutineScope(secondJob)) { file })
+ assertEquals(listOf("two", "one"), second.read().map { it.key })
+ assertEquals("updated.apk", second.read().last().filename)
+ assertEquals(42L, second.read().last().id)
+ second.remove("one")
+ assertEquals(listOf("two"), second.read().map { it.key })
+ } finally { secondJob.cancelAndJoin() }
+ }
+
+ @Test fun `source identity and duplicate lookup cannot reuse files from a different server or conversation`() {
+ val records = listOf(record("one"))
+ assertTrue(records.first().belongsTo(profile.copy(name = "Renamed", baseUrl = profile.baseUrl + "/")))
+ assertFalse(records.first().belongsTo(profile.copy(id = "another")))
+ assertFalse(records.first().belongsTo(profile.copy(baseUrl = "http://localhost:4000")))
+ val active = records.map { it.copy(status = FileDownloadStatus.DOWNLOADING) }
+ assertEquals("one", active.activeDownload(profile, 12, "/Downloads/app.apk")?.key)
+ assertNull(active.activeDownload(profile, 13, "/Downloads/app.apk"))
+ assertNull(active.activeDownload(profile.copy(id = "another"), 12, "/Downloads/app.apk"))
+ assertNull(records.activeDownload(profile, 12, "/Downloads/app.apk")) // A repeated artifact link must fetch the latest build.
+ assertNull(listOf(record("failed").copy(status = FileDownloadStatus.FAILED)).activeDownload(profile, 12, "/Downloads/app.apk"))
+ }
+
+ @Test fun `restart only interrupts preparation that has no system download id`() {
+ assertEquals(FileDownloadStatus.FAILED, record("one").copy(id = null, status = FileDownloadStatus.PREPARING).restored("Interrupted").status)
+ val active = record("one").copy(status = FileDownloadStatus.DOWNLOADING)
+ assertEquals(active, active.restored("Interrupted"))
+ val canceled = active.copy(id = null, status = FileDownloadStatus.CANCELED)
+ assertEquals(canceled, canceled.restored("Interrupted"))
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/feature/downloads/LocalFilePreviewTest.kt b/app/src/test/java/app/codeg/android/feature/downloads/LocalFilePreviewTest.kt
new file mode 100644
index 0000000..e66d4ad
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/feature/downloads/LocalFilePreviewTest.kt
@@ -0,0 +1,49 @@
+package app.codeg.android.feature.downloads
+
+import java.io.ByteArrayInputStream
+import org.junit.Assert.*
+import org.junit.Test
+
+class LocalFilePreviewTest {
+ @Test fun `code and images preview locally while executable and active formats stay external`() {
+ listOf("a.kt", "a.JSON", "a.tsx", "README", "Dockerfile", ".gitignore", "a.md").forEach {
+ assertEquals(it, FilePreviewKind.TEXT, filePreviewKind(it))
+ }
+ assertEquals(FilePreviewKind.IMAGE, filePreviewKind("picture.PNG"))
+ listOf("app.apk", "file.pdf", "picture.svg", "unknown.bin").forEach {
+ assertEquals(it, FilePreviewKind.EXTERNAL, filePreviewKind(it))
+ }
+ }
+
+ @Test fun `UTF8 UTF16 BOMs and ordinary unicode text are preserved`() {
+ val text = "你好😀\nval value = 1"
+ listOf(text.toByteArray(), byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) + text.toByteArray(),
+ byteArrayOf(0xFF.toByte(), 0xFE.toByte()) + text.toByteArray(Charsets.UTF_16LE),
+ byteArrayOf(0xFE.toByte(), 0xFF.toByte()) + text.toByteArray(Charsets.UTF_16BE)).forEach {
+ assertEquals(TextFilePreview(text, false), readTextPreview(ByteArrayInputStream(it)))
+ }
+ assertEquals(TextFilePreview("", false), readTextPreview(ByteArrayInputStream(byteArrayOf())))
+ }
+
+ @Test fun `bounded preview tolerates a cutoff inside a UTF8 character and never reads the full file`() {
+ val bytes = ("a".repeat(MAX_PREVIEW_BYTES - 1) + "😀" + "z".repeat(MAX_PREVIEW_BYTES)).toByteArray()
+ val input = ByteArrayInputStream(bytes)
+ val preview = readTextPreview(input)
+ assertTrue(preview.truncated)
+ assertEquals("a".repeat(MAX_PREVIEW_BYTES - 1), preview.text)
+ assertEquals(bytes.size - MAX_PREVIEW_BYTES - 1, input.available())
+ }
+
+ @Test fun `line limit truncates the preview without changing the downloaded file`() {
+ val preview = readTextPreview(ByteArrayInputStream((1..2100).joinToString("\n").toByteArray()))
+ assertTrue(preview.truncated)
+ assertEquals(2000, preview.text.lines().size)
+ assertTrue(preview.text.endsWith("2000"))
+ }
+
+ @Test fun `binary and malformed text cannot be silently displayed as code`() {
+ listOf(byteArrayOf(1, 0, 2), byteArrayOf(0xC3.toByte(), 0x28)).forEach {
+ try { readTextPreview(ByteArrayInputStream(it)); fail("Accepted binary text") } catch (_: Exception) { }
+ }
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/feature/projects/ProjectsVisibilityTest.kt b/app/src/test/java/app/codeg/android/feature/projects/ProjectsVisibilityTest.kt
new file mode 100644
index 0000000..70b08f0
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/feature/projects/ProjectsVisibilityTest.kt
@@ -0,0 +1,20 @@
+package app.codeg.android.feature.projects
+
+import app.codeg.android.core.model.FolderDetail
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ProjectsVisibilityTest {
+ @Test
+ fun `hidden projects and their worktrees disappear and restore without losing folder context`() {
+ val folders = listOf(
+ FolderDetail(1, "Root", "/root"),
+ FolderDetail(2, "Branch", "/branch", parentId = 1),
+ FolderDetail(3, "Other", "/other"),
+ )
+ val hidden = ProjectsUiState(folders = folders, hiddenFolderIds = setOf(1))
+ assertEquals(listOf(3), hidden.visibleFolders.map { it.id })
+ assertEquals(folders, hidden.folders)
+ assertEquals(folders, hidden.copy(hiddenFolderIds = emptySet()).visibleFolders)
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/feature/search/SearchViewModelTest.kt b/app/src/test/java/app/codeg/android/feature/search/SearchViewModelTest.kt
new file mode 100644
index 0000000..7af4e1d
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/feature/search/SearchViewModelTest.kt
@@ -0,0 +1,173 @@
+package app.codeg.android.feature.search
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.lifecycle.ViewModelStore
+import app.codeg.android.core.common.DispatcherProvider
+import app.codeg.android.core.data.ServerRepository
+import app.codeg.android.core.datastore.RecentSearchesStore
+import app.codeg.android.core.datastore.ServerProfile
+import app.codeg.android.core.datastore.ServerStore
+import app.codeg.android.core.network.CodegClientFactory
+import app.codeg.android.core.security.SecretStore
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.MockEngineConfig
+import io.ktor.client.engine.mock.respond
+import io.ktor.client.request.HttpRequestData
+import io.ktor.http.HttpHeaders
+import io.ktor.http.content.TextContent
+import io.ktor.http.headersOf
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SearchViewModelTest {
+ private val dispatcher = StandardTestDispatcher()
+
+ @Before fun setUp() = Dispatchers.setMain(dispatcher)
+ @After fun tearDown() = Dispatchers.resetMain()
+
+ @Test
+ fun `global search includes history beyond the home preview without folder or status filters`() = runTest {
+ val harness = harness { summaries((1..45).toList()) }
+ try {
+ harness.viewModel.onQueryChange("needle")
+ advanceUntilIdle()
+
+ assertEquals((1..45).toList(), harness.viewModel.ui.value.results.map { it.id })
+ val body = (harness.requests.single().body as TextContent).text
+ assertTrue(body.contains("\"search\":\"needle\""))
+ assertFalse(body.contains("folderIds"))
+ assertFalse(body.contains("status"))
+ assertFalse(body.contains("limit"))
+ } finally {
+ harness.close()
+ }
+ }
+
+ @Test
+ fun `changing servers reruns the same query against the new server`() = runTest {
+ val harness = harness { request -> summaries(listOf(if (request.url.host == "a.test") 1 else 2)) }
+ try {
+ harness.viewModel.onQueryChange("needle")
+ advanceUntilIdle()
+ assertEquals(listOf(1), harness.viewModel.ui.value.results.map { it.id })
+
+ harness.serverStore.select("b")
+ advanceUntilIdle()
+
+ assertEquals(listOf(2), harness.viewModel.ui.value.results.map { it.id })
+ assertEquals(listOf("a.test", "b.test"), harness.requests.map { it.url.host })
+ } finally {
+ harness.close()
+ }
+ }
+
+ @Test
+ fun `clearing a query immediately rejects its late response during debounce`() = runTest {
+ val started = CompletableDeferred()
+ val response = CompletableDeferred()
+ val harness = harness {
+ started.complete(Unit)
+ response.await()
+ }
+ try {
+ harness.viewModel.onQueryChange("needle")
+ advanceTimeBy(301)
+ runCurrent()
+ assertTrue(started.isCompleted)
+
+ harness.viewModel.onQueryChange("")
+ response.complete(summaries(listOf(1)))
+ runCurrent()
+
+ assertTrue(harness.viewModel.ui.value.results.isEmpty())
+ assertEquals("", harness.viewModel.ui.value.query)
+ assertFalse(harness.viewModel.ui.value.searching)
+ advanceUntilIdle()
+ } finally {
+ response.complete("[]")
+ harness.close()
+ }
+ }
+
+ private suspend fun TestScope.harness(
+ searchResponse: suspend (HttpRequestData) -> String,
+ ): Harness {
+ val requests = mutableListOf()
+ val http = HttpClient(MockEngine(MockEngineConfig().apply {
+ dispatcher = this@SearchViewModelTest.dispatcher
+ addHandler { request ->
+ val body = if (request.url.encodedPath.endsWith("list_all_conversations")) {
+ requests += request
+ searchResponse(request)
+ } else "[]"
+ respond(body, headers = headersOf(HttpHeaders.ContentType, "application/json"))
+ }
+ }))
+ val store = ServerStore(MemoryPreferences())
+ store.upsert(ServerProfile("a", "A", "https://a.test"))
+ store.upsert(ServerProfile("b", "B", "https://b.test"))
+ val dispatchers = object : DispatcherProvider {
+ override val io = dispatcher
+ override val default = dispatcher
+ override val main = dispatcher
+ }
+ val secrets = object : SecretStore {
+ override fun get(key: String) = "test-token"
+ override fun put(key: String, value: String) = Unit
+ override fun remove(key: String) = Unit
+ }
+ val cache = app.codeg.android.core.cache.ConversationDiskCache(
+ java.nio.file.Files.createTempDirectory("search-cache-test").toFile().apply { deleteOnExit() },
+ )
+ val repository = ServerRepository(store, secrets, CodegClientFactory(dagger.Lazy { http }, cache), dispatchers, cache)
+ val viewModel = SearchViewModel(repository, RecentSearchesStore(MemoryPreferences()))
+ val viewModels = ViewModelStore().apply { put("search", viewModel) }
+ advanceUntilIdle()
+ return Harness(viewModel, store, requests, viewModels, http)
+ }
+
+ private data class Harness(
+ val viewModel: SearchViewModel,
+ val serverStore: ServerStore,
+ val requests: List,
+ val viewModels: ViewModelStore,
+ val http: HttpClient,
+ ) {
+ fun close() {
+ viewModels.clear()
+ http.close()
+ }
+ }
+
+ private class MemoryPreferences : DataStore {
+ override val data = MutableStateFlow(emptyPreferences())
+ override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences =
+ transform(data.value).also { data.value = it }
+ }
+
+ private fun summaries(ids: List) = ids.joinToString(",", "[", "]") { id ->
+ """{"id":$id,"folder_id":$id,"kind":"chat","title":"needle $id",
+ "agent_type":"codex","status":"completed","created_at":"2024-01-02T03:04:05Z",
+ "updated_at":"2024-01-02T03:04:06Z"}"""
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/feature/sessiondetail/MessageFileLinkTest.kt b/app/src/test/java/app/codeg/android/feature/sessiondetail/MessageFileLinkTest.kt
new file mode 100644
index 0000000..32d9465
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/feature/sessiondetail/MessageFileLinkTest.kt
@@ -0,0 +1,61 @@
+package app.codeg.android.feature.sessiondetail
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class MessageFileLinkTest {
+ @Test fun `absolute artifact links keep the server root and relative download path`() {
+ assertEquals(WorkspaceFileLink("/work/codeg-android", "app/build/app-debug.apk"), workspaceFileLink(
+ "/work/codeg-android/app/build/app-debug.apk", listOf("/work", "/work/codeg-android"),
+ ))
+ assertEquals(WorkspaceFileLink("/work", "build/a b+c.apk"), workspaceFileLink(
+ "", listOf("/work"),
+ ))
+ assertEquals(WorkspaceFileLink("/work", "app.kt"), workspaceFileLink("/work/app.kt:12:3", listOf("/work")))
+ }
+
+ @Test fun `relative paths stay inside current workspace and windows roots are supported`() {
+ assertEquals(WorkspaceFileLink("/work", "build/app.apk"), workspaceFileLink("./build/app.apk", listOf("/work")))
+ assertEquals(WorkspaceFileLink("C:\\work", "build/app.apk"), workspaceFileLink("C:\\work\\build\\app.apk", listOf("C:\\work")))
+ assertEquals(WorkspaceFileLink("C:\\work", "app.apk"), workspaceFileLink("file:///C:/work/app.apk", listOf("C:\\work")))
+ }
+
+ @Test fun `absolute downloads outside registered projects use the explicit file parent`() {
+ assertEquals(WorkspaceFileLink("/Users/sofire/Downloads", "codeg-android-debug.apk"), workspaceFileLink(
+ "/Users/sofire/Downloads/codeg-android-debug.apk", listOf("/Users/sofire/Work/AI/ai-xcode"),
+ ))
+ assertEquals(WorkspaceFileLink("/Users/sofire/Downloads", "a b+c.apk"), workspaceFileLink(
+ "file:///Users/sofire/Downloads/a%20b+c.apk", emptyList(), currentRoot = null,
+ ))
+ assertEquals(WorkspaceFileLink("/work-other", "app.apk"), workspaceFileLink("/work-other/app.apk", listOf("/work")))
+ assertEquals(WorkspaceFileLink("/", "app.apk"), workspaceFileLink("/app.apk", emptyList()))
+ assertEquals(WorkspaceFileLink("C:/Downloads", "app.apk"), workspaceFileLink("C:\\Downloads\\app.apk", emptyList()))
+ assertEquals(WorkspaceFileLink("C:/", "app.apk"), workspaceFileLink("file:///C:/app.apk", emptyList()))
+ }
+
+ @Test fun `untrusted schemes missing context and traversal are rejected`() {
+ val roots = listOf("/work")
+ listOf("../secret.apk", "%2e%2e/secret.apk", "/work/../secret.apk", "/Downloads/", "/", "C:/",
+ "javascript:alert(1)", "content://downloads/12", "file://other/work/app.apk", "build/%00.apk", "https://example.test/app.apk")
+ .forEach { assertNull(it, workspaceFileLink(it, roots)) }
+ assertNull(workspaceFileLink("app.apk", emptyList()))
+ assertNull(workspaceFileLink("app.apk", roots, currentRoot = null))
+ }
+
+ @Test fun `web APK detection ignores queries and keeps ordinary websites separate`() {
+ assertTrue(isApkLink("https://example.test/app.APK?version=2#download"))
+ assertFalse(isApkLink("https://example.test/help"))
+ assertNull(messageWebUrl("javascript:alert(1)"))
+ assertNull(messageWebUrl("https://user:password@example.test/app.apk"))
+ }
+
+ @Test fun `download names cannot write paths or control characters`() {
+ assertEquals("app.apk", safeDownloadFilename("../../app.apk"))
+ assertEquals("app.apk", safeDownloadFilename("C:\\build\\app.apk"))
+ assertEquals("badname.apk", safeDownloadFilename("bad\r\n:name.apk"))
+ assertEquals("download", safeDownloadFilename("..."))
+ val unicode = safeDownloadFilename("构建😀".repeat(80) + ".apk")
+ assertTrue(unicode.endsWith(".apk"))
+ assertTrue(unicode.toByteArray(Charsets.UTF_8).size <= 180)
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/feature/sessiondetail/TranscriptWindowTest.kt b/app/src/test/java/app/codeg/android/feature/sessiondetail/TranscriptWindowTest.kt
new file mode 100644
index 0000000..459002c
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/feature/sessiondetail/TranscriptWindowTest.kt
@@ -0,0 +1,68 @@
+package app.codeg.android.feature.sessiondetail
+
+import app.codeg.android.core.model.ConversationTurnsPage
+import app.codeg.android.core.model.MessageTurn
+import app.codeg.android.core.model.TurnRole
+import org.junit.Assert.*
+import org.junit.Test
+import java.time.Instant
+
+class TranscriptWindowTest {
+ private val seed = "cbf29ce484222325"
+ private val turns = listOf(
+ turn("u", TurnRole.USER, 1_700_000_001),
+ turn("a", TurnRole.ASSISTANT, 1_700_000_002),
+ turn("s", TurnRole.SYSTEM, 1_700_000_003),
+ )
+
+ @Test fun `fingerprint matches the shared Rust vector and ignores rewritten ids`() {
+ assertEquals("7060d8542023feb2", extendTranscriptHash(seed, turns.take(1)))
+ assertEquals("f171a62320fc10d3", extendTranscriptHash(seed, turns))
+ assertEquals(extendTranscriptHash(seed, turns), extendTranscriptHash(seed, turns.map { it.copy(id = "changed") }))
+ assertEquals(extendTranscriptHash(seed, turns), extendTranscriptHash(
+ extendTranscriptHash(seed, turns.take(1))!!, turns.drop(1),
+ ))
+ }
+
+ @Test fun `verified page prepends while retaining the loaded tail count`() {
+ val window = TranscriptWindow(2, 3, extendTranscriptHash(seed, turns.take(2))!!)
+ val page = page()
+ assertEquals(TranscriptWindow(0, 3, seed), window.prepend(page, turns.drop(2)))
+ assertEquals(TranscriptWindow(0, 3, seed), window.prepend(page.copy(turnsTotal = 4), turns.drop(2)))
+ }
+
+ @Test fun `rewritten truncated overlapping or nonadvancing pages cannot join`() {
+ val window = TranscriptWindow(2, 3, extendTranscriptHash(seed, turns.take(2))!!)
+ val page = page()
+ assertNull(window.prepend(page.copy(prefixHashBeforeIndex = seed), turns.drop(2)))
+ assertNull(window.prepend(page.copy(prefixHash = "bad"), turns.drop(2)))
+ assertNull(window.prepend(page.copy(turnsTotal = 1), turns.drop(2)))
+ assertNull(window.prepend(page.copy(turnsOffset = 2), turns.drop(2)))
+ assertNull(window.prepend(page.copy(turns = turns), turns.drop(2)))
+ assertNull(window.prepend(page, turns))
+ assertNull(window.prepend(page.copy(turns = listOf(turns[1], turns[0])), turns.drop(2)))
+ }
+
+ @Test fun `refresh requires the same global start and history`() {
+ val window = TranscriptWindow(2, 3, "7060d8542023feb2")
+ assertTrue(window.acceptsRefresh(window.copy(total = 20)))
+ assertFalse(window.acceptsRefresh(window.copy(offset = 1)))
+ assertFalse(window.acceptsRefresh(window.copy(prefixHash = seed)))
+ assertFalse(window.acceptsRefresh(null))
+ }
+
+ @Test fun `send baseline includes locally folded replies and is unchanged by prepending`() {
+ val window = TranscriptWindow(90, 100, seed)
+ assertEquals(100, window.countIncludingLocal(10))
+ assertEquals(102, window.countIncludingLocal(12))
+ assertEquals(102, window.copy(offset = 80).countIncludingLocal(22))
+ }
+
+ private fun page() = ConversationTurnsPage(
+ turns.take(2), 0, 3, seed, extendTranscriptHash(seed, turns.take(2))!!,
+ )
+
+ private fun turn(id: String, role: TurnRole, seconds: Long) = MessageTurn(
+ id = id, role = role, blocks = emptyList(), timestamp = Instant.ofEpochSecond(seconds),
+ )
+}
diff --git a/app/src/test/java/app/codeg/android/feature/sessiondetail/timeline/TranscriptTimelineTest.kt b/app/src/test/java/app/codeg/android/feature/sessiondetail/timeline/TranscriptTimelineTest.kt
index aab27d4..7f4218f 100644
--- a/app/src/test/java/app/codeg/android/feature/sessiondetail/timeline/TranscriptTimelineTest.kt
+++ b/app/src/test/java/app/codeg/android/feature/sessiondetail/timeline/TranscriptTimelineTest.kt
@@ -24,6 +24,21 @@ class TranscriptTimelineTest {
private fun assistant(id: String, blocks: List, usage: TurnUsage? = null) =
MessageTurn(id, TurnRole.ASSISTANT, blocks, Instant.EPOCH, usage = usage)
+ @Test
+ fun `prepending a partial round preserves existing row keys and content`() {
+ val tail = listOf(assistant("a2", listOf(ContentBlock.Text("reading here\n\nnext paragraph"))))
+ val before = TranscriptTimeline.buildPersisted(tail, emptyList(), agent)
+ val full = listOf(user("u1", "hi"), assistant("a1", listOf(ContentBlock.Text("earlier content")))) + tail
+ val after = TranscriptTimeline.buildPersisted(full, emptyList(), agent, chunkStarts = setOf("a2"))
+ before.filter { it.content !is NodeContent.Footer }.forEach { old ->
+ val kept = after.single { it.id == old.id }
+ assertEquals(old.content, kept.content)
+ }
+ assertEquals(after.size, after.map { it.id }.toSet().size)
+ val tailFooter = after.single { it.id == "a2#footer" }.content as NodeContent.Footer
+ assertEquals("u1", tailFooter.questionId)
+ }
+
@Test
fun `the rail terminates at its endpoints`() {
val nodes = TranscriptTimeline.build(listOf(user("u1", "hi")), emptyList(), null, agent)
diff --git a/app/src/test/java/app/codeg/android/feature/sessions/SessionListCollapseStateTest.kt b/app/src/test/java/app/codeg/android/feature/sessions/SessionListCollapseStateTest.kt
new file mode 100644
index 0000000..960151d
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/feature/sessions/SessionListCollapseStateTest.kt
@@ -0,0 +1,49 @@
+package app.codeg.android.feature.sessions
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SessionListCollapseStateTest {
+ @Test
+ fun `restore returns to the mixed layout instead of expanding every group`() {
+ val original = SessionListCollapseState().toggleSection("chats").toggleSection("folder-1")
+ val compact = original.toggleCompact()
+ assertTrue(compact.isCollapsed("chats"))
+ assertTrue(compact.isCollapsed("folder-1"))
+ assertTrue(compact.isCollapsed("recent"))
+
+ val restored = compact.toggleCompact()
+ assertEquals(original, restored)
+ assertTrue(restored.isCollapsed("chats"))
+ assertTrue(restored.isCollapsed("folder-1"))
+ assertFalse(restored.isCollapsed("recent"))
+ }
+
+ @Test
+ fun `groups added by refresh stay collapsed during compact mode`() {
+ val compact = SessionListCollapseState().toggleCompact()
+ assertTrue(compact.isCollapsed("new-project"))
+ assertFalse(compact.toggleCompact().isCollapsed("new-project"))
+ }
+
+ @Test
+ fun `opening a group in compact mode is temporary and preserves the original layout`() {
+ val original = SessionListCollapseState().toggleSection("chats")
+ val compact = original.toggleCompact().toggleSection("chats")
+ assertFalse(compact.isCollapsed("chats"))
+ assertTrue(compact.isCollapsed("recent"))
+ assertTrue(compact.toggleSection("chats").isCollapsed("chats"))
+ assertEquals(original, compact.toggleCompact())
+ }
+
+ @Test
+ fun `each compact cycle restores the most recent normal layout`() {
+ val original = SessionListCollapseState().toggleSection("chats")
+ val changed = original.toggleCompact().toggleCompact().toggleSection("recent")
+ assertEquals(changed, changed.toggleCompact().toggleCompact())
+ assertTrue(changed.isCollapsed("recent"))
+ }
+
+}
diff --git a/app/src/test/java/app/codeg/android/feature/sessions/SessionListCollapseStoreTest.kt b/app/src/test/java/app/codeg/android/feature/sessions/SessionListCollapseStoreTest.kt
new file mode 100644
index 0000000..1eacd77
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/feature/sessions/SessionListCollapseStoreTest.kt
@@ -0,0 +1,85 @@
+package app.codeg.android.feature.sessions
+
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+
+class SessionListCollapseStoreTest {
+ @get:Rule val temporaryFolder = TemporaryFolder()
+
+ private suspend fun withStore(file: File, block: suspend (SessionListCollapseStore) -> Unit) {
+ val job = SupervisorJob()
+ val dataStore = PreferenceDataStoreFactory.create(
+ scope = CoroutineScope(job + Dispatchers.IO),
+ produceFile = { file },
+ )
+ try {
+ block(SessionListCollapseStore(dataStore))
+ } finally {
+ job.cancelAndJoin()
+ }
+ }
+
+ @Test
+ fun `a fresh app launch restores compact mode and the original mixed layout from disk`() = runBlocking {
+ val file = File(temporaryFolder.root, "layout.preferences_pb")
+ val normal = SessionListCollapseState().toggleSection("chats").toggleSection("folder-1")
+ val compact = normal.toggleCompact().toggleSection("recent")
+ withStore(file) { store ->
+ store.update("a") { compact }
+ }
+ // A new DataStore and store instance have no in-memory UI state to reuse.
+ withStore(file) { store ->
+ val restored = store.state("a").first()
+ assertEquals(compact, restored)
+ assertTrue(restored.compact)
+ assertFalse(restored.isCollapsed("recent"))
+ store.update("a") { it.toggleCompact() }
+ assertEquals(normal, store.state("a").first())
+ }
+ withStore(file) { store ->
+ assertEquals(normal, store.state("a").first())
+ }
+ }
+
+ @Test
+ fun `servers keep independent layouts and a new server starts with the default`() = runBlocking {
+ withStore(File(temporaryFolder.root, "layout.preferences_pb")) { store ->
+ assertEquals(SessionListCollapseState(), store.state("a").first())
+ store.update("a") { it.toggleSection("chats").toggleCompact() }
+ store.update("b") { it.toggleSection("recent") }
+ assertTrue(store.state("a").first().compact)
+ assertTrue(store.state("a").first().collapsed.contains("chats"))
+ assertFalse(store.state("b").first().compact)
+ assertEquals(setOf("recent"), store.state("b").first().collapsed)
+ assertEquals(SessionListCollapseState(), store.state("c").first())
+ }
+ }
+
+ @Test
+ fun `rapid toggles update the persisted state atomically`() = runBlocking {
+ withStore(File(temporaryFolder.root, "layout.preferences_pb")) { store ->
+ store.update("a") { it.toggleSection("chats") }
+ coroutineScope {
+ repeat(21) { launch { store.update("a") { it.toggleCompact() } } }
+ }
+ val saved = store.state("a").first()
+ assertTrue(saved.compact)
+ assertEquals(setOf("chats"), saved.collapsed)
+ assertTrue(saved.expandedInCompact.isEmpty())
+ }
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/feature/sessions/SessionListOptionsTest.kt b/app/src/test/java/app/codeg/android/feature/sessions/SessionListOptionsTest.kt
new file mode 100644
index 0000000..98760d2
--- /dev/null
+++ b/app/src/test/java/app/codeg/android/feature/sessions/SessionListOptionsTest.kt
@@ -0,0 +1,140 @@
+package app.codeg.android.feature.sessions
+
+import app.codeg.android.core.datastore.RecentSessionFilter
+import app.codeg.android.core.datastore.SessionListPreferences
+import app.codeg.android.core.datastore.SessionSortMode
+import app.codeg.android.core.model.AgentType
+import app.codeg.android.core.model.ConversationStatus
+import app.codeg.android.core.model.ConversationSummary
+import app.codeg.android.core.model.FolderDetail
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.time.Instant
+
+class SessionListOptionsTest {
+ private val folders = listOf(
+ FolderDetail(1, "Project", "/project"),
+ FolderDetail(2, "Chat", "/chat", kind = "chat"),
+ FolderDetail(3, "Closed project", "/closed"),
+ FolderDetail(4, "Branch", "/branch", parentId = 1),
+ )
+
+ private fun conversation(id: Int, folderId: Int = 1, created: Long = id.toLong(), updated: Long = id.toLong()) =
+ ConversationSummary(
+ id = id, folderId = folderId, agentType = AgentType.CLAUDE_CODE,
+ status = ConversationStatus.PENDING_REVIEW,
+ createdAt = Instant.ofEpochSecond(created), updatedAt = Instant.ofEpochSecond(updated),
+ )
+
+ private fun sections(
+ conversations: List,
+ preferences: SessionListPreferences = SessionListPreferences(),
+ ) = buildSessionSections(folders, conversations, preferences, openFolderIds = setOf(1, 4))
+
+ private fun List.ids(section: String) = first { it.id == section }.rows.map { it.conversation.id }
+
+ @Test
+ fun `project sort switches while chats pinned and recent keep their own ordering`() {
+ val conversations = listOf(
+ conversation(1, created = 100, updated = 400),
+ conversation(2, 4, created = 200, updated = 300),
+ conversation(3, 2, created = 300, updated = 500),
+ conversation(4, 2, created = 400, updated = 200),
+ conversation(5, updated = 100).copy(pinnedAt = Instant.ofEpochSecond(900)),
+ conversation(6, updated = 900).copy(pinnedAt = Instant.ofEpochSecond(800)),
+ )
+ val created = sections(conversations)
+ val updated = sections(conversations, SessionListPreferences(sortMode = SessionSortMode.UPDATED))
+
+ assertEquals(listOf(2, 1), created.ids("folder-1"))
+ assertEquals(listOf(1, 2), updated.ids("folder-1"))
+ for (result in listOf(created, updated)) {
+ assertEquals(listOf(3, 4), result.ids("chats"))
+ assertEquals(listOf(5, 6), result.ids("pinned"))
+ assertEquals(listOf(3, 1, 2, 4), result.ids("recent"))
+ assertEquals("Project", result.first { it.id == "recent" }.rows.first { it.conversation.id == 2 }.folderName)
+ }
+ assertEquals(Instant.ofEpochSecond(200), created.first { it.id == "folder-1" }.rows.first().timestamp)
+ assertEquals(Instant.ofEpochSecond(400), updated.first { it.id == "folder-1" }.rows.first().timestamp)
+ assertEquals(Instant.ofEpochSecond(500), created.first { it.id == "recent" }.rows.first().timestamp)
+ assertEquals(Instant.ofEpochSecond(500), created.first { it.id == "chats" }.rows.first().timestamp)
+ }
+
+ @Test
+ fun `time ties use the other timestamp then id for deterministic web ordering`() {
+ val conversations = listOf(
+ conversation(1, created = 10, updated = 40),
+ conversation(2, created = 20, updated = 30),
+ conversation(3, created = 20, updated = 40),
+ conversation(4, created = 20, updated = 40),
+ )
+ assertEquals(listOf(4, 3, 2, 1), sections(conversations).ids("folder-1"))
+ assertEquals(listOf(4, 3, 1, 2), sections(conversations).ids("recent"))
+ assertEquals(listOf(4, 3, 1, 2), sections(conversations, SessionListPreferences(sortMode = SessionSortMode.UPDATED)).ids("folder-1"))
+ val pinned = conversations.map { it.copy(pinnedAt = Instant.EPOCH) }
+ assertEquals(listOf(4, 3, 2, 1), sections(pinned).ids("pinned"))
+ }
+
+ @Test
+ fun `recent keeps old history but excludes pinned completed closed projects and orphans`() {
+ val conversations = listOf(
+ conversation(1), // All dates are in 1970: no 24-hour cutoff.
+ conversation(2, 2), // Legacy chat folder flag works without conversation.kind.
+ conversation(3, 99).copy(kind = "chat"),
+ conversation(4).copy(pinnedAt = Instant.EPOCH),
+ conversation(5).copy(status = ConversationStatus.COMPLETED),
+ conversation(6, 3),
+ conversation(7, 99),
+ conversation(8, 4),
+ )
+ val result = sections(conversations)
+ assertEquals(listOf(8, 3, 2, 1), result.ids("recent"))
+ assertEquals(listOf(8, 5, 3, 2, 1), sections(conversations, SessionListPreferences(recentShowCompleted = true)).ids("recent"))
+ assertEquals(conversations.map { it.id }.sorted(), result.filter { it.kind != SectionKind.Recent }.flatMap { it.rows }.map { it.conversation.id }.sorted())
+ }
+
+ @Test
+ fun `recent filters narrow only recent and cli source can cross chat and project`() {
+ val conversations = listOf(
+ conversation(1), conversation(2, 2),
+ conversation(3).copy(source = "cli"), conversation(4, 2).copy(source = "cli"),
+ )
+ val expected = mapOf(
+ RecentSessionFilter.ALL to listOf(4, 3, 2, 1),
+ RecentSessionFilter.CHATS to listOf(4, 2),
+ RecentSessionFilter.FOLDERS to listOf(3, 1),
+ RecentSessionFilter.CLI to listOf(4, 3),
+ )
+ for ((filter, ids) in expected) {
+ val result = sections(conversations, SessionListPreferences(recentFilter = filter))
+ assertEquals(ids, result.ids("recent"))
+ assertEquals(listOf(3, 1), result.ids("folder-1"))
+ assertEquals(listOf(4, 2), result.ids("chats"))
+ }
+ }
+
+ @Test
+ fun `recent visibility and preview sizes never truncate the underlying history`() {
+ val conversations = (1..65).map { conversation(it) }
+ val visible = sections(conversations)
+ val recent = visible.first { it.kind == SectionKind.Recent }
+ val project = visible.first { it.kind is SectionKind.Folder }
+ assertEquals(10, recent.pageSize)
+ assertEquals(20, project.pageSize)
+ assertEquals(65, recent.count)
+ assertEquals(65, project.count)
+ val hidden = sections(conversations, SessionListPreferences(showRecent = false))
+ assertFalse(hidden.any { it.kind == SectionKind.Recent })
+ assertEquals(visible.filter { it.kind != SectionKind.Recent }, hidden)
+ assertEquals(65, conversations.size)
+ }
+
+ @Test
+ fun `empty recent stays discoverable when filter has no matches`() {
+ val result = sections(listOf(conversation(1)), SessionListPreferences(recentFilter = RecentSessionFilter.CLI))
+ assertTrue(result.first { it.id == "recent" }.rows.isEmpty())
+ assertEquals(listOf(1), result.ids("folder-1"))
+ }
+}
diff --git a/app/src/test/java/app/codeg/android/feature/sessions/SessionSectionsTest.kt b/app/src/test/java/app/codeg/android/feature/sessions/SessionSectionsTest.kt
index 3812e60..86c4f06 100644
--- a/app/src/test/java/app/codeg/android/feature/sessions/SessionSectionsTest.kt
+++ b/app/src/test/java/app/codeg/android/feature/sessions/SessionSectionsTest.kt
@@ -4,6 +4,7 @@ import app.codeg.android.core.model.AgentType
import app.codeg.android.core.model.ConversationStatus
import app.codeg.android.core.model.ConversationSummary
import app.codeg.android.core.model.FolderDetail
+import app.codeg.android.core.datastore.SessionListPreferences
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -17,6 +18,21 @@ import java.time.Instant
*/
class SessionSectionsTest {
+ @Test
+ fun `manual folder order changes project headers and keeps worktrees merged with all history`() {
+ val folders = listOf(folder(1, "A"), folder(2, "B"), folder(3, "Branch").copy(parentId = 1))
+ val conversations = listOf(conv(1, 1, 10), conv(2, 2, 20), conv(3, 3, 30))
+ val sections = buildSessionSections(
+ folders, conversations, SessionListPreferences(showRecent = false), folderOrder = listOf(2, 1, 3),
+ )
+ assertEquals(listOf("folder-2", "folder-1"), sections.map { it.id })
+ assertEquals(listOf(3, 1), sections.last().rows.map { it.conversation.id })
+ assertEquals(listOf(1, 2, 3), sections.flatMap { it.rows }.map { it.conversation.id }.sorted())
+ }
+
+ private fun buildWithoutRecent(folders: List, conversations: List) =
+ buildSessionSections(folders, conversations, SessionListPreferences(showRecent = false))
+
private fun conv(
id: Int,
folderId: Int,
@@ -36,6 +52,32 @@ class SessionSectionsTest {
private fun folder(id: Int, name: String, sortOrder: Int = 0, color: String = "") =
FolderDetail(id = id, name = name, path = "/p/$id", sortOrder = sortOrder, color = color)
+ @Test
+ fun `hiding a root hides worktree and recent rows but preserves pinned and full source data`() {
+ val folders = listOf(folder(1, "Root"), folder(2, "Branch").copy(parentId = 1), folder(3, "Visible"))
+ val conversations = listOf(conv(1, 1, 10), conv(2, 2, 20), conv(3, 2, 30, pinnedAt = 40), conv(4, 3, 40))
+ val sections = buildSessionSections(
+ folders, conversations, SessionListPreferences(recentShowCompleted = true), hiddenFolderIds = setOf(1),
+ )
+ assertEquals(listOf("pinned", "folder-3", "recent"), sections.map { it.id })
+ assertEquals(listOf(3), sections.first().rows.map { it.conversation.id })
+ assertEquals("Root", sections.first().rows.single().folderName)
+ assertEquals(listOf(4), sections.last().rows.map { it.conversation.id })
+ assertEquals(4, conversations.size)
+ assertEquals(listOf(2, 1), buildWithoutRecent(folders, conversations).first { it.id == "folder-1" }.rows.map { it.conversation.id })
+ }
+
+ @Test
+ fun `hidden missing folder does not leak into Other and hiding one worktree keeps its siblings`() {
+ val folders = listOf(folder(1, "Root"), folder(2, "Hidden branch").copy(parentId = 1), folder(3, "Branch").copy(parentId = 1))
+ val conversations = listOf(conv(1, 1, 10), conv(2, 2, 20), conv(3, 3, 30), conv(4, 99, 40))
+ val sections = buildSessionSections(
+ folders, conversations, SessionListPreferences(showRecent = false), hiddenFolderIds = setOf(2, 99),
+ )
+ assertEquals(listOf("folder-1"), sections.map { it.id })
+ assertEquals(listOf(3, 1), sections.single().rows.map { it.conversation.id })
+ }
+
@Test
fun `sections are ordered pinned then folders then other`() {
val folders = listOf(
@@ -51,7 +93,7 @@ class SessionSectionsTest {
conv(40, folderId = 99, updated = 300), // orphan → Other
)
- val sections = buildSessionSections(folders, convs)
+ val sections = buildWithoutRecent(folders, convs)
// sortedFolders orders by sortOrder then name → Alpha(0) before Beta(1).
assertEquals(listOf("pinned", "folder-2", "folder-1", "other"), sections.map { it.id })
@@ -66,7 +108,7 @@ class SessionSectionsTest {
conv(30, folderId = 2, updated = 120),
)
- val pinned = buildSessionSections(folders, convs).first { it.id == "pinned" }
+ val pinned = buildWithoutRecent(folders, convs).first { it.id == "pinned" }
assertTrue(pinned.kind is SectionKind.Pinned)
// pinnedAt 70 (conv 11) is more recent than 50 (conv 10).
@@ -85,7 +127,7 @@ class SessionSectionsTest {
conv(30, folderId = 2, updated = 120),
)
- val sections = buildSessionSections(folders, convs)
+ val sections = buildWithoutRecent(folders, convs)
val beta = sections.first { it.id == "folder-1" }
val alpha = sections.first { it.id == "folder-2" }
@@ -97,16 +139,15 @@ class SessionSectionsTest {
}
@Test
- fun `empty folders keep a header so the section still shows`() {
- val sections = buildSessionSections(listOf(folder(1, "Solo")), emptyList())
+ fun `empty folders do not fill the conversation list with headers`() {
+ val sections = buildWithoutRecent(listOf(folder(1, "Solo")), emptyList())
- assertEquals(listOf("folder-1"), sections.map { it.id })
- assertEquals(0, sections.single().count)
+ assertTrue(sections.isEmpty())
}
@Test
fun `no pinned or other section when there are none`() {
- val sections = buildSessionSections(
+ val sections = buildWithoutRecent(
listOf(folder(1, "A")),
listOf(conv(1, folderId = 1, updated = 10)),
)
@@ -116,7 +157,7 @@ class SessionSectionsTest {
@Test
fun `orphan conversations fall into Other and resolve no folder tag`() {
- val sections = buildSessionSections(
+ val sections = buildWithoutRecent(
listOf(folder(1, "A")),
listOf(conv(40, folderId = 99, updated = 300)),
)
@@ -126,4 +167,73 @@ class SessionSectionsTest {
assertEquals(listOf(40), other.rows.map { it.conversation.id })
assertNull(other.rows.single().folderName) // unknown folder → no tag
}
+
+ @Test
+ fun `per-chat scratch folders become one newest-first chat section`() {
+ val folders = listOf(
+ folder(1, "Chat").copy(kind = "chat"),
+ folder(2, "Chat").copy(isChat = true), // older server
+ folder(3, "Chat").copy(kind = "regular"), // a real project named Chat
+ )
+ val conversations = listOf(
+ conv(1, 1, 10), conv(2, 2, 30), conv(3, 3, 20),
+ conv(4, 99, 40).copy(kind = "chat"), // folder missing from the response
+ )
+
+ val sections = buildWithoutRecent(folders, conversations)
+
+ assertEquals(listOf("chats", "folder-3"), sections.map { it.id })
+ assertEquals(listOf(4, 2, 1), sections.first().rows.map { it.conversation.id })
+ assertTrue(sections.first().rows.all { it.folderName == null })
+ assertEquals(listOf(3), sections.last().rows.map { it.conversation.id })
+ }
+
+ @Test
+ fun `pinned chats appear once and do not expose a scratch folder tag`() {
+ val sections = buildWithoutRecent(
+ listOf(folder(1, "Chat").copy(kind = "chat")),
+ listOf(conv(1, 1, 10, pinnedAt = 20).copy(kind = "chat"), conv(2, 1, 30)),
+ )
+
+ assertEquals(listOf("pinned", "chats"), sections.map { it.id })
+ assertEquals(listOf(1, 2), sections.flatMap { it.rows }.map { it.conversation.id })
+ assertNull(sections.first().rows.single().folderName)
+ }
+
+ @Test
+ fun `worktrees merge into their root while a missing root keeps its sessions reachable`() {
+ val sections = buildWithoutRecent(
+ listOf(
+ folder(1, "Repo"),
+ folder(2, "task-a").copy(parentId = 1),
+ folder(3, "task-b").copy(parentId = 1),
+ folder(4, "detached").copy(parentId = 999),
+ ),
+ listOf(conv(1, 1, 10), conv(2, 2, 30), conv(3, 3, 20), conv(4, 4, 40)),
+ )
+
+ assertEquals(listOf("folder-4", "folder-1"), sections.map { it.id })
+ assertEquals(listOf(2, 3, 1), sections.last().rows.map { it.conversation.id })
+ assertEquals(listOf(4), sections.first().rows.map { it.conversation.id })
+ }
+
+ @Test
+ fun `all history remains available exactly once before display pagination`() {
+ val folders = listOf(
+ folder(1, "Chat").copy(kind = "chat"), folder(2, "Project"),
+ folder(3, "branch").copy(parentId = 2), folder(4, "Empty"),
+ )
+ val conversations = (1..95).map { id ->
+ conv(id, if (id % 4 == 0) 99 else id % 3 + 1, id.toLong(),
+ pinnedAt = if (id % 10 == 0) id.toLong() else null)
+ }
+
+ val sections = buildWithoutRecent(folders, conversations)
+ val rows = sections.flatMap { it.rows }.map { it.conversation.id }
+
+ assertEquals(conversations.map { it.id }.sorted(), rows.sorted())
+ assertEquals(rows.size, rows.distinct().size)
+ assertTrue(sections.none { it.id == "folder-4" || it.id == "folder-3" || it.id == "folder-1" })
+ assertTrue(sections.first { it.id == "chats" }.count > 20)
+ }
}
diff --git a/docs/superpowers/plans/2026-10-24-android-local-cache.md b/docs/superpowers/plans/2026-10-24-android-local-cache.md
new file mode 100644
index 0000000..27eb225
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-24-android-local-cache.md
@@ -0,0 +1,11 @@
+# Android local conversation cache
+
+Approved design: show cached session lists and the latest five user rounds first; refresh stale data after 60 seconds, always reconnect live sessions, and allow manual refresh. Persist authoritative API JSON only. Keep history pages isolated by snapshot, endpoint, profile and credential identity. Never replay transient approvals or optimistic messages from disk.
+
+- [x] Add an atomic, bounded disk cache in the private cache directory, with request generations preventing writes after clear/delete/identity changes. Cover corruption, isolation, eviction and in-flight invalidation.
+- [x] Cache raw list/detail/history responses through the saved-profile API client. Split large loaded windows into five-round/500-record pages; retain JSON fields, and invalidate old history pages on authoritative detail refresh.
+- [x] Show cached lists/details immediately; fetch after 60 seconds or for live sessions. Manual refresh bypasses freshness and preserves displayed data, live streams and loaded history. Cache failures never fail a successful network request.
+- [x] Add detail Refresh, cache timestamps/status, and Settings cache size/clear action. Keep paging LazyColumn indexes unchanged.
+- [x] Run Android unit tests, Debug build and lint; verify diff and document usage. No backend changes or deployment.
+
+Limits: 100 MiB globally and 10 MiB per conversation, LRU eviction; cache is disposable and excluded from backups. Unknown server content blocks remain intact in raw JSON. Existing structural transcript hash cannot prove text equality, so successful detail refresh replaces the snapshot and invalidates older cached pages.
diff --git a/docs/superpowers/plans/2026-10-24-downloads-and-file-viewer.md b/docs/superpowers/plans/2026-10-24-downloads-and-file-viewer.md
new file mode 100644
index 0000000..3025755
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-24-downloads-and-file-viewer.md
@@ -0,0 +1,42 @@
+# Downloads and File Viewer Implementation Plan
+
+> **For agentic workers:** Execute inline, task by task, preserving the existing uncommitted work.
+
+**Goal:** Add a persistent download list and independent local file viewer, with navigation back to the source conversation.
+
+**Architecture:** A singleton repository owns transfer preparation and Android DownloadManager IDs. DataStore stores metadata, never server auth tokens or issued ticket URLs. Navigation pushes downloads/viewer destinations above conversations, keeping their ViewModels and saved UI state.
+
+**Tech Stack:** Kotlin, Compose, Navigation Compose, Hilt, DataStore, Android DownloadManager and ImageDecoder; no new dependencies.
+
+**Spec:** User-approved interaction described in this conversation: downloads list, local image/text/code viewer, return to source conversation, APK/system opening fallback.
+
+## Global Constraints
+
+- Keep server authentication in the existing download-ticket API.
+- Scope the list and source navigation to the selected saved server and endpoint.
+- Continue transfers after leaving the conversation; restore records after restart.
+- Relative links remain within the conversation directory; retain the absolute-path download fix.
+- Decode images off the main thread, capped at 2048 pixels per side; bound text preview to 128 KiB and 2000 lines.
+- Never remove completed phone files when dismissing a banner or removing a history entry.
+
+## Tasks
+
+- [x] Persist download metadata and own transfers outside conversation ViewModels. Test persistence, identity filtering, duplicate detection and restart recovery.
+- [x] Implement list with progress, refresh, retry, cancel, open and source-conversation actions.
+- [x] Implement local image zoom and text/code copy in a separate viewer; keep APK installation and external-format fallback.
+- [x] Wire session/settings navigation and preserve session draft/scroll state when viewing files.
+- [x] Run unit tests, lint and APK build, review the connected flows, update README and the generated Downloads APK.
+
+## Verification
+
+Run `ANDROID_HOME=/Users/sofire/Library/Android/sdk ./gradlew --offline :app:testDebugUnitTest :app:assembleDebug :app:lintDebug`.
+Check `git diff --check`. If a device/emulator is available, exercise conversation → file → source conversation, restart history, image zoom, missing-file errors and APK installation.
+
+## Completed verification
+
+- Full unit test task, debug APK build and lint passed; 8 new tests cover durable metadata, server identity, download freshness, restart states and bounded text previews.
+- Android 15 Small_Phone emulator, read-only session, with a loopback fixture server: downloaded and viewed Python/Unicode text and PNG, tested long text truncation, list opening, cancellation, retry, persisted records after restarting/updating the app, source navigation and preserved drafts.
+- Confirmed the Android installation permission screen and APK update confirmation dialog. The system confirmation dialog was canceled after verification.
+- Checked settings → downloads → source conversation → downloads → source conversation retains the input draft.
+- Final APK copied to `~/Downloads/codeg-android-debug.apk`; SHA-256 matches the build output. No server change or third-party service required.
+- Physical Android phone verification remains outstanding.
diff --git a/docs/superpowers/plans/2026-10-24-session-file-manager.md b/docs/superpowers/plans/2026-10-24-session-file-manager.md
new file mode 100644
index 0000000..3822d56
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-24-session-file-manager.md
@@ -0,0 +1,29 @@
+# 会话文件管理:补齐未下载文件
+
+用户要求:统一查看会话生成、修改的文件,不能只有下载记录。
+
+- [x] 服务端新增认证接口 `POST /api/get_conversation_files`,请求 `conversationId`。
+- [x] 从完整会话识别 write/edit/patch 路径与回复中的有效本地链接,按规范化路径去重,跳过失败和明确在途工具;返回最近来源、操作、时间、大小与可用状态。
+- [x] 安卓文件管理保留“会话文件 / 下载记录”,从会话进入直接选中当前会话,全局入口支持选择会话和搜索。
+- [x] 文件清单沿用现有磁盘缓存的服务器、凭据隔离和手动刷新;不额外下载全部历史消息。
+- [x] 未下载文件可直接远程预览文本和图片。点击“保存到手机”才进入已有 DownloadManager 下载流程。
+- [x] 普通文件消息链接直接远程查看;APK 继续直接下载。保留来源会话导航及草稿。
+- [x] Android 新增接口与预览读取上限测试;全量单测、APK 构建、Lint 通过。
+- [x] Rust 文件识别模块用现有编译依赖独立编译,3 项测试通过。
+- [x] Android 15 模拟器:未下载文件清单、代码远程预览、图片远程预览、预览不增加下载记录、保存后本地查看、返回来源会话通过。
+- [x] APK 已生成并同步到 `~/Downloads/codeg-android-debug.apk`,SHA256 一致。
+
+限制:完整 Rust Cargo 检查需要的锁定依赖不在离线缓存中,在线访问 crates.io 超时;未更新线上服务端,未安装用户真机。文件索引依赖会话记录中的明确路径,任意 shell 副作用和没有本地 URI 的内嵌图片不作为磁盘文件识别。远程图片上限 12 MiB,文本 128 KiB / 2000 行。
+
+## 后续修复:旧服务端返回 501
+
+用户截图的英文错误来自 Rust `api_not_found`:未知接口返回 `501`、`code=not_implemented`。原安卓错误处理只识别无结构 404,且缺少旧版读取方式。
+
+- [x] 新接口返回 501/not_implemented 或无结构 404 时,使用既有 `get_folder_conversation`、`fromIndex=0` 获取完整历史并在手机建立文件清单;不更改普通会话的分页和缓存。
+- [x] 使用原始工作目录或当前文件夹解析相对路径;失败、在途工具不入清单,规范化路径后去重,保留较早文件。
+- [x] 旧服务端无法提前检查文件时,将可用状态保留为未知,允许打开时检查;不把历史删除误当成当前不存在。
+- [x] 401、403、500 不触发兼容读取;不完整历史明确报错,避免漏掉早期文件。
+- [x] 补充 4 项路径识别单测和 4 项网络兼容回归,全量单测、Lint、APK 构建通过。
+- [x] Android 15 模拟器实际收到 501 后自动恢复文件清单,搜索和代码远程预览通过。
+
+修复后不要求必须先升级服务端;旧服务端只在文件管理页首次整理时传输完整会话,较大历史可能更慢。未操作用户真机或线上服务。